Coverage for src/lexigram/admin/dashboard/widgets.py: 43%

150 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:39 +0800

1"""Admin dashboard widget types and infrastructure. 

2 

3Provides WidgetType, WidgetConfig, DashboardConfig and supporting 

4store/registry infrastructure for admin dashboard assembly. 

5Widget definitions from lexigram-contracts are also re-exported here. 

6""" 

7 

8from __future__ import annotations 

9 

10from dataclasses import dataclass, field 

11from datetime import datetime 

12from enum import StrEnum 

13from typing import Any, Protocol 

14 

15from lexigram.admin.dashboard.page_filters import widget_fetch_url 

16from lexigram.contracts.admin.types import ( 

17 DashboardWidgetDefinition, 

18 WidgetCategory, 

19 WidgetSize, 

20) 

21from lexigram.ui import el, render_to_string 

22 

23 

24def _render_live_widget_script() -> str: 

25 """Shared EventSource connection driving all live widgets on the page. 

26 

27 One connection per page, not one per widget — browsers cap concurrent 

28 HTTP/1.1 connections per origin (~6), the same constraint the staggered 

29 hx-trigger delays above already work around. Each live widget's body 

30 element carries data-live-resources (comma-separated resource types, 

31 or "*" for broadcast-only widgets like activity); on a matching SSE 

32 message this re-triggers that widget's existing htmx load (reconcile 

33 via the same snapshot endpoint the widget already renders from — no 

34 separate patch/diff wire format). 

35 """ 

36 return ( 

37 "<script>" 

38 "(function(){" 

39 "if(window.__lexigramLiveWidgets)return;" 

40 "window.__lexigramLiveWidgets=true;" 

41 "var es=new EventSource('/admin/_sse/widgets');" 

42 "es.onmessage=function(ev){" 

43 "var data;" 

44 "try{data=JSON.parse(ev.data);}catch(e){return;}" 

45 "var resourceType=(data.data||{}).resource_type;" 

46 "document.querySelectorAll('[data-live-resources]').forEach(function(el){" 

47 "var types=el.getAttribute('data-live-resources').split(',');" 

48 "if(types.indexOf('*')!==-1||(resourceType&&types.indexOf(resourceType)!==-1)){" 

49 "htmx.trigger(el,'live-refresh');" 

50 "}" 

51 "});" 

52 "};" 

53 "})();" 

54 "</script>" 

55 ) 

56 

57 

58class WidgetType(StrEnum): 

59 """Widget types for legacy dashboard builder.""" 

60 

61 METRIC = "metric" 

62 CHART = "chart" 

63 TABLE = "table" 

64 TEXT = "text" 

65 CUSTOM = "custom" 

66 STAT_CARD = "stat_card" 

67 ACTIVITY = "activity" 

68 HEALTH = "health" 

69 

70 

71@dataclass 

72class WidgetConfig: 

73 """Widget configuration.""" 

74 

75 id: str 

76 type: WidgetType 

77 title: str 

78 config: dict[str, Any] = field(default_factory=dict) 

79 position: dict[str, int] = field( 

80 default_factory=lambda: {"x": 0, "y": 0, "w": 1, "h": 1}, 

81 ) 

82 

83 def to_dict(self) -> dict[str, Any]: 

84 """Serialize to dictionary.""" 

85 return { 

86 "id": self.id, 

87 "type": self.type.value, 

88 "title": self.title, 

89 "config": self.config, 

90 "position": self.position, 

91 } 

92 

93 @classmethod 

94 def from_dict(cls, data: dict[str, Any]) -> WidgetConfig: 

95 """Deserialize from dictionary.""" 

96 return cls( 

97 id=data["id"], 

98 type=WidgetType(data["type"]), 

99 title=data["title"], 

100 config=data.get("config", {}), 

101 position=data.get("position", {"x": 0, "y": 0, "w": 1, "h": 1}), 

102 ) 

103 

104 

105@dataclass 

106class DashboardConfig: 

107 """Dashboard configuration.""" 

108 

109 id: str 

110 name: str 

111 widgets: list[WidgetConfig] = field(default_factory=list) 

