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

`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:
2026-08-30 12:51:08 +02:00
co-authored by Claude Opus 5
parent 45cc7504e1
commit f370601aec
12 changed files with 926 additions and 35 deletions
+527
View File
@@ -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