Coverage for src/lexigram/admin/dashboard/content_renderer.py: 97%

75 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Host-side content renderer for structured widget content. 

2 

3``WidgetController`` is the only ``lexigram-ui`` caller for widget fragments — 

4this module is the single dispatcher that turns a ``WidgetContent`` variant 

5into an HTML string, so contributors never build markup themselves. Every 

6tone-to-color decision lives in the ``_TONE_*`` mapping tables below. 

7""" 

8 

9from __future__ import annotations 

10 

11from typing import TYPE_CHECKING, Any 

12 

13from lexigram.contracts.admin.health_payload import HealthCheckPayload 

14from lexigram.contracts.admin.widget_content import ( 

15 ChartContent, 

16 EmptyContent, 

17 MessageContent, 

18 StatContent, 

19 TableContent, 

20 Tone, 

21 WidgetContent, 

22) 

23from lexigram.contracts.core.health import HealthStatus 

24from lexigram.ui import ( 

25 AreaChart, 

26 Badge, 

27 BarChart, 

28 ChartDataPoint, 

29 EmptyState, 

30 LineChart, 

31 PieChart, 

32 el, 

33 render_to_string, 

34) 

35 

36if TYPE_CHECKING: 

37 from lexigram.ui.atoms.badge import BadgeVariant 

38 

39_TONE_TEXT_CLASS: dict[Tone, str] = { 

40 Tone.DEFAULT: "text-foreground", 

41 Tone.PRIMARY: "text-primary", 

42 Tone.SUCCESS: "text-success", 

43 Tone.WARNING: "text-warning", 

44 Tone.DANGER: "text-destructive", 

45 Tone.INFO: "text-info", 

46} 

47 

48_TONE_BADGE_VARIANT: dict[Tone, BadgeVariant] = { 

49 Tone.DEFAULT: "default", 

50 Tone.PRIMARY: "primary", 

51 Tone.SUCCESS: "success", 

52 Tone.WARNING: "warning", 

53 Tone.DANGER: "danger", 

54 Tone.INFO: "info", 

55} 

56 

57_HEALTH_STATUS_TONE: dict[HealthStatus, Tone] = { 

58 HealthStatus.HEALTHY: Tone.SUCCESS, 

59 HealthStatus.DEGRADED: Tone.WARNING, 

60 HealthStatus.UNHEALTHY: Tone.DANGER, 

61 HealthStatus.STARTING: Tone.INFO, 

62 HealthStatus.UNKNOWN: Tone.DEFAULT, 

63} 

64 

65_TONE_CHART_COLOR: dict[Tone, str] = { 

66 Tone.DEFAULT: "blue", 

67 Tone.PRIMARY: "primary", 

68 Tone.SUCCESS: "green", 

69 Tone.WARNING: "yellow", 

70 Tone.DANGER: "red", 

71 Tone.INFO: "teal", 

72} 

73 

74_CHART_COMPONENT: dict[str, type[Any]] = { 

75 "bar": BarChart, 

76 "line": LineChart, 

77 "pie": PieChart, 

78 "area": AreaChart, 

79} 

80 

81_STAT_CLASS = ( 

82 "rounded-lg border border-border bg-card p-4 flex flex-col gap-1 min-w-0 shadow-sm" 

83) 

84_STAT_LABEL_CLASS = ( 

85 "text-xs font-semibold text-muted-foreground uppercase tracking-wider truncate" 

86) 

87_STAT_VALUE_CLASS = "text-2xl font-bold tabular-nums" 

88_STAT_DELTA_CLASS = "text-xs font-medium text-muted-foreground" 

89_TABLE_CLASS = "min-w-full divide-y divide-border border-separate border-spacing-0" 

90_TABLE_STYLE = "table-layout: auto; min-width: 100%; width: max-content;" 

91_TABLE_CONTAINER_CLASS = ( 

92 "overflow-x-auto overflow-y-auto shadow-sm ring-1 ring-border dark:ring-border " 

93 "rounded-lg bg-muted-50" 

94) 

95_TABLE_CONTAINER_STYLE = ( 

96 "max-height: min(70vh, calc(100vh - 18rem)); min-height: 200px;" 

97) 

98_TABLE_HEAD_ROW_CLASS = "bg-muted dark:bg-card-50 border-b border-border" 

99_TABLE_ROW_CLASS = ( 

100 "hover:bg-muted dark:hover:bg-card-80 transition-shadow duration-150 " 

101 "border-b border-border last:border-0 group" 

102) 

103_TABLE_BODY_CLASS = "bg-card divide-y divide-border" 

104_TABLE_HEADING_CLASS = ( 

105 "px-6 py-3 text-left text-xs font-medium uppercase tracking-wider " 

106 "text-muted-foreground sticky top-0 z-20 bg-muted dark:bg-background group" 

107) 

108_TABLE_CELL_CLASS = "px-6 py-4 whitespace-nowrap align-middle" 

109_TABLE_ZEBRA_CLASS = "bg-muted-30" 

110_HEALTH_BADGE_CLASS = "health-check-badge" 

111_HEALTH_DETAIL_CLASS = "text-sm text-muted-foreground" 

112 

113 

114def render_content(content: WidgetContent) -> str: 

115 """Render a ``WidgetContent`` variant into an HTML fragment. 