112 layout: dict[str, Any] = field(default_factory=dict) 

113 created_at: datetime = field(default_factory=datetime.now) 

114 updated_at: datetime = field(default_factory=datetime.now) 

115 

116 def to_dict(self) -> dict[str, Any]: 

117 """Serialize to dictionary.""" 

118 return { 

119 "id": self.id, 

120 "name": self.name, 

121 "widgets": [w.to_dict() for w in self.widgets], 

122 "layout": self.layout, 

123 "created_at": self.created_at.isoformat(), 

124 "updated_at": self.updated_at.isoformat(), 

125 } 

126 

127 @classmethod 

128 def from_dict(cls, data: dict[str, Any]) -> DashboardConfig: 

129 """Deserialize from dictionary.""" 

130 return cls( 

131 id=data["id"], 

132 name=data["name"], 

133 widgets=[WidgetConfig.from_dict(w) for w in data.get("widgets", [])], 

134 layout=data.get("layout", {}), 

135 created_at=datetime.fromisoformat( 

136 data.get("created_at", datetime.now().isoformat()), 

137 ), 

138 updated_at=datetime.fromisoformat( 

139 data.get("updated_at", datetime.now().isoformat()), 

140 ), 

141 ) 

142 

143 

144class IWidget(Protocol): 

145 """Protocol for dashboard widgets.""" 

146 

147 async def render(self, config: dict[str, Any]) -> str: 

148 """Render widget HTML (async).""" 

149 ... 

150 

151 def get_default_config(self) -> dict[str, Any]: 

152 """Get default widget configuration.""" 

153 ... 

154 

155 

156class IDashboardStore(Protocol): 

157 """Protocol for dashboard persistence (async).""" 

158 

159 async def save(self, dashboard: DashboardConfig) -> bool: 

160 """Save dashboard configuration.""" 

161 ... 

162 

163 async def load(self, dashboard_id: str) -> DashboardConfig | None: 

164 """Load dashboard configuration.""" 

165 ... 

166 

167 async def list(self) -> list[DashboardConfig]: 

168 """List all dashboards.""" 

169 ... 

170 

171 async def delete(self, dashboard_id: str) -> bool: 

172 """Delete dashboard.""" 

173 ... 

174 

175 

176class InMemoryDashboardStore: 

177 """In-memory dashboard storage implementation.""" 

178 

179 def __init__(self) -> None: 

180 """Initialize store.""" 

181 self.dashboards: dict[str, DashboardConfig] = {} 

182 

183 async def save(self, dashboard: DashboardConfig) -> bool: 

184 """Save dashboard.""" 

185 dashboard.updated_at = datetime.now() 

186 self.dashboards[dashboard.id] = dashboard 

187 return True 

188 

189 async def load(self, dashboard_id: str) -> DashboardConfig | None: 

190 """Load dashboard.""" 

191 return self.dashboards.get(dashboard_id) 

192 

193 async def list(self) -> list[DashboardConfig]: 

194 """List all dashboards.""" 

195 return list(self.dashboards.values()) 

196 

197 async def delete(self, dashboard_id: str) -> bool: 

198 """Delete dashboard.""" 

199 if dashboard_id in self.dashboards: 

200 del self.dashboards[dashboard_id] 

201 return True 

202 return False 

203 

204 

205class WidgetRegistry: 

206 """Registry for dashboard widget implementations.""" 

207 

208 def __init__(self) -> None: 

209 """Initialize registry.""" 

210 self._widgets: dict[str, type[IWidget]] = {} 

211 

212 def register(self, widget_type: str, widget_class: type[IWidget]) -> None: 

213 """Register widget type.""" 

214 self._widgets[widget_type] = widget_class 

215 

216 def get(self, widget_type: str) -> type[IWidget] | None: 

217 """Get widget class by type.""" 

218 return self._widgets.get(widget_type) 

219 

220 def list_types(self) -> list[str]: 

221 """List registered widget types.""" 

222 return list(self._widgets.keys()) 

223 

224 def create_widget(self, widget_type: str) -> IWidget | None: 

