Coverage for src / lexigram / admin / controllers / dashboard.py: 26%
124 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Dashboard controller."""
3from __future__ import annotations
5from collections import defaultdict
6import contextvars
7from datetime import datetime
8from typing import Any
10from starlette.requests import Request
11from starlette.responses import HTMLResponse
13from lexigram.admin.controllers.base import AdminController
14from lexigram.admin.dashboard.assembler import DashboardAssembler
15from lexigram.admin.dashboard.widgets import WidgetRegistry
16from lexigram.admin.engine.renderer import AdminRenderer
17from lexigram.contracts.admin.types import WidgetSize
18from lexigram.contracts.web import get
19from lexigram.di.decorators import inject
20from lexigram.ui import el
22# Request-scoped in-memory dict, isolated per async context.
23_request_cache_var: contextvars.ContextVar[dict[str, Any] | None] = (
24 contextvars.ContextVar(
25 "admin_request_cache",
26 default=None,
27 )
28)
31def _get_request_cache() -> dict[str, Any]:
32 """Return the request-scoped cache dict for the current async context."""
33 cache = _request_cache_var.get()
34 return cache if cache is not None else {}
37@inject
38class DashboardController(AdminController):
39 """Controller for managing and viewing dashboards."""
41 prefix = ""
43 def __init__(
44 self,
45 renderer: AdminRenderer,
46 assembler: DashboardAssembler | None = None,
47 widget_registry: WidgetRegistry | None = None,
48 ):
49 super().__init__(renderer)
50 self.assembler = assembler
51 self.widget_registry = widget_registry
52 self._settings_service: Any = None
54 def _size_to_grid_cols(self, size: WidgetSize) -> int:
55 """Map WidgetSize to CSS grid column span."""
56 mapping = {
57 WidgetSize.SMALL: 1,
58 WidgetSize.MEDIUM: 2,
59 WidgetSize.LARGE: 3,
60 WidgetSize.FULL: 4,
61 }
62 return mapping.get(size, 1)
64 @get("/")
65 async def index(self, request: Request) -> HTMLResponse:
66 """Render the main dashboard overview."""
67 from lexigram.admin.ui.organisms.dashboard.widgets import (
68 ActivityFeed,
69 ActivityItem,
70 HealthEntry,
71 Stat,
72 StatCardGrid,
73 SystemHealthWidget,
74 )
75 from lexigram.ui.core.base import raw
77 dashboard_id = request.query_params.get("id", "default")
79 breadcrumbs = self.generate_breadcrumbs(
80 ("Home", "/admin/"),
81 current="Dashboard",
82 )
84 # Use assembler widgets when available; fall back to default overview
85 if self.assembler:
86 contributor_widgets = list(await self.assembler.get_all_widgets())
87 else:
88 contributor_widgets = []
90 # Load user preferences for widget visibility and ordering
91 widget_prefs = (
92 await self._settings_service.get_widget_prefs("default", "default")
93 if self._settings_service
94 else {}
95 )
96 # Filter and sort widgets. No saved prefs means everything is on.
97 if "enabled" in widget_prefs:
98 enabled_set = set(widget_prefs["enabled"])
99 contributor_widgets = [
100 w for w in contributor_widgets if w.name in enabled_set
101 ]
102 custom_order = widget_prefs.get("order", {})
103 if custom_order:
104 contributor_widgets.sort(key=lambda w: custom_order.get(w.name, w.order))
106 if contributor_widgets and self.widget_registry:
107 # Render HTMX lazy-load widget cards via the registry
108 rendered_html = self.widget_registry.render_contributor_widgets(
109 contributor_widgets,
110 )
111 widgets_section = el(
112 "div",
113 raw(rendered_html),
114 id="dashboard-grid",
115 class_="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4",
116 )
117 elif contributor_widgets and not self.widget_registry:
118 # Fallback: basic title rendering when no registry is available
119 rendered_widgets: list[Any] = []
120 for w in contributor_widgets:
121 rendered_widgets.append(
122 el(
123 "div",
124 el("h3", w.title, class_="font-semibold"),
125 class_="bg-card rounded-lg p-4 shadow",
126 )
127 )
128 widgets_section = el(
129 "div",
130 *rendered_widgets,
131 class_="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4",
132 )
133 else:
134 # Provide a beautiful default overview dashboard
135 default_stats = [
136 Stat(
137 label="Resources",
138 value=str(len(self._get_resource_list(request))),
139 icon="layers",
140 color="blue",
141 description="Registered admin resources",
142 ),
143 Stat(
144 label="Active Now",
145 value="—",
146 icon="users",
147 color="green",
148 description="Live sessions",
149 ),
150 Stat(
151 label="Actions Today",
152 value="—",
153 icon="zap",
154 color="primary",
155 description="Admin operations today",
156 ),
157 Stat(
158 label="Errors (24h)",
159 value="—",
160 icon="alert-triangle",
161 color="red",
162 description="Last 24 hours",
163 ),
164 ]
165 default_activity: list[ActivityItem] = []
166 default_health = [
167 HealthEntry(name="Admin API", status="ok"),
168 ]
169 bottom_row = el(
170 "div",
171 el(
172 "div",
173 ActivityFeed(default_activity, title="Recent Activity"),
174 class_="lg:col-span-2",
175 ),
176 SystemHealthWidget(default_health),
177 class_="grid grid-cols-1 lg:grid-cols-3 gap-4",
178 )
179 widgets_section = el(
180 "div",
181 StatCardGrid(default_stats, cols=4),
182 bottom_row,
183 class_="space-y-6",
184 )
186 # SortableJS drag-and-drop controls + widget config helpers
187 dnd_html = raw("""
188<div id="dashboard-dnd-controls" class="mt-4 text-center">
189 <button id="save-layout-btn"
190 class="hidden bg-primary text-primary-foreground px-4 py-2 rounded hover:bg-primary/90 text-sm">
191 💾 Save Layout
192 </button>
193</div>
194<script>
195(function() {
196 var grid = document.getElementById('dashboard-grid');
197 var saveBtn = document.getElementById('save-layout-btn');
198 var sortableInstance = null;
200 function initSortable() {
201 grid = document.getElementById('dashboard-grid');
202 if (!grid || sortableInstance || typeof Sortable === 'undefined') return;
203 sortableInstance = new Sortable(grid, {
204 animation: 150,
205 handle: '.widget-card',
206 onEnd: function() {
207 saveBtn.classList.remove('hidden');
208 }
209 });
210 }
212 initSortable();
213 if (!window.__adminDashboardListeners) {
214 window.__adminDashboardListeners = 1;
215 document.body.addEventListener('htmx:afterSwap', initSortable);
216 document.body.addEventListener('htmx:afterSwap', function(e) {
217 var t = e.detail && e.detail.target;
218 if (t && window.htmx) { try { htmx.process(t); } catch (err) {} }
219 });
220 }
222 if (saveBtn) {
223 saveBtn.addEventListener('click', async function() {
224 if (!sortableInstance) return;
225 var order = Array.from(grid.querySelectorAll('.widget-card')).map(function(card) {
226 return card.dataset.widgetName;
227 });
228 try {
229 var resp = await fetch('/admin/core/widgets/reorder', {
230 method: 'POST',
231 headers: {'Content-Type': 'application/json'},
232 body: JSON.stringify({order: order})
233 });
234 if (resp.ok) {
235 saveBtn.classList.add('hidden');
236 saveBtn.textContent = '✅ Saved!';
237 setTimeout(function() { saveBtn.textContent = '💾 Save Layout'; }, 2000);
238 }
239 } catch(e) {}
240 });
241 }
242})();
243</script>
244""")
246 customize_btn = el(
247 "button",
248 "⚙ Customize Dashboard",
249 **{
250 "hx-get": "/admin/core/widgets/customize",
251 "hx-target": "#slide-over-container",
252 "hx-swap": "innerHTML",
253 "hx-push-url": "false",
254 },
255 class_="text-sm bg-muted hover:bg-muted px-3 py-1.5 rounded border border-border cursor-pointer",
256 )
258 content = el(
259 "div",
260 el(
261 "div",
262 el(
263 "h2",
264 dashboard_id,
265 class_="text-2xl font-bold text-foreground",
266 ),
267 customize_btn,
268 class_="flex items-center justify-between",
269 ),
270 widgets_section,
271 dnd_html,
272 class_="dashboard-view space-y-6",
273 )
275 return await self.render_admin(
276 request,
277 content,
278 title=f"Dashboard: {dashboard_id}",
279 breadcrumbs=breadcrumbs,
280 )
282 def _get_resource_list(self, request: Request) -> list[str]:
283 """Return a list of registered resource names from app state."""
284 try:
285 app = request.app
286 return sorted(getattr(app.state, "admin_resources", {}).keys())
287 except Exception: # noqa: BLE001
288 return []
290 # MetricProtocol aggregation helpers with caching
292 def get_request_cache(self, request: Request) -> dict[str, Any]:
293 """
294 Get request-scoped cache.
296 Returns the context-var-backed cache dict for the current request.
297 The cache is isolated per async context — no shared state between
298 concurrent requests.
299 """
300 return _get_request_cache()
302 async def aggregate_metric(
303 self,
304 request: Request,
305 metric_name: str,
306 compute_func: Any,
307 use_request_cache: bool = True,
308 ) -> Any:
309 """
310 Aggregate metric with automatic caching.
312 Args:
313 request: Current request
314 metric_name: Name of the metric
315 compute_func: Async callable to compute metric value
316 use_request_cache: Use request-scoped cache (default: True)
318 Returns:
319 Computed or cached metric value
321 Example:
322 ```python
323 total_users = await self.aggregate_metric(
324 request,
325 "total_users",
326 lambda: db.execute("SELECT COUNT(*) FROM users")
327 )
328 ```
329 """
330 if use_request_cache:
331 cache = self.get_request_cache(request)
332 key = f"metric:{metric_name}"
333 if key not in cache:
334 cache[key] = await compute_func()
335 return cache[key]
336 return await compute_func()
338 # Time-series helpers
340 def format_time_series(
341 self,
342 data: list[tuple[datetime, float]],
343 interval: str = "hour",
344 ) -> list[dict[str, Any]]:
345 """
346 Format time-series data for charting libraries.
348 Args:
349 data: List of (timestamp, value) tuples
350 interval: Time interval ('hour', 'day', 'week', 'month')
352 Returns:
353 Formatted data for charting (Chart.js compatible)
355 Example:
356 ```python
357 raw_data = [(datetime(2024, 1, 1, 10), 42.0), ...]
358 chart_data = self.format_time_series(raw_data, interval='day')
359 ```
360 """
361 return [
362 {
363 "timestamp": ts.isoformat(),
364 "value": value,
365 "label": self._format_timestamp_label(ts, interval),
366 }
367 for ts, value in data
368 ]
370 def _format_timestamp_label(self, ts: datetime, interval: str) -> str:
371 """Format timestamp for display based on interval."""
372 if interval == "hour":
373 return ts.strftime("%H:%M")
374 if interval == "day":
375 return ts.strftime("%Y-%m-%d")
376 if interval == "week":
377 return f"Week {ts.isocalendar()[1]}, {ts.year}"
378 if interval == "month":
379 return ts.strftime("%b %Y")
380 return ts.isoformat()
382 def aggregate_time_series(
383 self,
384 data: list[tuple[datetime, float]],
385 interval: str = "hour",
386 ) -> list[tuple[datetime, float]]:
387 """
388 Aggregate time-series data into intervals.
390 Groups data points by time interval and sums/averages values.
392 Args:
393 data: List of (timestamp, value) tuples
394 interval: Aggregation interval ('hour', 'day', 'week', 'month')
396 Returns:
397 Aggregated time-series data
398 """
399 buckets: dict[str, list[float]] = defaultdict(list)
401 for ts, value in data:
402 bucket_key = self._get_bucket_key(ts, interval)
403 buckets[bucket_key].append(value)
405 # Compute average for each bucket
406 result: list[tuple[datetime, float]] = []
407 for key, values in sorted(buckets.items()):
408 avg_value = sum(values) / len(values)
409 # Parse bucket key back to datetime
410 bucket_ts = self._parse_bucket_key(key, interval)
411 result.append((bucket_ts, avg_value))
413 return result
415 def _get_bucket_key(self, ts: datetime, interval: str) -> str:
416 """Generate bucket key for timestamp."""
417 if interval == "hour":
418 return ts.strftime("%Y-%m-%d %H:00")
419 if interval == "day":
420 return ts.strftime("%Y-%m-%d")
421 if interval == "week":
422 iso_year, iso_week, _ = ts.isocalendar()
423 return f"{iso_year}-W{iso_week:02d}"
424 if interval == "month":
425 return ts.strftime("%Y-%m")
426 return ts.isoformat()
428 def _parse_bucket_key(self, key: str, interval: str) -> datetime:
429 """Parse bucket key back to datetime."""
430 if interval == "hour":
431 return datetime.strptime(key, "%Y-%m-%d %H:00")
432 if interval == "day":
433 return datetime.strptime(key, "%Y-%m-%d")
434 if interval == "week":
435 year, week = key.split("-W")
436 # ISO week to datetime
437 return datetime.strptime(f"{year} {week} 1", "%Y %W %w")
438 if interval == "month":
439 return datetime.strptime(key, "%Y-%m")
440 return datetime.fromisoformat(key)