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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +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, raw
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 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 )
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 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")
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 )
319# ---------------------------------------------------------------------------
320# SystemHealthWidget component
321# ---------------------------------------------------------------------------
324class SystemHealthWidget(Component):
325 """At-a-glance health status for backend services.
327 Args:
328 entries: List of :class:`HealthEntry` items.
329 title: Card heading.
330 """
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
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 )
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 )
400__all__ = [
401 "ActivityFeed",
402 "ActivityItem",
403 "HealthEntry",
404 "Stat",
405 "StatCard",
406 "StatCardGrid",
407 "SystemHealthWidget",
408]