Add a runs screen: a table, a run in full, and curves side by side

This commit is contained in:
2026-08-25 11:44:13 +02:00
parent 7e422c0047
commit d2951a325e
17 changed files with 1564 additions and 5 deletions
+37 -1
View File
@@ -14,9 +14,11 @@ from fluksio.flow.dashboards import (
DashboardNotFound,
DashboardsPublic,
default_dashboard,
results_dashboard,
results_name,
)
from fluksio.flow.events import event_bus
from fluksio.flow.store import StaleVersion
from fluksio.flow.store import FlowNotFound, StaleVersion
from fluksio.models import Message
router = APIRouter(
@@ -73,6 +75,40 @@ async def create_dashboard(name: str, store: DashboardStoreDep) -> Any:
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,
+101 -1
View File
@@ -23,7 +23,8 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from fluksio.flow.schemas import _validate_name
from fluksio.flow.messages import DType, qualify
from fluksio.flow.schemas import FlowDef, _validate_name
from fluksio.flow.store import FlowStore, StaleVersion
#: Sibling of the shared-node library, and likewise not a flow.
@@ -716,6 +717,105 @@ def default_dashboard(name: str) -> DashboardDef:
)
#: What the generated dashboard is called, for a flow of this name.
def results_name(flow: str) -> str:
return f"{flow}_results"
#: Numbers a stat or a chart can draw.
_NUMERIC = {DType.FLOAT, DType.INT}
def results_dashboard(flow: FlowDef) -> DashboardDef:
"""A results dashboard for a batch flow, from the ports it declares.
A streaming output is a curve and gets a chart; a scalar output is a
number and gets a stat. Nothing here is specific to runs: the widgets bind
to the flow's own message names, which is what makes the same page draw a
run live, draw a finished one when opened in a run's context, and stay an
ordinary dashboard anyone can edit afterwards.
A starting point rather than a finished page — which is the only reason
generating one is worth doing at all.
"""
charts = [
spec
for node in flow.nodes
for spec in node.provides
if spec.stream and spec.dtype in _NUMERIC and spec.name
]
# What a run reports. Declared outputs are unqualified names; an empty list
# means "everything the flow ends up holding", which is not a set this can
# enumerate, so it draws no stats rather than guessing at them.
produced = {
spec.name.rsplit(".", 1)[-1]: spec
for node in flow.nodes
for spec in node.provides
if spec.name
}
stats = [
produced[name]
for name in flow.outputs
if name in produced and not produced[name].stream
]
widgets: list[WidgetDef] = []
for index, spec in enumerate(charts):
widgets.append(
WidgetDef(
id=f"chart_{spec.port or index}",
type="chart",
title=spec.port or spec.name,
layout={"lg": Placement(x=0, y=index * 4, w=8, h=4)},
config={
"series": [
{
"message": qualify(flow.name, spec.name),
"dtype": spec.dtype.value,
"label": spec.port or spec.name,
}
],
"history": {"points": 600},
},
)
)
row = 0
for spec in stats:
if spec.dtype is DType.RECORD:
kind: WidgetType = "notification"
elif spec.dtype in _NUMERIC or spec.dtype is DType.STR:
kind = "stat"
else:
# A list, a series or an artifact has no single reading to show.
continue
widgets.append(
WidgetDef(
id=f"out_{spec.port}",
type=kind,
title=spec.port,
layout={"lg": Placement(x=8, y=row * 2, w=4, h=2)},
config={
"message": qualify(flow.name, spec.name),
"dtype": spec.dtype.value,
},
)
)
row += 1
return DashboardDef(
name=results_name(flow.name),
title=f"{flow.title or flow.name} results",
pages=[
PageDef(
id="main",
title="Results",
sections=[SectionDef(id="main", widgets=widgets)],
)
],
)
__all__ = [
"BAR_ROWS",
"COLOR_DTYPES",