Coverage for src / lexigram / admin / ui / organisms / dashboard / widgets.py: 0%

101 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-11 17:07 +0800

1"""Dashboard widget components for lexigram-admin. 

2 

3Provides production-quality widgets: 

4- StatCard — single metric with optional trend indicator 

5- StatCardGrid — responsive grid of StatCards 

6- ActivityFeed — recent admin events list 

7- SystemHealthWidget — service health at-a-glance 

8""" 

9 

10from __future__ import annotations 

11 

12from dataclasses import dataclass 

13from typing import Any 

14 

15from lexigram.ui import Component, el, raw 

16 

17# --------------------------------------------------------------------------- 

18# Data models 

19# --------------------------------------------------------------------------- 

20 

21 

22@dataclass 

23class Stat: 

24 """A single metric for display in a StatCard. 

25 

26 Attributes: 

27 label: Human-readable label (e.g. ``"Total Users"``). 

28 value: Current value as a string (e.g. ``"1,234"``). 

29 icon: Lucide icon name (e.g. ``"users"``). 

30 color: Tailwind colour token: ``"blue"``, ``"green"``, ``"red"``, ``"yellow"``, ``"purple"``, ``"gray"``. 

31 change: Percentage change string (e.g. ``"+12%"``). Shown when non-empty. 

32 change_positive: Whether the change is positive (green) or negative (red). 

33 description: Secondary text below the value. 

34 href: Optional link when card is clickable. 

35 """ 

36 

37 label: str 

38 value: str 

39 icon: str = "bar-chart-2" 

40 color: str = "blue" 

41 change: str = "" 

42 change_positive: bool = True 

43 description: str = "" 

44 href: str = "" 

45 

46 

47@dataclass 

48class ActivityItem: 

49 """A single item in the activity feed. 

50 

51 Attributes: 

52 actor: Name of user who performed the action. 

53 action: Past-tense verb (e.g. ``"created"``). 

54 resource: Resource type (e.g. ``"User"``). 

55 resource_id: Optional ID of affected record. 

56 timestamp: ISO-8601 timestamp string or human-relative string (e.g. ``"2m ago"``). 

57 icon: Lucide icon name. 

58 """ 

59 

60 actor: str 

61 action: str 

62 resource: str 

63 resource_id: str = "" 

64 timestamp: str = "" 

65 icon: str = "activity" 

66 

67 

68@dataclass 

69class HealthEntry: 

70 """Health status for a single service. 

71 

72 Attributes: 

73 name: Service name (e.g. ``"Database"``). 

74 status: ``"ok"``, ``"degraded"``, or ``"down"``. 

75 latency_ms: Optional response latency in milliseconds. 

76 message: Optional detail message. 

77 """ 

78 

79 name: str 

80 status: str = "ok" 

81 latency_ms: int | None = None 

82 message: str = "" 

83 

84 

85# --------------------------------------------------------------------------- 

86# Colour helpers 

87# --------------------------------------------------------------------------- 

88 

89_ICON_BG: dict[str, str] = { 

90 "blue": "bg-info/10 text-info", 

91 "green": "bg-success/10 text-success", 

92 "red": "bg-destructive/10 text-destructive", 

93 "yellow": "bg-warning/10 text-warning", 

94 "purple": "bg-primary/10 text-primary", 

95 "gray": "bg-muted text-muted-foreground", 

96 "indigo": "bg-primary/10 text-primary", 

97 "orange": "bg-warning/10 text-warning", 

98} 

99 

100_HEALTH_COLORS: dict[str, str] = { 

101 "ok": "text-success", 

102 "degraded": "text-warning", 

103 "down": "text-destructive", 

104} 

105 

106_HEALTH_DOT: dict[str, str] = { 

107 "ok": "bg-success", 

108 "degraded": "bg-warning", 

109 "down": "bg-destructive", 

110} 

111 

112 

113# --------------------------------------------------------------------------- 

114# StatCard component 

115# --------------------------------------------------------------------------- 

116 

117 

118class StatCard(Component): 

119 """A single stat card with icon, value, label, and optional trend. 

120 

121 Args: 

122 stat: :class:`Stat` data to render. 

123 """ 

124 

125 def __init__(self, stat: Stat) -> None: 

126 super().__init__() 

127 self.stat = stat 

128 

129 def render(self) -> Any: 

130 s = self.stat 

