flow: structured dtypes, and the widgets that read them

A series, record or list message declares its shape instead of riding
DType.JSON, so a widget binds a shape rather than some JSON and a wrong
binding is refused before anything runs. A list declares its item type,
which is what keeps list[float] expressible for a pipeline.

On top of that: an agenda over a list, a notification over a record, and
a dashboard alert channel that publishes engine faults as one — so a
panel can show what went wrong without a flow wiring it by hand.

Also: only None means a node published nothing, a falsy value of the
wrong shape is now the named error it always should have been; and the
gauge's readout says its size is viewBox geometry rather than type scale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 15:06:45 +02:00
co-authored by Claude Opus 5
parent 18837e8880
commit 413501c6ce
16 changed files with 751 additions and 43 deletions
+24 -1
View File
@@ -16,6 +16,7 @@ import asyncio
import logging
import time
from collections import deque
from collections.abc import Callable
from typing import Any, Literal
import httpx
@@ -51,9 +52,10 @@ class Channel(BaseModel):
"""Somewhere to send an alert."""
name: str
kind: Literal["ntfy", "smtp", "webhook"]
kind: Literal["ntfy", "smtp", "webhook", "dashboard"]
enabled: bool = True
# ntfy: server + topic + optional token. smtp: to. webhook: url.
# dashboard: message — the record a notification widget reads.
config: dict[str, Any] = Field(default_factory=dict)
@@ -160,6 +162,10 @@ class AlertManager:
self._events = events
self.config = config or AlertsConfig()
self._now = now
#: How a "dashboard" channel puts its alert into the graph. Bound after
#: construction, because the controller that publishes does not exist
#: yet when the manager is built.
self.publish: Callable[[str, Any], None] | None = None
self._last_sent: dict[str, float] = {}
self._suppressed: dict[str, int] = {}
self._health_flips: dict[str, deque[float]] = {}
@@ -294,6 +300,8 @@ class AlertManager:
await self._send_ntfy(config, alert)
elif channel.kind == "webhook":
await self._send_webhook(config, alert)
elif channel.kind == "dashboard":
await self._send_dashboard(config, alert)
else:
await self._send_email(config, alert)
except Exception as exc:
@@ -319,6 +327,21 @@ class AlertManager:
)
response.raise_for_status()
async def _send_dashboard(self, config: dict[str, Any], alert: Alert) -> None:
"""Put the alert into the graph, where a notification widget shows it.
The message has to be one a flow declares, like any other a dashboard
writes to — so a panel that shows engine faults says so in a flow
rather than appearing from nowhere.
"""
message = str(config.get("message") or "")
if not message:
raise ValueError("a dashboard channel needs a message name")
if self.publish is None:
raise RuntimeError("nothing is wired up to publish this")
# Blocking: it reads the store to find the declared message.
await asyncio.to_thread(self.publish, message, alert.model_dump())
async def _send_webhook(self, config: dict[str, Any], alert: Alert) -> None:
url = config.get("url")
if not url:
+43 -3
View File
@@ -40,6 +40,8 @@ WidgetType = Literal[
"gauge",
"chart",
"markdown",
"agenda",
"notification",
# Input
"button",
"switch",
@@ -57,9 +59,13 @@ INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown"}
#: (``frontend/src/components/Dashboard/widgets.tsx``).
WIDGET_DTYPES: dict[str, set[str]] = {
"gauge": {"float", "int"},
# A chart reading the engine's ring. One that queries binds a `series`
# answer and a `record` request instead, checked separately below.
"chart": {"float", "int"},
"slider": {"float", "int"},
"switch": {"bool"},
"agenda": {"list"},
"notification": {"record"},
}
@@ -78,6 +84,13 @@ class WidgetDef(BaseModel):
``config`` is per type — a chart names its series, a button names the
message it publishes — and is validated against the type below rather than
by a schema per class, because the whole set is small and closed.
A chart comes in two kinds. The default reads what the engine kept for a
message. One with ``source: "query"`` asks instead, and its config is
``{source, request, request_dtype: "record", message, dtype: "series",
refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
``request`` exactly as a slider publishes a value, and draws the ``series``
a flow answers with on ``message``.
"""
id: str
@@ -93,9 +106,17 @@ class WidgetDef(BaseModel):
def _check_id(cls, value: str) -> str:
return _validate_name(value)
@property
def _query_chart(self) -> bool:
"""A chart that asks a flow for its series instead of reading the ring."""
return self.type == "chart" and self.config.get("source") == "query"
@property
def messages(self) -> list[str]:
"""Every message name this widget reads."""
if self._query_chart:
name = self.config.get("message")
return [str(name)] if name else []
if self.type == "chart":
return [
str(series.get("message"))
@@ -107,13 +128,23 @@ class WidgetDef(BaseModel):
@property
def target(self) -> str:
"""The message this widget publishes, if it is an input."""
"""The message this widget publishes, if it is an input.
A querying chart is one too: its request is a value it puts into the
graph, so the canvas draws it as an endpoint like any other control.
"""
if self._query_chart:
return str(self.config.get("request") or "")
return str(self.config.get("target") or "")
@property
def history_points(self) -> int:
"""How much past this widget needs kept for it."""
if self.type != "chart":
"""How much past this widget needs kept for it.
Nothing, for a chart that queries: the answer carries its own past, so
asking the engine to keep a ring as well would store it twice.
"""
if self.type != "chart" or self._query_chart:
return 0
points = int((self.config.get("history") or {}).get("points") or 0)
return min(points, HISTORY_CAP)
@@ -135,6 +166,15 @@ class WidgetDef(BaseModel):
@model_validator(mode="after")
def _check_binding(self) -> WidgetDef:
"""Refuse a widget wired to a message it cannot carry."""
if self._query_chart:
for key, want in (("dtype", "series"), ("request_dtype", "record")):
bound = str(self.config.get(key) or "")
if bound and bound != want:
raise ValueError(
f"a querying chart's {key} must be '{want}', not '{bound}'"
)
return self
allowed = WIDGET_DTYPES.get(self.type)
if not allowed:
return self
+105 -12
View File
@@ -20,6 +20,11 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
class DType(str, Enum):
"""Serializable payload types.
The scalars carry what a single reading can say. The three structured ones
are declared shapes rather than "some JSON": a widget or a downstream node
knows what it is getting before anything runs, which is what lets the
dashboard picker offer a message and refuse a wrong binding.
Binary payloads (tensors, images) will arrive later as explicitly declared
codec fields; until then everything on the wire is JSON.
"""
@@ -29,10 +34,78 @@ class DType(str, Enum):
STR = "str"
BOOL = "bool"
JSON = "json"
#: ``{"lines": [{"label": str, "points": [[ts, value], ...]}], ...}``.
#: Keys beside ``lines`` are carried through untouched — a chart's query
#: puts the range and interval it asked for there and reads them back.
SERIES = "series"
#: Flat named scalars: ``{"title": "Boiler", "severity": "error"}``.
RECORD = "record"
#: Ordered items of one declared shape; see :attr:`MessageSpec.item`.
LIST = "list"
_JSON_TYPES = (dict, list, str, int, float, bool, type(None))
#: What a record may hold. Nesting is deliberately out: a record that can
#: contain a record is a schema language, and the shape stops being readable
#: from the declaration alone.
_SCALARS = (str, int, float, bool)
#: Item types a list may declare. Recursion is refused for the same reason.
_ITEM_TYPES = frozenset(
{DType.FLOAT, DType.INT, DType.STR, DType.BOOL, DType.JSON, DType.RECORD}
)
def _is_number(value: Any) -> bool:
"""A measurement. bool is an int subclass; a flag is not a number here."""
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _is_record(value: Any) -> bool:
return isinstance(value, dict) and all(
isinstance(key, str) and (item is None or isinstance(item, _SCALARS))
for key, item in value.items()
)
def _is_series(value: Any) -> bool:
"""Labelled lines of ``(timestamp, value)`` pairs.
Checked all the way down. That is O(n) in the number of points, but so is
the JSON encoding every message already pays for.
"""
if not isinstance(value, dict) or not isinstance(value.get("lines"), list):
return False
return all(
isinstance(line, dict)
and isinstance(line.get("label"), str)
and isinstance(line.get("points"), list)
and all(
isinstance(point, (list, tuple))
and len(point) == 2
and _is_number(point[0])
and _is_number(point[1])
for point in line["points"]
)
for line in value["lines"]
)
def _matches(dtype: DType, value: Any) -> bool:
"""Whether one value satisfies a scalar or record type."""
if dtype is DType.BOOL:
return isinstance(value, bool)
if dtype is DType.INT:
return isinstance(value, int) and not isinstance(value, bool)
if dtype is DType.FLOAT:
return _is_number(value)
if dtype is DType.STR:
return isinstance(value, str)
if dtype is DType.RECORD:
return _is_record(value)
return isinstance(value, _JSON_TYPES)
class MessageSpec(BaseModel):
"""A single port of a node, and the message it is bound to.
@@ -42,6 +115,10 @@ class MessageSpec(BaseModel):
:param port: The identifier the node function sees. Defaults to the last
segment of ``name``, so unqualified flows read naturally.
:param dtype: Payload type, validated on every message that passes through.
:param item: The type of each item of a ``list`` port, ignored otherwise.
Unset means ``record``, which is what the agenda and forecast widgets
read; ``float`` is the numeric list a pipeline passes around. A list of
lists, or of series, is refused — one declared level is the point.
:param interval: Deliver at most every this many seconds; 0 is every time.
On an output it holds back publishing, on an input it holds back waking
the node. The value is never lost — state keeps the latest — only the
@@ -57,6 +134,7 @@ class MessageSpec(BaseModel):
name: str = ""
port: str = ""
dtype: DType = DType.FLOAT
item: DType | None = None
interval: float = Field(default=0, ge=0)
trigger: bool = True
@@ -64,25 +142,36 @@ class MessageSpec(BaseModel):
def _default_port(self) -> MessageSpec:
if not self.port and self.name:
object.__setattr__(self, "port", self.name.rsplit(".", 1)[-1])
if self.item is not None and self.item not in _ITEM_TYPES:
raise ValueError(f"a list cannot hold '{self.item.value}' items")
return self
@property
def item_dtype(self) -> DType:
"""What each item of a ``list`` port is, declared or defaulted."""
return self.item or DType.RECORD
def check(self, value: Any) -> None:
"""Raise if ``value`` does not match this port's declared type."""
if self.dtype is DType.BOOL:
ok = isinstance(value, bool)
elif self.dtype is DType.INT:
# bool is an int subclass; a flag is not a number here.
ok = isinstance(value, int) and not isinstance(value, bool)
elif self.dtype is DType.FLOAT:
ok = isinstance(value, (int, float)) and not isinstance(value, bool)
elif self.dtype is DType.STR:
ok = isinstance(value, str)
where = self.name or self.port
if self.dtype is DType.SERIES:
ok = _is_series(value)
elif self.dtype is DType.LIST:
if not isinstance(value, list):
raise TypeError(f"{where}: expected list, got {type(value).__name__}")
item = self.item_dtype
for index, element in enumerate(value):
if not _matches(item, element):
raise TypeError(
f"{where}: expected list[{item.value}], got "
f"{type(element).__name__} at index {index}"
)
return
else:
ok = isinstance(value, _JSON_TYPES)
ok = _matches(self.dtype, value)
if not ok:
raise TypeError(
f"{self.name or self.port}: expected {self.dtype.value}, "
f"got {type(value).__name__}"
f"{where}: expected {self.dtype.value}, got {type(value).__name__}"
)
def coerce(self, value: Any) -> Any:
@@ -101,6 +190,10 @@ class MessageSpec(BaseModel):
return str(value).lower() in ("true", "1", "yes", "on")
if self.dtype is DType.STR:
return value if isinstance(value, str) else json.dumps(value)
if self.dtype in (DType.SERIES, DType.RECORD, DType.LIST):
# A structured payload arriving as text is the same hint a numeric
# one is; the shape itself is still checked afterwards.
return json.loads(value) if isinstance(value, str) else value
return value
def __repr__(self) -> str:
+9 -3
View File
@@ -271,13 +271,19 @@ class Node:
return kwargs
def _to_messages(self, retval: Any) -> dict[str, Any] | None:
"""Map a function's port-keyed return value onto message names."""
if not retval:
"""Map a function's port-keyed return value onto message names.
Only ``None`` means "nothing to publish". A falsy value of the wrong
shape — ``0``, ``""``, an empty list — is a mistake worth naming rather
than silence someone has to debug from an empty canvas.
"""
if retval is None:
return None
if not isinstance(retval, dict):
raise NodeOutputError(
f"'{self.local_id}' returned {type(retval).__name__}. Outputs are "
"keyed by port, so return a dict like {'out': value}, or None."
"keyed by port, so return a dict like {'out': value}, or None to "
"publish nothing."
)
by_port = {s.port: s for s in self.output_ports if s.name}
outputs = {}
+82 -5
View File
@@ -29,6 +29,22 @@ class InfluxDbNode(Node):
Both operations use a similar configuration pattern in params, making the
API consistent and the input/output data simple (just values).
- **Query passthrough**: an incoming message holding a ``flux`` key is run
as written, and every row comes back on the first output port as
``{"rows": [{ts, value, field, measurement, tags}], **echo}``.
The passthrough is what keeps a database node a database node. It holds the
credentials and the connection and nothing else: building a query and
shaping its rows are ordinary Python nodes on either side, so a dashboard
widget never learns which database answered it. A chart drawing an
InfluxDB series is::
[chart] -record-> [py: build] -record{flux, ...}-> [influx]
[chart] <-series- [py: parse] <-json{rows, ...}---'
Nothing rate-limits the requests here; an ``interval`` on the request port
is what holds back a caller asking too often.
:param requires: Messages to write to InfluxDB. The actual value from each
message is written according to the corresponding config in ``writes``.
:type requires: MessageSpec | list[MessageSpec] | None
@@ -208,10 +224,13 @@ class InfluxDbNode(Node):
def _handler(self, params: dict[str, Any], **kwargs: Any) -> dict[str, Any] | None:
"""
Handle incoming data - write to InfluxDB and optionally query.
Handle incoming data - write to InfluxDB, run a query, or both.
This method is called when upstream dependencies (requires) are satisfied.
It writes the incoming data to InfluxDB and can also perform reads.
An incoming dict carrying a ``flux`` key is a query request rather than
something to store: it is executed as written and answered on the first
output port. Everything else on the inputs is written as points.
:param params: Node parameters.
:type params: dict
@@ -220,9 +239,24 @@ class InfluxDbNode(Node):
:returns: Query results if provides is configured, None otherwise.
:rtype: dict | None
"""
# Write incoming data
if kwargs:
self._write_points(kwargs)
# ponytail: one request/answer pair per node — the first input holding
# a "flux" key is the request and the answer goes out on the first
# output port. A second query stream means a second node.
request = next(
(v for v in kwargs.values() if isinstance(v, dict) and "flux" in v), None
)
writes = {k: v for k, v in kwargs.items() if v is not request}
if writes:
self._write_points(writes)
if request is not None:
if not self.output_ports:
raise ValueError(
f"Node '{self.name}' was sent a query but has no output to "
"answer on"
)
return {self.output_ports[0].port: self._run_flux(request)}
# If we have provides, perform queries
if self.provides:
@@ -230,6 +264,49 @@ class InfluxDbNode(Node):
return None
def _run_flux(self, request: dict[str, Any]) -> dict[str, Any]:
"""
Execute a Flux query exactly as it was handed over, and answer its rows.
The node stays out of the query's business: building it is the job of
whatever produced the request, and shaping the rows into something a
widget draws is the job of whatever reads the answer. Every field of
the request except ``flux`` is echoed back untouched, which is how a
caller tells its own answer from someone else's.
:param request: The query and whatever the caller wants echoed.
:type request: dict
:returns: ``{"rows": [...], **echo}``.
:rtype: dict
"""
from influxdb_client import InfluxDBClient
flux = str(request["flux"])
echo = {key: value for key, value in request.items() if key != "flux"}
logger.info("Running Flux for node '%s': %s", self.name, flux)
with InfluxDBClient(url=self.url, token=self.token, org=self.org) as client:
tables = client.query_api().query(flux, org=self.org)
rows = [
{
"ts": record.get_time().timestamp() if record.get_time() else None,
"value": record.get_value(),
# Read off `values` rather than the getters: a query that keeps
# only what it needs drops these columns, and the getters raise.
"field": record.values.get("_field"),
"measurement": record.values.get("_measurement"),
"tags": {
key: value
for key, value in record.values.items()
if not key.startswith("_") and key not in ("result", "table")
},
}
for table in tables
for record in table.records
]
return {"rows": rows, **echo}
def _write_points(self, data: dict[str, Any]) -> None:
"""
Write data points to InfluxDB using configuration from ``writes``.
+6
View File
@@ -22,6 +22,7 @@ from app.flow.events import event_bus
from app.flow.executor import ExecutionService
from app.flow.metrics import MetricsCollector
from app.flow.nodes.http import close_shared_client
from app.flow.pipeline import ValueSource
from app.flow.plugins import load_plugins
from app.flow.queue import MemoryWorkQueue, RedisWorkQueue, WorkQueue
from app.flow.secrets import init_secrets
@@ -98,6 +99,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
workers=pool,
)
app.state.flow_controller = controller
# A "dashboard" alert channel puts its alert into the graph. Bound here
# rather than passed in: the manager is built before the controller is.
alerts.publish = lambda name, value: controller.publish_message(
name, value, ValueSource(kind="api", id="alerts", label="Alerts")
)
dashboards = DashboardStore(controller.store)
app.state.dashboard_store = dashboards
controller.dashboards = dashboards
+33
View File
@@ -197,3 +197,36 @@ def test_alerting_can_be_switched_off():
def test_every_alerting_event_reads_as_a_sentence(event, expected):
alert = describe(event)
assert (alert.title if alert else None) == expected
def test_a_dashboard_channel_publishes_the_alert_as_a_record():
published: list[tuple[str, dict]] = []
config = AlertsConfig(
channels=[
Channel(name="panel", kind="dashboard", config={"message": "house.notice"})
],
rules=[Rule(events=[], channels=["panel"])],
)
alerts = AlertManager(EventBus(), config=config, now=Clock())
alerts.publish = lambda name, value: published.append((name, value))
asyncio.run(alerts.handle(error_event()))
assert len(published) == 1
name, record = published[0]
assert name == "house.notice"
# Flat named scalars, which is what a notification widget binds.
assert record["title"] == "heating.pump failed"
assert record["severity"] == "error"
assert all(isinstance(v, str) for v in record.values())
def test_a_dashboard_channel_without_a_message_says_so():
alerts = AlertManager(EventBus(), now=Clock())
alerts.publish = lambda name, value: None
channel = Channel(name="panel", kind="dashboard")
with pytest.raises(ValueError):
asyncio.run(
alerts.send(channel, Alert(title="t", body="b"), raise_on_error=True)
)
+61
View File
@@ -187,3 +187,64 @@ def test_publishing_nothing_does_nothing():
pipeline.publish({})
assert pipeline.values() == {}
def query_chart(**config) -> dict:
return {
"source": "query",
"request": "heating.chart_req",
"request_dtype": "record",
"message": "heating.chart_series",
"dtype": "series",
"refresh_s": 30,
**config,
}
def test_a_widget_refuses_a_dtype_it_cannot_carry():
WidgetDef(id="s", type="switch", config={"message": "a.b", "dtype": "bool"})
# A document written before the editor recorded types binds anything.
WidgetDef(id="s", type="switch", config={"message": "a.b"})
with pytest.raises(ValueError):
WidgetDef(id="s", type="switch", config={"message": "a.b", "dtype": "float"})
def test_the_structured_widgets_bind_their_shapes():
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "list"})
WidgetDef(id="n", type="notification", config={"message": "a.b", "dtype": "record"})
with pytest.raises(ValueError):
WidgetDef(id="a", type="agenda", config={"message": "a.b", "dtype": "json"})
with pytest.raises(ValueError):
WidgetDef(
id="n", type="notification", config={"message": "a.b", "dtype": "list"}
)
def test_a_querying_chart_asks_with_a_record_and_draws_a_series():
widget = WidgetDef(id="c", type="chart", config=query_chart())
# It publishes its request and reads the answer, so the canvas draws both.
assert widget.target == "heating.chart_req"
assert widget.messages == ["heating.chart_series"]
with pytest.raises(ValueError):
WidgetDef(id="c", type="chart", config=query_chart(dtype="float"))
with pytest.raises(ValueError):
WidgetDef(id="c", type="chart", config=query_chart(request_dtype="json"))
def test_a_querying_chart_keeps_no_ring():
"""The answer carries its own past; a ring would store it twice."""
widget = WidgetDef(
id="c", type="chart", config=query_chart(history={"points": 900})
)
assert widget.history_points == 0
def test_a_dashboard_is_cut_into_a_sane_number_of_columns():
for columns in (0, 49):
with pytest.raises(ValueError):
DashboardDef(name="house", columns=columns)
+61
View File
@@ -36,6 +36,67 @@ def test_json_dtype_round_trips():
assert json.loads(json.dumps(value)) == value
def test_record_takes_flat_scalars_only():
spec = MessageSpec(name="notice", dtype=DType.RECORD)
spec.check({"title": "Boiler", "count": 3, "hot": True, "detail": None})
for wrong in ({"a": {"nested": 1}}, {"a": [1]}, [], "x"):
with pytest.raises(TypeError):
spec.check(wrong)
def test_series_carries_lines_and_whatever_else_the_answer_echoes():
spec = MessageSpec(name="temps", dtype=DType.SERIES)
spec.check(
{
"range_s": 3600,
"interval_s": 60,
"lines": [{"label": "living", "points": [[1.0, 21.5], [2.0, 21.6]]}],
}
)
spec.check({"lines": []})
for wrong in (
{"lines": [{"label": "a", "points": [[1.0, 2.0, 3.0]]}]},
{"lines": [{"label": "a", "points": [["1", 2.0]]}]},
{"lines": [{"label": 1, "points": []}]},
{"lines": {}},
{},
):
with pytest.raises(TypeError):
spec.check(wrong)
def test_list_items_follow_the_declared_shape():
records = MessageSpec(name="agenda", dtype=DType.LIST)
records.check([{"title": "Dentist", "ts": 1.0}])
records.check([])
with pytest.raises(TypeError):
records.check([1.0])
numbers = MessageSpec(name="window", dtype=DType.LIST, item=DType.FLOAT)
numbers.check([21.4, 2])
for wrong in ([True], ["1"], 1.0):
with pytest.raises(TypeError):
numbers.check(wrong)
def test_the_failing_item_is_named():
spec = MessageSpec(name="window", dtype=DType.LIST, item=DType.FLOAT)
with pytest.raises(TypeError, match="index 1"):
spec.check([1.0, "x"])
def test_a_list_holds_one_declared_level():
for item in (DType.LIST, DType.SERIES):
with pytest.raises(ValueError):
MessageSpec(name="x", dtype=DType.LIST, item=item)
def test_coerce_parses_structured_text():
spec = MessageSpec(name="notice", dtype=DType.RECORD)
assert spec.coerce('{"title": "Boiler"}') == {"title": "Boiler"}
assert spec.coerce({"title": "Boiler"}) == {"title": "Boiler"}
def test_coerce_from_text():
assert MessageSpec(name="a", dtype=DType.FLOAT).coerce("2.5") == 2.5
assert MessageSpec(name="a", dtype=DType.INT).coerce("7") == 7
+57
View File
@@ -133,3 +133,60 @@ def test_every_offered_type_has_a_fixture():
if info.type != "python" and not info.plugin
}
assert offered == set(FIXTURES)
def test_a_flux_request_is_run_rather_than_written(monkeypatch):
"""A query passing through is not a point to store."""
from app.flow.nodes import InfluxDbNode
node = InfluxDbNode(
requires=[
MessageSpec(name="query", dtype=DType.RECORD),
MessageSpec(name="temp", dtype=DType.FLOAT),
],
provides=[MessageSpec(name="answer", dtype=DType.JSON)],
params={"url": "http://influx", "token": "t", "org": "o", "bucket": "b"},
)
# The class, not the instance: the node types use __slots__.
written: list[dict] = []
monkeypatch.setattr(
InfluxDbNode, "_write_points", lambda self, data: written.append(data)
)
monkeypatch.setattr(
InfluxDbNode,
"_run_flux",
lambda self, request: {
"rows": [],
**{key: value for key, value in request.items() if key != "flux"},
},
)
out = node.execute(
{
"query": {"flux": 'from(bucket: "b")', "range_s": 3600},
"temp": 21.5,
}
)
# The reading is stored, the query is not.
assert written == [{"temp": 21.5}]
# The answer carries back what the caller asked for.
assert out == {"answer": {"rows": [], "range_s": 3600}}
def test_a_falsy_return_is_a_mistake_not_silence():
"""Only None means "nothing to publish"."""
from app.flow.nodes import Node
from app.flow.nodes.base import NodeOutputError
def make(retval):
return Node(
f=lambda params: retval,
provides=[MessageSpec(name="out", dtype=DType.FLOAT)],
)
assert make(None).execute({}) is None
assert make({}).execute({}) is None
for wrong in ([], 0, ""):
with pytest.raises(NodeOutputError):
make(wrong).execute({})
+30 -4
View File
@@ -298,7 +298,7 @@ export const ChannelSchema = {
},
kind: {
type: 'string',
enum: ['ntfy', 'smtp', 'webhook'],
enum: ['ntfy', 'smtp', 'webhook', 'dashboard'],
title: 'Kind'
},
enabled: {
@@ -320,10 +320,15 @@ export const ChannelSchema = {
export const DTypeSchema = {
type: 'string',
enum: ['float', 'int', 'str', 'bool', 'json'],
enum: ['float', 'int', 'str', 'bool', 'json', 'series', 'record', 'list'],
title: 'DType',
description: `Serializable payload types.
The scalars carry what a single reading can say. The three structured ones
are declared shapes rather than "some JSON": a widget or a downstream node
knows what it is getting before anything runs, which is what lets the
dashboard picker offer a message and refuse a wrong binding.
Binary payloads (tensors, images) will arrive later as explicitly declared
codec fields; until then everything on the wire is JSON.`
} as const;
@@ -1151,6 +1156,16 @@ export const MessageSpecSchema = {
'$ref': '#/components/schemas/DType',
default: 'float'
},
item: {
anyOf: [
{
'$ref': '#/components/schemas/DType'
},
{
type: 'null'
}
]
},
interval: {
type: 'number',
minimum: 0,
@@ -1172,6 +1187,10 @@ export const MessageSpecSchema = {
:param port: The identifier the node function sees. Defaults to the last
segment of \`\`name\`\`, so unqualified flows read naturally.
:param dtype: Payload type, validated on every message that passes through.
:param item: The type of each item of a \`\`list\`\` port, ignored otherwise.
Unset means \`\`record\`\`, which is what the agenda and forecast widgets
read; \`\`float\`\` is the numeric list a pipeline passes around. A list of
lists, or of series, is refused — one declared level is the point.
:param interval: Deliver at most every this many seconds; 0 is every time.
On an output it holds back publishing, on an input it holds back waking
the node. The value is never lost — state keeps the latest — only the
@@ -2497,7 +2516,7 @@ export const WidgetDefSchema = {
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'button', 'switch', 'slider', 'input', 'dropdown'],
enum: ['stat', 'gauge', 'chart', 'markdown', 'agenda', 'notification', 'button', 'switch', 'slider', 'input', 'dropdown'],
title: 'Type'
},
title: {
@@ -2525,7 +2544,14 @@ export const WidgetDefSchema = {
\`\`config\`\` is per type — a chart names its series, a button names the
message it publishes — and is validated against the type below rather than
by a schema per class, because the whole set is small and closed.`
by a schema per class, because the whole set is small and closed.
A chart comes in two kinds. The default reads what the engine kept for a
message. One with \`\`source: "query"\`\` asks instead, and its config is
\`\`{source, request, request_dtype: "record", message, dtype: "series",
refresh_s, range_s}\`\`: it publishes \`\`{range_s, interval_s}\`\` to
\`\`request\`\` exactly as a slider publishes a value, and draws the \`\`series\`\`
a flow answers with on \`\`message\`\`.`
} as const;
export const app__api__routes__dashboards__PublishRequestSchema = {
+22 -5
View File
@@ -120,14 +120,14 @@ export type BrainNode = {
*/
export type Channel = {
name: string;
kind: 'ntfy' | 'smtp' | 'webhook';
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
enabled?: boolean;
config?: {
[key: string]: unknown;
};
};
export type kind = 'ntfy' | 'smtp' | 'webhook';
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
/**
* A dashboard as stored, and as the API hands it over.
@@ -185,10 +185,15 @@ export type DeadLetter = {
/**
* Serializable payload types.
*
* The scalars carry what a single reading can say. The three structured ones
* are declared shapes rather than "some JSON": a widget or a downstream node
* knows what it is getting before anything runs, which is what lets the
* dashboard picker offer a message and refuse a wrong binding.
*
* Binary payloads (tensors, images) will arrive later as explicitly declared
* codec fields; until then everything on the wire is JSON.
*/
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json';
export type DType = 'float' | 'int' | 'str' | 'bool' | 'json' | 'series' | 'record' | 'list';
/**
* Something wired into this flow that is not a node in it.
@@ -390,6 +395,10 @@ export type MessagePoints = {
* :param port: The identifier the node function sees. Defaults to the last
* segment of ``name``, so unqualified flows read naturally.
* :param dtype: Payload type, validated on every message that passes through.
* :param item: The type of each item of a ``list`` port, ignored otherwise.
* Unset means ``record``, which is what the agenda and forecast widgets
* read; ``float`` is the numeric list a pipeline passes around. A list of
* lists, or of series, is refused — one declared level is the point.
* :param interval: Deliver at most every this many seconds; 0 is every time.
* On an output it holds back publishing, on an input it holds back waking
* the node. The value is never lost — state keeps the latest — only the
@@ -403,6 +412,7 @@ export type MessageSpec = {
name?: string;
port?: string;
dtype?: DType;
item?: (DType | null);
interval?: number;
trigger?: boolean;
};
@@ -764,10 +774,17 @@ export type ValidationResult = {
* ``config`` is per type — a chart names its series, a button names the
* message it publishes — and is validated against the type below rather than
* by a schema per class, because the whole set is small and closed.
*
* A chart comes in two kinds. The default reads what the engine kept for a
* message. One with ``source: "query"`` asks instead, and its config is
* ``{source, request, request_dtype: "record", message, dtype: "series",
* refresh_s, range_s}``: it publishes ``{range_s, interval_s}`` to
* ``request`` exactly as a slider publishes a value, and draws the ``series``
* a flow answers with on ``message``.
*/
export type WidgetDef = {
id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
title?: string;
layout?: {
[key: string]: Placement;
@@ -777,7 +794,7 @@ export type WidgetDef = {
};
};
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'agenda' | 'notification' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type AlertsReadAlertsConfigResponse = (AlertsConfig);
+20 -7
View File
@@ -58,6 +58,8 @@ export function UplotChart({
labels,
plots,
empty = "Nothing has come through yet.",
unit,
yRange,
onCursor,
onSelect,
}: {
@@ -66,6 +68,10 @@ export function UplotChart({
/** The points of each series, in the same order as `labels`. */
plots: HistoryPoint[][]
empty?: string
/** Written after every reading, on the axis and in the legend. */
unit?: string
/** A y axis fixed to these bounds; unset lets it follow the data. */
yRange?: [number, number]
/** The x value under the pointer, and null once it leaves the plot. */
onCursor?: (ts: number | null) => void
/** The x value clicked, or null for a click that landed on no point. */
@@ -85,8 +91,9 @@ export function UplotChart({
const points = plots.reduce((total, plot) => total + plot.length, 0)
// The identity of the series set: the chart is rebuilt when it changes,
// while a new reading only sets its data.
const key = labels.join(" ")
// while a new reading only sets its data. The unit and the fixed range are
// part of it — both are baked into the axes when the chart is built.
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}`
// uPlot leaves its axes half-initialised while the scales have no range, and
// a resize in that window (a card still settling, say) draws them anyway and
// throws. Waiting for the first reading avoids the state altogether.
@@ -139,16 +146,21 @@ export function UplotChart({
},
],
},
scales: { x: { time: true } },
scales: {
x: { time: true },
...(yRange ? { y: { range: yRange } } : {}),
},
axes: [
{ ...axis, size: 28 },
{
...axis,
size: 46,
// The unit is written after every tick, so the gutter widens to
// hold it rather than clipping the number in front of it.
size: unit ? 62 : 46,
// The gutter has a fixed width, so a grouped "15,000" would be
// clipped to something that reads as a different number entirely.
values: (_self: uPlot, ticks: number[]) =>
ticks.map((value) => si(value)),
ticks.map((value) => (unit ? `${si(value)} ${unit}` : si(value))),
},
],
series: [
@@ -160,8 +172,9 @@ export function UplotChart({
// rebuilt chart.
stroke: () => seriesColor(index),
// The cursor readout is what decides how wide the legend gets, so
// it is shortened here and the unit named in the card's title.
value: (_self: uPlot, raw: number) => si(raw),
// it is shortened here; a named unit is short enough to keep.
value: (_self: uPlot, raw: number) =>
unit ? `${si(raw)} ${unit}` : si(raw),
// Series arrive on their own clocks; a joined table is mostly
// holes, and a line with a hole per point is not a line.
spanGaps: true,
+155 -1
View File
@@ -43,9 +43,13 @@ export type WidgetKind = WidgetDef["type"]
*/
export const WIDGET_DTYPES: Partial<Record<WidgetKind, string[]>> = {
gauge: ["float", "int"],
// A chart reading the engine's ring. One that queries binds a `series`
// answer and a `record` request instead, checked in `widgetIssue`.
chart: ["float", "int"],
slider: ["float", "int"],
switch: ["bool"],
agenda: ["list"],
notification: ["record"],
}
/** Whether a message of this payload type may drive this kind of widget. */
@@ -59,6 +63,8 @@ export const WIDGET_LABELS: Record<WidgetKind, string> = {
gauge: "Gauge",
chart: "Chart",
markdown: "Text",
agenda: "Agenda",
notification: "Notification",
button: "Button",
switch: "Switch",
slider: "Slider",
@@ -72,6 +78,8 @@ export const WIDGET_SIZES: Record<WidgetKind, { w: number; h: number }> = {
gauge: { w: 3, h: 3 },
chart: { w: 6, h: 4 },
markdown: { w: 6, h: 2 },
agenda: { w: 4, h: 4 },
notification: { w: 4, h: 2 },
button: { w: 3, h: 2 },
switch: { w: 3, h: 2 },
slider: { w: 4, h: 2 },
@@ -120,6 +128,20 @@ export function widgetIssue(widget: WidgetDef): string | null {
if (widget.type === "markdown") return null
const cfg = config(widget)
if (widget.type === "chart" && cfg.source === "query") {
if (!text(cfg.request)) return "This chart does not ask for anything yet."
if (!text(cfg.message)) return "This chart has no answer to draw yet."
const answer = text(cfg.dtype)
if (answer && answer !== "series") {
return `${text(cfg.message)} is a ${answer}; a chart that queries draws a series.`
}
const asked = text(cfg.request_dtype)
if (asked && asked !== "record") {
return `${text(cfg.request)} is a ${asked}; a request is a record.`
}
return null
}
if (widget.type === "chart") {
const series = seriesOf(widget)
if (series.length === 0) return "This chart has no series yet."
@@ -317,8 +339,11 @@ function GaugeWidget({ widget }: WidgetProps) {
<text
x={50}
y={54}
// User units of the viewBox, not the text scale: the readout has to
// stay proportional to the dial at whatever size the tile is.
fontSize={13}
textAnchor="middle"
className="fill-foreground text-[13px] tabular-nums"
className="fill-foreground tabular-nums"
>
{format(
value,
@@ -365,6 +390,119 @@ function MarkdownWidget({ widget }: WidgetProps) {
)
}
/** One item of an agenda, as the `list` message declares it. */
type AgendaItem = { title: string; ts: number; all_day?: boolean }
const DAY_MS = 86_400_000
/**
* Which day something falls on, said the way a person would.
*
* Today and tomorrow by name, the rest of the week by weekday, and anything
* further out by date — past a week "Thursday" stops telling you which one.
*/
function dayLabel(when: Date, now: Date): string {
const midnight = new Date(now).setHours(0, 0, 0, 0)
const days = Math.floor(
(new Date(when).setHours(0, 0, 0, 0) - midnight) / DAY_MS,
)
if (days === 0) return "Today"
if (days === 1) return "Tomorrow"
if (days < 7) return when.toLocaleDateString(undefined, { weekday: "long" })
return when.toLocaleDateString()
}
/**
* What is coming up, from a `list` of items the message declares.
*
* The shape is the widget's contract rather than a path per binding: every
* item is `{title, ts}` with an optional `all_day`, so a flow answering with
* a calendar decides what an entry is called and this only has to draw it.
*/
function AgendaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const now = new Date()
const today = new Date(now).setHours(0, 0, 0, 0) / 1000
const items = (Array.isArray(live?.value) ? live.value : [])
.filter(
(item): item is AgendaItem =>
typeof item?.title === "string" && Number.isFinite(item?.ts),
)
.filter((item) => item.ts >= today)
.sort((a, b) => a.ts - b.ts)
.slice(0, num(cfg.count, 5))
if (items.length === 0) {
return <p className="text-sm text-muted-foreground">Nothing coming up.</p>
}
return (
<ul className="grid gap-1.5 text-sm">
{items.map((item, index) => {
const when = new Date(item.ts * 1000)
return (
<li
// Two entries can share a title and a time; position is the identity.
key={`item-${index}`}
className="flex items-baseline gap-2"
>
<span className="shrink-0 text-muted-foreground tabular-nums">
{dayLabel(when, now)}
{item.all_day
? ""
: ` ${when.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
})}`}
</span>
<span className="truncate">{item.title}</span>
</li>
)
})}
</ul>
)
}
/**
* The last thing worth saying, held until something replaces it.
*
* No state of its own: the live store already keeps the latest value of a
* message, so what was published stays on the panel until the next one lands.
*/
function NotificationWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const live = useLiveValue(message || undefined)
if (!message) return <Unbound />
const record = (live?.value ?? null) as Record<string, unknown> | null
const title = text(record?.title)
const body = text(record?.body)
if (!title && !body) {
return <p className="text-sm text-muted-foreground">Nothing to report.</p>
}
return (
<div className="grid gap-1">
{title ? (
<p
className={cn(
"font-medium",
record?.severity === "error" && "text-destructive",
)}
>
{title}
</p>
) : null}
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
</div>
)
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
@@ -437,6 +575,12 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
const current =
dragging ?? (typeof live?.value === "number" ? live.value : min)
// A 2022 °C setpoint at 0.1 is unusable without marks to aim at. Past
// fifty of them the ticks are a smear, so the browser gets none.
const steps = step > 0 ? (max - min) / step : 0
const ticks = Number.isFinite(steps) && steps > 0 && steps <= 50 ? steps : 0
const ticksId = `ticks-${widget.id}`
return (
<div className="grid gap-2">
<div className="flex items-baseline justify-between">
@@ -447,12 +591,20 @@ function SliderWidget({ widget, dashboard }: WidgetProps) {
</span>
) : null}
</div>
{ticks ? (
<datalist id={ticksId}>
{Array.from({ length: Math.floor(ticks) + 1 }, (_, index) => (
<option key={index} value={min + index * step} />
))}
</datalist>
) : null}
<input
type="range"
min={min}
max={max}
step={step}
value={current}
list={ticks ? ticksId : undefined}
aria-label={widget.title || target}
className="h-11 w-full accent-[var(--primary)] md:h-8"
onChange={(event) => setDragging(Number(event.target.value))}
@@ -543,6 +695,8 @@ const RENDERERS: Partial<
gauge: GaugeWidget,
chart: ChartWidget,
markdown: MarkdownWidget,
agenda: AgendaWidget,
notification: NotificationWidget,
button: ButtonWidget,
switch: SwitchWidget,
slider: SliderWidget,
+39 -1
View File
@@ -50,7 +50,19 @@ import { PANEL_SECTION, PanelTitle, SidePanel } from "./SidePanel"
const NodeEditor = lazy(() => import("./NodeEditor"))
const DTYPES: DType[] = ["float", "int", "str", "bool", "json"]
const DTYPES: DType[] = [
"float",
"int",
"str",
"bool",
"json",
"series",
"record",
"list",
]
/** What a list may hold. One declared level: no list of lists. */
const ITEM_DTYPES: DType[] = ["record", "float", "int", "str", "bool", "json"]
/** Radix selects cannot hold an empty value, so "no secret" needs a name. */
const NO_SECRET = "__none__"
@@ -274,6 +286,29 @@ function PortList({
))}
</SelectContent>
</Select>
{spec.dtype === "list" ? (
<Select
value={spec.item ?? "record"}
onValueChange={(value) =>
update(index, { item: value as DType })
}
>
<SelectTrigger
className="!h-8 w-[92px] text-sm"
aria-label="Item type"
title="What each item of the list is"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ITEM_DTYPES.map((dtype) => (
<SelectItem key={dtype} value={dtype}>
{dtype}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
type="number"
min={0}
@@ -765,6 +800,9 @@ const PLACEHOLDER: Record<DType, string> = {
bool: "False",
str: '""',
json: "{}",
series: '{"lines": []}',
record: "{}",
list: "[]",
}
const SCAFFOLD_DOC =
+4 -1
View File
@@ -57,9 +57,12 @@ const FIELDS: Record<Channel["kind"], [string, string, string][]> = {
],
smtp: [["to", "Send to", "someone@example.com"]],
webhook: [["url", "URL", "https://example.com/hook"]],
// The message has to be one a flow declares, like anything a dashboard
// writes to. A notification widget bound to it is what shows the alert.
dashboard: [["message", "Message", "house.notice"]],
}
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook"]
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook", "dashboard"]
/** A setting may hold a `{"$secret": "name"}` reference rather than a literal,
* so text that parses as JSON is stored as JSON and survives a round trip. */