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
113 lines
3.9 KiB
Python
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))
|