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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user