Dashboards as documents, and messages as something to bind to

A dashboard is its own document rather than widgets placed in a flow.
Node-RED's dashboard tab is 260 nodes, about forty of them pure layout,
which is exactly what the small-graph principle exists to avoid — and
since the graph is already wired by message name, a widget can bind to a
name without belonging to any flow.

Stored beside the flows in the same repository, sharing their write lock
and commit, under a directory the flow listing ignores. No draft/publish
split: nothing executes a dashboard, so edit mode is its own staging area.

Two things it needs from the engine. A message catalog spanning every
flow, because a wall panel shows the heating next to the solar and the
flow-scoped API is the wrong shape for that. And a way to put a value in
without owning a node — a slider is a real value that happened to come
from a person — which runs whatever consumes it and applies the same type
check a node's output gets. Only a message some flow declares can be
published to; flows own the namespace.

Charts also need more past than the 120 points a sparkline wanted, so a
chart widget declares its depth and the engine keeps that message's
series that deep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
2026-08-16 09:20:49 +02:00
co-authored by Claude Fable 5
parent 80acf342f4
commit 895bd89b2b
13 changed files with 1713 additions and 66 deletions
+11
View File
@@ -12,6 +12,7 @@ from app.core import security
from app.core.config import settings from app.core.config import settings
from app.core.db import engine from app.core.db import engine
from app.flow.controller import FlowController from app.flow.controller import FlowController
from app.flow.dashboards import DashboardStore
from app.models import TokenPayload, User from app.models import TokenPayload, User
reusable_oauth2 = OAuth2PasswordBearer( reusable_oauth2 = OAuth2PasswordBearer(
@@ -102,6 +103,16 @@ def get_flow_controller(request: Request) -> FlowController:
FlowControllerDep = Annotated[FlowController, Depends(get_flow_controller)] FlowControllerDep = Annotated[FlowController, Depends(get_flow_controller)]
def get_dashboard_store(request: Request) -> DashboardStore:
store: DashboardStore | None = getattr(request.app.state, "dashboard_store", None)
if store is None:
raise HTTPException(status_code=503, detail="The flow engine is not running")
return store
DashboardStoreDep = Annotated[DashboardStore, Depends(get_dashboard_store)]
def get_current_active_superuser(current_user: CurrentUser) -> User: def get_current_active_superuser(current_user: CurrentUser) -> User:
if not current_user.is_superuser: if not current_user.is_superuser:
raise HTTPException( raise HTTPException(
+4
View File
@@ -2,8 +2,10 @@ from fastapi import APIRouter
from app.api.routes import ( from app.api.routes import (
alerts, alerts,
dashboards,
flows, flows,
login, login,
messages,
oauth, oauth,
private, private,
secrets, secrets,
@@ -19,6 +21,8 @@ api_router.include_router(flows.router)
api_router.include_router(flows.ws_router) api_router.include_router(flows.ws_router)
api_router.include_router(secrets.router) api_router.include_router(secrets.router)
api_router.include_router(alerts.router) api_router.include_router(alerts.router)
api_router.include_router(dashboards.router)
api_router.include_router(messages.router)
# Always mounted so the generated SDK stays the same shape; the endpoints # Always mounted so the generated SDK stays the same shape; the endpoints
# themselves refuse to work unless MCP is switched on. # themselves refuse to work unless MCP is switched on.
api_router.include_router(oauth.router) api_router.include_router(oauth.router)
+112
View File
@@ -0,0 +1,112 @@
"""Dashboards: documents of widgets bound to message names."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from app.api.deps import DashboardStoreDep, FlowControllerDep, get_current_user
from app.flow.dashboards import (
DashboardDef,
DashboardExists,
DashboardNotFound,
DashboardsPublic,
default_dashboard,
)
from app.flow.store import StaleVersion
from app.models import Message
router = APIRouter(
prefix="/dashboards", tags=["dashboards"], dependencies=[Depends(get_current_user)]
)
class RenameRequest(BaseModel):
name: str
def _apply_history_limits(store: Any, controller: Any) -> None:
"""Tell the engine how much past each charted message needs kept."""
controller.set_history_limits(store.history_requirements())
@router.get("/", response_model=DashboardsPublic)
async def read_dashboards(store: DashboardStoreDep) -> Any:
"""Every dashboard, without its contents."""
summaries = await run_in_threadpool(store.list)
return DashboardsPublic(data=summaries, count=len(summaries))
@router.get("/{name}", response_model=DashboardDef)
async def read_dashboard(name: str, store: DashboardStoreDep) -> Any:
try:
return await run_in_threadpool(store.read, name)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
@router.post("/{name}", response_model=DashboardDef)
async def create_dashboard(
name: str, store: DashboardStoreDep, controller: FlowControllerDep
) -> Any:
"""Start a dashboard: one page, one section, nothing on it yet."""
if await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=409, detail=f"'{name}' already exists")
try:
defn = default_dashboard(name)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
saved = await run_in_threadpool(store.write, defn, 0)
await run_in_threadpool(_apply_history_limits, store, controller)
return saved
@router.put("/{name}", response_model=DashboardDef)
async def save_dashboard(
name: str,
body: DashboardDef,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Replace a dashboard, refusing a save someone else has moved past."""
if body.name != name:
raise HTTPException(status_code=422, detail="The name in the body must match")
try:
saved = await run_in_threadpool(store.write, body, body.version)
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Someone else saved this dashboard first",
"current_version": exc.current,
},
)
await run_in_threadpool(_apply_history_limits, store, controller)
return saved
@router.delete("/{name}", response_model=Message)
async def delete_dashboard(
name: str, store: DashboardStoreDep, controller: FlowControllerDep
) -> Any:
try:
await run_in_threadpool(store.delete, name)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
await run_in_threadpool(_apply_history_limits, store, controller)
return Message(message=f"Deleted dashboard '{name}'")
@router.post("/{name}/rename", response_model=DashboardDef)
async def rename_dashboard(
name: str, body: RenameRequest, store: DashboardStoreDep
) -> Any:
try:
return await run_in_threadpool(store.rename, name, body.name)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
except DashboardExists:
raise HTTPException(status_code=409, detail=f"'{body.name}' already exists")
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
+101
View File
@@ -0,0 +1,101 @@
"""Messages: what a dashboard binds to, across every flow.
The flow API is scoped to one flow, which is the wrong shape here — a wall
panel shows the heating alongside the solar. These endpoints are the whole
namespace at once: what exists, what it last was, and a way to put a value
into it without owning a node.
"""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from app.api.deps import FlowControllerDep, get_current_user
from app.flow.messages import flow_of
from app.flow.state import as_number
router = APIRouter(
prefix="/messages", tags=["messages"], dependencies=[Depends(get_current_user)]
)
class MessageInfo(BaseModel):
"""One message, as something to bind a widget to."""
name: str
flow: str
dtype: str
#: Nodes publishing it. Empty means the flow declares it as an input.
providers: list[str] = []
#: Whether a dashboard may publish to it — that is, whether it is declared.
writable: bool = True
numeric: bool = False
value: Any = None
ts: float | None = None
class MessagesPublic(BaseModel):
data: list[MessageInfo]
count: int
class PublishRequest(BaseModel):
value: Any
class MessageValue(BaseModel):
name: str
value: Any
ts: float | None = None
class MessagePoints(BaseModel):
message: str
numeric: bool
points: list[dict[str, float]]
@router.get("/", response_model=MessagesPublic)
async def read_messages(controller: FlowControllerDep) -> Any:
"""Every message any published flow declares, with its last value."""
infos = await run_in_threadpool(controller.message_catalog)
return MessagesPublic(data=infos, count=len(infos))
@router.post("/{name}", response_model=MessageValue)
async def publish_message(
name: str, body: PublishRequest, controller: FlowControllerDep
) -> Any:
"""Put a value into the graph, as a dashboard control does.
Only a message some flow declares can be published to: flows own the
namespace, and a dashboard is a client of it rather than a second author.
"""
try:
await run_in_threadpool(controller.publish_message, name, body.value)
except KeyError:
raise HTTPException(
status_code=404, detail=f"No flow declares a message named '{name}'"
)
except TypeError as exc:
raise HTTPException(status_code=422, detail=str(exc))
values = controller.values(flow_of(name))
current = values.get(name, {})
return MessageValue(name=name, value=current.get("value"), ts=current.get("ts"))
@router.get("/{name}/history", response_model=MessagePoints)
def read_message_history(name: str, controller: FlowControllerDep) -> Any:
"""The series behind a chart. Numbers only — nothing else plots."""
if controller.pipeline is None:
return MessagePoints(message=name, numeric=False, points=[])
series = controller.state.history(name)
numeric = as_number(controller.state.get(name)) is not None
return MessagePoints(
message=name,
numeric=numeric or bool(series),
points=[{"ts": ts, "value": value} for ts, value in series],
)
+77 -1
View File
@@ -25,7 +25,7 @@ from fastapi.concurrency import run_in_threadpool
from app.flow.alerts import AlertManager from app.flow.alerts import AlertManager
from app.flow.events import EventBus from app.flow.events import EventBus
from app.flow.executor import ExecutionService from app.flow.executor import ExecutionService
from app.flow.messages import MessageSpec, qualify from app.flow.messages import MessageSpec, flow_of, qualify
from app.flow.nodes import ( from app.flow.nodes import (
ChangeNode, ChangeNode,
DelayNode, DelayNode,
@@ -282,6 +282,7 @@ class FlowController:
self.issues: list[ValidationIssue] = [] self.issues: list[ValidationIssue] = []
self.disabled: set[str] = set() self.disabled: set[str] = set()
self.supervisor = Supervisor(events) self.supervisor = Supervisor(events)
self.history_limits: dict[str, int] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -339,6 +340,7 @@ class FlowController:
work_queue=self.execution.queue if self.execution else None, work_queue=self.execution.queue if self.execution else None,
node_pool=self.execution.node_pool if self.execution else None, node_pool=self.execution.node_pool if self.execution else None,
) )
self.pipeline.history_limits = self.history_limits
if self.execution is not None: if self.execution is not None:
self.execution.bind(self.pipeline) self.execution.bind(self.pipeline)
self.issues = _collect_issues(loaded, self.pipeline, flow_inputs) self.issues = _collect_issues(loaded, self.pipeline, flow_inputs)
@@ -598,6 +600,80 @@ class FlowController:
for item in self.execution.queue.unpark(flow): for item in self.execution.queue.unpark(flow):
self.execution.queue.add(item) self.execution.queue.add(item)
def set_history_limits(self, limits: dict[str, int]) -> None:
"""How deep to keep each charted message's series. Applies at once."""
self.history_limits = limits
if self.pipeline is not None:
self.pipeline.history_limits = limits
def message_catalog(self) -> list[Any]:
"""Every message the published flows declare, with its last value.
What a dashboard picks from, so it spans flows rather than sitting
inside one.
"""
from app.api.routes.messages import MessageInfo
specs: dict[str, MessageSpec] = {}
providers: dict[str, list[str]] = {}
for flow in self.store.read_all():
for node in flow.nodes:
for spec in _bound(node.provides):
name = qualify(flow.name, spec.name)
specs.setdefault(name, spec)
providers.setdefault(name, []).append(f"{flow.name}.{node.id}")
for spec in _bound(node.requires):
specs.setdefault(qualify(flow.name, spec.name), spec)
for declared in flow.inputs:
if declared.spec.name:
specs.setdefault(
qualify(flow.name, declared.spec.name), declared.spec
)
values = self.values()
infos = []
for name, spec in sorted(specs.items()):
current = values.get(name, {})
infos.append(
MessageInfo(
name=name,
flow=flow_of(name),
dtype=spec.dtype.value,
providers=sorted(providers.get(name, [])),
writable=True,
numeric=spec.dtype.value in ("float", "int"),
value=current.get("value"),
ts=current.get("ts"),
)
)
return infos
def publish_message(self, name: str, value: Any) -> None:
"""Put a value into the graph from outside. Blocking.
Refuses a name no flow declares: the flows own the namespace, and a
message nothing reads or writes would just be a key nobody sees.
"""
if self.pipeline is None:
raise KeyError(name)
spec = None
for flow in self.store.read_all():
for node in flow.nodes:
for candidate in [*_bound(node.provides), *_bound(node.requires)]:
if qualify(flow.name, candidate.name) == name:
spec = candidate
break
for declared in flow.inputs:
if qualify(flow.name, declared.spec.name) == name:
spec = declared.spec
if spec is None:
raise KeyError(name)
# The same check a node's output gets; a dashboard is not looser.
spec.check(value)
self.pipeline.publish({name: value})
def queue_stats(self) -> dict[str, Any]: def queue_stats(self) -> dict[str, Any]:
return self.execution.stats() if self.execution is not None else {} return self.execution.stats() if self.execution is not None else {}
+311
View File
@@ -0,0 +1,311 @@
"""Dashboards: what a wall panel shows, and what its buttons do.
A dashboard is its own document, not a set of nodes placed in a flow. Widgets
bind to message names — the same names that wire the graph — so a dashboard
reads across flows without being part of any of them, and a flow stays the
logic it was.
Stored beside the flows in the same git repository, under a directory the flow
listing ignores. No draft/publish split: nothing executes a dashboard, so edit
mode is its own staging area and the version counter is enough to stop two
clients overwriting each other.
"""
from __future__ import annotations
import threading
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator
from app.flow.schemas import _validate_name
from app.flow.store import FlowStore, StaleVersion
#: Sibling of the shared-node library, and likewise not a flow.
DASHBOARD_DIR = "_dashboards"
#: A chart cannot ask for an unbounded series; this is the ceiling.
HISTORY_CAP = 5000
WidgetType = Literal[
# Display
"stat",
"gauge",
"chart",
"markdown",
# Input
"button",
"switch",
"slider",
"input",
"dropdown",
]
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown"}
class Placement(BaseModel):
"""Where a widget sits in its section's grid, in grid units."""
x: int = 0
y: int = 0
w: int = 3
h: int = 2
class WidgetDef(BaseModel):
"""One tile: what it shows or does, and where it sits.
``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.
"""
id: str
type: WidgetType
title: str = ""
#: Keyed by breakpoint (``lg``/``md``/``sm``); missing ones are derived by
#: the client from the widest one it has.
layout: dict[str, Placement] = Field(default_factory=dict)
config: dict[str, Any] = Field(default_factory=dict)
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
@property
def messages(self) -> list[str]:
"""Every message name this widget reads."""
if self.type == "chart":
return [
str(series.get("message"))
for series in self.config.get("series") or []
if series.get("message")
]
name = self.config.get("message")
return [str(name)] if name else []
@property
def target(self) -> str:
"""The message this widget publishes, if it is an input."""
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":
return 0
points = int((self.config.get("history") or {}).get("points") or 0)
return min(points, HISTORY_CAP)
class SectionDef(BaseModel):
"""A grid of widgets under a heading."""
id: str
title: str = ""
widgets: list[WidgetDef] = Field(default_factory=list)
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
class PageDef(BaseModel):
"""One tab of a dashboard."""
id: str
title: str = ""
#: A lucide icon name, or empty.
icon: str = ""
sections: list[SectionDef] = Field(default_factory=list)
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
class DashboardDef(BaseModel):
"""A dashboard as stored, and as the API hands it over."""
name: str
title: str = ""
pages: list[PageDef] = Field(default_factory=list)
#: Bumped on every save; a save based on an older one is refused.
version: int = 1
@field_validator("name")
@classmethod
def _check_name(cls, value: str) -> str:
return _validate_name(value)
@property
def widgets(self) -> list[WidgetDef]:
return [w for p in self.pages for s in p.sections for w in s.widgets]
class DashboardSummary(BaseModel):
"""A dashboard in a list, without its contents."""
name: str
title: str = ""
page_count: int = 0
widget_count: int = 0
class DashboardsPublic(BaseModel):
data: list[DashboardSummary]
count: int
class DashboardNotFound(KeyError):
def __init__(self, name: str) -> None:
super().__init__(name)
self.name = name
class DashboardExists(ValueError):
def __init__(self, name: str) -> None:
super().__init__(name)
self.name = name
class DashboardStore:
"""Dashboards in the flow store's repository, invisible to the flow listing.
Shares the flow store's write lock and commit, so a dashboard save and a
flow save cannot interleave into one confused commit.
"""
def __init__(self, flows: FlowStore) -> None:
self.flows = flows
self.root = flows.root / DASHBOARD_DIR
# Read-modify-write of the version counter, same as the flow store.
self._lock = threading.Lock()
def _file(self, name: str) -> Path:
return self.root / name / "dashboard.json"
def list(self) -> list[DashboardSummary]:
summaries = []
for path in sorted(self.root.glob("*/dashboard.json")):
try:
defn = DashboardDef.model_validate_json(path.read_text())
except Exception:
continue
summaries.append(
DashboardSummary(
name=defn.name,
title=defn.title,
page_count=len(defn.pages),
widget_count=len(defn.widgets),
)
)
return summaries
def exists(self, name: str) -> bool:
return self._file(name).exists()
def read(self, name: str) -> DashboardDef:
path = self._file(name)
if not path.exists():
raise DashboardNotFound(name)
return DashboardDef.model_validate_json(path.read_text())
def write(
self, defn: DashboardDef, base_version: int | None = None
) -> DashboardDef:
"""Save, refusing a write based on a version someone has moved past."""
with self._lock, self.flows._write_lock:
path = self._file(defn.name)
current = 0
if path.exists():
current = DashboardDef.model_validate_json(path.read_text()).version
if base_version is not None and base_version != current:
raise StaleVersion(defn.name, current)
saved = defn.model_copy(update={"version": current + 1})
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(saved.model_dump_json(indent=2))
self.flows._commit(f"Save dashboard '{defn.name}'")
return saved
def delete(self, name: str) -> None:
path = self._file(name)
if not path.exists():
raise DashboardNotFound(name)
with self.flows._write_lock:
path.unlink()
try:
path.parent.rmdir()
except OSError:
pass
self.flows._commit(f"Delete dashboard '{name}'")
def rename(self, name: str, new_name: str) -> DashboardDef:
defn = self.read(name)
if self.exists(new_name):
raise DashboardExists(new_name)
with self.flows._write_lock:
renamed = defn.model_copy(update={"name": new_name})
target = self._file(new_name)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(renamed.model_dump_json(indent=2))
self._file(name).unlink()
try:
self._file(name).parent.rmdir()
except OSError:
pass
self.flows._commit(f"Rename dashboard '{name}' to '{new_name}'")
return renamed
def history_requirements(self) -> dict[str, int]:
"""How many points to keep per message, so charts have a past to draw.
The deepest chart bound to a message wins; a message no chart reads
keeps the default.
"""
limits: dict[str, int] = {}
for path in self.root.glob("*/dashboard.json"):
try:
defn = DashboardDef.model_validate_json(path.read_text())
except Exception:
continue
for widget in defn.widgets:
points = widget.history_points
if not points:
continue
for message in widget.messages:
limits[message] = max(limits.get(message, 0), points)
return limits
def default_dashboard(name: str) -> DashboardDef:
"""A new dashboard: one page, one section, nothing in it yet."""
return DashboardDef(
name=name,
title=name.replace("_", " ").capitalize(),
pages=[PageDef(id="main", title="Overview", sections=[SectionDef(id="main")])],
)
__all__ = [
"DASHBOARD_DIR",
"HISTORY_CAP",
"INPUT_WIDGETS",
"DashboardDef",
"DashboardExists",
"DashboardNotFound",
"DashboardStore",
"DashboardSummary",
"DashboardsPublic",
"PageDef",
"Placement",
"SectionDef",
"WidgetDef",
"default_dashboard",
]
+42 -2
View File
@@ -64,6 +64,7 @@ class Pipeline:
"_gate_lock", "_gate_lock",
"_queue", "_queue",
"_node_pool", "_node_pool",
"history_limits",
) )
def __init__( def __init__(
@@ -94,6 +95,9 @@ class Pipeline:
self._queue = work_queue self._queue = work_queue
# A pool owned by the execution service, so a wave does not build one. # A pool owned by the execution service, so a wave does not build one.
self._node_pool = node_pool self._node_pool = node_pool
# How deep to keep each message's series; a chart asking for more
# than the default puts its message in here. Swapped, never mutated.
self.history_limits: dict[str, int] = {}
# A message may have several producers; every one of them is upstream # A message may have several producers; every one of them is upstream
# of the nodes consuming it. # of the nodes consuming it.
@@ -489,7 +493,7 @@ class Pipeline:
{self._timestamp_key(name): ts for name in result}, {self._timestamp_key(name): ts for name in result},
) )
# Append-only, so it needs no lock of its own. # Append-only, so it needs no lock of its own.
state.append_history(result, ts) state.append_history(result, ts, self.history_limits)
self._increment_message_versions(result) self._increment_message_versions(result)
for name, value in result.items(): for name, value in result.items():
self._publish( self._publish(
@@ -679,7 +683,7 @@ class Pipeline:
with state.lock(): with state.lock():
state.update(outputs) state.update(outputs)
state.update({self._timestamp_key(name): ts for name in outputs}) state.update({self._timestamp_key(name): ts for name in outputs})
state.append_history(outputs, ts) state.append_history(outputs, ts, self.history_limits)
self._increment_message_versions(outputs) self._increment_message_versions(outputs)
for name, value in outputs.items(): for name, value in outputs.items():
self._publish( self._publish(
@@ -756,6 +760,42 @@ class Pipeline:
self.apply_outputs(node, outputs) self.apply_outputs(node, outputs)
return self.run_downstream(node) return self.run_downstream(node)
def publish(self, values: dict[str, Any]) -> None:
"""Put values into the graph without a node having produced them.
This is what a dashboard control does: the value is real, it just came
from a person rather than a sensor. Everything consuming those names
runs, the same as if a node had published them.
"""
if not values:
return
ts = time.time()
with self._state.lock():
self._state.update(values)
self._state.update({self._timestamp_key(name): ts for name in values})
self._state.append_history(values, ts, self.history_limits)
self._increment_message_versions(values)
for name, value in values.items():
self._publish(
{
"type": "message_value",
"flow": flow_of(name),
"name": name,
"value": value,
"ts": ts,
}
)
targets: set[Node] = set()
for name in values:
for consumer in self._nodes:
if name in consumer.requires:
targets.add(consumer)
targets.update(self._get_downstream(consumer))
if targets:
self._execute_parallel(targets, self._state, check_ready=True)
def defer( def defer(
self, self,
node: Node, node: Node,
+19 -5
View File
@@ -173,7 +173,9 @@ class StateBackend(ABC):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@abstractmethod @abstractmethod
def append_history(self, values: dict[str, Any], ts: float) -> None: def append_history(
self, values: dict[str, Any], ts: float, limits: dict[str, int] | None = None
) -> None:
""" """
Record freshly published values in each message's capped history. Record freshly published values in each message's capped history.
@@ -182,6 +184,8 @@ class StateBackend(ABC):
:param values: Message names mapped to the value just published. :param values: Message names mapped to the value just published.
:type values: dict[str, Any] :type values: dict[str, Any]
:param ts: When they were published. :param ts: When they were published.
:param limits: How many points to keep per message, where a chart asks
for more than the default.
:type ts: float :type ts: float
""" """
... ...
@@ -307,14 +311,21 @@ class MemoryState(StateBackend):
self._data.update(updates) self._data.update(updates)
return True return True
def append_history(self, values: dict[str, Any], ts: float) -> None: def append_history(
self, values: dict[str, Any], ts: float, limits: dict[str, int] | None = None
) -> None:
"""Append the numeric values to their message's bounded series.""" """Append the numeric values to their message's bounded series."""
with self._lock: with self._lock:
for key, value in values.items(): for key, value in values.items():
number = as_number(value) number = as_number(value)
if number is None: if number is None:
continue continue
series = self._history.setdefault(key, deque(maxlen=HISTORY_LIMIT)) cap = (limits or {}).get(key, HISTORY_LIMIT)
series = self._history.get(key)
if series is None or series.maxlen != cap:
# A widget asking for a deeper series re-caps it in place.
series = deque(series or (), maxlen=cap)
self._history[key] = series
series.append((ts, number)) series.append((ts, number))
def history(self, key: str) -> list[tuple[float, float]]: def history(self, key: str) -> list[tuple[float, float]]:
@@ -563,7 +574,9 @@ class RedisState(StateBackend):
def _history_key(self, key: str) -> str: def _history_key(self, key: str) -> str:
return self._key(f"__history__:{key}") return self._key(f"__history__:{key}")
def append_history(self, values: dict[str, Any], ts: float) -> None: def append_history(
self, values: dict[str, Any], ts: float, limits: dict[str, int] | None = None
) -> None:
"""Push the numeric values onto their capped list, in one round-trip.""" """Push the numeric values onto their capped list, in one round-trip."""
pipe = self._client.pipeline() pipe = self._client.pipeline()
queued = False queued = False
@@ -572,8 +585,9 @@ class RedisState(StateBackend):
if number is None: if number is None:
continue continue
history_key = self._history_key(key) history_key = self._history_key(key)
cap = (limits or {}).get(key, HISTORY_LIMIT)
pipe.lpush(history_key, json.dumps([ts, number])) pipe.lpush(history_key, json.dumps([ts, number]))
pipe.ltrim(history_key, 0, HISTORY_LIMIT - 1) pipe.ltrim(history_key, 0, cap - 1)
if self._ttl: if self._ttl:
pipe.expire(history_key, self._ttl) pipe.expire(history_key, self._ttl)
queued = True queued = True
+6
View File
@@ -16,6 +16,7 @@ from app.core.config import settings
from app.flow import logs from app.flow import logs
from app.flow.alerts import AlertManager from app.flow.alerts import AlertManager
from app.flow.controller import FlowController from app.flow.controller import FlowController
from app.flow.dashboards import DashboardStore
from app.flow.events import event_bus from app.flow.events import event_bus
from app.flow.executor import ExecutionService from app.flow.executor import ExecutionService
from app.flow.nodes.http import close_shared_client from app.flow.nodes.http import close_shared_client
@@ -83,6 +84,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
alerts=alerts, alerts=alerts,
) )
app.state.flow_controller = controller app.state.flow_controller = controller
dashboards = DashboardStore(controller.store)
app.state.dashboard_store = dashboards
# Charts need a deeper series than the default; tell the engine
# before it starts recording.
controller.set_history_limits(dashboards.history_requirements())
watchdog = LoopWatchdog(event_bus) watchdog = LoopWatchdog(event_bus)
app.state.watchdog = watchdog app.state.watchdog = watchdog
watchdog_task = asyncio.create_task(watchdog.run(), name="loop-watchdog") watchdog_task = asyncio.create_task(watchdog.run(), name="loop-watchdog")
+161
View File
@@ -0,0 +1,161 @@
"""Dashboards: documents beside the flows, and the values their widgets move."""
import pytest
from app.flow.dashboards import (
DashboardDef,
DashboardNotFound,
DashboardStore,
PageDef,
SectionDef,
WidgetDef,
default_dashboard,
)
from app.flow.messages import DType, MessageSpec
from app.flow.nodes import Node
from app.flow.pipeline import Pipeline
from app.flow.state import MemoryState
from app.flow.store import FlowStore, StaleVersion
@pytest.fixture
def store(tmp_path) -> DashboardStore:
return DashboardStore(FlowStore(tmp_path / "flows"))
def chart(message: str, points: int) -> WidgetDef:
return WidgetDef(
id="temp",
type="chart",
config={"series": [{"message": message}], "history": {"points": points}},
)
def test_a_dashboard_survives_a_round_trip(store: DashboardStore):
saved = store.write(default_dashboard("house"))
read = store.read("house")
assert read.name == "house"
assert [p.id for p in read.pages] == ["main"]
assert read.version == saved.version
def test_dashboards_are_invisible_to_the_flow_listing(store: DashboardStore):
"""They share the repository; they are not flows."""
store.write(default_dashboard("house"))
assert store.flows.list_flows() == []
assert [d.name for d in store.list()] == ["house"]
def test_a_save_based_on_a_version_someone_moved_past_is_refused(
store: DashboardStore,
):
first = store.write(default_dashboard("house"))
store.write(first, first.version)
with pytest.raises(StaleVersion):
store.write(first, first.version)
def test_deleting_and_renaming(store: DashboardStore):
store.write(default_dashboard("house"))
renamed = store.rename("house", "home")
assert renamed.name == "home"
assert not store.exists("house")
store.delete("home")
with pytest.raises(DashboardNotFound):
store.read("home")
def test_the_deepest_chart_decides_how_much_past_is_kept(store: DashboardStore):
store.write(
DashboardDef(
name="house",
pages=[
PageDef(
id="main",
sections=[
SectionDef(id="a", widgets=[chart("heating.temp", 400)]),
SectionDef(
id="b",
widgets=[
chart("heating.temp", 900),
chart("solar.watts", 100),
],
),
],
)
],
)
)
assert store.history_requirements() == {"heating.temp": 900, "solar.watts": 100}
def test_a_chart_cannot_ask_for_an_unbounded_series(store: DashboardStore):
store.write(
DashboardDef(
name="house",
pages=[
PageDef(
id="main",
sections=[
SectionDef(id="a", widgets=[chart("heating.temp", 10**9)])
],
)
],
)
)
assert store.history_requirements() == {"heating.temp": 5000}
def test_history_is_kept_to_the_depth_a_chart_asked_for():
state = MemoryState()
limits = {"f.temp": 300}
for i in range(400):
state.append_history({"f.temp": float(i)}, float(i), limits)
assert len(state.history("f.temp")) == 300
# ---------------------------------------------------------------------------
# What an input widget does
# ---------------------------------------------------------------------------
def test_publishing_a_value_runs_what_consumes_it():
"""A slider is a value arriving; the graph should not care who sent it."""
seen: list[float] = []
def consume(setpoint, params):
seen.append(setpoint)
return {"applied": setpoint}
node = Node(
f=consume,
requires=[MessageSpec(name="setpoint", port="setpoint", dtype=DType.FLOAT)],
provides=[MessageSpec(name="applied", port="applied", dtype=DType.FLOAT)],
name="thermostat",
)
node.assign_flow("heating", "thermostat")
state = MemoryState()
pipeline = Pipeline(nodes=[node], state=state)
pipeline.publish({"heating.setpoint": 21.5})
assert seen == [21.5]
assert state["heating.applied"] == 21.5
def test_publishing_nothing_does_nothing():
pipeline = Pipeline(nodes=[], state=MemoryState())
pipeline.publish({})
assert pipeline.values() == {}
+470 -39
View File
@@ -200,6 +200,113 @@ Binary payloads (tensors, images) will arrive later as explicitly declared
codec fields; until then everything on the wire is JSON.` codec fields; until then everything on the wire is JSON.`
} as const; } as const;
export const DashboardDef_InputSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
pages: {
items: {
'$ref': '#/components/schemas/PageDef-Input'
},
type: 'array',
title: 'Pages'
},
version: {
type: 'integer',
title: 'Version',
default: 1
}
},
type: 'object',
required: ['name'],
title: 'DashboardDef',
description: 'A dashboard as stored, and as the API hands it over.'
} as const;
export const DashboardDef_OutputSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
pages: {
items: {
'$ref': '#/components/schemas/PageDef-Output'
},
type: 'array',
title: 'Pages'
},
version: {
type: 'integer',
title: 'Version',
default: 1
}
},
type: 'object',
required: ['name'],
title: 'DashboardDef',
description: 'A dashboard as stored, and as the API hands it over.'
} as const;
export const DashboardSummarySchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
page_count: {
type: 'integer',
title: 'Page Count',
default: 0
},
widget_count: {
type: 'integer',
title: 'Widget Count',
default: 0
}
},
type: 'object',
required: ['name'],
title: 'DashboardSummary',
description: 'A dashboard in a list, without its contents.'
} as const;
export const DashboardsPublicSchema = {
properties: {
data: {
items: {
'$ref': '#/components/schemas/DashboardSummary'
},
type: 'array',
title: 'Data'
},
count: {
type: 'integer',
title: 'Count'
}
},
type: 'object',
required: ['data', 'count'],
title: 'DashboardsPublic'
} as const;
export const FlowDef_InputSchema = { export const FlowDef_InputSchema = {
properties: { properties: {
name: { name: {
@@ -367,7 +474,7 @@ export const FlowStatePublicSchema = {
properties: { properties: {
values: { values: {
additionalProperties: { additionalProperties: {
'$ref': '#/components/schemas/MessageValue' '$ref': '#/components/schemas/app__flow__schemas__MessageValue'
}, },
type: 'object', type: 'object',
title: 'Values' title: 'Values'
@@ -541,6 +648,85 @@ Only numbers are recorded, so \`\`numeric\`\` tells the panel whether an empty
series means "nothing plottable here" or "nothing has arrived yet".` series means "nothing plottable here" or "nothing has arrived yet".`
} as const; } as const;
export const MessageInfoSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
flow: {
type: 'string',
title: 'Flow'
},
dtype: {
type: 'string',
title: 'Dtype'
},
providers: {
items: {
type: 'string'
},
type: 'array',
title: 'Providers',
default: []
},
writable: {
type: 'boolean',
title: 'Writable',
default: true
},
numeric: {
type: 'boolean',
title: 'Numeric',
default: false
},
value: {
title: 'Value'
},
ts: {
anyOf: [
{
type: 'number'
},
{
type: 'null'
}
],
title: 'Ts'
}
},
type: 'object',
required: ['name', 'flow', 'dtype'],
title: 'MessageInfo',
description: 'One message, as something to bind a widget to.'
} as const;
export const MessagePointsSchema = {
properties: {
message: {
type: 'string',
title: 'Message'
},
numeric: {
type: 'boolean',
title: 'Numeric'
},
points: {
items: {
additionalProperties: {
type: 'number'
},
type: 'object'
},
type: 'array',
title: 'Points'
}
},
type: 'object',
required: ['message', 'numeric', 'points'],
title: 'MessagePoints'
} as const;
export const MessageSpecSchema = { export const MessageSpecSchema = {
properties: { properties: {
name: { name: {
@@ -588,26 +774,23 @@ export const MessageSpecSchema = {
message it also produces without depending on itself.` message it also produces without depending on itself.`
} as const; } as const;
export const MessageValueSchema = { export const MessagesPublicSchema = {
properties: { properties: {
value: { data: {
title: 'Value' items: {
'$ref': '#/components/schemas/MessageInfo'
}, },
ts: { type: 'array',
anyOf: [ title: 'Data'
{
type: 'number'
}, },
{ count: {
type: 'null' type: 'integer',
} title: 'Count'
],
title: 'Ts'
} }
}, },
type: 'object', type: 'object',
title: 'MessageValue', required: ['data', 'count'],
description: 'The last payload seen on a message.' title: 'MessagesPublic'
} as const; } as const;
export const NewPasswordSchema = { export const NewPasswordSchema = {
@@ -1006,6 +1189,94 @@ export const OAuthClientRegisterSchema = {
description: 'RFC 7591 dynamic client registration request.' description: 'RFC 7591 dynamic client registration request.'
} as const; } as const;
export const PageDef_InputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
default: ''
},
sections: {
items: {
'$ref': '#/components/schemas/SectionDef-Input'
},
type: 'array',
title: 'Sections'
}
},
type: 'object',
required: ['id'],
title: 'PageDef',
description: 'One tab of a dashboard.'
} as const;
export const PageDef_OutputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
icon: {
type: 'string',
title: 'Icon',
default: ''
},
sections: {
items: {
'$ref': '#/components/schemas/SectionDef-Output'
},
type: 'array',
title: 'Sections'
}
},
type: 'object',
required: ['id'],
title: 'PageDef',
description: 'One tab of a dashboard.'
} as const;
export const PlacementSchema = {
properties: {
x: {
type: 'integer',
title: 'X',
default: 0
},
y: {
type: 'integer',
title: 'Y',
default: 0
},
w: {
type: 'integer',
title: 'W',
default: 3
},
h: {
type: 'integer',
title: 'H',
default: 2
}
},
type: 'object',
title: 'Placement',
description: "Where a widget sits in its section's grid, in grid units."
} as const;
export const PositionSchema = { export const PositionSchema = {
properties: { properties: {
x: { x: {
@@ -1049,30 +1320,6 @@ export const PrivateUserCreateSchema = {
title: 'PrivateUserCreate' title: 'PrivateUserCreate'
} as const; } as const;
export const PublishRequestSchema = {
properties: {
version: {
type: 'integer',
title: 'Version'
}
},
type: 'object',
required: ['version'],
title: 'PublishRequest'
} as const;
export const RenameRequestSchema = {
properties: {
new_name: {
type: 'string',
title: 'New Name'
}
},
type: 'object',
required: ['new_name'],
title: 'RenameRequest'
} as const;
export const RuleSchema = { export const RuleSchema = {
properties: { properties: {
events: { events: {
@@ -1144,6 +1391,56 @@ export const SecretValueSchema = {
title: 'SecretValue' title: 'SecretValue'
} as const; } as const;
export const SectionDef_InputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
widgets: {
items: {
'$ref': '#/components/schemas/WidgetDef'
},
type: 'array',
title: 'Widgets'
}
},
type: 'object',
required: ['id'],
title: 'SectionDef',
description: 'A grid of widgets under a heading.'
} as const;
export const SectionDef_OutputSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
widgets: {
items: {
'$ref': '#/components/schemas/WidgetDef'
},
type: 'array',
title: 'Widgets'
}
},
type: 'object',
required: ['id'],
title: 'SectionDef',
description: 'A grid of widgets under a heading.'
} as const;
export const ShareRequestSchema = { export const ShareRequestSchema = {
properties: { properties: {
lib_name: { lib_name: {
@@ -1547,3 +1844,137 @@ export const ValidationResultSchema = {
type: 'object', type: 'object',
title: 'ValidationResult' title: 'ValidationResult'
} as const; } as const;
export const WidgetDefSchema = {
properties: {
id: {
type: 'string',
title: 'Id'
},
type: {
type: 'string',
enum: ['stat', 'gauge', 'chart', 'markdown', 'button', 'switch', 'slider', 'input', 'dropdown'],
title: 'Type'
},
title: {
type: 'string',
title: 'Title',
default: ''
},
layout: {
additionalProperties: {
'$ref': '#/components/schemas/Placement'
},
type: 'object',
title: 'Layout'
},
config: {
additionalProperties: true,
type: 'object',
title: 'Config'
}
},
type: 'object',
required: ['id', 'type'],
title: 'WidgetDef',
description: `One tile: what it shows or does, and where it sits.
\`\`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.`
} as const;
export const app__api__routes__dashboards__RenameRequestSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
}
},
type: 'object',
required: ['name'],
title: 'RenameRequest'
} as const;
export const app__api__routes__flows__PublishRequestSchema = {
properties: {
version: {
type: 'integer',
title: 'Version'
}
},
type: 'object',
required: ['version'],
title: 'PublishRequest'
} as const;
export const app__api__routes__flows__RenameRequestSchema = {
properties: {
new_name: {
type: 'string',
title: 'New Name'
}
},
type: 'object',
required: ['new_name'],
title: 'RenameRequest'
} as const;
export const app__api__routes__messages__MessageValueSchema = {
properties: {
name: {
type: 'string',
title: 'Name'
},
value: {
title: 'Value'
},
ts: {
anyOf: [
{
type: 'number'
},
{
type: 'null'
}
],
title: 'Ts'
}
},
type: 'object',
required: ['name', 'value'],
title: 'MessageValue'
} as const;
export const app__api__routes__messages__PublishRequestSchema = {
properties: {
value: {
title: 'Value'
}
},
type: 'object',
required: ['value'],
title: 'PublishRequest'
} as const;
export const app__flow__schemas__MessageValueSchema = {
properties: {
value: {
title: 'Value'
},
ts: {
anyOf: [
{
type: 'number'
},
{
type: 'null'
}
],
title: 'Ts'
}
},
type: 'object',
title: 'MessageValue',
description: 'The last payload seen on a message.'
} as const;
+187 -1
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise'; import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI'; import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request'; import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen'; import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
export class AlertsService { export class AlertsService {
/** /**
@@ -61,6 +61,129 @@ export class AlertsService {
} }
} }
export class DashboardsService {
/**
* Read Dashboards
* Every dashboard, without its contents.
* @returns DashboardsPublic Successful Response
* @throws ApiError
*/
public static readDashboards(): CancelablePromise<DashboardsReadDashboardsResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/dashboards/'
});
}
/**
* Read Dashboard
* @param data The data for the request.
* @param data.name
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static readDashboard(data: DashboardsReadDashboardData): CancelablePromise<DashboardsReadDashboardResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/dashboards/{name}',
path: {
name: data.name
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Create Dashboard
* Start a dashboard: one page, one section, nothing on it yet.
* @param data The data for the request.
* @param data.name
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static createDashboard(data: DashboardsCreateDashboardData): CancelablePromise<DashboardsCreateDashboardResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/dashboards/{name}',
path: {
name: data.name
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Save Dashboard
* Replace a dashboard, refusing a save someone else has moved past.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static saveDashboard(data: DashboardsSaveDashboardData): CancelablePromise<DashboardsSaveDashboardResponse> {
return __request(OpenAPI, {
method: 'PUT',
url: '/api/v1/dashboards/{name}',
path: {
name: data.name
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Delete Dashboard
* @param data The data for the request.
* @param data.name
* @returns Message Successful Response
* @throws ApiError
*/
public static deleteDashboard(data: DashboardsDeleteDashboardData): CancelablePromise<DashboardsDeleteDashboardResponse> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/api/v1/dashboards/{name}',
path: {
name: data.name
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Rename Dashboard
* @param data The data for the request.
* @param data.name
* @param data.requestBody
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static renameDashboard(data: DashboardsRenameDashboardData): CancelablePromise<DashboardsRenameDashboardResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/dashboards/{name}/rename',
path: {
name: data.name
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
}
export class FlowsService { export class FlowsService {
/** /**
* Read Flows * Read Flows
@@ -671,6 +794,69 @@ export class LoginService {
} }
} }
export class MessagesService {
/**
* Read Messages
* Every message any published flow declares, with its last value.
* @returns MessagesPublic Successful Response
* @throws ApiError
*/
public static readMessages(): CancelablePromise<MessagesReadMessagesResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/messages/'
});
}
/**
* Publish Message
* Put a value into the graph, as a dashboard control does.
*
* Only a message some flow declares can be published to: flows own the
* namespace, and a dashboard is a client of it rather than a second author.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
* @returns app__api__routes__messages__MessageValue Successful Response
* @throws ApiError
*/
public static publishMessage(data: MessagesPublishMessageData): CancelablePromise<MessagesPublishMessageResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/messages/{name}',
path: {
name: data.name
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Read Message History
* The series behind a chart. Numbers only — nothing else plots.
* @param data The data for the request.
* @param data.name
* @returns MessagePoints Successful Response
* @throws ApiError
*/
public static readMessageHistory(data: MessagesReadMessageHistoryData): CancelablePromise<MessagesReadMessageHistoryResponse> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/messages/{name}/history',
path: {
name: data.name
},
errors: {
422: 'Validation Error'
}
});
}
}
export class OauthService { export class OauthService {
/** /**
* Register Client * Register Client
+211 -17
View File
@@ -9,6 +9,36 @@ export type AlertsConfig = {
rules?: Array<Rule>; rules?: Array<Rule>;
}; };
export type app__api__routes__dashboards__RenameRequest = {
name: string;
};
export type app__api__routes__flows__PublishRequest = {
version: number;
};
export type app__api__routes__flows__RenameRequest = {
new_name: string;
};
export type app__api__routes__messages__MessageValue = {
name: string;
value: unknown;
ts?: (number | null);
};
export type app__api__routes__messages__PublishRequest = {
value: unknown;
};
/**
* The last payload seen on a message.
*/
export type app__flow__schemas__MessageValue = {
value?: unknown;
ts?: (number | null);
};
export type Body_login_login_access_token = { export type Body_login_login_access_token = {
grant_type?: (string | null); grant_type?: (string | null);
username: string; username: string;
@@ -42,6 +72,41 @@ export type Channel = {
export type kind = 'ntfy' | 'smtp' | 'webhook'; export type kind = 'ntfy' | 'smtp' | 'webhook';
/**
* A dashboard as stored, and as the API hands it over.
*/
export type DashboardDef_Input = {
name: string;
title?: string;
pages?: Array<PageDef_Input>;
version?: number;
};
/**
* A dashboard as stored, and as the API hands it over.
*/
export type DashboardDef_Output = {
name: string;
title?: string;
pages?: Array<PageDef_Output>;
version?: number;
};
export type DashboardsPublic = {
data: Array<DashboardSummary>;
count: number;
};
/**
* A dashboard in a list, without its contents.
*/
export type DashboardSummary = {
name: string;
title?: string;
page_count?: number;
widget_count?: number;
};
/** /**
* Serializable payload types. * Serializable payload types.
* *
@@ -111,7 +176,7 @@ export type FlowsPublic = {
export type FlowStatePublic = { export type FlowStatePublic = {
values?: { values?: {
[key: string]: MessageValue; [key: string]: app__flow__schemas__MessageValue;
}; };
nodes?: Array<NodeStatusPublic>; nodes?: Array<NodeStatusPublic>;
}; };
@@ -163,6 +228,28 @@ export type MessageHistory = {
points?: Array<HistoryPoint>; points?: Array<HistoryPoint>;
}; };
/**
* One message, as something to bind a widget to.
*/
export type MessageInfo = {
name: string;
flow: string;
dtype: string;
providers?: Array<(string)>;
writable?: boolean;
numeric?: boolean;
value?: unknown;
ts?: (number | null);
};
export type MessagePoints = {
message: string;
numeric: boolean;
points: Array<{
[key: string]: (number);
}>;
};
/** /**
* A single port of a node, and the message it is bound to. * A single port of a node, and the message it is bound to.
* *
@@ -188,12 +275,9 @@ export type MessageSpec = {
trigger?: boolean; trigger?: boolean;
}; };
/** export type MessagesPublic = {
* The last payload seen on a message. data: Array<MessageInfo>;
*/ count: number;
export type MessageValue = {
value?: unknown;
ts?: (number | null);
}; };
export type NewPassword = { export type NewPassword = {
@@ -302,6 +386,36 @@ export type OAuthClientRegister = {
token_endpoint_auth_method?: (string | null); token_endpoint_auth_method?: (string | null);
}; };
/**
* One tab of a dashboard.
*/
export type PageDef_Input = {
id: string;
title?: string;
icon?: string;
sections?: Array<SectionDef_Input>;
};
/**
* One tab of a dashboard.
*/
export type PageDef_Output = {
id: string;
title?: string;
icon?: string;
sections?: Array<SectionDef_Output>;
};
/**
* Where a widget sits in its section's grid, in grid units.
*/
export type Placement = {
x?: number;
y?: number;
w?: number;
h?: number;
};
/** /**
* Where a node sits on the canvas. * Where a node sits on the canvas.
*/ */
@@ -317,14 +431,6 @@ export type PrivateUserCreate = {
is_verified?: boolean; is_verified?: boolean;
}; };
export type PublishRequest = {
version: number;
};
export type RenameRequest = {
new_name: string;
};
/** /**
* Which events go to which channels. * Which events go to which channels.
*/ */
@@ -349,6 +455,24 @@ export type SecretValue = {
value: string; value: string;
}; };
/**
* A grid of widgets under a heading.
*/
export type SectionDef_Input = {
id: string;
title?: string;
widgets?: Array<WidgetDef>;
};
/**
* A grid of widgets under a heading.
*/
export type SectionDef_Output = {
id: string;
title?: string;
widgets?: Array<WidgetDef>;
};
export type ShareRequest = { export type ShareRequest = {
lib_name: string; lib_name: string;
}; };
@@ -435,6 +559,27 @@ export type ValidationResult = {
issues?: Array<ValidationIssue>; issues?: Array<ValidationIssue>;
}; };
/**
* One tile: what it shows or does, and where it sits.
*
* ``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.
*/
export type WidgetDef = {
id: string;
type: 'stat' | 'gauge' | 'chart' | 'markdown' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
title?: string;
layout?: {
[key: string]: Placement;
};
config?: {
[key: string]: unknown;
};
};
export type type = 'stat' | 'gauge' | 'chart' | 'markdown' | 'button' | 'switch' | 'slider' | 'input' | 'dropdown';
export type AlertsReadAlertsConfigResponse = (AlertsConfig); export type AlertsReadAlertsConfigResponse = (AlertsConfig);
export type AlertsSaveAlertsConfigData = { export type AlertsSaveAlertsConfigData = {
@@ -449,6 +594,40 @@ export type AlertsTestChannelData = {
export type AlertsTestChannelResponse = (Message); export type AlertsTestChannelResponse = (Message);
export type DashboardsReadDashboardsResponse = (DashboardsPublic);
export type DashboardsReadDashboardData = {
name: string;
};
export type DashboardsReadDashboardResponse = (DashboardDef_Output);
export type DashboardsCreateDashboardData = {
name: string;
};
export type DashboardsCreateDashboardResponse = (DashboardDef_Output);
export type DashboardsSaveDashboardData = {
name: string;
requestBody: DashboardDef_Input;
};
export type DashboardsSaveDashboardResponse = (DashboardDef_Output);
export type DashboardsDeleteDashboardData = {
name: string;
};
export type DashboardsDeleteDashboardResponse = (Message);
export type DashboardsRenameDashboardData = {
name: string;
requestBody: app__api__routes__dashboards__RenameRequest;
};
export type DashboardsRenameDashboardResponse = (DashboardDef_Output);
export type FlowsReadFlowsResponse = (FlowsPublic); export type FlowsReadFlowsResponse = (FlowsPublic);
export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>); export type FlowsReadNodeTypesResponse = (Array<NodeTypeInfo>);
@@ -482,7 +661,7 @@ export type FlowsDeleteFlowResponse = (Message);
export type FlowsPublishFlowData = { export type FlowsPublishFlowData = {
name: string; name: string;
requestBody: PublishRequest; requestBody: app__api__routes__flows__PublishRequest;
}; };
export type FlowsPublishFlowResponse = (FlowDetail); export type FlowsPublishFlowResponse = (FlowDetail);
@@ -495,7 +674,7 @@ export type FlowsDiscardDraftResponse = (FlowDetail);
export type FlowsRenameFlowData = { export type FlowsRenameFlowData = {
name: string; name: string;
requestBody: RenameRequest; requestBody: app__api__routes__flows__RenameRequest;
}; };
export type FlowsRenameFlowResponse = (FlowDetail); export type FlowsRenameFlowResponse = (FlowDetail);
@@ -614,6 +793,21 @@ export type LoginRecoverPasswordHtmlContentData = {
export type LoginRecoverPasswordHtmlContentResponse = (string); export type LoginRecoverPasswordHtmlContentResponse = (string);
export type MessagesReadMessagesResponse = (MessagesPublic);
export type MessagesPublishMessageData = {
name: string;
requestBody: app__api__routes__messages__PublishRequest;
};
export type MessagesPublishMessageResponse = (app__api__routes__messages__MessageValue);
export type MessagesReadMessageHistoryData = {
name: string;
};
export type MessagesReadMessageHistoryResponse = (MessagePoints);
export type OauthRegisterClientData = { export type OauthRegisterClientData = {
requestBody: OAuthClientRegister; requestBody: OAuthClientRegister;
}; };