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
This commit is contained in:
@@ -356,6 +356,14 @@ def _free_port(host: str, start: int) -> int:
|
||||
|
||||
|
||||
def cmd_serve(args: argparse.Namespace) -> int:
|
||||
# At a terminal this is a dashboard with the engine as a child of it. The
|
||||
# import is here rather than at the top because it is only ever needed on
|
||||
# that path, and `serve` in a container must not pay for it.
|
||||
if not args.plain and sys.stdout.isatty() and sys.stdin.isatty():
|
||||
from fluksio.tui import run_tui
|
||||
|
||||
return run_tui(args)
|
||||
|
||||
data_dir = _data_dir(args.data_dir, args.shared)
|
||||
for flag, name in CONCURRENCY_FLAGS.items():
|
||||
value = getattr(args, flag, None)
|
||||
@@ -566,6 +574,11 @@ def _parser() -> argparse.ArgumentParser:
|
||||
metavar="N",
|
||||
help="python worker processes (default 4, FLOW_MAX_WORKERS)",
|
||||
)
|
||||
serve.add_argument(
|
||||
"--plain",
|
||||
action="store_true",
|
||||
help="the log stream rather than the dashboard (the default with no terminal)",
|
||||
)
|
||||
serve.add_argument(
|
||||
"--gpus",
|
||||
type=_at_least(0),
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user