#!/usr/bin/env python3 """Read-only FBTERM dashboard for a real tmux pane (Python standard library). The display is an operational summary, not a screenshot or an execution engine. Job files remain authoritative. No lifecycle recovery or job mutation is done. """ from __future__ import annotations import argparse from collections import OrderedDict from contextlib import nullcontext from dataclasses import dataclass from datetime import datetime import curses import heapq import json import math import os from pathlib import Path import re import socket import stat import textwrap import time VERSION = "1.0" MAX_FILE = 32768 MAX_TAIL = 12288 RECENT_LIMIT = 40 CACHE_LIMIT = 2048 ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))") SENSITIVE = re.compile( r"(?i)(password|passwd|passphrase|secret|token|bearer|authorization|cookie|" r"api[ _.-]?key|private[ _.-]?key|client[ _.-]?secret|oauth|" r"ssh-(?:rsa|ed25519)|BEGIN [A-Z ]*PRIVATE KEY|://[^ /]+:[^ /]+@)" ) BLOB = re.compile(r"[A-Za-z0-9+/_=-]{160,}") ENCODED_LINE = re.compile(r"\s*[A-Za-z0-9+/]{48,}={0,2}\s*\Z") IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,159}\Z") TRANSPORT = "[transport payload omitted; raw output retained]" REDACTED = "[potentially sensitive output hidden]" def ascii_text(value): """Discard terminal controls and non-ASCII glyphs for the physical VT.""" text = ANSI.sub("", str(value)).replace("\r", " ") return "".join(c for c in text if 32 <= ord(c) < 127) def safe_line(value): text = ascii_text(value) if SENSITIVE.search(text): return REDACTED low = text.lower() if (BLOB.search(text) or ENCODED_LINE.fullmatch(text) or "data:image/" in low or "base64" in low or '"capture_id"' in low or '"data"' in low and '"encoding"' in low): return TRANSPORT return text[:400] + (" [truncated]" if len(text) > 400 else "") def read_small(path, limit=MAX_FILE): """Bound reads and reject symlinks/special files (including stdout pipes).""" fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK) try: info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_size > limit: raise ValueError("not a bounded regular file") return os.read(fd, limit + 1) finally: os.close(fd) def read_exit(job): try: if job.is_symlink(): return None value = read_small(job / "exit.code", 32).decode("ascii").strip() return int(value) if re.fullmatch(r"-?[0-9]{1,4}", value) else None except (OSError, ValueError, UnicodeError): return None def worker_alive(token, proc_root=Path("/proc")): """Three-valued PID identity check: true, false or unavailable.""" if not isinstance(token, dict) or type(token.get("pid")) is not int or token["pid"] <= 0: return None try: raw = (proc_root / str(token["pid"]) / "stat").read_text() fields = raw[raw.rfind(")") + 2:].split() boot = (proc_root / "sys/kernel/random/boot_id").read_text().strip() if not all(k in token for k in ("start_ticks", "boot")): return None return (fields[0] != "Z" and fields[19] == str(token["start_ticks"]) and boot == token["boot"]) except FileNotFoundError: return False except (OSError, IndexError, ValueError): return None def number(value): return float(value) if type(value) in (int, float) and math.isfinite(value) else None @dataclass(frozen=True) class Job: name: str updated: float state: str exit_code: int | None = None started: float | None = None finished: float | None = None recorded: bool = False def duration(self, now): if self.started is None: return "--" stop = self.finished if self.finished is not None else now if self.state == "RUNNING" else None if stop is None or stop < self.started: return "--" seconds = int(stop - self.started) if seconds >= 3600: return f"{seconds // 3600}h{seconds % 3600 // 60:02}m" if seconds >= 60: return f"{seconds // 60}m{seconds % 60:02}s" return f"{seconds}s" def describe(name, receipt, job_path, updated, proc_root=Path("/proc")): rc = read_exit(job_path) if receipt is None: return Job(name, updated, "EXIT" if rc is not None else "UNKNOWN", rc) if (not isinstance(receipt, dict) or receipt.get("schema") != 4 or receipt.get("job_id") != name): return Job(name, updated, "INVALID", rc) phase = receipt.get("phase") started = number(receipt.get("started_at")) finished = number(receipt.get("finished_at")) if phase == "COMPLETED": recorded = (rc is not None and type(receipt.get("exit_code")) is int and rc == receipt["exit_code"] and finished is not None) return Job(name, updated, ("DONE" if rc == 0 else "FAILED") if recorded else "MISMATCH", rc, started, finished, recorded) if phase == "RUNNING": live = worker_alive(receipt.get("worker"), proc_root) state = "RUNNING" if live is True else "STALE" if live is False else "UNCONFIRMED" return Job(name, updated, state, rc, started) if phase in ("RESERVED", "ARMED"): return Job(name, updated, phase, rc, started, finished) if phase == "INTERRUPTED": return Job(name, updated, "INTERRUPTED", rc, started, finished) return Job(name, updated, "INVALID", rc, started, finished) class Catalogue: """Stream the full census; keep bounded metadata and bounded output tails.""" def __init__(self, root, proc_root=Path("/proc")): self.root = Path(root) self.proc_root = Path(proc_root) self.cache = OrderedDict() self.recent = [] self.counts = {} self.total = 0 self.errors = 0 self.last_scan = None self.output_cache = (None, []) def receipt(self, path): info = path.lstat() stamp = (info.st_ino, info.st_mtime_ns, info.st_size) key = str(path) if key in self.cache and self.cache[key][0] == stamp: self.cache.move_to_end(key) return self.cache[key][1] try: data = json.loads(read_small(path)) except (OSError, ValueError, UnicodeError): data = {} self.cache[key] = (stamp, data) self.cache.move_to_end(key) while len(self.cache) > CACHE_LIMIT: self.cache.popitem(last=False) return data def scan(self): counts, newest, total, errors = {}, [], 0, 0 receipts = self.root / "receipts" jobs = self.root / "jobs" def accept(job): nonlocal total total += 1 counts[job.state] = counts.get(job.state, 0) + 1 # A stable tie breaker means equal mtimes cannot compare Job objects. key = (job.updated, job.name, job) if len(newest) < RECENT_LIMIT: heapq.heappush(newest, key) elif key[:2] > newest[0][:2]: heapq.heapreplace(newest, key) # Enumerate every receipt, not only recent directories. Older running # jobs are counted using PID identity even when newer jobs have arrived. try: try: receipt_entries = os.scandir(receipts) except FileNotFoundError: # Legacy producers need no receipts directory. Only opening # this optional source may be absent; entry errors still count. receipt_entries = nullcontext(()) with receipt_entries as entries: for entry in entries: if not entry.name.endswith(".json") or not entry.is_file(follow_symlinks=False): continue name = entry.name[:-5] if not IDENTIFIER.fullmatch(name): continue try: path = Path(entry.path) info = entry.stat(follow_symlinks=False) accept(describe(name, self.receipt(path), jobs / name, info.st_mtime, self.proc_root)) except OSError: errors += 1 except OSError: errors += 1 # Legacy jobs with an exit file are useful, but never assumed running. try: with os.scandir(jobs) as entries: for entry in entries: if not IDENTIFIER.fullmatch(entry.name) or not entry.is_dir(follow_symlinks=False): continue if (receipts / (entry.name + ".json")).exists(): continue try: accept(describe(entry.name, None, Path(entry.path), entry.stat(follow_symlinks=False).st_mtime, self.proc_root)) except OSError: errors += 1 except OSError: errors += 1 self.recent = [row[2] for row in sorted(newest, reverse=True)] self.counts, self.total, self.errors = counts, total, errors self.last_scan = time.time() def output(self, name): if not IDENTIFIER.fullmatch(name): return [] job = self.root / "jobs" / name if job.is_symlink(): return [] stamps = [] for stream in ("stdout.bin", "stderr.bin"): try: info = (job / stream).lstat() stamps.append((info.st_ino, info.st_mtime_ns, info.st_ctime_ns, info.st_size)) except OSError: stamps.append(None) cache_key = (name, *stamps) if self.output_cache[0] == cache_key: return self.output_cache[1] lines = [] transport_seen = False for stream, prefix in (("stdout.bin", "OUT"), ("stderr.bin", "ERR")): try: fd = os.open(job / stream, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK) try: info = os.fstat(fd) if not stat.S_ISREG(info.st_mode): continue start = max(0, info.st_size - MAX_TAIL) os.lseek(fd, start, os.SEEK_SET) data = os.read(fd, MAX_TAIL) finally: os.close(fd) chunks = data.decode("utf-8", errors="replace").splitlines() if start and chunks: chunks.pop(0) # Never expose a partial potentially sensitive line. # Multiline credential blocks must not leak short base64 lines. secret_block = False for text in chunks[-80:]: upper = text.upper() if "-----BEGIN " in upper and "KEY-----" in upper: secret_block = True if secret_block: line = REDACTED if "-----END " in upper and "KEY-----" in upper: secret_block = False else: line = safe_line(text) if line == TRANSPORT: if transport_seen: continue transport_seen = True if line.strip() and (not lines or lines[-1] != (prefix, line)): lines.append((prefix, line)) except (OSError, ValueError): continue lines = lines[-100:] self.output_cache = (cache_key, lines) return lines def put(screen, row, text, attr=0): h, w = screen.getmaxyx() if 0 <= row < h and w > 1: try: screen.addnstr(row, 0, ascii_text(text).ljust(w - 1), w - 1, attr) except (curses.error, ValueError): pass def recent_label(job, width): # Date-heavy execution IDs are shortened from the left; their unique suffix # and observed time remain visible, while the complete ID is shown below. name = job.name if len(job.name) <= width else ".." + job.name[-max(1, width - 2):] return name def draw(screen, catalogue, node, selection=0, follow=True, help_open=False): screen.erase() h, w = screen.getmaxyx() now = time.time() title = curses.A_REVERSE | curses.A_BOLD accent = curses.color_pair(1) | curses.A_BOLD if curses.has_colors() else curses.A_BOLD good = curses.color_pair(2) if curses.has_colors() else curses.A_NORMAL warning = curses.color_pair(3) if curses.has_colors() else curses.A_BOLD bad = curses.color_pair(4) if curses.has_colors() else curses.A_BOLD put(screen, 0, f" FBTERM / ACTIVITY | {node}", title) clock = datetime.now().astimezone().strftime("%H:%M:%S %Z") running = catalogue.counts.get("RUNNING", 0) pending = sum(catalogue.counts.get(k, 0) for k in ("RESERVED", "ARMED")) uncertain = sum(catalogue.counts.get(k, 0) for k in ("UNKNOWN", "STALE", "UNCONFIRMED", "INVALID", "MISMATCH")) put(screen, 1, f" {clock} | RUN {running} WAIT {pending} CHECK {uncertain}", accent) if h < 10 or w < 35: put(screen, 2, f" {catalogue.total} jobs | {'LIVE' if follow else 'REVIEW'}", good) put(screen, 3, " Ctrl+B z: zoom | Ctrl+B o: focus") return screen.refresh() if help_open: help_lines = [ " MONITOR HELP", "", " j/k or Up/Down : review recent jobs", " f : follow newest job", " ? : close this help", "", " TMUX", " Ctrl+B then o : switch pane", " Ctrl+B then arrow: choose pane", " Ctrl+B then z : zoom / restore", "", " RUNNING: worker PID, birth and boot match.", " DONE/FAILED: receipt and exit agree.", " EXIT: legacy exit file, no receipt.", " STALE/CHECK: activity cannot be confirmed.", "", " Output is a filtered, bounded tail.", " Raw stdout/stderr remain in job files.", " Output hashes are not checked by this UI.", " q does not close the monitor.", ] for row, text in enumerate(help_lines, 3): if row < h - 2: put(screen, row, text) put(screen, h - 1, " ?: close help | Ctrl+B o: Bash", title) return screen.refresh() put(screen, 2, f" {'LIVE' if follow else 'REVIEW'} | {catalogue.total} jobs | receipts + process identity", good) if catalogue.errors: put(screen, 3, f" Source unavailable/read errors: {catalogue.errors}", warning) else: put(screen, 3, " Recent jobs / recorded results", accent) list_count = min(8 if h >= 40 else 5 if h >= 25 else 2, max(1, h // 6)) start_index = max(0, selection - list_count + 1) rows = catalogue.recent[start_index:start_index + list_count] for index, job in enumerate(rows): selected = start_index + index == selection rc = f" rc={job.exit_code}" if job.exit_code is not None else "" state = f"{job.state}{rc} {job.duration(now)}" name_width = max(8, w - len(state) - 5) line = f"{'>' if selected else ' '} {recent_label(job, name_width):<{name_width}} {state}" color = bad if job.state in ("FAILED", "MISMATCH", "INVALID") else accent if selected else good put(screen, 4 + index, line, color) detail_row = 5 + list_count put(screen, detail_row - 1, " " + "-" * max(0, w - 3)) if catalogue.recent: job = catalogue.recent[min(selection, len(catalogue.recent) - 1)] wrapped = textwrap.wrap(" Job: " + job.name, width=max(1, w - 2))[:2] for line in wrapped: put(screen, detail_row, line, accent) detail_row += 1 lines = catalogue.output(job.name) available = max(0, h - detail_row - 4) wrapped_output = [] for prefix, line in lines: for part in textwrap.wrap(prefix + " " + line, width=max(1, w - 3)) or [prefix]: wrapped_output.append((prefix, " " + part)) if not wrapped_output: wrapped_output = [("OUT", " No readable output recorded.")] for index, (prefix, text) in enumerate(wrapped_output[-available:] if available else []): put(screen, detail_row + index, text, warning if prefix == "ERR" else curses.A_NORMAL) else: put(screen, detail_row, " Waiting for recorded FBTERM jobs.", good) put(screen, h - 3, " " + "-" * max(0, w - 3)) put(screen, h - 2, " j/k: review | f: live | ?: help", good) put(screen, h - 1, " Ctrl+B o: focus | Ctrl+B z: zoom", title) screen.refresh() def main_screen(screen, root, node): try: curses.curs_set(0) except curses.error: pass screen.timeout(200) screen.keypad(True) if curses.has_colors(): curses.start_color() try: curses.use_default_colors() except curses.error: pass for pair, fg in ((1, curses.COLOR_CYAN), (2, curses.COLOR_GREEN), (3, curses.COLOR_YELLOW), (4, curses.COLOR_RED)): try: curses.init_pair(pair, fg, -1) except curses.error: pass catalogue = Catalogue(root) previous = 0.0 selection, follow, help_open = 0, True, False selected_name = None while True: now = time.monotonic() if now - previous >= 1.5: catalogue.scan() previous = now if follow: selection = 0 elif selected_name: selection = next((i for i, j in enumerate(catalogue.recent) if j.name == selected_name), min(selection, max(0, len(catalogue.recent) - 1))) draw(screen, catalogue, node, selection, follow, help_open) ch = screen.getch() if ch in (ord("j"), curses.KEY_DOWN): selection = min(selection + 1, max(0, len(catalogue.recent) - 1)) follow = False elif ch in (ord("k"), curses.KEY_UP): selection = max(0, selection - 1) follow = False elif ch in (ord("f"), ord("F")): selection, follow = 0, True elif ch == ord("?"): help_open = not help_open selected_name = catalogue.recent[selection].name if catalogue.recent else None def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path(os.environ.get( "FRAMEBRIDGE_FBTERM_ROOT", str(Path.home() / ".local/share/framebridge/fbterm")))) parser.add_argument("--node", default=os.environ.get("FRAMEBRIDGE_NODE", socket.gethostname())) parser.add_argument("--version", action="version", version=VERSION) args = parser.parse_args() node = ascii_text(args.node)[:64] try: curses.wrapper(main_screen, args.root, node) except KeyboardInterrupt: pass if __name__ == "__main__": main()