Draw curves in the terminal, and stop waiting five seconds to hear
Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 18m28s
Playwright Tests / test-playwright (2, 2) (push) Successful in 5m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m32s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 5m50s
Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 18m28s
Playwright Tests / test-playwright (2, 2) (push) Successful in 5m11s
pre-commit / pre-commit (push) Failing after 2m2s
Test Backend / test-backend (push) Failing after 2m32s
Compose Smoke Test / test-compose (push) Successful in 32s
Playwright Tests / merge-reports (push) Successful in 5m50s
`space` ticks runs on the serve dashboard's table and `enter` compares them: one metric in braille, five distinct hues, and the table of what actually differs under it. Both halves are routes that already existed — the browser's own comparison endpoint for the curves, the runs export for the table, whose input columns are filtered to the ones that vary. The palette is hue rather than the web's lightness ramp on purpose: five steps of one brand hue collapse to a single colour on a 16-colour tty. The screen also subscribes to the engine's event bus over the same websocket a browser uses, so a run that starts and finishes inside a tick is seen rather than only recorded. `a` lists what a run left behind and fetches it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SmyLMSqcJQ8tUL2qLj21s
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
"""Runs beside each other: one metric's curves, and what differs between them.
|
||||
|
||||
The engine already answers both halves. `GET /runs/series/compare` returns the
|
||||
chart's own series shape for any number of runs, and `GET /runs/export/runs`
|
||||
returns one row per run with every input and final number the selection
|
||||
recorded as its own column — the table this screen keeps the varying half of.
|
||||
So nothing here computes what a route already knows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
from textual import work
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import DataTable, Footer, Header, Select, Static
|
||||
|
||||
from fluksio.sdk.cli import _dur, metric_names
|
||||
from fluksio.sdk.client import Client
|
||||
from fluksio.tui.chart import MAX_SERIES, Curves, legend
|
||||
|
||||
#: The axes every comparison offers, before the run's own metrics are added.
|
||||
STEP_AXIS, TIME_AXIS = "step", "time"
|
||||
|
||||
#: What the columns of `export runs` are called that are not a run's own.
|
||||
PARAM, METRIC = "param.", "metric."
|
||||
|
||||
#: The statuses that mean a run's numbers are not final yet.
|
||||
RUNNING = frozenset({"queued", "running"})
|
||||
|
||||
#: How often a curve is re-read while a run in the selection is still going.
|
||||
#: Metric points are not published on the event bus — the run's sink writes
|
||||
#: them — so the socket makes the *table* live and this makes the *curve* live.
|
||||
LIVE_S = 1.0
|
||||
|
||||
|
||||
def _cell(value: Any) -> str:
|
||||
"""A reading narrow enough for a column, at the precision one reads at.
|
||||
|
||||
Four significant figures, which is what the browser's legend settles on:
|
||||
the seventeen digits a float prints are a column nobody can scan.
|
||||
"""
|
||||
if isinstance(value, float):
|
||||
return f"{value:.4g}"
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def varying(rows: list[dict[str, Any]], prefix: str) -> list[str]:
|
||||
"""The prefixed columns whose value is not the same in every row.
|
||||
|
||||
What a reader is looking for across a page of runs is where they differ;
|
||||
a sweep over one parameter should not draw twelve identical columns.
|
||||
"""
|
||||
if len(rows) < 2:
|
||||
return sorted(key for key in rows[0] if key.startswith(prefix)) if rows else []
|
||||
return sorted(
|
||||
key
|
||||
for key in rows[0]
|
||||
if key.startswith(prefix) and len({str(row.get(key)) for row in rows}) > 1
|
||||
)
|
||||
|
||||
|
||||
class CompareScreen(Screen[None]):
|
||||
"""One metric across the picked runs, over the table of what differs."""
|
||||
|
||||
CSS = """
|
||||
#head { padding: 0 1; }
|
||||
#pickers { height: 3; padding: 0 1; }
|
||||
#pickers Select { width: 34; }
|
||||
#legend { padding: 0 1; height: auto; }
|
||||
#diff { height: auto; max-height: 12; }
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "app.pop_screen", "back"),
|
||||
("r", "reload", "reload"),
|
||||
]
|
||||
|
||||
def __init__(self, client: Client, ids: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.client = client
|
||||
self.ids = ids
|
||||
self.metric = ""
|
||||
self.x = STEP_AXIS
|
||||
self.names: list[str] = []
|
||||
#: What the metric picker is currently offering. Rebuilding it on every
|
||||
#: refresh would shut a dropdown under whoever opened it.
|
||||
self.names_shown: list[str] = []
|
||||
self.live: Any = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static(id="head")
|
||||
with Horizontal(id="pickers"):
|
||||
yield Select([], id="metric", prompt="metric", allow_blank=True)
|
||||
yield Select(
|
||||
[("step", STEP_AXIS), ("time (s)", TIME_AXIS)],
|
||||
id="x",
|
||||
value=STEP_AXIS,
|
||||
allow_blank=False,
|
||||
)
|
||||
yield Curves(id="chart")
|
||||
yield Static(id="legend")
|
||||
table: DataTable[Any] = DataTable(id="diff", cursor_type="row")
|
||||
yield table
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = f"{len(self.ids)} runs"
|
||||
self.query_one("#head", Static).update("Reading these runs\u2026")
|
||||
self.read_table()
|
||||
|
||||
def action_reload(self) -> None:
|
||||
self.read_table()
|
||||
|
||||
def on_select_changed(self, event: Select.Changed) -> None:
|
||||
if event.value is Select.BLANK:
|
||||
return
|
||||
if event.select.id == "metric":
|
||||
self.metric = str(event.value)
|
||||
else:
|
||||
self.x = str(event.value)
|
||||
self.read_curves()
|
||||
|
||||
# -- reading ---------------------------------------------------------------
|
||||
|
||||
@work(thread=True, exclusive=True, group="table")
|
||||
def read_table(self) -> None:
|
||||
try:
|
||||
rows = self.client.export_runs(ids=self.ids)
|
||||
names = metric_names(self.client, self.ids)
|
||||
except Exception as exc: # noqa: BLE001 — the screen reports it
|
||||
self.app.call_from_thread(self.show_error, exc)
|
||||
return
|
||||
self.app.call_from_thread(self.show_table, rows, names)
|
||||
|
||||
@work(thread=True, exclusive=True, group="curves")
|
||||
def read_curves(self) -> None:
|
||||
if not self.metric:
|
||||
return
|
||||
try:
|
||||
answer = self.client.compare(self.ids[:MAX_SERIES], self.metric, self.x)
|
||||
except Exception as exc: # noqa: BLE001 — the screen reports it
|
||||
self.app.call_from_thread(self.show_error, exc)
|
||||
return
|
||||
self.app.call_from_thread(self.show_curves, answer)
|
||||
|
||||
# -- drawing ---------------------------------------------------------------
|
||||
|
||||
def show_error(self, exc: Exception) -> None:
|
||||
self.query_one("#head", Static).update(
|
||||
Text(f"{type(exc).__name__}: {exc}", style="red")
|
||||
)
|
||||
|
||||
def show_table(self, rows: list[dict[str, Any]], names: list[str]) -> None:
|
||||
self.names = names
|
||||
flow = str(rows[0].get("flow", "")) if rows else ""
|
||||
drawn = (
|
||||
f" {MAX_SERIES} of {len(self.ids)} drawn"
|
||||
if len(self.ids) > MAX_SERIES
|
||||
else ""
|
||||
)
|
||||
self.query_one("#head", Static).update(
|
||||
f"{len(self.ids)} runs \u00b7 {flow}{drawn}"
|
||||
)
|
||||
|
||||
picker = self.query_one("#metric", Select)
|
||||
if names and names != self.names_shown:
|
||||
self.names_shown = list(names)
|
||||
picker.set_options([(name, name) for name in names])
|
||||
self.metric = self.metric if self.metric in names else names[0]
|
||||
picker.value = self.metric
|
||||
axes = self.query_one("#x", Select)
|
||||
axes.set_options(
|
||||
[("step", STEP_AXIS), ("time (s)", TIME_AXIS)]
|
||||
+ [(name, name) for name in names if name != self.metric]
|
||||
)
|
||||
axes.value = (
|
||||
self.x if self.x in {STEP_AXIS, TIME_AXIS, *names} else STEP_AXIS
|
||||
)
|
||||
|
||||
table = self.query_one("#diff", DataTable)
|
||||
table.clear(columns=True)
|
||||
params = varying(rows, PARAM)
|
||||
scores = sorted(
|
||||
key for key in (rows[0] if rows else {}) if key.startswith(METRIC)
|
||||
)
|
||||
# Not a parameter, but it is part of what produced the number, and in
|
||||
# a sweep it is sometimes the only thing that moved.
|
||||
seeds = len({str(row.get("seed")) for row in rows}) > 1
|
||||
table.add_columns(
|
||||
"run",
|
||||
"status",
|
||||
"took",
|
||||
*(["seed"] if seeds else []),
|
||||
*(key[len(PARAM) :] for key in params),
|
||||
*(key[len(METRIC) :] for key in scores),
|
||||
)
|
||||
for row in rows:
|
||||
table.add_row(
|
||||
str(row.get("id", ""))[-8:],
|
||||
str(row.get("status", "")),
|
||||
_dur(row.get("duration_ms")),
|
||||
*([_cell(row.get("seed"))] if seeds else []),
|
||||
*(_cell(row.get(key)) for key in params),
|
||||
*(_cell(row.get(key)) for key in scores),
|
||||
)
|
||||
# A curve only grows while its run does, and metric points are not on
|
||||
# the event bus — so this is the one thing the screen polls for.
|
||||
self.keep_live(any(str(row.get("status")) in RUNNING for row in rows))
|
||||
self.read_curves()
|
||||
|
||||
def show_curves(self, answer: dict[str, Any]) -> None:
|
||||
lines = [
|
||||
(str(line.get("label", "")), line.get("points") or [])
|
||||
for line in answer.get("lines") or []
|
||||
]
|
||||
self.query_one("#chart", Curves).show(lines, str(answer.get("x") or ""))
|
||||
self.query_one("#legend", Static).update(
|
||||
legend(label for label, _ in lines[:MAX_SERIES])
|
||||
)
|
||||
|
||||
def keep_live(self, running: bool) -> None:
|
||||
if running and self.live is None:
|
||||
self.live = self.set_interval(LIVE_S, self.read_curves)
|
||||
elif not running and self.live is not None:
|
||||
self.live.stop()
|
||||
self.live = None
|
||||
Reference in New Issue
Block a user