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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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],
|
||||
)
|
||||
Reference in New Issue
Block a user