diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index d477a7c..1d9b2a7 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -12,6 +12,7 @@ from app.core import security from app.core.config import settings from app.core.db import engine from app.flow.controller import FlowController +from app.flow.dashboards import DashboardStore from app.models import TokenPayload, User reusable_oauth2 = OAuth2PasswordBearer( @@ -102,6 +103,16 @@ def get_flow_controller(request: Request) -> FlowController: 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: if not current_user.is_superuser: raise HTTPException( diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 2f9b508..4416722 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -2,8 +2,10 @@ from fastapi import APIRouter from app.api.routes import ( alerts, + dashboards, flows, login, + messages, oauth, private, secrets, @@ -19,6 +21,8 @@ api_router.include_router(flows.router) api_router.include_router(flows.ws_router) api_router.include_router(secrets.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 # themselves refuse to work unless MCP is switched on. api_router.include_router(oauth.router) diff --git a/backend/app/api/routes/dashboards.py b/backend/app/api/routes/dashboards.py new file mode 100644 index 0000000..fb3e852 --- /dev/null +++ b/backend/app/api/routes/dashboards.py @@ -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)) diff --git a/backend/app/api/routes/messages.py b/backend/app/api/routes/messages.py new file mode 100644 index 0000000..a506631 --- /dev/null +++ b/backend/app/api/routes/messages.py @@ -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], + ) diff --git a/backend/app/flow/controller.py b/backend/app/flow/controller.py index 192f100..e513b6e 100644 --- a/backend/app/flow/controller.py +++ b/backend/app/flow/controller.py @@ -25,7 +25,7 @@ from fastapi.concurrency import run_in_threadpool from app.flow.alerts import AlertManager from app.flow.events import EventBus 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 ( ChangeNode, DelayNode, @@ -282,6 +282,7 @@ class FlowController: self.issues: list[ValidationIssue] = [] self.disabled: set[str] = set() self.supervisor = Supervisor(events) + self.history_limits: dict[str, int] = {} self._lock = asyncio.Lock() # ------------------------------------------------------------------------- @@ -339,6 +340,7 @@ class FlowController: work_queue=self.execution.queue 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: self.execution.bind(self.pipeline) self.issues = _collect_issues(loaded, self.pipeline, flow_inputs) @@ -598,6 +600,80 @@ class FlowController: for item in self.execution.queue.unpark(flow): 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]: return self.execution.stats() if self.execution is not None else {} diff --git a/backend/app/flow/dashboards.py b/backend/app/flow/dashboards.py new file mode 100644 index 0000000..cdd3af9 --- /dev/null +++ b/backend/app/flow/dashboards.py @@ -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", +] diff --git a/backend/app/flow/pipeline.py b/backend/app/flow/pipeline.py index 4b14525..545258b 100644 --- a/backend/app/flow/pipeline.py +++ b/backend/app/flow/pipeline.py @@ -64,6 +64,7 @@ class Pipeline: "_gate_lock", "_queue", "_node_pool", + "history_limits", ) def __init__( @@ -94,6 +95,9 @@ class Pipeline: self._queue = work_queue # A pool owned by the execution service, so a wave does not build one. 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 # of the nodes consuming it. @@ -489,7 +493,7 @@ class Pipeline: {self._timestamp_key(name): ts for name in result}, ) # 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) for name, value in result.items(): self._publish( @@ -679,7 +683,7 @@ class Pipeline: with state.lock(): state.update(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) for name, value in outputs.items(): self._publish( @@ -756,6 +760,42 @@ class Pipeline: self.apply_outputs(node, outputs) 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( self, node: Node, diff --git a/backend/app/flow/state.py b/backend/app/flow/state.py index a690324..9f8f158 100644 --- a/backend/app/flow/state.py +++ b/backend/app/flow/state.py @@ -173,7 +173,9 @@ class StateBackend(ABC): # ------------------------------------------------------------------------- @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. @@ -182,6 +184,8 @@ class StateBackend(ABC): :param values: Message names mapped to the value just published. :type values: dict[str, Any] :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 """ ... @@ -307,14 +311,21 @@ class MemoryState(StateBackend): self._data.update(updates) 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.""" with self._lock: for key, value in values.items(): number = as_number(value) if number is None: 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)) def history(self, key: str) -> list[tuple[float, float]]: @@ -563,7 +574,9 @@ class RedisState(StateBackend): def _history_key(self, key: str) -> str: 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.""" pipe = self._client.pipeline() queued = False @@ -572,8 +585,9 @@ class RedisState(StateBackend): if number is None: continue history_key = self._history_key(key) + cap = (limits or {}).get(key, HISTORY_LIMIT) 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: pipe.expire(history_key, self._ttl) queued = True diff --git a/backend/app/main.py b/backend/app/main.py index 730203c..1c48c19 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.core.config import settings from app.flow import logs from app.flow.alerts import AlertManager from app.flow.controller import FlowController +from app.flow.dashboards import DashboardStore from app.flow.events import event_bus from app.flow.executor import ExecutionService from app.flow.nodes.http import close_shared_client @@ -83,6 +84,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: alerts=alerts, ) 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) app.state.watchdog = watchdog watchdog_task = asyncio.create_task(watchdog.run(), name="loop-watchdog") diff --git a/backend/tests/flow/test_dashboards.py b/backend/tests/flow/test_dashboards.py new file mode 100644 index 0000000..7390a28 --- /dev/null +++ b/backend/tests/flow/test_dashboards.py @@ -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() == {} diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index 335cd70..c3da4ee 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -200,6 +200,113 @@ Binary payloads (tensors, images) will arrive later as explicitly declared codec fields; until then everything on the wire is JSON.` } 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 = { properties: { name: { @@ -367,7 +474,7 @@ export const FlowStatePublicSchema = { properties: { values: { additionalProperties: { - '$ref': '#/components/schemas/MessageValue' + '$ref': '#/components/schemas/app__flow__schemas__MessageValue' }, type: 'object', 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".` } 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 = { properties: { name: { @@ -588,26 +774,23 @@ export const MessageSpecSchema = { message it also produces without depending on itself.` } as const; -export const MessageValueSchema = { +export const MessagesPublicSchema = { properties: { - value: { - title: 'Value' + data: { + items: { + '$ref': '#/components/schemas/MessageInfo' + }, + type: 'array', + title: 'Data' }, - ts: { - anyOf: [ - { - type: 'number' - }, - { - type: 'null' - } - ], - title: 'Ts' + count: { + type: 'integer', + title: 'Count' } }, type: 'object', - title: 'MessageValue', - description: 'The last payload seen on a message.' + required: ['data', 'count'], + title: 'MessagesPublic' } as const; export const NewPasswordSchema = { @@ -1006,6 +1189,94 @@ export const OAuthClientRegisterSchema = { description: 'RFC 7591 dynamic client registration request.' } 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 = { properties: { x: { @@ -1049,30 +1320,6 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } 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 = { properties: { events: { @@ -1144,6 +1391,56 @@ export const SecretValueSchema = { title: 'SecretValue' } 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 = { properties: { lib_name: { @@ -1546,4 +1843,138 @@ export const ValidationResultSchema = { }, type: 'object', title: 'ValidationResult' +} 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; \ No newline at end of file diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index b4d1fb0..e47247c 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; 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 { /** @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { /** * 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 { + 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 { + 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 { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/messages/{name}/history', + path: { + name: data.name + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class OauthService { /** * Register Client diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 2e4dfc4..294493c 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -9,6 +9,36 @@ export type AlertsConfig = { rules?: Array; }; +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 = { grant_type?: (string | null); username: string; @@ -42,6 +72,41 @@ export type Channel = { 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; + version?: number; +}; + +/** + * A dashboard as stored, and as the API hands it over. + */ +export type DashboardDef_Output = { + name: string; + title?: string; + pages?: Array; + version?: number; +}; + +export type DashboardsPublic = { + data: Array; + 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. * @@ -111,7 +176,7 @@ export type FlowsPublic = { export type FlowStatePublic = { values?: { - [key: string]: MessageValue; + [key: string]: app__flow__schemas__MessageValue; }; nodes?: Array; }; @@ -163,6 +228,28 @@ export type MessageHistory = { points?: Array; }; +/** + * 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. * @@ -188,12 +275,9 @@ export type MessageSpec = { trigger?: boolean; }; -/** - * The last payload seen on a message. - */ -export type MessageValue = { - value?: unknown; - ts?: (number | null); +export type MessagesPublic = { + data: Array; + count: number; }; export type NewPassword = { @@ -302,6 +386,36 @@ export type OAuthClientRegister = { token_endpoint_auth_method?: (string | null); }; +/** + * One tab of a dashboard. + */ +export type PageDef_Input = { + id: string; + title?: string; + icon?: string; + sections?: Array; +}; + +/** + * One tab of a dashboard. + */ +export type PageDef_Output = { + id: string; + title?: string; + icon?: string; + sections?: Array; +}; + +/** + * 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. */ @@ -317,14 +431,6 @@ export type PrivateUserCreate = { is_verified?: boolean; }; -export type PublishRequest = { - version: number; -}; - -export type RenameRequest = { - new_name: string; -}; - /** * Which events go to which channels. */ @@ -349,6 +455,24 @@ export type SecretValue = { value: string; }; +/** + * A grid of widgets under a heading. + */ +export type SectionDef_Input = { + id: string; + title?: string; + widgets?: Array; +}; + +/** + * A grid of widgets under a heading. + */ +export type SectionDef_Output = { + id: string; + title?: string; + widgets?: Array; +}; + export type ShareRequest = { lib_name: string; }; @@ -435,6 +559,27 @@ export type ValidationResult = { issues?: Array; }; +/** + * 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 AlertsSaveAlertsConfigData = { @@ -449,6 +594,40 @@ export type AlertsTestChannelData = { 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 FlowsReadNodeTypesResponse = (Array); @@ -482,7 +661,7 @@ export type FlowsDeleteFlowResponse = (Message); export type FlowsPublishFlowData = { name: string; - requestBody: PublishRequest; + requestBody: app__api__routes__flows__PublishRequest; }; export type FlowsPublishFlowResponse = (FlowDetail); @@ -495,7 +674,7 @@ export type FlowsDiscardDraftResponse = (FlowDetail); export type FlowsRenameFlowData = { name: string; - requestBody: RenameRequest; + requestBody: app__api__routes__flows__RenameRequest; }; export type FlowsRenameFlowResponse = (FlowDetail); @@ -614,6 +793,21 @@ export type LoginRecoverPasswordHtmlContentData = { 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 = { requestBody: OAuthClientRegister; };