FrameBridge 0.3.1 community source release
This commit is contained in:
commit
b23819fff1
1354 files changed
+343716
No files matched your search
@@ -0,0 +1,464 @@
|
||||
#!/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()
|
||||
Reference in new issue
Block a user