Make a dashboard its widgets: drop the pages and sections nobody drew
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -243,7 +243,7 @@ def _dashboard_with(
|
||||
) -> None:
|
||||
"""A published dashboard carrying these widgets."""
|
||||
saved = _dashboard(client, headers, name)
|
||||
saved["pages"] = [{"id": "main", "sections": [{"id": "main", "widgets": widgets}]}]
|
||||
saved["widgets"] = widgets
|
||||
written = client.put(f"{DASHBOARDS}/{name}", headers=headers, json=saved)
|
||||
assert written.status_code == 200, written.text
|
||||
published = client.post(
|
||||
@@ -706,22 +706,11 @@ def test_a_panels_socket_carries_only_what_it_draws(
|
||||
)
|
||||
|
||||
saved = _dashboard(client, superuser_token_headers, "panel_socket")
|
||||
saved["pages"] = [
|
||||
saved["widgets"] = [
|
||||
{
|
||||
"id": "main",
|
||||
"title": "Overview",
|
||||
"sections": [
|
||||
{
|
||||
"id": "main",
|
||||
"widgets": [
|
||||
{
|
||||
"id": "w1",
|
||||
"type": "stat",
|
||||
"config": {"message": "house.kitchen.temperature"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"id": "w1",
|
||||
"type": "stat",
|
||||
"config": {"message": "house.kitchen.temperature"},
|
||||
}
|
||||
]
|
||||
written = client.put(
|
||||
|
||||
@@ -6,8 +6,6 @@ from fluksio.flow.dashboards import (
|
||||
DashboardDef,
|
||||
DashboardNotFound,
|
||||
DashboardStore,
|
||||
PageDef,
|
||||
SectionDef,
|
||||
SettingDef,
|
||||
WidgetDef,
|
||||
default_dashboard,
|
||||
@@ -40,7 +38,7 @@ def test_a_dashboard_survives_a_round_trip(store: DashboardStore):
|
||||
read = store.read("house")
|
||||
|
||||
assert read.name == "house"
|
||||
assert [p.id for p in read.pages] == ["main"]
|
||||
assert read.widgets == []
|
||||
assert read.version == saved.version
|
||||
|
||||
|
||||
@@ -138,20 +136,10 @@ def test_the_deepest_chart_decides_how_much_past_is_kept(store: DashboardStore):
|
||||
store.write(
|
||||
DashboardDef(
|
||||
name="house",
|
||||
pages=[
|
||||
PageDef(
|
||||
id="main",
|
||||
sections=[
|
||||
SectionDef(id="a", widgets=[chart("heating.temp", 400)]),
|
||||
SectionDef(
|
||||
id="b",
|
||||
widgets=[
|
||||
chart("heating.temp", 900),
|
||||
chart("solar.watts", 100),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
widgets=[
|
||||
chart("heating.temp", 400),
|
||||
chart("heating.temp", 900),
|
||||
chart("solar.watts", 100),
|
||||
],
|
||||
)
|
||||
)
|
||||
@@ -163,14 +151,7 @@ def test_a_chart_cannot_ask_for_an_unbounded_series(store: DashboardStore):
|
||||
store.write(
|
||||
DashboardDef(
|
||||
name="house",
|
||||
pages=[
|
||||
PageDef(
|
||||
id="main",
|
||||
sections=[
|
||||
SectionDef(id="a", widgets=[chart("heating.temp", 10**9)])
|
||||
],
|
||||
)
|
||||
],
|
||||
widgets=[chart("heating.temp", 10**9)],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -514,3 +495,49 @@ def test_a_chart_of_runs_must_say_which_runs_and_which_metric():
|
||||
runs_chart(metric="study.loss")
|
||||
with pytest.raises(ValueError, match="between 1 and 5"):
|
||||
runs_chart(metric="study.loss", flow="study", latest=9)
|
||||
|
||||
|
||||
def test_a_document_written_as_pages_is_read_as_one_grid():
|
||||
"""Stored dashboards live in each installation's repository.
|
||||
|
||||
So the old shape is normalised on the way in rather than migrated, and a
|
||||
placed second section keeps its arrangement instead of piling onto the
|
||||
first — which is how the viewer always drew it.
|
||||
"""
|
||||
old = {
|
||||
"name": "house",
|
||||
"pages": [
|
||||
{
|
||||
"id": "main",
|
||||
"sections": [
|
||||
{
|
||||
"id": "a",
|
||||
"widgets": [
|
||||
{
|
||||
"id": "top",
|
||||
"type": "stat",
|
||||
"layout": {"lg": {"x": 0, "y": 0, "w": 3, "h": 2}},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "b",
|
||||
"widgets": [
|
||||
{
|
||||
"id": "under",
|
||||
"type": "stat",
|
||||
"layout": {"lg": {"x": 0, "y": 1, "w": 3, "h": 2}},
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
read = DashboardDef.model_validate(old)
|
||||
|
||||
assert [w.id for w in read.widgets] == ["top", "under"]
|
||||
# The first section is two rows deep, so the second one starts under it.
|
||||
assert read.widgets[1].layout["lg"].y == 3
|
||||
assert "pages" not in read.model_dump()
|
||||
|
||||
@@ -8,8 +8,6 @@ control or another flow, it pulses a node that did nothing at all.
|
||||
from fluksio.flow.dashboards import (
|
||||
DashboardDef,
|
||||
DashboardStore,
|
||||
PageDef,
|
||||
SectionDef,
|
||||
WidgetDef,
|
||||
)
|
||||
from fluksio.flow.events import EventBus
|
||||
@@ -104,35 +102,25 @@ def test_the_widgets_wired_into_a_flow_are_reported(tmp_path):
|
||||
DashboardDef(
|
||||
name="panel",
|
||||
title="Panel",
|
||||
pages=[
|
||||
PageDef(
|
||||
id="main",
|
||||
sections=[
|
||||
SectionDef(
|
||||
id="main",
|
||||
widgets=[
|
||||
WidgetDef(
|
||||
id="setpoint",
|
||||
type="slider",
|
||||
title="Setpoint",
|
||||
config={"target": "house.setpoint"},
|
||||
),
|
||||
WidgetDef(
|
||||
id="reading",
|
||||
type="stat",
|
||||
title="Reading",
|
||||
config={"message": "house.temp"},
|
||||
),
|
||||
# Another flow's message: not this flow's business.
|
||||
WidgetDef(
|
||||
id="elsewhere",
|
||||
type="stat",
|
||||
config={"message": "garage.temp"},
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
widgets=[
|
||||
WidgetDef(
|
||||
id="setpoint",
|
||||
type="slider",
|
||||
title="Setpoint",
|
||||
config={"target": "house.setpoint"},
|
||||
),
|
||||
WidgetDef(
|
||||
id="reading",
|
||||
type="stat",
|
||||
title="Reading",
|
||||
config={"message": "house.temp"},
|
||||
),
|
||||
# Another flow's message: not this flow's business.
|
||||
WidgetDef(
|
||||
id="elsewhere",
|
||||
type="stat",
|
||||
config={"message": "garage.temp"},
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user