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,14 @@
|
||||
"""The dashboard `fluksio serve` opens at a terminal.
|
||||
|
||||
A supervisor, not an engine: it starts `serve --plain` as a child and talks to
|
||||
it over the same HTTP API and the same websocket every other client uses. So
|
||||
the engine is a process that can be stopped and started under the screen
|
||||
watching it, an engine somebody else started can be adopted rather than
|
||||
duplicated, and quitting the dashboard is not the same as stopping the engine.
|
||||
|
||||
Nothing of the engine is imported here — that lives in the child.
|
||||
"""
|
||||
|
||||
from fluksio.tui.app import ServeApp, child_argv, run_tui
|
||||
|
||||
__all__ = ["ServeApp", "child_argv", "run_tui"]
|
||||
@@ -0,0 +1,527 @@
|
||||
"""The screen itself: the engine under it, and what it is doing.
|
||||
|
||||
See `fluksio.tui` for why the engine is a child process rather than a thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from textual import work
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, DataTable, Footer, Header, Input, Label, RichLog
|
||||
|
||||
from fluksio.cli import (
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PORTAL,
|
||||
_data_dir,
|
||||
_token_for,
|
||||
probe_engine,
|
||||
read_pidfile,
|
||||
)
|
||||
from fluksio.sdk import stream
|
||||
from fluksio.sdk.cli import WATCH_INTERVAL_S, _dur, _status_screen
|
||||
from fluksio.sdk.client import Client, config_path
|
||||
from fluksio.tui.compare import CompareScreen
|
||||
|
||||
#: How long to keep asking the child for the credential it writes on the way
|
||||
#: up, before giving up and letting the panels say so.
|
||||
STARTUP_TRIES = 60
|
||||
|
||||
#: How many runs one comparison holds. The chart draws five of them
|
||||
#: (`chart.MAX_SERIES`); the table below it carries the rest, which is the
|
||||
#: split the browser makes for the same reason.
|
||||
MAX_PICKED = 20
|
||||
|
||||
#: What the dashboard subscribes to. `message_value` is most of what the bus
|
||||
#: carries and none of what this screen draws.
|
||||
KINDS = frozenset(
|
||||
{"run_started", "run_finished", "flow_paused", "engine_fatal", "flow_changed"}
|
||||
)
|
||||
|
||||
#: How long an event waits for the ones behind it. A sweep starting twenty
|
||||
#: runs should cost one read of the engine, not twenty.
|
||||
COALESCE_S = 0.25
|
||||
|
||||
|
||||
def child_argv(argv: list[str]) -> list[str]:
|
||||
"""The same command, told to serve without a dashboard of its own.
|
||||
|
||||
Every flag is passed through: whatever `serve` was asked for is what the
|
||||
engine under this screen is running with.
|
||||
"""
|
||||
passed = [flag for flag in argv if flag != "--plain"]
|
||||
return [sys.executable, "-m", "fluksio.cli", *passed, "--plain"]
|
||||
|
||||
|
||||
class Enroll(ModalScreen[tuple[str, str] | None]):
|
||||
"""The claim code a portal minted, and which portal minted it."""
|
||||
|
||||
BINDINGS = [("escape", "dismiss(None)", "cancel")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="enroll"):
|
||||
yield Label("Pair this installation with a portal")
|
||||
yield Input(placeholder="claim code", id="code")
|
||||
yield Input(value=DEFAULT_PORTAL, id="portal")
|
||||
with Horizontal():
|
||||
yield Button("Enroll", variant="primary", id="go")
|
||||
yield Button("Cancel", id="cancel")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "cancel":
|
||||
self.dismiss(None)
|
||||
return
|
||||
code = self.query_one("#code", Input).value.strip()
|
||||
portal = self.query_one("#portal", Input).value.strip()
|
||||
self.dismiss((code, portal or DEFAULT_PORTAL) if code else None)
|
||||
|
||||
|
||||
class Artifacts(ModalScreen[None]):
|
||||
"""What a run left behind, and one key to fetch it.
|
||||
|
||||
The rows are already on `GET /runs/{id}` — the same list the browser draws
|
||||
on a run's page — so this is a view of them rather than a second reading.
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("escape", "dismiss(None)", "close"),
|
||||
("enter", "download", "download"),
|
||||
]
|
||||
|
||||
def __init__(self, client: Client, run_id: str) -> None:
|
||||
super().__init__()
|
||||
self.client = client
|
||||
self.run_id = run_id
|
||||
self.rows: list[dict[str, Any]] = []
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="artifacts"):
|
||||
yield Label(f"Artifacts of {self.run_id[-8:]}")
|
||||
table: DataTable[str] = DataTable(id="files", cursor_type="row")
|
||||
yield table
|
||||
yield Label("", id="note")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one("#files", DataTable)
|
||||
table.add_columns("name", "node", "type", "bytes")
|
||||
table.focus()
|
||||
self.read()
|
||||
|
||||
@work(thread=True)
|
||||
def read(self) -> None:
|
||||
try:
|
||||
detail = self.client.run(self.run_id)
|
||||
except Exception as exc: # noqa: BLE001 — the modal reports it
|
||||
self.app.call_from_thread(self.note, f"Could not read the run: {exc}")
|
||||
return
|
||||
self.app.call_from_thread(self.show, detail.get("artifacts") or [])
|
||||
|
||||
def show(self, rows: list[dict[str, Any]]) -> None:
|
||||
self.rows = rows
|
||||
table = self.query_one("#files", DataTable)
|
||||
for row in rows:
|
||||
table.add_row(
|
||||
str(row.get("name", "")),
|
||||
str(row.get("node", "")),
|
||||
str(row.get("media_type", "")),
|
||||
f"{int(row.get('size') or 0):,}",
|
||||
key=str(row.get("digest", "")),
|
||||
)
|
||||
self.note("This run produced no artifacts." if not rows else "Enter downloads.")
|
||||
|
||||
def note(self, message: str) -> None:
|
||||
self.query_one("#note", Label).update(message)
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
self.action_download()
|
||||
|
||||
def action_download(self) -> None:
|
||||
table = self.query_one("#files", DataTable)
|
||||
if not table.row_count:
|
||||
return
|
||||
digest = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
|
||||
row = next((one for one in self.rows if one.get("digest") == digest), None)
|
||||
if row is not None:
|
||||
self.fetch(row)
|
||||
|
||||
@work(thread=True)
|
||||
def fetch(self, row: dict[str, Any]) -> None:
|
||||
# The name it was written under reads better than the message's, which
|
||||
# is chosen for the graph — the same choice `fluksio artifacts` makes.
|
||||
out = Path(str(row.get("filename") or row.get("name")))
|
||||
try:
|
||||
out.write_bytes(self.client.download(str(row["digest"])))
|
||||
except Exception as exc: # noqa: BLE001 — the modal reports it
|
||||
self.app.call_from_thread(self.note, f"Could not fetch it: {exc}")
|
||||
return
|
||||
self.app.call_from_thread(self.note, f"Wrote {out.resolve()}")
|
||||
|
||||
|
||||
class ServeApp(App[int]):
|
||||
"""One screen: how the engine is, what has run, and what it is saying."""
|
||||
|
||||
CSS = """
|
||||
#status { height: auto; padding: 0 1; }
|
||||
#runs { height: 2fr; }
|
||||
#log { height: 1fr; border-top: solid $panel; }
|
||||
#enroll { width: 60; height: auto; padding: 1 2; background: $surface; }
|
||||
#artifacts { width: 80; height: auto; padding: 1 2; background: $surface; }
|
||||
#artifacts DataTable { height: auto; max-height: 14; }
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
("q", "quit", "quit (engine keeps running)"),
|
||||
("s", "stop_start", "stop/start"),
|
||||
("r", "restart", "restart"),
|
||||
("c", "cancel_run", "cancel run"),
|
||||
("space", "pick", "pick for comparison"),
|
||||
("enter", "compare", "compare"),
|
||||
("a", "artifacts", "artifacts"),
|
||||
("e", "enroll", "enroll"),
|
||||
]
|
||||
|
||||
def __init__(self, args: argparse.Namespace) -> None:
|
||||
super().__init__()
|
||||
self.args = args
|
||||
self.data_dir: Path = _data_dir(args.data_dir, args.shared)
|
||||
self.child: subprocess.Popen[str] | None = None
|
||||
#: The pid of an engine this dashboard did not start. Only ever one
|
||||
#: whose data directory is this one — the probe is what proves it.
|
||||
self.adopted: int | None = None
|
||||
self.client: Client | None = None
|
||||
self.url = ""
|
||||
#: The runs ticked for a comparison, in the order they were ticked.
|
||||
self.picked: list[str] = []
|
||||
#: Set by the socket thread, drained by the coalescing timer — so a
|
||||
#: cascade of events costs one read of the engine rather than one each.
|
||||
self.stirred = False
|
||||
self.stop_stream = threading.Event()
|
||||
|
||||
# -- layout ---------------------------------------------------------------
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield RichLog(id="status", markup=True, wrap=True)
|
||||
table: DataTable[str] = DataTable(id="runs", cursor_type="row")
|
||||
yield table
|
||||
yield RichLog(id="log", markup=False, highlight=False, max_lines=2000)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = f"fluksio — {self.data_dir}"
|
||||
table = self.query_one("#runs", DataTable)
|
||||
table.add_columns(" ", "run", "status", "flow", "took", "params")
|
||||
table.focus()
|
||||
self.start_engine(first=True)
|
||||
# The socket is what makes a run that starts and finishes between two
|
||||
# ticks visible; this stays as the heartbeat for what is not on the bus.
|
||||
self.set_interval(WATCH_INTERVAL_S, self.refresh_panels)
|
||||
self.set_interval(COALESCE_S, self.drain)
|
||||
|
||||
# -- the engine under the screen ------------------------------------------
|
||||
|
||||
def note(self, message: str) -> None:
|
||||
self.query_one("#log", RichLog).write(message)
|
||||
|
||||
def start_engine(self, first: bool = False) -> None:
|
||||
"""Adopt whatever is already serving this directory, or start one."""
|
||||
host = self.args.host
|
||||
reachable = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
|
||||
wanted = self.args.port or DEFAULT_PORT
|
||||
url = f"http://{reachable}:{wanted}"
|
||||
who = probe_engine(url, _token_for(self.data_dir)) if first else "other"
|
||||
|
||||
if who == "ours":
|
||||
running = read_pidfile(self.data_dir)
|
||||
self.adopted = running["pid"] if running else None
|
||||
self.url = url
|
||||
named = f" (pid {self.adopted})" if self.adopted else ""
|
||||
self.note(f"Adopted the engine already serving this directory{named}.")
|
||||
if self.adopted is None:
|
||||
self.note("It wrote no pidfile, so this screen cannot stop it.")
|
||||
self.connect()
|
||||
return
|
||||
if who == "foreign":
|
||||
self.note(f"Port {wanted} holds another installation's Fluksio.")
|
||||
|
||||
self.child = subprocess.Popen( # noqa: S603
|
||||
child_argv(sys.argv[1:]),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env={**os.environ, "PYTHONUNBUFFERED": "1"},
|
||||
)
|
||||
self.tail_child(self.child)
|
||||
self.await_engine()
|
||||
|
||||
@work(thread=True, exclusive=False)
|
||||
def tail_child(self, child: subprocess.Popen[str]) -> None:
|
||||
"""The engine's own output, which is why a second terminal was needed."""
|
||||
if child.stdout is None:
|
||||
return
|
||||
for line in child.stdout:
|
||||
self.call_from_thread(self.note, line.rstrip())
|
||||
self.call_from_thread(self.note, f"The engine stopped ({child.wait()}).")
|
||||
|
||||
@work(thread=True, exclusive=True, group="startup")
|
||||
def await_engine(self) -> None:
|
||||
"""Wait for the child to say where it landed, then talk to it.
|
||||
|
||||
The port is the child's to choose — it moves off a taken one — and
|
||||
`client.json` is where it says which it took.
|
||||
"""
|
||||
import time
|
||||
|
||||
for _ in range(STARTUP_TRIES):
|
||||
if self.child is not None and self.child.poll() is not None:
|
||||
return
|
||||
try:
|
||||
stored = json.loads(config_path(self.data_dir).read_text())
|
||||
except (OSError, ValueError):
|
||||
stored = {}
|
||||
if stored.get("url") and stored.get("token"):
|
||||
self.url = str(stored["url"])
|
||||
self.call_from_thread(self.connect)
|
||||
return
|
||||
time.sleep(0.5)
|
||||
self.call_from_thread(self.note, "The engine did not come up.")
|
||||
|
||||
def connect(self) -> None:
|
||||
stored = config_path(self.data_dir)
|
||||
try:
|
||||
config = json.loads(stored.read_text())
|
||||
self.client = Client(
|
||||
url=str(config["url"]), token=str(config["token"]), retries=0
|
||||
)
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
self.note(f"No credential to talk to the engine with: {exc}")
|
||||
return
|
||||
self.url = self.client.url
|
||||
self.stop_stream = threading.Event()
|
||||
self.watch_engine(self.client.url, self.client.token)
|
||||
self.refresh_panels()
|
||||
|
||||
def engine_pid(self) -> int | None:
|
||||
return self.child.pid if self.child is not None else self.adopted
|
||||
|
||||
def stop_engine(self) -> None:
|
||||
if self.child is not None:
|
||||
self.note(f"Stopping the engine (pid {self.child.pid}).")
|
||||
self.child.terminate()
|
||||
self.child = None
|
||||
elif self.adopted is not None:
|
||||
self.note(f"Stopping the adopted engine (pid {self.adopted}).")
|
||||
try:
|
||||
os.kill(self.adopted, signal.SIGTERM)
|
||||
except OSError as exc:
|
||||
self.note(f"Could not stop it: {exc}")
|
||||
self.adopted = None
|
||||
self.stop_stream.set()
|
||||
self.client = None
|
||||
|
||||
# -- the engine's own events ----------------------------------------------
|
||||
|
||||
@work(thread=True, exclusive=True, group="stream")
|
||||
def watch_engine(self, url: str, token: str) -> None:
|
||||
"""The bus, over the websocket the browser subscribes to."""
|
||||
stream.subscribe(
|
||||
url,
|
||||
token,
|
||||
lambda event: setattr(self, "stirred", True),
|
||||
self.stop_stream,
|
||||
kinds=KINDS,
|
||||
on_error=lambda exc: self.call_from_thread(
|
||||
self.note, f"Not receiving events: {exc}"
|
||||
),
|
||||
)
|
||||
|
||||
def drain(self) -> None:
|
||||
"""Whatever the bus said since the last tick, as one read."""
|
||||
if not self.stirred:
|
||||
return
|
||||
self.stirred = False
|
||||
self.refresh_panels()
|
||||
# A run finishing is exactly when a comparison's numbers become final,
|
||||
# and the screen showing them cannot hear the bus itself.
|
||||
if isinstance(self.screen, CompareScreen):
|
||||
self.screen.action_reload()
|
||||
|
||||
# -- what the panels show -------------------------------------------------
|
||||
|
||||
def refresh_panels(self) -> None:
|
||||
if self.client is not None:
|
||||
self.read_engine()
|
||||
|
||||
@work(thread=True, exclusive=True, group="poll")
|
||||
def read_engine(self) -> None:
|
||||
client = self.client
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
screen = _status_screen(client)
|
||||
rows = client.runs(limit=20)
|
||||
except Exception as exc:
|
||||
self.call_from_thread(self.show_offline, exc)
|
||||
return
|
||||
self.call_from_thread(self.show, screen, rows)
|
||||
|
||||
def show(self, screen: Any, rows: list[dict[str, Any]]) -> None:
|
||||
status = self.query_one("#status", RichLog)
|
||||
status.clear()
|
||||
status.write(screen)
|
||||
table = self.query_one("#runs", DataTable)
|
||||
cursor = table.cursor_row
|
||||
table.clear()
|
||||
for row in rows:
|
||||
run_id = str(row.get("id", ""))
|
||||
table.add_row(
|
||||
"·" if run_id in self.picked else " ",
|
||||
run_id[-8:],
|
||||
str(row.get("status", "")),
|
||||
str(row.get("flow", "")),
|
||||
_dur(row.get("duration_ms")),
|
||||
json.dumps(row.get("params") or {})[:60],
|
||||
key=run_id,
|
||||
)
|
||||
# The rows are rewritten wholesale every refresh, and a cursor that
|
||||
# jumped back to the top on each one would make picking three runs a
|
||||
# race against the clock.
|
||||
if 0 <= cursor < table.row_count:
|
||||
table.move_cursor(row=cursor)
|
||||
|
||||
def show_offline(self, exc: Exception) -> None:
|
||||
status = self.query_one("#status", RichLog)
|
||||
status.clear()
|
||||
which = "starting" if self.engine_pid() is not None else "not running"
|
||||
status.write(f"[yellow]The engine is {which}.[/] ({type(exc).__name__})")
|
||||
|
||||
# -- keys -----------------------------------------------------------------
|
||||
|
||||
def action_stop_start(self) -> None:
|
||||
if self.engine_pid() is not None:
|
||||
self.stop_engine()
|
||||
else:
|
||||
self.start_engine()
|
||||
|
||||
def action_restart(self) -> None:
|
||||
self.stop_engine()
|
||||
self.start_engine()
|
||||
|
||||
def cursor_run(self) -> str:
|
||||
"""The whole id of the run the cursor is on.
|
||||
|
||||
The cell holds its tail, which is what fits in a column; the row's key
|
||||
is the whole of it, which is what the engine is asked about.
|
||||
"""
|
||||
table = self.query_one("#runs", DataTable)
|
||||
if self.client is None or not table.row_count:
|
||||
return ""
|
||||
key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
|
||||
return str(key.value or "")
|
||||
|
||||
def action_cancel_run(self) -> None:
|
||||
run_id = self.cursor_run()
|
||||
if run_id:
|
||||
self.cancel_run(run_id)
|
||||
|
||||
def action_pick(self) -> None:
|
||||
"""Tick the run under the cursor into the comparison, or untick it."""
|
||||
run_id = self.cursor_run()
|
||||
if not run_id:
|
||||
return
|
||||
if run_id in self.picked:
|
||||
self.picked.remove(run_id)
|
||||
elif len(self.picked) >= MAX_PICKED:
|
||||
self.note(f"A comparison carries {MAX_PICKED} runs; untick one first.")
|
||||
return
|
||||
else:
|
||||
self.picked.append(run_id)
|
||||
self.refresh_panels()
|
||||
|
||||
def action_compare(self) -> None:
|
||||
"""The ticked runs, or the one under the cursor if none are ticked."""
|
||||
if self.client is None:
|
||||
return
|
||||
ids = self.picked or [self.cursor_run()]
|
||||
if any(ids):
|
||||
self.push_screen(CompareScreen(self.client, [one for one in ids if one]))
|
||||
|
||||
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Enter on the focused runs table, which never reaches the binding."""
|
||||
if event.data_table.id == "runs":
|
||||
self.action_compare()
|
||||
|
||||
def action_artifacts(self) -> None:
|
||||
run_id = self.cursor_run()
|
||||
if self.client is not None and run_id:
|
||||
self.push_screen(Artifacts(self.client, run_id))
|
||||
|
||||
@work(thread=True)
|
||||
def cancel_run(self, run_id: str) -> None:
|
||||
client = self.client
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.cancel(run_id)
|
||||
except Exception as exc:
|
||||
self.call_from_thread(self.note, f"Could not cancel {run_id}: {exc}")
|
||||
return
|
||||
self.call_from_thread(self.note, f"Cancelled {run_id}.")
|
||||
self.call_from_thread(self.refresh_panels)
|
||||
|
||||
def action_enroll(self) -> None:
|
||||
self.push_screen(Enroll(), self.enrolled)
|
||||
|
||||
def enrolled(self, answer: tuple[str, str] | None) -> None:
|
||||
if answer is None:
|
||||
return
|
||||
code, portal = answer
|
||||
self.run_enroll(code, portal)
|
||||
|
||||
@work(thread=True)
|
||||
def run_enroll(self, code: str, portal: str) -> None:
|
||||
"""`fluksio enroll`, as its own process for the same reason serve is.
|
||||
|
||||
A running engine picks the configuration up on its own; this screen
|
||||
only has to report what the command said.
|
||||
"""
|
||||
done = subprocess.run( # noqa: S603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"fluksio.cli",
|
||||
"enroll",
|
||||
code,
|
||||
"--portal",
|
||||
portal,
|
||||
"--data-dir",
|
||||
str(self.data_dir),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
for line in (done.stdout + done.stderr).splitlines():
|
||||
self.call_from_thread(self.note, line)
|
||||
|
||||
|
||||
def run_tui(args: argparse.Namespace) -> int:
|
||||
"""Open the dashboard, and say what is still running when it closes."""
|
||||
app = ServeApp(args)
|
||||
app.run()
|
||||
pid, url = app.engine_pid(), app.url
|
||||
if pid is not None:
|
||||
print(f"The engine is still running: pid {pid} at {url or 'its port'}.")
|
||||
print(f" fluksio serve reattaches to it; kill {pid} stops it.")
|
||||
return 0
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Curves at a terminal, drawn with braille.
|
||||
|
||||
A terminal cell holds 2x4 braille dots, so an 80x20 pane is a 160x80 plot —
|
||||
enough resolution that a training curve reads as a curve rather than as a bar
|
||||
chart. Nothing is imported for this: a plotting library would be two
|
||||
dependencies on a package whose every dependency is argued for in
|
||||
`pyproject.toml`, for something a screenful of arithmetic does.
|
||||
|
||||
The colours are deliberately not the web's. `--chart-1…5` step one brand hue
|
||||
by lightness, which is right against a designed surface and unreadable in a
|
||||
terminal — five steps of one hue collapse to one colour on a 16-colour tty.
|
||||
Here identity is carried by hue, and the legend names every series anyway.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Iterator, Sequence
|
||||
from typing import Any
|
||||
|
||||
from rich.text import Text
|
||||
from textual.widget import Widget
|
||||
|
||||
#: One run's readings: a label, and `[[x, y], ...]` as the engine answers.
|
||||
Series = tuple[str, Sequence[Sequence[float]]]
|
||||
|
||||
#: The cap the web draws at, for the same reason: past five, a legend is a
|
||||
#: puzzle. A sweep of twenty wants faceting, not a sixth colour.
|
||||
MAX_SERIES = 5
|
||||
|
||||
#: Distinct hues, and the bright half of the ANSI sixteen so they hold up on a
|
||||
#: light terminal as well as a dark one.
|
||||
SERIES_STYLES = (
|
||||
"bright_cyan",
|
||||
"bright_magenta",
|
||||
"bright_yellow",
|
||||
"bright_green",
|
||||
"bright_red",
|
||||
)
|
||||
|
||||
#: `⠀`, and the dot bits of the cell above it, by (column, row) within it.
|
||||
BRAILLE = 0x2800
|
||||
DOTS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
|
||||
|
||||
#: What the y tick labels take, and what the x axis and its labels take below.
|
||||
GUTTER = 9
|
||||
AXIS_ROWS = 2
|
||||
|
||||
|
||||
def _num(value: float) -> str:
|
||||
"""A tick label short enough to sit in the gutter."""
|
||||
if value and (abs(value) >= 1e5 or abs(value) < 1e-3):
|
||||
return f"{value:.1e}"
|
||||
return f"{value:.4g}"
|
||||
|
||||
|
||||
def shorten(label: str) -> str:
|
||||
"""A run's label, with the id cut to the tail that identifies it.
|
||||
|
||||
The engine answers `<run id> (seed 3)`; a terminal wants the eight
|
||||
characters people actually read it by, and the seed kept.
|
||||
"""
|
||||
head, space, rest = label.partition(" ")
|
||||
return head[-8:] + space + rest
|
||||
|
||||
|
||||
def _segment(x0: int, y0: int, x1: int, y1: int) -> Iterator[tuple[int, int]]:
|
||||
"""Every dot along a straight line between two readings.
|
||||
|
||||
Points alone would draw a dotted cloud: forty readings across a
|
||||
two-hundred dot axis touch one dot in five.
|
||||
"""
|
||||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||||
step_x = 1 if x0 < x1 else -1
|
||||
step_y = 1 if y0 < y1 else -1
|
||||
error = dx + dy
|
||||
while True:
|
||||
yield x0, y0
|
||||
if x0 == x1 and y0 == y1:
|
||||
return
|
||||
doubled = 2 * error
|
||||
if doubled >= dy:
|
||||
error += dy
|
||||
x0 += step_x
|
||||
if doubled <= dx:
|
||||
error += dx
|
||||
y0 += step_y
|
||||
|
||||
|
||||
def _bounds(lines: Sequence[Series]) -> tuple[float, float, float, float]:
|
||||
xs = [point[0] for _, points in lines for point in points]
|
||||
ys = [point[1] for _, points in lines for point in points]
|
||||
return min(xs), max(xs), min(ys), max(ys)
|
||||
|
||||
|
||||
def _place(value: float, low: float, high: float, span: int) -> int:
|
||||
"""A reading's dot along an axis of `span` dots.
|
||||
|
||||
A flat curve has no range to divide by and is drawn down the middle, which
|
||||
is the honest picture of a metric that never moved.
|
||||
"""
|
||||
if high <= low:
|
||||
return (span - 1) // 2
|
||||
return round((value - low) / (high - low) * (span - 1))
|
||||
|
||||
|
||||
def _cells(
|
||||
lines: Sequence[Series], cols: int, rows: int
|
||||
) -> tuple[dict[tuple[int, int], list[int]], tuple[float, float, float, float]]:
|
||||
"""The braille cells the curves fill, and the bounds they were scaled to."""
|
||||
low_x, high_x, low_y, high_y = _bounds(lines)
|
||||
width, height = cols * 2, rows * 4
|
||||
cells: dict[tuple[int, int], list[int]] = {}
|
||||
for index, (_, points) in enumerate(lines):
|
||||
previous: tuple[int, int] | None = None
|
||||
for x_value, y_value in points:
|
||||
spot = (
|
||||
_place(x_value, low_x, high_x, width),
|
||||
height - 1 - _place(y_value, low_y, high_y, height),
|
||||
)
|
||||
dots = _segment(*previous, *spot) if previous is not None else [spot]
|
||||
for dot_x, dot_y in dots:
|
||||
cell = cells.get((dot_y // 4, dot_x // 2))
|
||||
bit = DOTS[dot_x % 2][dot_y % 4]
|
||||
if cell is None:
|
||||
cells[dot_y // 4, dot_x // 2] = [bit, index]
|
||||
else:
|
||||
# ponytail: the first series to reach a cell keeps its
|
||||
# colour — a terminal cell has one foreground. Half-blocks
|
||||
# would let two share it, at a quarter of the resolution.
|
||||
cell[0] |= bit
|
||||
previous = spot
|
||||
return cells, (low_x, high_x, low_y, high_y)
|
||||
|
||||
|
||||
def _row(cells: dict[tuple[int, int], list[int]], row: int, cols: int) -> Text:
|
||||
"""One line of the plot, as few spans as the colours allow."""
|
||||
out = Text()
|
||||
run, style = "", ""
|
||||
for col in range(cols):
|
||||
cell = cells.get((row, col))
|
||||
glyph = chr(BRAILLE + cell[0]) if cell else " "
|
||||
wanted = SERIES_STYLES[cell[1] % len(SERIES_STYLES)] if cell else ""
|
||||
if wanted != style:
|
||||
out.append(run, style=style or None)
|
||||
run, style = "", wanted
|
||||
run += glyph
|
||||
out.append(run, style=style or None)
|
||||
return out
|
||||
|
||||
|
||||
def plot(lines: Sequence[Series], width: int, height: int, x_label: str = "") -> Text:
|
||||
"""The curves, scaled to a pane `width` by `height` cells.
|
||||
|
||||
Every series shares one y scale, which is the whole point of drawing them
|
||||
together: two runs whose losses differ by a factor of ten should look like
|
||||
it.
|
||||
"""
|
||||
drawn = [line for line in lines[:MAX_SERIES] if len(line[1]) > 0]
|
||||
if not drawn:
|
||||
return Text("No readings.", style="dim")
|
||||
cols, rows = width - GUTTER, height - AXIS_ROWS
|
||||
if cols < 8 or rows < 2:
|
||||
return Text("Too small to draw.", style="dim")
|
||||
|
||||
cells, (low_x, high_x, low_y, high_y) = _cells(drawn, cols, rows)
|
||||
#: Top, middle and bottom carry a value; labelling every row would be
|
||||
#: noise on a chart that is mostly one curve.
|
||||
labels = {0: high_y, rows // 2: (low_y + high_y) / 2, rows - 1: low_y}
|
||||
|
||||
out = Text(no_wrap=True, overflow="crop")
|
||||
for row in range(rows):
|
||||
mark = _num(labels[row]) if row in labels else ""
|
||||
out.append(f"{mark:>{GUTTER - 2}} ", style="dim")
|
||||
out.append("┤" if row in labels else "│", style="dim")
|
||||
out.append_text(_row(cells, row, cols))
|
||||
out.append("\n")
|
||||
out.append(" " * (GUTTER - 1) + "└" + "─" * cols + "\n", style="dim")
|
||||
left, right = _num(low_x), _num(high_x)
|
||||
tail = f" {x_label}" if x_label else ""
|
||||
pad = max(cols - len(left) - len(right) - len(tail), 1)
|
||||
out.append(" " * GUTTER + left + " " * pad + right + tail, style="dim")
|
||||
return out
|
||||
|
||||
|
||||
def legend(labels: Iterable[str]) -> Text:
|
||||
"""Which colour is which run — never colour alone."""
|
||||
out = Text()
|
||||
for index, label in enumerate(list(labels)[:MAX_SERIES]):
|
||||
if index:
|
||||
out.append(" ")
|
||||
out.append("■ ", style=SERIES_STYLES[index % len(SERIES_STYLES)])
|
||||
out.append(shorten(label))
|
||||
return out
|
||||
|
||||
|
||||
class Curves(Widget):
|
||||
"""The plot, redrawn for whatever size the terminal gives it."""
|
||||
|
||||
DEFAULT_CSS = "Curves { height: 1fr; }"
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.lines: list[Series] = []
|
||||
self.x_label = ""
|
||||
|
||||
def show(self, lines: Sequence[Series], x_label: str = "") -> None:
|
||||
self.lines = list(lines)[:MAX_SERIES]
|
||||
self.x_label = x_label
|
||||
self.refresh()
|
||||
|
||||
def render(self) -> Text:
|
||||
return plot(self.lines, self.size.width, self.size.height, self.x_label)
|
||||
@@ -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