Add a runs screen: a table, a run in full, and curves side by side
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,10 +11,12 @@ from fluksio.flow.dashboards import (
|
||||
SettingDef,
|
||||
WidgetDef,
|
||||
default_dashboard,
|
||||
results_dashboard,
|
||||
)
|
||||
from fluksio.flow.messages import DType, MessageSpec
|
||||
from fluksio.flow.nodes import Node
|
||||
from fluksio.flow.pipeline import Pipeline
|
||||
from fluksio.flow.schemas import FlowDef, NodeDef
|
||||
from fluksio.flow.state import MemoryState
|
||||
from fluksio.flow.store import FlowStore, StaleVersion
|
||||
|
||||
@@ -444,3 +446,50 @@ def test_a_bound_setting_is_drawn_on_the_canvas(store: DashboardStore):
|
||||
assert binding["requires"] == ["home.theme"]
|
||||
assert not binding["provides"]
|
||||
assert store.bindings_for("other") == []
|
||||
|
||||
|
||||
def training_flow() -> FlowDef:
|
||||
"""A batch flow shaped like an experiment: a curve and two results."""
|
||||
return FlowDef(
|
||||
name="study",
|
||||
mode="batch",
|
||||
outputs=["accuracy", "report"],
|
||||
nodes=[
|
||||
NodeDef(
|
||||
id="fit",
|
||||
provides=[
|
||||
MessageSpec(name="loss", dtype=DType.FLOAT, stream=True),
|
||||
MessageSpec(name="accuracy", dtype=DType.FLOAT),
|
||||
MessageSpec(name="report", dtype=DType.RECORD),
|
||||
MessageSpec(name="weights", dtype=DType.ARTIFACT),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_a_generated_dashboard_charts_the_curves_and_states_the_results():
|
||||
"""The ports are the whole specification; nothing else is guessed."""
|
||||
defn = results_dashboard(training_flow())
|
||||
kinds = [(w.type, w.config.get("message")) for w in defn.widgets]
|
||||
|
||||
assert [w.type for w in defn.widgets] == ["chart", "stat", "notification"]
|
||||
assert defn.widgets[0].config["series"][0]["message"] == "study.loss"
|
||||
assert ("stat", "study.accuracy") in kinds
|
||||
assert ("notification", "study.report") in kinds
|
||||
|
||||
|
||||
def test_an_artifact_output_gets_no_widget():
|
||||
"""A checkpoint has no single reading to draw."""
|
||||
defn = results_dashboard(
|
||||
training_flow().model_copy(update={"outputs": ["weights"]})
|
||||
)
|
||||
|
||||
assert [w.type for w in defn.widgets] == ["chart"]
|
||||
|
||||
|
||||
def test_a_flow_declaring_no_outputs_draws_no_stats():
|
||||
"""Empty outputs means "everything", which is not a set to enumerate."""
|
||||
defn = results_dashboard(training_flow().model_copy(update={"outputs": []}))
|
||||
|
||||
assert [w.type for w in defn.widgets] == ["chart"]
|
||||
|
||||
Reference in New Issue
Block a user