Files
app/backend/fluksio/api/routes/dashboards.py
T

248 lines
8.9 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
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import (
CurrentUser,
DashboardStoreDep,
FlowControllerDep,
check_pin,
get_current_user,
)
from fluksio.flow.dashboards import (
DashboardCreate,
DashboardDef,
DashboardExists,
DashboardNotFound,
DashboardsPublic,
default_dashboard,
results_dashboard,
)
from fluksio.flow.events import event_bus
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)