A wheel whose top-level module is `app` collides with anything else in a user's venv, so the package that is about to be published takes the name it is published under. Only the Python package moves; the repo, the Docker WORKDIR and the compose project keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
182 lines
6.6 KiB
Python
182 lines
6.6 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,
|
|
)
|
|
from fluksio.flow.events import event_bus
|
|
from fluksio.flow.store import 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.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))
|