225 """Create widget instance.""" 

226 widget_class = self.get(widget_type) 

227 if widget_class: 

228 return widget_class() 

229 return None 

230 

231 def render_contributor_widgets( 

232 self, 

233 contributor_widgets: list[DashboardWidgetDefinition], 

234 width: str = "100%", 

235 page_filters: dict[str, Any] | None = None, 

236 ) -> str: 

237 """Render HTML for all contributor-supplied ``DashboardWidgetDefinition`` items. 

238 

239 For each widget definition the registry is consulted using the widget's 

240 ``name`` as the lookup key. If a matching ``IWidget`` class is found, 

241 a rich card is rendered that loads its content via an HTMX ``hx-get`` 

242 request to the definition's ``render_endpoint``. When no match is 

243 found a lightweight ``<div class="widget-placeholder">`` card is 

244 rendered instead so contributors can always see their widget slot. 

245 

246 Widget sizing from ``DashboardWidgetDefinition.size`` maps to CSS 

247 grid column spans (SMALL=1, MEDIUM=2, LARGE=3, FULL=4). If 

248 ``refresh_interval_seconds`` is set, the card body auto-refreshes 

249 via ``hx-trigger="every <N>ms"``. 

250 

251 Args: 

252 contributor_widgets: Widget definitions supplied by contributors. 

253 width: CSS ``width`` value applied to each card wrapper. 

254 page_filters: Optional page-level filter values appended as query 

255 parameters to every widget's fetch URL, so widget render 

256 endpoints can react to the page's filter state. 

257 

258 Returns: 

259 Concatenated HTML string for all widget cards. 

260 """ 

261 if not contributor_widgets: 

262 return render_to_string( 

263 el( 

264 "div", 

265 el( 

266 "div", 

267 class_="text-muted-foreground text-lg mb-1", 

268 ), 

269 el( 

270 "p", 

271 "No contributor widgets configured.", 

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

273 ), 

274 class_="widget-empty-state bg-muted border border-border rounded-lg p-6 text-center", 

275 ) 

276 ) 

277 

278 parts: list[str] = [] 

279 for widget_index, widget_def in enumerate(contributor_widgets): 

280 # Map widget size to grid column span 

281 size_col_map = { 

282 WidgetSize.SMALL: "", 

283 WidgetSize.MEDIUM: "lg:col-span-2", 

284 WidgetSize.LARGE: "lg:col-span-3", 

285 WidgetSize.FULL: "lg:col-span-4", 

286 } 

287 col_span = size_col_map.get(widget_def.size, "") 

288 

289 # Build refresh trigger if interval is set. Live widgets (declared 

290 # via live_resource_types) are pushed to via a shared EventSource 

291 # instead of polled — see the script emitted after this loop. 

292 is_live = bool(widget_def.live_resource_types) 

293 refresh_trigger = "" 

294 if ( 

295 not is_live 

296 and widget_def.refresh_interval_seconds 

297 and widget_def.refresh_interval_seconds > 0 

298 ): 

299 interval_ms = widget_def.refresh_interval_seconds * 1000 

300 refresh_trigger = f"every {interval_ms}ms" 

301 

302 # Resolve a matching IWidget class from the registry. 

303 # Primary key is the definition name; fall back to category value. 

304 lookup_key = getattr(widget_def, "widget_type", widget_def.name) 

305 widget_class = self.get(lookup_key) or self.get(widget_def.name) 

306 

307 # Build common HTMX trigger: load on page render, plus optional polling. 

308 # Initial loads are staggered so the dashboard does not fire every 

309 # widget request at once — that would saturate the browser's HTTP/1.1 

310 # connection pool (~6 per origin) and starve sidebar navigation 

311 # requests for the whole drain. 

312 load_trigger = f"load delay:{widget_index * 350}ms" 

313 if refresh_trigger: 

314 load_trigger = f"{load_trigger}, {refresh_trigger}" 

315 if is_live: 

316 load_trigger = f"{load_trigger}, live-refresh" 

317 

318 # Build the title bar with optional icon and config cog. 