131 icon_bg = _ICON_BG.get(s.color, _ICON_BG["blue"]) 

132 

133 change_el: Any = "" 

134 if s.change: 

135 color = "text-success" if s.change_positive else "text-destructive" 

136 arrow = "↑" if s.change_positive else "↓" 

137 change_el = el( 

138 "span", f"{arrow} {s.change}", class_=f"text-xs font-medium {color}" 

139 ) 

140 

141 description_el: Any = "" 

142 if s.description: 

143 description_el = el( 

144 "p", 

145 s.description, 

146 class_="text-xs text-muted-foreground mt-1", 

147 ) 

148 

149 icon_el = el( 

150 "div", 

151 raw(f'<i data-lucide="{s.icon}" class="w-5 h-5"></i>'), 

152 class_=f"flex-shrink-0 rounded-lg p-3 {icon_bg}", 

153 ) 

154 value_row = el( 

155 "div", 

156 el( 

157 "span", 

158 s.value, 

159 class_="text-2xl font-bold text-foreground tabular-nums", 

160 ), 

161 change_el, 

162 class_="flex items-baseline gap-2", 

163 ) 

164 info_el = el( 

165 "div", 

166 el( 

167 "p", 

168 s.label, 

169 class_="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1", 

170 ), 

171 value_row, 

172 description_el, 

173 class_="flex-1 min-w-0", 

174 ) 

175 inner = el( 

176 "div", 

177 icon_el, 

178 info_el, 

179 class_="bg-card rounded-xl shadow-sm border border-border p-5 flex items-start gap-4 hover:shadow-md transition-shadow", 

180 ) 

181 

182 if s.href: 

183 return el("a", inner, href=s.href, class_="block") 

184 return inner 

185 

186 

187# --------------------------------------------------------------------------- 

188# StatCardGrid component 

189# --------------------------------------------------------------------------- 

190 

191 

192class StatCardGrid(Component): 

193 """A responsive grid of :class:`StatCard` components. 

194 

195 Args: 

196 stats: List of :class:`Stat` items to render. 

197 cols: Number of columns (2, 3, or 4). Defaults to 4. 

198 """ 

199 

200 def __init__(self, stats: list[Stat], *, cols: int = 4) -> None: 

201 super().__init__() 

202 self.stats = stats 

203 self.cols = cols 

204 

205 def render(self) -> Any: 

206 col_class = { 

207 2: "sm:grid-cols-2", 

208 3: "sm:grid-cols-2 lg:grid-cols-3", 

209 4: "sm:grid-cols-2 lg:grid-cols-4", 

210 }.get(self.cols, "sm:grid-cols-2 lg:grid-cols-4") 

211 return el( 

212 "div", 

213 *[StatCard(s) for s in self.stats], 

214 class_=f"grid grid-cols-1 {col_class} gap-4", 

215 ) 

216 

217 

218# --------------------------------------------------------------------------- 

219# ActivityFeed component 

220# --------------------------------------------------------------------------- 

221 

222 

223class ActivityFeed(Component): 

224 """Recent activity log feed. 

225 

226 Args: 

227 items: List of :class:`ActivityItem` entries to render. 

228 title: Card heading. 

229 view_all_href: Optional "View all" link URL. 

230 max_items: Maximum items to show (0 = show all). 

231 """ 

232 

233 def __init__( 

234 self, 

235 items: list[ActivityItem], 

236 *, 

237 title: str = "Recent Activity", 

238 view_all_href: str = "", 

239 max_items: int = 8, 

240 ) -> None: 

241 super().__init__() 

242 self.items = items[:max_items] if max_items else items 

243 self.title = title 

244 self.view_all_href = view_all_href 

245 

246 def render(self) -> Any: 

247 header_children: list[Any] = [ 

248 el( 

249 "h3", 

250 self.title, 

251 class_="text-sm font-semibold text-foreground", 

252 ), 

253 ] 

254 if self.view_all_href: 

255 header_children.append( 

256 el( 

257 "a", 

258 "View all →", 

259 href=self.view_all_href, 

260 class_="text-xs text-primary-500 hover:text-primary-600 dark:text-primary-400", 

261 ) 

262 ) 

263 

264 if not self.items: 

265 body = el( 

266 "p", 

267 "No recent activity.", 

268 class_="text-sm text-muted-foreground py-4 text-center", 

269 ) 

270 else: 