116 

117 Args: 

118 content: Structured widget content produced by a contributor. 

119 

120 Returns: 

121 Rendered HTML string. 

122 

123 Raises: 

124 TypeError: If *content* is not a recognized ``WidgetContent`` variant. 

125 """ 

126 if isinstance(content, StatContent): 

127 return _render_stat_content(content) 

128 if isinstance(content, TableContent): 

129 return _render_table_content(content) 

130 if isinstance(content, HealthCheckPayload): 

131 return _render_health_content(content) 

132 if isinstance(content, MessageContent): 

133 return render_to_string( 

134 el("p", content.text, class_=_TONE_TEXT_CLASS.get(content.tone, "")) 

135 ) 

136 if isinstance(content, EmptyContent): 

137 return render_to_string( 

138 EmptyState(title=content.title, message=content.message, icon=content.icon) 

139 ) 

140 if isinstance(content, ChartContent): 

141 return _render_chart_content(content) 

142 raise TypeError(f"unhandled WidgetContent variant: {type(content)!r}") 

143 

144 

145def _render_stat_content(content: StatContent) -> str: 

146 """Render a stat card grid (single stat or N-stats).""" 

147 cards: list[Any] = [] 

148 for stat in content.stats: 

149 value_cls = f"{_STAT_VALUE_CLASS} {_TONE_TEXT_CLASS.get(stat.tone, '')}".strip() 

150 children: list[Any] = [ 

151 el("p", stat.label, class_=_STAT_LABEL_CLASS), 

152 el("p", stat.value, class_=value_cls), 

153 ] 

154 if stat.delta: 

155 children.append(el("p", stat.delta, class_=_STAT_DELTA_CLASS)) 

156 cards.append(el("div", *children, class_=_STAT_CLASS)) 

157 return render_to_string( 

158 el("div", *cards, class_="grid grid-cols-1 sm:grid-cols-2 gap-4") 

159 ) 

160 

161 

162def _render_table_content(content: TableContent) -> str: 

163 """Render a table with per-cell ``Tone`` text classes.""" 

164 if not content.rows: 

165 return render_to_string( 

166 el("p", content.empty_message, class_="text-sm text-muted-foreground") 

167 ) 

168 headings = [ 

169 el("th", column, class_=_TABLE_HEADING_CLASS) for column in content.columns 

170 ] 

171 body_rows: list[Any] = [] 

172 for index, row in enumerate(content.rows): 

173 row_class = _TABLE_ROW_CLASS 

174 if index % 2 == 1: 

175 row_class += " " + _TABLE_ZEBRA_CLASS 

176 cells = [ 

177 el( 

178 "td", 

179 cell.text, 

180 class_=_TONE_TEXT_CLASS.get(cell.tone, "") + " " + _TABLE_CELL_CLASS, 

181 ) 

182 for cell in row 

183 ] 

184 body_rows.append(el("tr", *cells, class_=row_class)) 

185 return render_to_string( 

186 el( 

187 "div", 

188 el( 

189 "table", 

190 el( 

191 "thead", 

192 el("tr", *headings, class_=_TABLE_HEAD_ROW_CLASS), 

193 ), 

194 el("tbody", *body_rows, class_=_TABLE_BODY_CLASS), 

195 class_=_TABLE_CLASS, 

196 style=_TABLE_STYLE, 

197 ), 

198 class_=_TABLE_CONTAINER_CLASS, 

199 style=_TABLE_CONTAINER_STYLE, 

200 ) 

201 ) 

202 

203 

204def _render_health_content(payload: HealthCheckPayload) -> str: 

205 """Render a health check as a status badge plus optional detail.""" 

206 tone = _HEALTH_STATUS_TONE[payload.status] 

207 children: list[Any] = [ 

208 Badge(text=payload.status.value, variant=_TONE_BADGE_VARIANT[tone]) 

209 ] 

210 if payload.detail: 

211 children.append(el("span", f"{payload.detail}", class_=_HEALTH_DETAIL_CLASS)) 

212 if payload.latency_ms is not None: 

213 children.append( 

214 el("span", f"{payload.latency_ms:.0f}ms", class_=_HEALTH_DETAIL_CLASS) 

215 ) 

216 return render_to_string(el("div", *children, class_=_HEALTH_BADGE_CLASS)) 

217 

218 

219def _render_chart_content(content: ChartContent) -> str: 

220 """Render chart points with the declared chart primitive and tone colors.""" 

221 chart_cls = _CHART_COMPONENT[content.chart_type] 

222 points = [ 

223 ChartDataPoint( 

224 label=point.label, 

225 value=point.value, 

226 color=_TONE_CHART_COLOR[point.tone], 

227 secondary_value=point.secondary_value, 

228 ) 

229 for point in content.points 

230 ] 

231 return render_to_string(chart_cls(points)) 

232 

233 

234__all__ = ["render_content"]