Files
app/backend/fluksio/tui.py
T
stroblmeandClaude Opus 5 a4ea1dd0d6 Open a dashboard when serve is run at a terminal
`fluksio serve` printed a log stream and nothing else, so watching an engine
meant a second terminal running `status --watch`, and stopping or pairing it
meant a third. At a terminal it now opens a dashboard: the health and flow
overview `status` draws, the recent runs as a table, and the engine's own
output in a pane below — which is what the earlier decision against this
was protecting, and it is still all there.

The engine is a child process running `serve --plain`, not a thread, so it
outlives the dashboard: q leaves it running and says so, s and r stop and
restart it, c cancels the selected run and e pairs with a portal. An engine
already serving this directory is adopted rather than duplicated, and it can
be stopped from here only because the pidfile and the token together prove
it is this installation's.

`--plain` and no terminal both keep the old behaviour, which is what the
container and CI run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Hra4ndWMCLU5F3KjUuVAc
2026-08-29 14:21:13 +02:00

343 lines
12 KiB
Python

"""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 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 __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
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.cli import WATCH_INTERVAL_S, _status_screen
from fluksio.sdk.client import Client, config_path
#: 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
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 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; }
"""
BINDINGS = [
("q", "quit", "quit (engine keeps running)"),
("s", "stop_start", "stop/start"),
("r", "restart", "restart"),
("c", "cancel_run", "cancel run"),
("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 = ""
# -- 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")
self.start_engine(first=True)
self.set_interval(WATCH_INTERVAL_S, self.refresh_panels)
# -- 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.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.client = None
# -- 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)
table.clear()
for row in rows:
table.add_row(
str(row.get("id", ""))[-8:],
str(row.get("status", "")),
str(row.get("flow", "")),
f"{(row.get('duration_ms') or 0) / 1000:.1f}s",
json.dumps(row.get("params") or {})[:60],
key=str(row.get("id", "")),
)
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 action_cancel_run(self) -> None:
table = self.query_one("#runs", DataTable)
if self.client is None or not table.row_count:
return
# The cell holds the tail of the id, which is what fits; the row's key
# is the whole of it, which is what the engine is asked about.
key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
if key.value:
self.cancel_run(str(key.value))
@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