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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 20–22 °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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user