319 icon_html = ( 

320 el("span", widget_def.icon, class_="widget-icon mr-1") 

321 if widget_def.icon 

322 else None 

323 ) 

324 title_children: list[Any] = [] 

325 if icon_html is not None: 

326 title_children.append(icon_html) 

327 title_children.append(widget_def.title) 

328 

329 cog = el( 

330 "button", 

331 "⚙", 

332 **{ 

333 "hx-get": f"/admin/core/widgets/{widget_def.name}/config", 

334 "hx-target": "#slide-over-container", 

335 "hx-swap": "innerHTML", 

336 "hx-push-url": "false", 

337 }, 

338 class_="opacity-0 group-hover:opacity-100 transition-opacity ml-auto text-muted-foreground hover:text-muted-foreground text-sm cursor-pointer", 

339 ) 

340 title_row = el( 

341 "div", 

342 *title_children, 

343 cog, 

344 class_="widget-title text-sm font-semibold text-foreground mb-2 flex items-center", 

345 ) 

346 

347 # Contributor label and description 

348 subtitle_parts: list[str] = [] 

349 if widget_def.contributor: 

350 subtitle_parts.append(widget_def.contributor) 

351 if widget_def.description: 

352 subtitle_parts.append(widget_def.description) 

353 

354 # Loading skeleton shown while HTMX request is in flight 

355 skeleton = el( 

356 "div", 

357 el("div", class_="h-4 bg-muted rounded w-3/4 mb-2"), 

358 el("div", class_="h-4 bg-muted rounded w-1/2 mb-2"), 

359 el("div", class_="h-4 bg-muted rounded w-5/6"), 

360 class_="animate-pulse py-2", 

361 ) 

362 

363 card_children: list[Any] = [ 

364 title_row, 

365 ] 

366 

367 if subtitle_parts: 

368 card_children.append( 

369 el( 

370 "div", 

371 " · ".join(subtitle_parts), 

372 class_="text-xs text-muted-foreground mb-3", 

373 ), 

374 ) 

375 

376 body_kwargs: dict[str, Any] = { 

377 "class": "widget-body", 

378 "id": f"widget-{widget_def.name}-body", 

379 "hx-get": widget_fetch_url(widget_def.render_endpoint, page_filters), 

380 "hx-trigger": load_trigger, 

381 "hx-swap": "innerHTML", 

382 } 

383 if is_live: 

384 body_kwargs["data-live-resources"] = ",".join( 

385 widget_def.live_resource_types 

386 ) 

387 card_children.append( 

388 el( 

389 "div", 

390 skeleton, 

391 **body_kwargs, 

392 ), 

393 ) 

394 

395 card = el( 

396 "div", 

397 *card_children, 

398 id=f"widget-card-{widget_def.name}", 

399 data_widget_name=widget_def.name, 

400 class_=f"widget-card bg-card border border-border rounded-lg shadow p-4 group {col_span}".strip(), 

401 style=f"width:{width};", 

402 ) 

403 

404 parts.append(render_to_string(card)) 

405 

406 if any(w.live_resource_types for w in contributor_widgets): 

407 parts.append(_render_live_widget_script()) 

408 

409 return "".join(parts) 

410 

411 

412def render_dashboard_widgets( 

413 definitions: list[DashboardWidgetDefinition], 

414 registry: WidgetRegistry, 

415) -> str: 

416 """Render all dashboard widget definitions using the provided registry. 

417 

418 Convenience wrapper around :meth:`WidgetRegistry.render_contributor_widgets` 

419 for callers that already hold a ``WidgetRegistry`` instance and a list of 

420 ``DashboardWidgetDefinition`` objects. 

421 

422 Args: 

423 definitions: Widget definitions to render. 

424 registry: Widget registry to look up implementations. 

425 

426 Returns: 

427 Concatenated HTML string for all widget cards. 

428 """ 

429 return registry.render_contributor_widgets(definitions) 

430 

431 

432from lexigram.admin.dashboard.widget_types import ConfigField 

433 

434 

435def render_widget_config_popup( 

436 widget_name: str, 

437 title: str, 

438 fields: list[ConfigField], 

439 current_values: dict[str, Any], 

440 enabled: bool = True, 

441) -> str: 

