Files
app/backend/app/api/routes/dashboards.py
T
stroblmeandClaude Fable 5 895bd89b2b 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
2026-08-16 09:20:49 +02:00

113 lines
3.9 KiB
Python

"""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))