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

1"""Dashboard controller.""" 

2 

3from __future__ import annotations 

4 

5from collections import defaultdict 

6import contextvars 

7from datetime import datetime 

8from typing import Any 

9 

10from starlette.requests import Request 

11from starlette.responses import HTMLResponse 

12 

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 

21 

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) 

29 

30 

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 {} 

35 

36 

37@inject 

38class DashboardController(AdminController): 

39 """Controller for managing and viewing dashboards.""" 

40 

41 prefix = "" 

42 

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 

53 

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) 

63 

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 

76 

77 dashboard_id = request.query_params.get("id", "default") 

78 

79 breadcrumbs = self.generate_breadcrumbs( 

80 ("Home", "/admin/"), 

81 current="Dashboard", 

82 ) 

83 

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 = [] 

89 

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)) 

105 

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 ) 

185 

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; 

199 

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 } 

211 

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 } 

221 

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""") 

245 

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 ) 

257 

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 ) 

274 

275 return await self.render_admin( 

276 request, 

277 content, 

278 title=f"Dashboard: {dashboard_id}", 

279 breadcrumbs=breadcrumbs, 

280 ) 

281 

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 [] 

289 

290 # MetricProtocol aggregation helpers with caching 

291 

292 def get_request_cache(self, request: Request) -> dict[str, Any]: 

293 """ 

294 Get request-scoped cache. 

295 

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() 

301 

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. 

311 

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) 

317 

318 Returns: 

319 Computed or cached metric value 

320 

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() 

337 

338 # Time-series helpers 

339 

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. 

347 

348 Args: 

349 data: List of (timestamp, value) tuples 

350 interval: Time interval ('hour', 'day', 'week', 'month') 

351 

352 Returns: 

353 Formatted data for charting (Chart.js compatible) 

354 

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 ] 

369 

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() 

381 

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. 

389 

390 Groups data points by time interval and sums/averages values. 

391 

392 Args: 

393 data: List of (timestamp, value) tuples 

394 interval: Aggregation interval ('hour', 'day', 'week', 'month') 

395 

396 Returns: 

397 Aggregated time-series data 

398 """ 

399 buckets: dict[str, list[float]] = defaultdict(list) 

400 

401 for ts, value in data: 

402 bucket_key = self._get_bucket_key(ts, interval) 

403 buckets[bucket_key].append(value) 

404 

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)) 

412 

413 return result 

414 

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() 

427 

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)