271 rows = [] 

272 for item in self.items: 

273 action_text = el( 

274 "p", 

275 el("span", item.actor, class_="font-medium"), 

276 raw(f" {item.action} "), 

277 el( 

278 "span", 

279 item.resource, 

280 class_="font-medium text-primary-600 dark:text-primary-400", 

281 ), 

282 raw(f" {item.resource_id}" if item.resource_id else ""), 

283 class_="text-sm text-foreground leading-snug", 

284 ) 

285 ts_el = ( 

286 el( 

287 "p", 

288 item.timestamp, 

289 class_="text-xs text-muted-foreground mt-0.5", 

290 ) 

291 if item.timestamp 

292 else "" 

293 ) 

294 detail_el = el("div", action_text, ts_el, class_="flex-1 min-w-0") 

295 icon_span = raw( 

296 f'<span class="flex-shrink-0 mt-0.5 w-7 h-7 rounded-full bg-muted flex items-center justify-center"><i data-lucide="{item.icon}" class="w-3.5 h-3.5 text-muted-foreground"></i></span>' 

297 ) 

298 rows.append( 

299 el( 

300 "li", 

301 icon_span, 

302 detail_el, 

303 class_="flex items-start gap-3 py-3 border-b border-border/50 last:border-0", 

304 ) 

305 ) 

306 body = el("ul", *rows, class_="divide-y-0") 

307 

308 header_el = el( 

309 "div", *header_children, class_="flex items-center justify-between mb-4" 

310 ) 

311 return el( 

312 "div", 

313 header_el, 

314 body, 

315 class_="bg-card rounded-xl shadow-sm border border-border p-5", 

316 ) 

317 

318 

319# --------------------------------------------------------------------------- 

320# SystemHealthWidget component 

321# --------------------------------------------------------------------------- 

322 

323 

324class SystemHealthWidget(Component): 

325 """At-a-glance health status for backend services. 

326 

327 Args: 

328 entries: List of :class:`HealthEntry` items. 

329 title: Card heading. 

330 """ 

331 

332 def __init__( 

333 self, entries: list[HealthEntry], *, title: str = "System Health" 

334 ) -> None: 

335 super().__init__() 

336 self.entries = entries 

337 self.title = title 

338 

339 def render(self) -> Any: 

340 rows = [] 

341 for entry in self.entries: 

342 status_color = _HEALTH_COLORS.get(entry.status, _HEALTH_COLORS["ok"]) 

343 dot_color = _HEALTH_DOT.get(entry.status, _HEALTH_DOT["ok"]) 

344 latency_html = ( 

345 f'<span class="text-xs text-muted-foreground">{entry.latency_ms}ms</span>' 

346 if entry.latency_ms is not None 

347 else "" 

348 ) 

349 status_label = entry.status.upper() 

350 left = el( 

351 "div", 

352 raw( 

353 f'<span class="w-2 h-2 rounded-full {dot_color} flex-shrink-0"></span>' 

354 ), 

355 el( 

356 "span", 

357 entry.name, 

358 class_="text-sm text-foreground", 

359 ), 

360 class_="flex items-center gap-2", 

361 ) 

362 right = el( 

363 "div", 

364 raw(latency_html), 

365 el( 

366 "span", status_label, class_=f"text-xs font-semibold {status_color}" 

367 ), 

368 class_="flex items-center gap-2", 

369 ) 

370 rows.append( 

371 el( 

372 "li", 

373 left, 

374 right, 

375 class_="flex items-center justify-between py-2.5 border-b border-border/50 last:border-0", 

376 ) 

377 ) 

378 

379 body = ( 

380 el("ul", *rows, class_="divide-y-0") 

381 if rows 

382 else el( 

383 "p", 

384 "No services configured.", 

385 class_="text-sm text-muted-foreground", 

386 ) 

387 ) 

388 return el( 

389 "div", 

390 el( 

391 "h3", 

392 self.title, 

393 class_="text-sm font-semibold text-foreground mb-4", 

394 ), 

395 body, 

396 class_="bg-card rounded-xl shadow-sm border border-border p-5", 

397 ) 

398 

399 

400__all__ = [ 

401 "ActivityFeed", 

402 "ActivityItem", 

403 "HealthEntry", 

404 "Stat", 

405 "StatCard", 

406 "StatCardGrid", 

407 "SystemHealthWidget", 

408]