442 """Render HTML for a widget config dialog.""" 

443 rows: list[Any] = [ 

444 el( 

445 "label", 

446 el( 

447 "input", 

448 type_="checkbox", 

449 name="enabled", 

450 checked="checked" if enabled else None, 

451 ), 

452 " Show on dashboard", 

453 class_="flex items-center gap-2 text-sm mb-4", 

454 ) 

455 ] 

456 

457 for f in fields: 

458 if isinstance(f, dict): 

459 f = ConfigField(**f) 

460 value = current_values.get(f.name, f.default) 

461 rows.append( 

462 el( 

463 "div", 

464 el("label", f.label, class_="block text-sm font-medium mb-1"), 

465 _render_field_input(f, value), 

466 class_="mb-3", 

467 ), 

468 ) 

469 

470 from lexigram.admin.ui.organisms.admin_slide_over import ( 

471 render_slide_over_fragment, 

472 ) 

473 

474 form = el( 

475 "form", 

476 *rows, 

477 el("input", type_="hidden", name="widget_name", value=widget_name), 

478 id=f"widget-config-form-{widget_name}", 

479 **{ 

480 "hx-post": "/admin/core/widgets/config", 

481 "hx-swap": "none", 

482 "hx-on:htmx:after-request": "if(event.detail.successful){window.location.reload();}", 

483 }, 

484 class_="space-y-3", 

485 ) 

486 

487 return render_slide_over_fragment( 

488 title=f"Configure: {title}", 

489 subtitle="Update this widget's settings.", 

490 content=form, 

491 size="md", 

492 footer=[ 

493 el( 

494 "button", 

495 "Cancel", 

496 type_="button", 

497 **{"x-on:click": "open = false"}, 

498 class_="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-foreground bg-card border border-border hover:bg-muted transition-colors", 

499 ), 

500 el( 

501 "button", 

502 "Save", 

503 type_="submit", 

504 form=f"widget-config-form-{widget_name}", 

505 class_="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary/90 transition-colors", 

506 ), 

507 ], 

508 ) 

509 

510 

511def _render_field_input( 

512 field: ConfigField, current: Any, widget_name: str | None = None 

513) -> str: 

514 prefix = f"param__{widget_name}__" if widget_name else "param_" 

515 if field.type == "select" and field.options: 

516 opts = [ 

517 el( 

518 "option", 

519 label, 

520 value=str(val), 

521 selected="selected" if val == current else None, 

522 ) 

523 for val, label in field.options 

524 ] 

525 return str( 

526 el( 

527 "select", 

528 *opts, 

529 name=f"{prefix}{field.name}", 

530 class_="w-full border rounded px-2 py-1 text-sm", 

531 ) 

532 ) 

533 if field.type == "number": 

534 return str( 

535 el( 

536 "input", 

537 type_="number", 

538 name=f"{prefix}{field.name}", 

539 value=str(current) if current is not None else "", 

540 class_="w-full border rounded px-2 py-1 text-sm", 

541 ) 

542 ) 

543 if field.type == "boolean": 

544 return str( 

545 el( 

546 "input", 

547 type_="checkbox", 

548 name=f"{prefix}{field.name}", 

549 checked="checked" if current else None, 

550 ) 

551 ) 

552 return str( 

553 el( 

554 "input", 

555 type_="text", 

556 name=f"{prefix}{field.name}", 

557 value=str(current) if current is not None else "", 

558 class_="w-full border rounded px-2 py-1 text-sm", 

559 ) 

560 ) 

561 

562 

563__all__ = [ 

564 "ConfigField", 

565 "DashboardConfig", 

566 "DashboardWidgetDefinition", 

567 "IDashboardStore", 

568 "IWidget", 

569 "InMemoryDashboardStore", 

570 "WidgetCategory", 

571 "WidgetConfig", 

572 "WidgetRegistry", 

573 "WidgetSize", 

574 "WidgetType", 

575 "render_dashboard_widgets", 

576 "render_widget_config_popup", 

577]