Dashboards as documents, and messages as something to bind to
A dashboard is its own document rather than widgets placed in a flow. Node-RED's dashboard tab is 260 nodes, about forty of them pure layout, which is exactly what the small-graph principle exists to avoid — and since the graph is already wired by message name, a widget can bind to a name without belonging to any flow. Stored beside the flows in the same repository, sharing their write lock and commit, under a directory the flow listing ignores. No draft/publish split: nothing executes a dashboard, so edit mode is its own staging area. Two things it needs from the engine. A message catalog spanning every flow, because a wall panel shows the heating next to the solar and the flow-scoped API is the wrong shape for that. And a way to put a value in without owning a node — a slider is a real value that happened to come from a person — which runs whatever consumes it and applies the same type check a node's output gets. Only a message some flow declares can be published to; flows own the namespace. Charts also need more past than the 120 points a sparkline wanted, so a chart widget declares its depth and the engine keeps that message's series that deep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""Dashboards: documents beside the flows, and the values their widgets move."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.flow.dashboards import (
|
||||
DashboardDef,
|
||||
DashboardNotFound,
|
||||
DashboardStore,
|
||||
PageDef,
|
||||
SectionDef,
|
||||
WidgetDef,
|
||||
default_dashboard,
|
||||
)
|
||||
from app.flow.messages import DType, MessageSpec
|
||||
from app.flow.nodes import Node
|
||||
from app.flow.pipeline import Pipeline
|
||||
from app.flow.state import MemoryState
|
||||
from app.flow.store import FlowStore, StaleVersion
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path) -> DashboardStore:
|
||||
return DashboardStore(FlowStore(tmp_path / "flows"))
|
||||
|
||||
|
||||
def chart(message: str, points: int) -> WidgetDef:
|
||||
return WidgetDef(
|
||||
id="temp",
|
||||
type="chart",
|
||||
config={"series": [{"message": message}], "history": {"points": points}},
|
||||
)
|
||||
|
||||
|
||||
def test_a_dashboard_survives_a_round_trip(store: DashboardStore):
|
||||
saved = store.write(default_dashboard("house"))
|
||||
|
||||
read = store.read("house")
|
||||
|
||||
assert read.name == "house"
|
||||
assert [p.id for p in read.pages] == ["main"]
|
||||
assert read.version == saved.version
|
||||
|
||||
|
||||
def test_dashboards_are_invisible_to_the_flow_listing(store: DashboardStore):
|
||||
"""They share the repository; they are not flows."""
|
||||
store.write(default_dashboard("house"))
|
||||
|
||||
assert store.flows.list_flows() == []
|
||||
assert [d.name for d in store.list()] == ["house"]
|
||||
|
||||
|
||||
def test_a_save_based_on_a_version_someone_moved_past_is_refused(
|
||||
store: DashboardStore,
|
||||
):
|
||||
first = store.write(default_dashboard("house"))
|
||||
store.write(first, first.version)
|
||||
|
||||
with pytest.raises(StaleVersion):
|
||||
store.write(first, first.version)
|
||||
|
||||
|
||||
def test_deleting_and_renaming(store: DashboardStore):
|
||||
store.write(default_dashboard("house"))
|
||||
|
||||
renamed = store.rename("house", "home")
|
||||
assert renamed.name == "home"
|
||||
assert not store.exists("house")
|
||||
|
||||
store.delete("home")
|
||||
with pytest.raises(DashboardNotFound):
|
||||
store.read("home")
|
||||
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert store.history_requirements() == {"heating.temp": 900, "solar.watts": 100}
|
||||
|
||||
|
||||
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)])
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
assert store.history_requirements() == {"heating.temp": 5000}
|
||||
|
||||
|
||||
def test_history_is_kept_to_the_depth_a_chart_asked_for():
|
||||
state = MemoryState()
|
||||
limits = {"f.temp": 300}
|
||||
|
||||
for i in range(400):
|
||||
state.append_history({"f.temp": float(i)}, float(i), limits)
|
||||
|
||||
assert len(state.history("f.temp")) == 300
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What an input widget does
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_publishing_a_value_runs_what_consumes_it():
|
||||
"""A slider is a value arriving; the graph should not care who sent it."""
|
||||
seen: list[float] = []
|
||||
|
||||
def consume(setpoint, params):
|
||||
seen.append(setpoint)
|
||||
return {"applied": setpoint}
|
||||
|
||||
node = Node(
|
||||
f=consume,
|
||||
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
|
||||
provides=[MessageSpec(name="applied", port="applied", dtype=DType.FLOAT)],
|
||||
name="thermostat",
|
||||
)
|
||||
node.assign_flow("heating", "thermostat")
|
||||
state = MemoryState()
|
||||
pipeline = Pipeline(nodes=[node], state=state)
|
||||
|
||||
pipeline.publish({"heating.setpoint": 21.5})
|
||||
|
||||
assert seen == [21.5]
|
||||
assert state["heating.applied"] == 21.5
|
||||
|
||||
|
||||
def test_publishing_nothing_does_nothing():
|
||||
pipeline = Pipeline(nodes=[], state=MemoryState())
|
||||
|
||||
pipeline.publish({})
|
||||
|
||||
assert pipeline.values() == {}
|
||||
Reference in New Issue
Block a user