"""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))