Make a dashboard its widgets: drop the pages and sections nobody drew

This commit is contained in:
2026-08-25 12:30:22 +02:00
parent 2840cc8e2b
commit 7ff29ca939
17 changed files with 273 additions and 497 deletions
+68 -39
View File
@@ -374,32 +374,57 @@ class WidgetDef(BaseModel):
return self
class SectionDef(BaseModel):
"""A grid of widgets under a heading."""
id: str
title: str = ""
widgets: list[WidgetDef] = Field(default_factory=list)
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
def _placement(widget: dict[str, Any]) -> dict[str, Any]:
"""Where a stored widget sits, by the widest breakpoint it names."""
layout = widget.get("layout") or {}
for key in ("lg", "md", "sm"):
box = layout.get(key)
if isinstance(box, dict):
return dict(box)
return {}
class PageDef(BaseModel):
"""One tab of a dashboard."""
def _flatten_pages(pages: list[Any]) -> list[dict[str, Any]]:
"""The widgets of a document written as pages and sections.
id: str
title: str = ""
#: A lucide icon name, or empty.
icon: str = ""
sections: list[SectionDef] = Field(default_factory=list)
Only the first page: no UI ever wrote a second one, and a panel carries
several whole dashboards instead. Its sections are stacked into one grid
the way the viewer always drew them, so a document that placed its widgets
keeps the arrangement it had rather than piling everything at row zero.
"""
if not pages or not isinstance(pages[0], dict):
return []
sections = [s for s in (pages[0].get("sections") or []) if isinstance(s, dict)]
lists = [
[w for w in (s.get("widgets") or []) if isinstance(w, dict)] for s in sections
]
flat = [w for widgets in lists for w in widgets]
placed = any(
(_placement(w).get("x") or 0) > 0 or (_placement(w).get("y") or 0) > 0
for w in flat
)
if len(sections) < 2 or not placed:
return flat
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
stacked: list[dict[str, Any]] = []
offset = 0
for widgets in lists:
bottom = 0
for widget in widgets:
box = _placement(widget)
y = max(0, int(box.get("y") or 0))
bottom = max(bottom, y + max(1, int(box.get("h") or 2)))
if offset:
widget = {
**widget,
"layout": {
**(widget.get("layout") or {}),
"lg": {**box, "y": y + offset},
},
}
stacked.append(widget)
offset += bottom
return stacked
class DashboardDef(BaseModel):
@@ -419,7 +444,11 @@ class DashboardDef(BaseModel):
#: A lucide icon name, drawn on the panel rail; empty falls back to two
#: letters of the title.
icon: str = ""
pages: list[PageDef] = Field(default_factory=list)
#: One grid. Pages and sections were in the schema and never in the UI —
#: only the first page was ever read and its sections were drawn as one —
#: so a dashboard is its widgets, and several dashboards on one device is
#: what a panel is for.
widgets: list[WidgetDef] = Field(default_factory=list)
#: Settings the whole dashboard carries, by name — see ``SettingDef``. The
#: one channel a dashboard consumes as a dashboard rather than as a set of
#: tiles, so a screen on a wall can be told things nobody standing at it
@@ -431,6 +460,21 @@ class DashboardDef(BaseModel):
#: never stored — the draft file's existence is the only record of it.
has_draft: bool = False
@model_validator(mode="before")
@classmethod
def _flatten(cls, data: Any) -> Any:
"""Read a document written as pages and sections as one grid.
Stored dashboards live in each installation's git repository, so the
old shape is normalised on the way in rather than migrated: an
untouched document keeps working, and the next save writes it flat.
"""
if isinstance(data, dict) and "widgets" not in data and "pages" in data:
pages = data.get("pages") or []
data = {k: v for k, v in data.items() if k != "pages"}
data["widgets"] = _flatten_pages(pages)
return data
@field_validator("name")
@classmethod
def _check_name(cls, value: str) -> str:
@@ -456,10 +500,6 @@ class DashboardDef(BaseModel):
)
return self
@property
def widgets(self) -> list[WidgetDef]:
return [w for p in self.pages for s in p.sections for w in s.widgets]
@property
def setting_messages(self) -> list[str]:
"""Every message a bound setting reads. Empty for a static dashboard."""
@@ -471,7 +511,6 @@ class DashboardSummary(BaseModel):
name: str
title: str = ""
page_count: int = 0
widget_count: int = 0
has_draft: bool = False
#: Of the working copy, so publishing from a list needs no second read.
@@ -533,7 +572,6 @@ class DashboardStore:
DashboardSummary(
name=defn.name,
title=defn.title,
page_count=len(defn.pages),
widget_count=len(defn.widgets),
has_draft=defn.has_draft,
version=defn.version,
@@ -747,7 +785,6 @@ def default_dashboard(name: str) -> DashboardDef:
return DashboardDef(
name=name,
title=name.replace("_", " ").capitalize(),
pages=[PageDef(id="main", title="Overview", sections=[SectionDef(id="main")])],
)
@@ -840,13 +877,7 @@ def results_dashboard(flow: FlowDef) -> DashboardDef:
return DashboardDef(
name=results_name(flow.name),
title=f"{flow.title or flow.name} results",
pages=[
PageDef(
id="main",
title="Results",
sections=[SectionDef(id="main", widgets=widgets)],
)
],
widgets=widgets,
)
@@ -865,9 +896,7 @@ __all__ = [
"DashboardStore",
"DashboardSummary",
"DashboardsPublic",
"PageDef",
"Placement",
"SectionDef",
"SettingDef",
"WidgetDef",
"default_dashboard",