Coverage for src/lexigram/admin/ui/organisms/dashboard/widgets.py: 0%
101 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Dashboard widget components for lexigram-admin.
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"""
10from __future__ import annotations
12from dataclasses import dataclass
13from typing import Any
15from lexigram.ui import Component, el
17# ---------------------------------------------------------------------------
18# Data models
19# ---------------------------------------------------------------------------
22@dataclass
23class Stat:
24 """A single metric for display in a StatCard.
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 """
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 = ""
47@dataclass
48class ActivityItem:
49 """A single item in the activity feed.
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 """
60 actor: str
61 action: str
62 resource: str
63 resource_id: str = ""
64 timestamp: str = ""
65 icon: str = "activity"
68@dataclass
69class HealthEntry:
70 """Health status for a single service.
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 """
79 name: str
80 status: str = "ok"
81 latency_ms: int | None = None
82 message: str = ""
85# ---------------------------------------------------------------------------
86# Colour helpers
87# ---------------------------------------------------------------------------
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}
100_HEALTH_COLORS: dict[str, str] = {
101 "ok": "text-success",
102 "degraded": "text-warning",
103 "down": "text-destructive",
104}
106_HEALTH_DOT: dict[str, str] = {
107 "ok": "bg-success",
108 "degraded": "bg-warning",
109 "down": "bg-destructive",
110}
113# ---------------------------------------------------------------------------
114# StatCard component
115# ---------------------------------------------------------------------------
118class StatCard(Component):
119 """A single stat card with icon, value, label, and optional trend.
121 Args:
122 stat: :class:`Stat` data to render.
123 """
125 def __init__(self, stat: Stat) -> None:
126 super().__init__()
127 self.stat = stat
129 def render(self) -> Any:
130 s = self.stat
131 icon_bg = _ICON_BG.get(s.color, _ICON_BG["blue"])
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 )
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 )
149 icon_el = el(
150 "div",
151 el("i", {"data-lucide": s.icon, "class": "w-5 h-5"}),
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 )
182 if s.href:
183 return el("a", inner, href=s.href, class_="block")
184 return inner
187# ---------------------------------------------------------------------------
188# StatCardGrid component
189# ---------------------------------------------------------------------------
192class StatCardGrid(Component):
193 """A responsive grid of :class:`StatCard` components.
195 Args:
196 stats: List of :class:`Stat` items to render.
197 cols: Number of columns (2, 3, or 4). Defaults to 4.
198 """
200 def __init__(self, stats: list[Stat], *, cols: int = 4) -> None:
201 super().__init__()
202 self.stats = stats
203 self.cols = cols
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 )
218# ---------------------------------------------------------------------------
219# ActivityFeed component
220# ---------------------------------------------------------------------------
223class ActivityFeed(Component):
224 """Recent activity log feed.
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 """
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
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 )
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 f" {item.action} ",
277 el(
278 "span",
279 item.resource,
280 class_="font-medium text-primary-600 dark:text-primary-400",
281 ),
282 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 = el(
296 "span",
297 {
298 "class": "flex-shrink-0 mt-0.5 w-7 h-7 rounded-full bg-muted flex items-center justify-center"
299 },
300 el(
301 "i",
302 {
303 "data-lucide": item.icon,
304 "class": "w-3.5 h-3.5 text-muted-foreground",
305 },
306 ),
307 )
308 rows.append(
309 el(
310 "li",
311 icon_span,
312 detail_el,
313 class_="flex items-start gap-3 py-3 border-b border-border/50 last:border-0",
314 )
315 )
316 body = el("ul", *rows, class_="divide-y-0")
318 header_el = el(
319 "div", *header_children, class_="flex items-center justify-between mb-4"
320 )
321 return el(
322 "div",
323 header_el,
324 body,
325 class_="bg-card rounded-xl shadow-sm border border-border p-5",
326 )
329# ---------------------------------------------------------------------------
330# SystemHealthWidget component
331# ---------------------------------------------------------------------------
334class SystemHealthWidget(Component):
335 """At-a-glance health status for backend services.
337 Args:
338 entries: List of :class:`HealthEntry` items.
339 title: Card heading.
340 """
342 def __init__(
343 self, entries: list[HealthEntry], *, title: str = "System Health"
344 ) -> None:
345 super().__init__()
346 self.entries = entries
347 self.title = title
349 def render(self) -> Any:
350 rows = []
351 for entry in self.entries:
352 status_color = _HEALTH_COLORS.get(entry.status, _HEALTH_COLORS["ok"])
353 dot_color = _HEALTH_DOT.get(entry.status, _HEALTH_DOT["ok"])
354 latency_el = (
355 el(
356 "span",
357 {"class": "text-xs text-muted-foreground"},
358 f"{entry.latency_ms}ms",
359 )
360 if entry.latency_ms is not None
361 else ""
362 )
363 status_label = entry.status.upper()
364 left = el(
365 "div",
366 el(
367 "span",
368 {"class": f"w-2 h-2 rounded-full {dot_color} flex-shrink-0"},
369 ),
370 el(
371 "span",
372 entry.name,
373 class_="text-sm text-foreground",
374 ),
375 class_="flex items-center gap-2",
376 )
377 right = el(
378 "div",
379 latency_el,
380 el(
381 "span", status_label, class_=f"text-xs font-semibold {status_color}"
382 ),
383 class_="flex items-center gap-2",
384 )
385 rows.append(
386 el(
387 "li",
388 left,
389 right,
390 class_="flex items-center justify-between py-2.5 border-b border-border/50 last:border-0",
391 )
392 )
394 body = (
395 el("ul", *rows, class_="divide-y-0")
396 if rows
397 else el(
398 "p",
399 "No services configured.",
400 class_="text-sm text-muted-foreground",
401 )
402 )
403 return el(
404 "div",
405 el(
406 "h3",
407 self.title,
408 class_="text-sm font-semibold text-foreground mb-4",
409 ),
410 body,
411 class_="bg-card rounded-xl shadow-sm border border-border p-5",
412 )
415__all__ = [
416 "ActivityFeed",
417 "ActivityItem",
418 "HealthEntry",
419 "Stat",
420 "StatCard",
421 "StatCardGrid",
422 "SystemHealthWidget",
423]