"""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