315 lines
11 KiB
Python
315 lines
11 KiB
Python
"""Dashboards: documents of widgets bound to message names.
|
|
|
|
A dashboard is addressed by its id in every path. Its key, ``name``, is a
|
|
label unique among dashboards and what ``GET /dashboards?name=`` looks up.
|
|
"""
|
|
|
|
import time
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from fastapi.concurrency import run_in_threadpool
|
|
from pydantic import BaseModel
|
|
|
|
from fluksio.api.deps import (
|
|
CurrentUser,
|
|
DashboardStoreDep,
|
|
FlowControllerDep,
|
|
RenamerDep,
|
|
check_pin,
|
|
get_current_active_superuser,
|
|
get_current_user,
|
|
)
|
|
from fluksio.api.routes.alerts import read_config as read_alerts_config
|
|
from fluksio.flow.dashboards import (
|
|
DashboardCreate,
|
|
DashboardDef,
|
|
DashboardExists,
|
|
DashboardNotFound,
|
|
DashboardsPublic,
|
|
default_dashboard,
|
|
results_dashboard,
|
|
)
|
|
from fluksio.flow.events import event_bus
|
|
from fluksio.flow.rename import (
|
|
RenamePlan,
|
|
RenamePlanRequest,
|
|
RenameRequest,
|
|
RenameResult,
|
|
plan_rename,
|
|
)
|
|
from fluksio.flow.store import FlowNotFound, IdTaken, StaleVersion
|
|
from fluksio.models import Message
|
|
|
|
router = APIRouter(
|
|
prefix="/dashboards", tags=["dashboards"], dependencies=[Depends(get_current_user)]
|
|
)
|
|
|
|
|
|
class PublishRequest(BaseModel):
|
|
version: int
|
|
|
|
|
|
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())
|
|
|
|
|
|
def _missing(dashboard_id: str) -> HTTPException:
|
|
return HTTPException(
|
|
status_code=404, detail=f"No dashboard with the id '{dashboard_id}'"
|
|
)
|
|
|
|
|
|
@router.get("/", response_model=DashboardsPublic)
|
|
async def read_dashboards(store: DashboardStoreDep, name: str | None = None) -> Any:
|
|
"""Every dashboard, without its contents, or with ``name`` the one with
|
|
that key."""
|
|
summaries = await run_in_threadpool(store.list)
|
|
if name is not None:
|
|
summaries = [summary for summary in summaries if summary.name == name]
|
|
return DashboardsPublic(data=summaries, count=len(summaries))
|
|
|
|
|
|
@router.post("/", response_model=DashboardDef, status_code=201)
|
|
async def create_dashboard(
|
|
body: DashboardCreate, store: DashboardStoreDep, user: CurrentUser
|
|
) -> Any:
|
|
"""Start a dashboard: one empty grid under a key of its own.
|
|
|
|
The server gives it an opaque id; a superuser may pin one with ``id``. A
|
|
draft, like every edit that follows it — a dashboard reaches a panel only
|
|
once someone publishes it, so an empty one never does.
|
|
"""
|
|
check_pin(body.id, user, router)
|
|
try:
|
|
# No history limits to apply: they are read from the published
|
|
# documents, and this one is not one of them yet.
|
|
return await run_in_threadpool(
|
|
store.create, default_dashboard(body.name, body.title), body.id
|
|
)
|
|
except (DashboardExists, IdTaken) as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc))
|
|
|
|
|
|
@router.get("/{dashboard_id}", response_model=DashboardDef)
|
|
async def read_dashboard(
|
|
dashboard_id: str, store: DashboardStoreDep, draft: bool = False
|
|
) -> Any:
|
|
"""What a panel shows, or with ``draft`` the copy the editor is on."""
|
|
try:
|
|
return await run_in_threadpool(store.read, dashboard_id, draft)
|
|
except DashboardNotFound:
|
|
raise _missing(dashboard_id)
|
|
|
|
|
|
@router.post("/from-flow/{flow_id}", response_model=DashboardDef, status_code=201)
|
|
async def generate_results_dashboard(
|
|
flow_id: str,
|
|
store: DashboardStoreDep,
|
|
controller: FlowControllerDep,
|
|
) -> Any:
|
|
"""Draw a batch flow's results as a dashboard, from the ports it declares.
|
|
|
|
Keyed after the flow's key, ``<key>_results``, which a second one refuses
|
|
(409) rather than numbering. Published straight away rather than left as a
|
|
draft: there is nothing to review that the flow did not already say, and
|
|
what makes it useful is being able to open it against a run immediately.
|
|
It is an ordinary dashboard afterwards — editing it is how it stops being
|
|
generic.
|
|
"""
|
|
try:
|
|
definition = await run_in_threadpool(controller.store.read_flow, flow_id)
|
|
except FlowNotFound:
|
|
raise HTTPException(status_code=404, detail=f"No flow with the id '{flow_id}'")
|
|
if definition.mode != "batch":
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=(
|
|
f"'{definition.name}' is a live flow; a results dashboard is a "
|
|
"run's view"
|
|
),
|
|
)
|
|
try:
|
|
written = await run_in_threadpool(
|
|
store.create, results_dashboard(definition), "", True
|
|
)
|
|
except DashboardExists as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc))
|
|
await run_in_threadpool(_apply_history_limits, store, controller)
|
|
event_bus.publish(
|
|
{"type": "dashboard_changed", "dashboard": written.id, "ts": time.time()}
|
|
)
|
|
return written
|
|
|
|
|
|
@router.put("/{dashboard_id}", response_model=DashboardDef)
|
|
async def save_dashboard(
|
|
dashboard_id: str,
|
|
body: DashboardDef,
|
|
store: DashboardStoreDep,
|
|
) -> Any:
|
|
"""Save unpublished changes, refusing a save someone else has moved past.
|
|
|
|
Only to a dashboard that exists: creating one is ``POST /dashboards``, and
|
|
neither the id nor the key changes here. This writes a draft: panels keep
|
|
showing the published document until someone publishes, so nothing here
|
|
can change what a wall is displaying — which is also why the engine's
|
|
history limits are left alone.
|
|
"""
|
|
if body.id and body.id != dashboard_id:
|
|
raise HTTPException(status_code=422, detail="The id in the body must match")
|
|
try:
|
|
return await run_in_threadpool(
|
|
store.write_draft,
|
|
body.model_copy(update={"id": dashboard_id}),
|
|
body.version,
|
|
)
|
|
except DashboardNotFound:
|
|
raise _missing(dashboard_id)
|
|
except StaleVersion as exc:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail={
|
|
"message": "Someone else saved this dashboard first",
|
|
"current_version": exc.current,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/{dashboard_id}/publish", response_model=DashboardDef)
|
|
async def publish_dashboard(
|
|
dashboard_id: str,
|
|
body: PublishRequest,
|
|
store: DashboardStoreDep,
|
|
controller: FlowControllerDep,
|
|
) -> Any:
|
|
"""Put the unpublished changes on the panels."""
|
|
if not await run_in_threadpool(store.exists, dashboard_id):
|
|
raise _missing(dashboard_id)
|
|
if not await run_in_threadpool(store.has_draft, dashboard_id):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Dashboard '{dashboard_id}' has no unpublished changes",
|
|
)
|
|
try:
|
|
published = await run_in_threadpool(store.publish, dashboard_id, 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)
|
|
# What a panel is showing has changed. Panels watch the flow socket, and
|
|
# the tile values alone cannot tell them the document itself moved.
|
|
event_bus.publish(
|
|
{"type": "dashboard_changed", "dashboard": dashboard_id, "ts": time.time()}
|
|
)
|
|
return published
|
|
|
|
|
|
@router.post("/{dashboard_id}/discard", response_model=DashboardDef)
|
|
async def discard_dashboard_draft(dashboard_id: str, store: DashboardStoreDep) -> Any:
|
|
"""Throw the unpublished changes away and go back to what is shown."""
|
|
if not await run_in_threadpool(store.exists, dashboard_id):
|
|
raise _missing(dashboard_id)
|
|
if not await run_in_threadpool(store.has_draft, dashboard_id):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Dashboard '{dashboard_id}' has no unpublished changes",
|
|
)
|
|
if not await run_in_threadpool(store.is_published, dashboard_id):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
f"Dashboard '{dashboard_id}' has never been published. Delete it "
|
|
"instead of discarding it"
|
|
),
|
|
)
|
|
return await run_in_threadpool(store.discard_draft, dashboard_id)
|
|
|
|
|
|
@router.delete("/{dashboard_id}", response_model=Message)
|
|
async def delete_dashboard(
|
|
dashboard_id: str, store: DashboardStoreDep, controller: FlowControllerDep
|
|
) -> Any:
|
|
try:
|
|
await run_in_threadpool(store.delete, dashboard_id)
|
|
except DashboardNotFound:
|
|
raise _missing(dashboard_id)
|
|
await run_in_threadpool(_apply_history_limits, store, controller)
|
|
return Message(message=f"Deleted dashboard '{dashboard_id}'")
|
|
|
|
|
|
@router.post("/{dashboard_id}/duplicate", response_model=DashboardDef)
|
|
async def duplicate_dashboard(dashboard_id: str, store: DashboardStoreDep) -> Any:
|
|
"""Copy a dashboard into a new one that is only a draft, named after it.
|
|
|
|
The copy gets an id of its own. No history limits to apply, as for a new
|
|
dashboard: nothing published moved.
|
|
"""
|
|
try:
|
|
return await run_in_threadpool(store.duplicate, dashboard_id)
|
|
except DashboardNotFound:
|
|
raise _missing(dashboard_id)
|
|
|
|
|
|
@router.post(
|
|
"/{dashboard_id}/rename/plan",
|
|
response_model=RenamePlan,
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
)
|
|
async def plan_dashboard_rename(
|
|
dashboard_id: str,
|
|
body: RenamePlanRequest,
|
|
store: DashboardStoreDep,
|
|
controller: FlowControllerDep,
|
|
) -> Any:
|
|
"""What renaming this dashboard's key would change, and what refuses it.
|
|
|
|
Changes nothing. Nothing binds to a dashboard's key, so the plan is the
|
|
dashboard alone; a superuser's all the same, as a flow's is.
|
|
"""
|
|
try:
|
|
return await run_in_threadpool(
|
|
lambda: plan_rename(
|
|
controller,
|
|
store,
|
|
"dashboard",
|
|
dashboard_id,
|
|
body.new_name,
|
|
read_alerts_config(),
|
|
)
|
|
)
|
|
except DashboardNotFound:
|
|
raise _missing(dashboard_id)
|
|
|
|
|
|
@router.post(
|
|
"/{dashboard_id}/rename",
|
|
response_model=RenameResult,
|
|
dependencies=[Depends(get_current_active_superuser)],
|
|
)
|
|
async def rename_dashboard(
|
|
dashboard_id: str,
|
|
body: RenameRequest,
|
|
request: Request,
|
|
renamer: RenamerDep,
|
|
user: CurrentUser,
|
|
) -> Any:
|
|
"""Rename this dashboard's key, as its plan described it.
|
|
|
|
Its two documents and nothing else: devices list it by id, and nothing
|
|
binds to it by key. The same ``request_id`` again gets the same answer;
|
|
refusals are as for a flow.
|
|
"""
|
|
try:
|
|
return await renamer.rename(
|
|
"dashboard", dashboard_id, body, user.email, request.is_disconnected
|
|
)
|
|
except DashboardNotFound:
|
|
raise _missing(dashboard_id)
|