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:
@@ -805,7 +805,7 @@ def _status_screen(client: Client) -> Any:
|
||||
)
|
||||
+ Text(
|
||||
f"{str(row.get('flow', '')):<16}"
|
||||
f"{(row.get('duration_ms') or 0) / 1000:7.1f}s"
|
||||
f"{_dur(row.get('duration_ms')):>8}"
|
||||
f" {_ago(row.get('finished_at') or row.get('created_at')):>9}",
|
||||
style="dim",
|
||||
)
|
||||
@@ -886,6 +886,19 @@ def _declared(client: Client, flows: Iterable[str]) -> dict[str, dict[str, Any]]
|
||||
return known
|
||||
|
||||
|
||||
def _dur(ms: Any) -> str:
|
||||
"""How long something took, in the same notation `_ago` reads an age in.
|
||||
|
||||
A run measured in hours used to print four digits of seconds, because the
|
||||
three places that formatted a duration each did it their own way.
|
||||
"""
|
||||
seconds = max(float(ms or 0.0), 0.0) / 1000
|
||||
for span, unit in ((86400, "d"), (3600, "h"), (60, "min")):
|
||||
if seconds >= span:
|
||||
return f"{seconds / span:.1f}{unit}"
|
||||
return f"{seconds:.1f}s"
|
||||
|
||||
|
||||
def _ago(stamp: Any) -> str:
|
||||
"""How long ago something happened, in the notation the screens use.
|
||||
|
||||
@@ -950,7 +963,7 @@ def cmd_runs(args: argparse.Namespace) -> int:
|
||||
params = params[: room - 3] + "..."
|
||||
_say(
|
||||
f"{row['id']} {_status(row['status'], 9)} {row['flow']:<16} "
|
||||
f"{row['duration_ms'] / 1000:7.1f}s {_ago(row.get('created_at')):>9} "
|
||||
f"{_dur(row['duration_ms']):>8} {_ago(row.get('created_at')):>9} "
|
||||
f"{_stamp(row):<22} {params}"
|
||||
)
|
||||
return 0
|
||||
@@ -1131,12 +1144,20 @@ def _write_rows(rows: list[dict[str, Any]], fmt: str, out: str, hint: str = "")
|
||||
LIST_SCAN = 10
|
||||
|
||||
|
||||
def _list_names(client: Client, args: argparse.Namespace) -> int:
|
||||
def metric_names(client: Client, ids: Iterable[str]) -> list[str]:
|
||||
"""The metric names these runs carry, since a name is flow-qualified.
|
||||
|
||||
`train_loss` is recorded as `train.train_loss`, and asking for the bare
|
||||
one matches nothing — so this is the answer to "what would match".
|
||||
"""
|
||||
for run_id in list(ids)[:LIST_SCAN]:
|
||||
names = sorted({point["name"] for point in client.metrics(run_id)})
|
||||
if names:
|
||||
return names
|
||||
return []
|
||||
|
||||
|
||||
def _list_names(client: Client, args: argparse.Namespace) -> int:
|
||||
filters = _selection(args)
|
||||
if "until" in filters:
|
||||
# The history spells the same bound `before`, where it is also the
|
||||
@@ -1145,12 +1166,8 @@ def _list_names(client: Client, args: argparse.Namespace) -> int:
|
||||
ids = args.run or [
|
||||
row["id"] for row in client.runs(flow=args.flow, limit=LIST_SCAN, **filters)
|
||||
]
|
||||
for run_id in ids[:LIST_SCAN]:
|
||||
names = sorted({point["name"] for point in client.metrics(run_id)})
|
||||
if names:
|
||||
_say("\n".join(names))
|
||||
return 0
|
||||
_say("No metrics recorded by these runs.")
|
||||
names = metric_names(client, ids)
|
||||
_say("\n".join(names) if names else "No metrics recorded by these runs.")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -390,12 +390,17 @@ class Client:
|
||||
query["name"] = name
|
||||
return self._call("GET", f"/runs/{run_id}/metrics", params=query)
|
||||
|
||||
def compare(self, ids: Iterable[str], metric: str) -> Any:
|
||||
return self._call(
|
||||
"GET",
|
||||
"/runs/series/compare",
|
||||
params={"ids": ",".join(ids), "metric": metric},
|
||||
)
|
||||
def compare(self, ids: Iterable[str], metric: str, x: str = "") -> Any:
|
||||
"""One metric across several runs, in the chart widget's series shape.
|
||||
|
||||
``x`` is what the readings are plotted against: the step they were
|
||||
recorded at, "time" for the seconds since each run's own first one, or
|
||||
another metric of the same runs.
|
||||
"""
|
||||
query = {"ids": ",".join(ids), "metric": metric}
|
||||
if x:
|
||||
query["x"] = x
|
||||
return self._call("GET", "/runs/series/compare", params=query)
|
||||
|
||||
def export_metrics(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""The engine's event bus, read the way a browser reads it.
|
||||
|
||||
Polling answers "what is true now", so a run that starts and finishes between
|
||||
two ticks was only ever a row in the history. This subscribes to the same
|
||||
websocket the dashboard in a browser does — nothing new is served for it.
|
||||
|
||||
Blocking on purpose: `subscribe` is a thread's whole job, and the caller
|
||||
brings the thread. Reconnecting is this module's, since a dashboard outlives
|
||||
the engine it supervises.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from collections.abc import Callable, Collection
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
#: What a reconnect waits before trying again, growing to a minute — the shape
|
||||
#: the engine's own supervision backs off with.
|
||||
BACKOFF = (1.0, 5.0, 30.0, 60.0)
|
||||
|
||||
#: How long a read waits before looking at `stop`. The socket is otherwise
|
||||
#: silent for as long as the house is, and a dashboard that is closing should
|
||||
#: not wait for the next event to notice.
|
||||
POLL_S = 1.0
|
||||
|
||||
|
||||
def socket_url(url: str, token: str) -> str:
|
||||
"""The websocket beside an engine's HTTP address.
|
||||
|
||||
The token goes in the query string because a handshake carries no headers
|
||||
of its own — the same reason the browser sends it that way.
|
||||
"""
|
||||
parts = urlsplit(url)
|
||||
scheme = "wss" if parts.scheme == "https" else "ws"
|
||||
return urlunsplit((scheme, parts.netloc, "/api/v1/flows/ws", f"token={token}", ""))
|
||||
|
||||
|
||||
def subscribe(
|
||||
url: str,
|
||||
token: str,
|
||||
on_event: Callable[[dict[str, Any]], None],
|
||||
stop: threading.Event,
|
||||
kinds: Collection[str] | None = None,
|
||||
on_error: Callable[[Exception], None] | None = None,
|
||||
) -> None:
|
||||
"""Hand every published event to `on_event` until `stop` is set.
|
||||
|
||||
`kinds` keeps only the event types named, which is worth doing here rather
|
||||
than in the caller: `message_value` is most of what the bus carries and a
|
||||
screen drawing runs wants none of it.
|
||||
|
||||
A failure is reported once per outage. A dashboard is the thing that stops
|
||||
and starts the engine, so a reconnect loop announcing itself every second
|
||||
would bury the log pane it shares.
|
||||
"""
|
||||
from websockets.sync.client import connect
|
||||
|
||||
attempt = 0
|
||||
while not stop.is_set():
|
||||
try:
|
||||
with connect(socket_url(url, token), open_timeout=POLL_S * 5) as socket:
|
||||
attempt = 0
|
||||
while not stop.is_set():
|
||||
try:
|
||||
frame = socket.recv(timeout=POLL_S)
|
||||
except TimeoutError:
|
||||
continue
|
||||
payload = json.loads(frame)
|
||||
events = (
|
||||
payload.get("events") or []
|
||||
if payload.get("type") == "batch"
|
||||
else [payload]
|
||||
)
|
||||
for event in events:
|
||||
if kinds is None or event.get("type") in kinds:
|
||||
on_event(event)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 — any transport failure retries
|
||||
if attempt == 0 and on_error is not None:
|
||||
on_error(exc)
|
||||
stop.wait(BACKOFF[min(attempt, len(BACKOFF) - 1)])
|
||||
attempt += 1
|
||||
Reference in New Issue
Block a user