`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
343 lines
12 KiB
Python
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
|