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

218 lines
8.0 KiB
Python

"""Dashboards: documents of widgets bound to message names."""
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 DashboardStoreDep, FlowControllerDep, get_current_user
from fluksio.flow.dashboards import (
DashboardDef,
DashboardExists,
DashboardNotFound,
DashboardsPublic,
default_dashboard,
results_dashboard,
results_name,
)
from fluksio.flow.events import event_bus
from fluksio.flow.store import FlowNotFound, StaleVersion
from fluksio.models import Message
router = APIRouter(
prefix="/dashboards", tags=["dashboards"], dependencies=[Depends(get_current_user)]
)
class RenameRequest(BaseModel):
name: str
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())
@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, 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, name, draft)
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) -> Any:
"""Start a dashboard: one page, one section, nothing on it yet.
A draft, like every edit that follows it — a dashboard reaches a panel
only once someone publishes it, so an empty one never does.
"""
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))
# 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.write_draft, defn, 0)
@router.post("/from-flow/{flow}", response_model=DashboardDef)
async def generate_results_dashboard(
flow: str,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Draw a batch flow's results as a dashboard, from the ports it declares.
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)
except FlowNotFound:
raise HTTPException(status_code=404, detail=f"No flow named '{flow}'")
if definition.mode != "batch":
raise HTTPException(
status_code=422,
detail=f"'{flow}' is a live flow; a results dashboard is a run's view",
)
name = results_name(flow)
if await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=409, detail=f"'{name}' already exists")
written = await run_in_threadpool(store.write, results_dashboard(definition))
await run_in_threadpool(_apply_history_limits, store, controller)
event_bus.publish(
{"type": "dashboard_changed", "dashboard": name, "ts": time.time()}
)
return written
@router.put("/{name}", response_model=DashboardDef)
async def save_dashboard(
name: str,
body: DashboardDef,
store: DashboardStoreDep,
) -> Any:
"""Save unpublished changes, refusing a save someone else has moved past.
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.name != name:
raise HTTPException(status_code=422, detail="The name in the body must match")
try:
saved = await run_in_threadpool(store.write_draft, body, body.version)
except DashboardNotFound:
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
except StaleVersion as exc:
raise HTTPException(
status_code=409,
detail={
"message": "Someone else saved this dashboard first",
"current_version": exc.current,
},
)
return saved
@router.post("/{name}/publish", response_model=DashboardDef)
async def publish_dashboard(
name: str,
body: PublishRequest,
store: DashboardStoreDep,
controller: FlowControllerDep,
) -> Any:
"""Put the unpublished changes on the panels."""
if not await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
if not await run_in_threadpool(store.has_draft, name):
raise HTTPException(
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
)
try:
published = await run_in_threadpool(store.publish, name, 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": name, "ts": time.time()}
)
return published
@router.post("/{name}/discard", response_model=DashboardDef)
async def discard_dashboard_draft(name: str, store: DashboardStoreDep) -> Any:
"""Throw the unpublished changes away and go back to what is shown."""
if not await run_in_threadpool(store.exists, name):
raise HTTPException(status_code=404, detail=f"No dashboard named '{name}'")
if not await run_in_threadpool(store.has_draft, name):
raise HTTPException(
status_code=400, detail=f"Dashboard '{name}' has no unpublished changes"
)
if not await run_in_threadpool(store.is_published, name):
raise HTTPException(
status_code=400,
detail=(
f"Dashboard '{name}' has never been published — delete it instead "
"of discarding it"
),
)
return await run_in_threadpool(store.discard_draft, name)
@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))