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 @@
|
||||
"""Accepted two-pane console display components."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FrameBridge's two-pane physical console, using the existing user's tmux.
|
||||
|
||||
No login, privilege, service or desktop configuration is changed here.
|
||||
The normal entry point is called by an existing authenticated tty1 login.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fcntl
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def arguments():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--node', default=os.environ.get('FRAMEBRIDGE_NODE', socket.gethostname()))
|
||||
p.add_argument('--socket')
|
||||
p.add_argument('--session')
|
||||
p.add_argument('--apply', action='store_true', help='Set up without attaching a client')
|
||||
p.add_argument('--probe', action='store_true', help='Read current pane metadata only')
|
||||
p.add_argument('--reflow', action='store_true', help='Resize existing panes without taking focus')
|
||||
p.add_argument('--refresh-monitor', action='store_true')
|
||||
p.add_argument('--layout', choices=('auto','horizontal','vertical'), default='auto')
|
||||
p.add_argument('--monitor', action='store_true', help=argparse.SUPPRESS)
|
||||
a = p.parse_args()
|
||||
a.node = re.sub(r'[^A-Za-z0-9_-]', '_', a.node)[:64] or 'NODE'
|
||||
a.socket = a.socket or str(Path('/run/user') / str(os.getuid()) / (a.node.lower() + '-tty1') / 'console.sock')
|
||||
a.session = a.session or a.node.upper() + '-FBTERM'
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]{1,90}', a.session):
|
||||
p.error('Session name must contain only letters, digits, underscore and hyphen')
|
||||
return a
|
||||
|
||||
|
||||
class Console:
|
||||
def __init__(self, args):
|
||||
self.a = args
|
||||
self.session_target = args.session
|
||||
self.window = '=' + args.session + ':0'
|
||||
self.env = {k:v for k,v in os.environ.items() if k not in ('TMUX', 'TMUX_PANE')}
|
||||
self.env['LC_ALL'] = 'C.UTF-8'
|
||||
|
||||
def tmux(self, *args, check=True):
|
||||
r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, *args],
|
||||
env=self.env, capture_output=True, text=True, timeout=8)
|
||||
if check and r.returncode:
|
||||
raise RuntimeError('tmux ' + args[0] + ': ' + r.stderr.strip())
|
||||
return r.stdout.strip()
|
||||
|
||||
def exists(self):
|
||||
r = subprocess.run(['/usr/bin/tmux', '-S', self.a.socket, 'has-session', '-t', '=' + self.a.session],
|
||||
env=self.env, capture_output=True, timeout=5)
|
||||
if r.returncode == 0:
|
||||
matches=[line.rsplit('|',1)[1] for line in self.tmux('list-sessions','-F','#{session_name}|#{session_id}').splitlines()
|
||||
if line.rsplit('|',1)[0]==self.a.session]
|
||||
if len(matches)!=1:
|
||||
raise RuntimeError('Console session identity is ambiguous')
|
||||
self.session_target=matches[0]
|
||||
windows=self.tmux('list-windows','-t',self.session_target,'-F','#{window_id}').splitlines()
|
||||
if len(windows)!=1:
|
||||
raise RuntimeError('Use a dedicated console session containing exactly one window')
|
||||
self.window=windows[0]
|
||||
return True
|
||||
return False
|
||||
|
||||
def panes(self):
|
||||
fmt = '|'.join('#{' + f + '}' for f in ('pane_id','pane_pid','pane_current_command',
|
||||
'pane_left','pane_top','pane_width','pane_height','pane_active','pane_dead',
|
||||
'@framebridge_role'))
|
||||
rows = []
|
||||
for line in self.tmux('list-panes', '-t', self.window, '-F', fmt).splitlines():
|
||||
ident,pid,command,left,top,width,height,active,dead,role = line.split('|')
|
||||
rows.append(dict(id=ident,pid=int(pid),command=command,left=int(left),top=int(top),
|
||||
width=int(width),height=int(height),active=active=='1',dead=dead=='1',role=role))
|
||||
return rows
|
||||
|
||||
def monitor_command(self):
|
||||
return shlex.join(['/usr/bin/python3', str(HERE / 'framebridge_console.py'), '--monitor', '--node', self.a.node])
|
||||
|
||||
def locate(self, rows):
|
||||
shell = next((p for p in rows if p['role']=='shell'), None)
|
||||
monitor = next((p for p in rows if p['role']=='monitor'), None)
|
||||
# Adopt only the known original dashboard, never an arbitrary Python pane.
|
||||
for p in rows:
|
||||
if p['role']:
|
||||
continue
|
||||
try:
|
||||
cmd = Path('/proc', str(p['pid']), 'cmdline').read_bytes().split(b'\0')
|
||||
names = {Path(x.decode(errors='replace')).name for x in cmd if x}
|
||||
except OSError:
|
||||
names = set()
|
||||
if not monitor and ('fbterm_live.py' in names or 'fbterm_dashboard.py' in names):
|
||||
monitor = p
|
||||
elif not shell and p['command']=='bash' and not p['dead']:
|
||||
shell = p
|
||||
return shell, monitor
|
||||
|
||||
def layout(self, shell, monitor):
|
||||
active=next((p['id'] for p in self.panes() if p['active']),None)
|
||||
width,height = map(int, self.tmux('display-message','-p','-t',self.window,
|
||||
'#{window_width} #{window_height}').split())
|
||||
mode=getattr(self.a,'layout','auto')
|
||||
horizontal=mode=='horizontal' or (mode=='auto' and width>=160)
|
||||
if horizontal:
|
||||
self.tmux('select-layout','-t',self.window,'even-horizontal')
|
||||
current={p['id']:p for p in self.panes()}
|
||||
if current[monitor['id']]['left']>current[shell['id']]['left']:
|
||||
self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id'])
|
||||
monitor_width=max(2,min(max(2,width-21),max(24,round((width-1)*0.30))))
|
||||
self.tmux('resize-pane','-t',monitor['id'],'-x',str(monitor_width))
|
||||
else:
|
||||
self.tmux('select-layout','-t',self.window,'even-vertical')
|
||||
current={p['id']:p for p in self.panes()}
|
||||
if current[monitor['id']]['top']>current[shell['id']]['top']:
|
||||
self.tmux('swap-pane','-d','-s',shell['id'],'-t',monitor['id'])
|
||||
self.tmux('resize-pane','-t',monitor['id'],'-y',str(max(2,min(8,(height-3)//3))))
|
||||
if active:
|
||||
self.tmux('select-pane','-t',active)
|
||||
|
||||
def apply(self):
|
||||
existed=self.exists()
|
||||
if not existed:
|
||||
parent=Path(self.a.socket).parent
|
||||
parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
st=parent.stat()
|
||||
if st.st_uid!=os.getuid() or st.st_mode & 0o022:
|
||||
raise RuntimeError('Console socket directory is not private to this user')
|
||||
size=os.get_terminal_size(0) if os.isatty(0) else os.terminal_size((240,67))
|
||||
self.session_target,self.window=self.tmux('new-session','-d','-P','-F','#{session_id}|#{window_id}',
|
||||
'-x',str(size.columns),'-y',str(size.lines),
|
||||
'-s',self.a.session,'-n','Console','-c',str(Path.home()),'/bin/bash --login').split('|')
|
||||
rows=self.panes()
|
||||
shell,monitor=self.locate(rows)
|
||||
if self.a.reflow:
|
||||
if shell and monitor and len(rows)==2:
|
||||
# Zoom is an explicit user choice; never undo it in a resize hook.
|
||||
if self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1':
|
||||
self.layout(shell,monitor)
|
||||
return
|
||||
if not shell or shell['dead']:
|
||||
raise RuntimeError('Normal Bash pane is absent; refusing to replace another program')
|
||||
if len(rows)>2 or (len(rows)==2 and not monitor):
|
||||
raise RuntimeError('Unexpected console panes; refusing to modify them')
|
||||
original_pid=shell['pid']
|
||||
previous_focus=next((p['id'] for p in rows if p['active']),shell['id'])
|
||||
previous_zoom=self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')=='1'
|
||||
if not monitor:
|
||||
ident=self.tmux('split-window','-d','-h','-l','30%','-t',shell['id'],'-P','-F','#{pane_id}',self.monitor_command())
|
||||
monitor=next(p for p in self.panes() if p['id']==ident)
|
||||
else:
|
||||
try:
|
||||
cmd=Path('/proc',str(monitor['pid']),'cmdline').read_bytes()
|
||||
except OSError:
|
||||
cmd=b''
|
||||
if self.a.refresh_monitor or monitor['dead'] or b'framebridge_console.py' not in cmd:
|
||||
self.tmux('respawn-pane','-k','-t',monitor['id'],self.monitor_command())
|
||||
for role,pane in [('shell',shell),('monitor',monitor)]:
|
||||
self.tmux('set-option','-p','-t',pane['id'],'@framebridge_role',role)
|
||||
self.tmux('set-option','-p','-t',monitor['id'],'remain-on-exit','on')
|
||||
self.tmux('select-pane','-t',shell['id'],'-T','SHELL | '+self.a.node+' | '+getpass.getuser())
|
||||
self.tmux('select-pane','-t',monitor['id'],'-T','FBTERM | ACTIVITY')
|
||||
self.tmux('set-window-option','-t',self.window,'automatic-rename','off')
|
||||
self.tmux('rename-window','-t',self.window,'Console')
|
||||
for key,value in [('pane-border-status','top'),('pane-border-style','fg=blue'),
|
||||
('pane-active-border-style','fg=cyan'),
|
||||
('pane-border-format',' #{?pane_active,>, } #{pane_title} '),
|
||||
('window-status-format',''),('window-status-current-format','')]:
|
||||
self.tmux('set-window-option','-t',self.window,key,value)
|
||||
for key,value in [('status','on'),('status-style','bg=blue,fg=white'),
|
||||
('status-left','#[bold] FRAMEBRIDGE #[default] | '+self.a.node+' | '),
|
||||
('status-left-length','80'),('status-right-length','110'),
|
||||
('status-right',' Ctrl+B, then o: switch | z: zoom | %H:%M %Z ')]:
|
||||
self.tmux('set-option','-t',self.session_target,key,value)
|
||||
self.tmux('set-option','-t',self.session_target,'mouse','off')
|
||||
# This server is dedicated to FrameBridge; do not alter global tmux configuration.
|
||||
for key in ('z','C-z','Z'):
|
||||
self.tmux('bind-key','-T','prefix',key,'resize-pane','-Z')
|
||||
# Reattachment must preserve the user's zoom and pane focus.
|
||||
if not previous_zoom:
|
||||
self.layout(shell,monitor)
|
||||
command=shlex.join(['/usr/bin/python3',str(HERE/'framebridge_console.py'),'--node',self.a.node,
|
||||
'--socket',self.a.socket,'--session',self.a.session,
|
||||
'--layout',getattr(self.a,'layout','auto'),'--reflow'])
|
||||
self.tmux('set-hook','-t',self.session_target,'client-resized[90]','run-shell -b '+shlex.quote(command))
|
||||
self.tmux('set-hook','-t',self.session_target,'client-attached[90]','run-shell -b '+shlex.quote(command))
|
||||
self.tmux('select-window','-t',self.window)
|
||||
selected=previous_focus if existed else shell['id']
|
||||
self.tmux('select-pane','-t',selected)
|
||||
if previous_zoom and self.tmux('display-message','-p','-t',self.window,'#{window_zoomed_flag}')!='1':
|
||||
self.tmux('resize-pane','-Z','-t',selected)
|
||||
current=next(p for p in self.panes() if p['id']==shell['id'])
|
||||
if current['pid']!=original_pid:
|
||||
raise RuntimeError('Bash identity changed unexpectedly')
|
||||
|
||||
|
||||
def supervise(node):
|
||||
while True:
|
||||
try:
|
||||
result=subprocess.run(['/usr/bin/python3','-u',str(HERE/'fbterm_dashboard.py'),'--node',node])
|
||||
print('\r\nFBTERM monitor stopped (exit '+str(result.returncode)+'). Restarting in 2 seconds.',flush=True)
|
||||
print('Ctrl+B o switches to the shell.',flush=True)
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print('\r\nFBTERM monitor restarting. Ctrl+B o switches to the shell.',flush=True)
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def main():
|
||||
args=arguments()
|
||||
if args.monitor:
|
||||
supervise(args.node)
|
||||
return 0
|
||||
console=Console(args)
|
||||
if args.probe:
|
||||
print(json.dumps({'node':args.node,'socket':args.socket,'session':args.session,
|
||||
'panes':console.panes() if console.exists() else [],'login':'existing-authenticated-login'}))
|
||||
return 0
|
||||
if not (args.apply or args.reflow):
|
||||
if not os.isatty(0) or os.ttyname(0)!='/dev/tty1' or os.environ.get('TMUX') or os.environ.get('SSH_TTY'):
|
||||
print('Open this console from an authenticated tty1 login. --probe is read-only.',file=sys.stderr)
|
||||
return 1
|
||||
parent=Path(args.socket).parent
|
||||
parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
info=parent.stat()
|
||||
if info.st_uid!=os.getuid() or info.st_mode & 0o022:
|
||||
raise RuntimeError('Console socket directory is not private to this user')
|
||||
fd=os.open(parent/'console-layout.lock',os.O_CREAT|os.O_RDWR|os.O_CLOEXEC|os.O_NOFOLLOW,0o600)
|
||||
try:
|
||||
info=os.fstat(fd)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid!=os.getuid():
|
||||
raise RuntimeError('Invalid console layout lock')
|
||||
fcntl.flock(fd,fcntl.LOCK_EX)
|
||||
console.apply()
|
||||
finally:
|
||||
os.close(fd)
|
||||
if args.apply:
|
||||
print(json.dumps({'node':args.node,'status':'READY','panes':console.panes()}))
|
||||
elif not args.reflow:
|
||||
os.execve('/usr/bin/tmux',['tmux','-S',args.socket,'attach-session','-t',console.session_target],console.env)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired) as e:
|
||||
print('FrameBridge console: '+str(e),file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in new issue
Block a user