Rework the dashboards onto the flow canvas

One shell for both editors. Flows and dashboards each get a searchable
overview under the padded shell, their editors move to the full-bleed
canvas, and the floating chrome is shared: a title bar that only says
what you are looking at, and a bottom dock carrying everything else —
the flow bar's status, settings and Publish moved down there, the
add-flow button moved to the overview.

Dashboards gain the rest of M4's visualization work:

- widgets are picked by clicking them, with the header as the drag
  handle so a slider still slides and a switch still flips while
  editing; settings moved into the flows' SidePanel
- react-grid-layout for drag and edge-resize, so the stored x/y finally
  mean something; a dashboard nobody arranged is shelf-packed once
- a per-dashboard grid size, so a panel can be matched to its screen
- the chart widget, drawn with uPlot: several messages on one axis, fed
  from the stored history plus the live socket tail, coloured from the
  new --chart-1..5 ramp
- /view/{name}: the URL a wall panel is pointed at — no sidebar, no
  footer, no editing, and no editor code, since routes are split
- a widget wired to a payload type it cannot carry, or wired to nothing
  at all, carries the same red dot a failing node does; the picker
  records the type it bound and WidgetDef refuses a mismatch on save

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H7LwYgJfpkbLCTeiAf8U4A
This commit is contained in:
2026-08-16 17:13:10 +02:00
co-authored by Claude Fable 5
parent 74bb956805
commit 0af09eedbe
24 changed files with 1799 additions and 1558 deletions
+44 -1
View File
@@ -17,7 +17,7 @@ import threading
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from app.flow.schemas import _validate_name
from app.flow.store import FlowStore, StaleVersion
@@ -48,6 +48,18 @@ WidgetType = Literal[
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown"}
#: What a widget may be pointed at, by payload type. A switch that reads a
#: float has nothing to show and nothing safe to send, so the pairing belongs
#: to the document rather than to the editor that happened to write it. Types
#: missing here take anything. Mirrored in the client
#: (``frontend/src/components/Dashboard/widgets.tsx``).
WIDGET_DTYPES: dict[str, set[str]] = {
"gauge": {"float", "int"},
"chart": {"float", "int"},
"slider": {"float", "int"},
"switch": {"bool"},
}
class Placement(BaseModel):
"""Where a widget sits in its section's grid, in grid units."""
@@ -104,6 +116,33 @@ class WidgetDef(BaseModel):
points = int((self.config.get("history") or {}).get("points") or 0)
return min(points, HISTORY_CAP)
@property
def bound_dtypes(self) -> list[str]:
"""The payload types this widget was bound to, as the editor recorded.
Empty for a document written before the editor kept them, which is why
a missing type is never an error.
"""
if self.type == "chart":
return [
str(series.get("dtype") or "")
for series in self.config.get("series") or []
]
return [str(self.config.get("dtype") or "")]
@model_validator(mode="after")
def _check_binding(self) -> WidgetDef:
"""Refuse a widget wired to a message it cannot carry."""
allowed = WIDGET_DTYPES.get(self.type)
if not allowed:
return self
for dtype in self.bound_dtypes:
if dtype and dtype not in allowed:
raise ValueError(
f"a '{self.type}' widget cannot carry a '{dtype}' message"
)
return self
class SectionDef(BaseModel):
"""A grid of widgets under a heading."""
@@ -138,6 +177,9 @@ class DashboardDef(BaseModel):
name: str
title: str = ""
#: How many columns the grid is cut into, so a dashboard can be matched to
#: the panel it will hang on.
columns: int = Field(default=12, ge=1, le=48)
pages: list[PageDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
@@ -335,6 +377,7 @@ __all__ = [
"DASHBOARD_DIR",
"HISTORY_CAP",
"INPUT_WIDGETS",
"WIDGET_DTYPES",
"DashboardDef",
"DashboardExists",
"DashboardNotFound",