From 69cb255120c1f27074640507c8a5f81114ef0c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Tue, 20 Oct 2026 17:43:55 +0200 Subject: [PATCH] Rig Pocket: phone status page and ntfy notifier for OpenRig Installable PWA showing rigs, seats (working, idle, waiting for an answer), queue items and recent commits; seat screens with replies; push through ntfy. Reads state through the rig CLI only, Python standard library, no build step, styled entirely with the shared weirsoe-theme. Claude-Session: https://claude.ai/code/session_01QhLkBGEip8wQdaZ54z4AqJ --- .gitignore | 2 + LICENSE | 21 +++ README.md | 60 ++++++++ rig-pocket.service | 16 ++ server.py | 283 ++++++++++++++++++++++++++++++++++++ static/app.js | 146 +++++++++++++++++++ static/boot.js | 3 + static/icon-192.png | Bin 0 -> 9168 bytes static/icon-512.png | Bin 0 -> 25837 bytes static/icon.svg | 9 ++ static/index.html | 39 +++++ static/manifest.webmanifest | 15 ++ static/sw.js | 16 ++ test_server.py | 64 ++++++++ 14 files changed, 674 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 rig-pocket.service create mode 100644 server.py create mode 100644 static/app.js create mode 100644 static/boot.js create mode 100644 static/icon-192.png create mode 100644 static/icon-512.png create mode 100644 static/icon.svg create mode 100644 static/index.html create mode 100644 static/manifest.webmanifest create mode 100644 static/sw.js create mode 100644 test_server.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4016819 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Asger Geel Weirsøe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..abcd6b3 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Rig Pocket + +A phone-friendly status page and push notifier for [OpenRig](https://openrig.dev) teams. +The OpenRig TUI is great on a laptop and hard to use on a phone; Rig Pocket gives the same overview +as an installable web app (PWA): + +- every rig and seat, with **working / idle / waiting for an answer** +- open and recently finished queue items +- the latest commits on every branch of the repositories your teams work in +- tap a seat to see its screen (refreshes every 5 s), answer a prompt with **1 / 2 / 3**, or type a message to it +- optional push notifications through [ntfy](https://ntfy.sh): new commits, a seat waiting for an answer + (only if it is still waiting on the next poll), a new request to the operator, a lead's task finished + +It reads everything through the documented `rig` CLI (`--json`), so it does not depend on daemon internals. +Python 3 standard library only, no build step. + +## Run + +Run it as the same user as the OpenRig daemon, next to it: + +```sh +RIG_POCKET_REPOS="~/work/*" python3 server.py # listens on 127.0.0.1:7440 +python3 -m unittest # tests +``` + +`rig-pocket.service` is an example systemd unit. + +| Variable | Default | | +|---|---|---| +| `RIG_BIN` | `rig` | path to the rig CLI | +| `RIG_POCKET_BIND` / `RIG_POCKET_PORT` | `127.0.0.1` / `7440` | listen address | +| `RIG_POCKET_POLL` | `30` | seconds between snapshots | +| `RIG_POCKET_REPOS` | (none) | space separated globs of git work trees to show commits from | +| `RIG_POCKET_ORIGIN` | (none) | if set, POSTs must come from this origin, e.g. `https://rig.example.com` | +| `RIG_POCKET_URL` | (none) | where tapping a notification opens | +| `RIG_POCKET_THEME_CSS` / `_JS` | weirsoe-theme on assets.weircon.dk | stylesheet and script of the design system | +| `NTFY_URL` / `NTFY_TOPIC` / `NTFY_TOKEN` | (none) | ntfy server, topic and access token; unset = no push | +| `RIG_POCKET_DONE_DEST` | `^orch-lead@` | regex: notify when a task to such a seat closes | +| `RIG_POCKET_HUMAN_DEST` | `^operator-agent@kernel$` | regex: notify when such a seat gets a new task | + +## Security + +Rig Pocket can type into your agents, and OpenRig agents usually run without permission prompts. +**Never expose it without authentication.** Put it behind a reverse proxy with single sign-on (forward auth) +and, ideally, an IP allow list, and keep it bound to localhost. Write requests need a custom header +(which forces a CORS preflight) and, with `RIG_POCKET_ORIGIN`, a matching `Origin`. Seat names are checked +against the current snapshot before anything is passed to `rig`, and arguments are never run through a shell. + +The page is served with a strict Content Security Policy: scripts and styles only from itself and the theme host, +no inline scripts, `connect-src 'self'`. + +## Look + +The page uses only classes from the shared [weirsoe-theme](https://gitea.weircon.dk/agw/Weircon-wind) design system +(`.wt-card`, `.wt-tag--warn`, `.wt-pre`, `.wt-row`, …) and has no stylesheet of its own. Point the two theme variables +at your own copy to re-skin it. + +## License + +MIT diff --git a/rig-pocket.service b/rig-pocket.service new file mode 100644 index 0000000..8b7c208 --- /dev/null +++ b/rig-pocket.service @@ -0,0 +1,16 @@ +# Example unit. Run as the user that owns the OpenRig daemon. +[Unit] +Description=Rig Pocket (phone status page for OpenRig) +After=network-online.target openrig.service + +[Service] +User=rig +WorkingDirectory=/opt/rig-pocket +Environment=PATH=/home/rig/.local/bin:/home/rig/.npm-global/bin:/usr/local/bin:/usr/bin:/bin +Environment=HOME=/home/rig +EnvironmentFile=-/etc/rig-pocket.env +ExecStart=/usr/bin/python3 /opt/rig-pocket/server.py +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/server.py b/server.py new file mode 100644 index 0000000..c1621cc --- /dev/null +++ b/server.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""rig-pocket: a phone-friendly status page and push notifier for OpenRig. + +Reads state only through the documented `rig` CLI (`--json`), so it needs no +access to the daemon's internals. Standard library only. +""" +import glob +import json +import os +import re +import subprocess +import threading +import time +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HERE = Path(__file__).resolve().parent +STATIC = HERE / "static" + +RIG = os.environ.get("RIG_BIN", "rig") +BIND = os.environ.get("RIG_POCKET_BIND", "127.0.0.1") +PORT = int(os.environ.get("RIG_POCKET_PORT", "7440")) +POLL = int(os.environ.get("RIG_POCKET_POLL", "30")) +REPOS = os.environ.get("RIG_POCKET_REPOS", "") # space separated globs of git work trees +ORIGIN = os.environ.get("RIG_POCKET_ORIGIN", "") # e.g. https://rig.example.com, checked on POST +PUBLIC_URL = os.environ.get("RIG_POCKET_URL", "") # where a notification tap should go +THEME_CSS = os.environ.get("RIG_POCKET_THEME_CSS", "https://assets.weircon.dk/css/weirsoe-theme.css") +THEME_JS = os.environ.get("RIG_POCKET_THEME_JS", "https://assets.weircon.dk/js/weirsoe-theme.js") +NTFY_URL = os.environ.get("NTFY_URL", "") +NTFY_TOPIC = os.environ.get("NTFY_TOPIC", "") +NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") +DONE_DEST = re.compile(os.environ.get("RIG_POCKET_DONE_DEST", r"^orch-lead@")) +HUMAN_DEST = re.compile(os.environ.get("RIG_POCKET_HUMAN_DEST", r"^operator-agent@kernel$")) + +PROMPT = re.compile(r"Do you want to proceed\?|^\s*❯ 1\. ", re.M) +SEAT = re.compile(r"^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+$") +KEYS = {"1", "2", "3", "4"} +CLOSED = {"done", "closed", "failed", "canceled", "cancelled"} + + +def rig(*args, timeout=20): + try: + out = subprocess.run([RIG, *args], capture_output=True, text=True, timeout=timeout) + return out.stdout + except (OSError, subprocess.TimeoutExpired): + return "" + + +def rig_json(*args): + try: + return json.loads(rig(*args) or "null") + except json.JSONDecodeError: + return None + + +def git(path, *args): + try: + return subprocess.run(["git", "-C", path, *args], capture_output=True, text=True, timeout=10).stdout + except (OSError, subprocess.TimeoutExpired): + return "" + + +def as_list(data): + if isinstance(data, list): + return data + if isinstance(data, dict): + return data.get("items") or data.get("entries") or [] + return [] + + +def repo_paths(): + paths = [] + for pattern in REPOS.split(): + for p in sorted(glob.glob(os.path.expanduser(pattern))): + if os.path.exists(os.path.join(p, ".git")): + paths.append(p) + return paths + + +def collect(): + rigs = [] + for r in as_list(rig_json("ps", "--json")): + if r.get("isArchived"): + continue + seats = [] + for n in as_list(rig_json("ps", "--nodes", "--rig", r["name"], "--json")): + name = n.get("canonicalSessionName", "") + screen = rig("capture", name, "--lines", "25") if name else "" + seats.append({ + "name": name, + "activity": (n.get("agentActivity") or {}).get("state", "unknown"), + "status": n.get("sessionStatus"), + "work": n.get("assignedWorkCount", 0), + "lastActivity": n.get("lastActivity"), + "waiting": bool(PROMPT.search(screen)), + }) + rigs.append({"name": r["name"], "status": r.get("status"), "uptime": r.get("uptime"), + "attention": r.get("attentionCount", 0), "seats": seats}) + + queue = [] + for q in as_list(rig_json("queue", "list", "-A", "-a", "--limit", "60", "--json")): + queue.append({"id": q.get("qitemId"), "state": q.get("state"), "dest": q.get("destinationSession"), + "source": q.get("sourceSession"), "summary": q.get("summary"), "updated": q.get("tsUpdated")}) + + repos = [] + for p in repo_paths(): + commits = [] + for line in git(p, "log", "--branches", "-15", "--format=%H%x09%h%x09%cI%x09%D%x09%s").splitlines(): + full, short, when, refs, subject = (line.split("\t") + [""] * 5)[:5] + commits.append({"hash": full, "short": short, "when": when, "refs": refs, "subject": subject}) + repos.append({"name": os.path.basename(p), "commits": commits}) + + return {"updated": time.time(), "rigs": rigs, "queue": queue, "repos": repos} + + +def notify(title, body, priority=3, tags=""): + if not (NTFY_URL and NTFY_TOPIC): + return + headers = {"Title": title.encode("utf-8"), "Priority": str(priority)} + if tags: + headers["Tags"] = tags + if PUBLIC_URL: + headers["Click"] = PUBLIC_URL + if NTFY_TOKEN: + headers["Authorization"] = f"Bearer {NTFY_TOKEN}" + req = urllib.request.Request(f"{NTFY_URL.rstrip('/')}/{NTFY_TOPIC}", data=body.encode("utf-8"), headers=headers) + try: + urllib.request.urlopen(req, timeout=10).close() + except OSError: + pass + + +class Watcher: + """Turns successive snapshots into notifications. The first snapshot only seeds.""" + + def __init__(self): + self.seen_commits = None + self.waiting_polls = {} + self.notified_waiting = set() + self.queue_state = None + + def events(self, snap): + out = [] + commits = {c["hash"]: (r["name"], c) for r in snap["repos"] for c in r["commits"]} + queue = {q["id"]: q for q in snap["queue"]} + if self.seen_commits is None: + self.seen_commits = set(commits) + self.queue_state = {k: q["state"] for k, q in queue.items()} + else: + new = [commits[h] for h in commits if h not in self.seen_commits] + self.seen_commits |= set(commits) + by_repo = {} + for repo, c in new: + by_repo.setdefault(repo, []).append(c["subject"]) + for repo, subjects in by_repo.items(): + out.append((f"{repo}: {len(subjects)} ny commit" + ("s" if len(subjects) > 1 else ""), + "\n".join(subjects), 2, "package")) + for k, q in queue.items(): + before = self.queue_state.get(k) + if before is None and HUMAN_DEST.search(q["dest"] or "") and q["state"] not in CLOSED: + out.append(("Teamet spørger dig", q["summary"] or q["source"] or k, 4, "raising_hand")) + if before and before not in CLOSED and q["state"] in CLOSED and DONE_DEST.search(q["dest"] or ""): + out.append((f"Opgave {q['state']}: {q['dest']}", q["summary"] or k, 4, "white_check_mark")) + self.queue_state = {k: q["state"] for k, q in queue.items()} + + # a prompt must survive two polls before it pings, so prompts answered quickly stay quiet + for r in snap["rigs"]: + for s in r["seats"]: + n = s["name"] + self.waiting_polls[n] = self.waiting_polls.get(n, 0) + 1 if s["waiting"] else 0 + if self.waiting_polls[n] >= 2 and n not in self.notified_waiting: + self.notified_waiting.add(n) + out.append((f"{n} venter på svar", "Åbn for at se skærmen og svare.", 4, "warning")) + if not s["waiting"]: + self.notified_waiting.discard(n) + return out + + +STATE = {"snap": None} +LOCK = threading.Lock() + + +def poll_loop(): + watcher = Watcher() + while True: + try: + snap = collect() + with LOCK: + STATE["snap"] = snap + for title, body, prio, tags in watcher.events(snap): + notify(title, body, prio, tags) + except Exception as e: # keep polling whatever one round did + print("poll failed:", e, flush=True) + time.sleep(POLL) + + +def known_seats(): + with LOCK: + snap = STATE["snap"] or {"rigs": []} + return {s["name"] for r in snap["rigs"] for s in r["seats"]} + + +TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", + ".webmanifest": "application/manifest+json", ".svg": "image/svg+xml", ".png": "image/png", + ".json": "application/json"} + + +def csp(): + ext = " ".join(sorted({u.split("/", 3)[0] + "//" + u.split("/", 3)[2] for u in (THEME_CSS, THEME_JS) if u.startswith("http")})) + return (f"default-src 'self'; style-src 'self' {ext}; script-src 'self' {ext}; img-src 'self' data:; " + "connect-src 'self'; manifest-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'") + + +class Handler(BaseHTTPRequestHandler): + server_version = "rig-pocket" + + def log_message(self, *args): + pass + + def send(self, code, body, ctype="application/json", cache="no-store"): + data = body if isinstance(body, bytes) else body.encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", cache) + self.send_header("Content-Security-Policy", csp()) + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + path = self.path.split("?", 1)[0] + if path == "/api/status": + with LOCK: + snap = STATE["snap"] + return self.send(200, json.dumps(snap or {"updated": 0, "rigs": [], "queue": [], "repos": []})) + if path.startswith("/api/capture/"): + seat = urllib.parse.unquote(path.rsplit("/", 1)[1]) + if not SEAT.match(seat) or seat not in known_seats(): + return self.send(404, '{"error":"unknown seat"}') + return self.send(200, json.dumps({"seat": seat, "screen": rig("capture", seat, "--lines", "60")})) + name = "index.html" if path in ("/", "") else path.lstrip("/") + f = (STATIC / name).resolve() + if STATIC not in f.parents or not f.is_file(): + return self.send(404, "not found", "text/plain") + data = f.read_bytes() + if f.name == "index.html": + data = data.replace(b"{{THEME_CSS}}", THEME_CSS.encode()).replace(b"{{THEME_JS}}", THEME_JS.encode()) + cache = "no-cache" if f.suffix in (".html", ".webmanifest") or f.name == "sw.js" else "max-age=300" + self.send(200, data, TYPES.get(f.suffix, "application/octet-stream"), cache) + + def do_POST(self): + if self.path != "/api/send": + return self.send(404, '{"error":"not found"}') + # a custom header forces a CORS preflight, so other sites cannot post here + if self.headers.get("X-Rig-Pocket") != "1" or (ORIGIN and self.headers.get("Origin") != ORIGIN): + return self.send(403, '{"error":"forbidden"}') + try: + body = json.loads(self.rfile.read(min(int(self.headers.get("Content-Length", 0)), 8192))) + except (ValueError, json.JSONDecodeError): + return self.send(400, '{"error":"bad json"}') + seat, text, key = body.get("seat", ""), (body.get("text") or "").strip(), body.get("key") + if not SEAT.match(seat) or seat not in known_seats(): + return self.send(404, '{"error":"unknown seat"}') + if key in KEYS: + out = rig("send", seat, key, "--dangerously-interact", "--reason", "rig-pocket: answered from phone") + elif text and len(text) <= 2000: + out = rig("send", seat, f"From the person (via rig-pocket on their phone): {text}") + else: + return self.send(400, '{"error":"nothing to send"}') + self.send(200, json.dumps({"ok": True, "result": out.strip()[:500]})) + + +def main(): + threading.Thread(target=poll_loop, daemon=True).start() + ThreadingHTTPServer((BIND, PORT), Handler).serve_forever() + + +if __name__ == "__main__": + main() diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..5224d8b --- /dev/null +++ b/static/app.js @@ -0,0 +1,146 @@ +// Rig Pocket: one page, two views (#/ overview, #/seat/ screen + reply). Plain DOM, no build. +const $ = (tag, attrs = {}, ...kids) => { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') el.className = v; + else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); + else el.setAttribute(k, v); + } + for (const kid of kids.flat()) if (kid != null) el.append(kid.nodeType ? kid : String(kid)); + return el; +}; + +const ago = (iso) => { + if (!iso) return ''; + const s = Math.max(0, (Date.now() - new Date(typeof iso === 'number' ? iso * 1000 : iso)) / 1000); + if (s < 60) return `${Math.round(s)} s`; + if (s < 3600) return `${Math.round(s / 60)} min`; + if (s < 86400) return `${Math.round(s / 3600)} t`; + return `${Math.round(s / 86400)} d`; +}; + +const seatTag = (s) => { + if (s.waiting) return $('span', { class: 'wt-tag wt-tag--warn' }, 'venter på svar'); + if (s.status && s.status !== 'running') return $('span', { class: 'wt-tag wt-tag--bad' }, s.status); + if (s.activity === 'running' || s.activity === 'working') return $('span', { class: 'wt-tag wt-tag--accent' }, 'arbejder'); + return $('span', { class: 'wt-tag' }, 'ledig'); +}; + +const QSTATE = { 'in-progress': 'wt-tag--accent', blocked: '', parked: '', done: 'wt-tag--good', failed: 'wt-tag--bad' }; + +let snap = null; +let timer = null; + +async function load() { + try { + const r = await fetch('api/status', { cache: 'no-store' }); + snap = await r.json(); + render(); + } catch (e) { + document.getElementById('updated').textContent = 'offline'; + } +} + +function overview() { + const view = []; + const waiting = snap.rigs.flatMap((r) => r.seats.filter((s) => s.waiting)); + if (waiting.length) { + view.push($('section', { class: 'wt-card wt-card--raised wt-stack' }, + $('h2', { class: 'wt-card-title' }, 'Venter på dig'), + waiting.map((s) => $('a', { class: 'wt-row wt-row--between', href: `#/seat/${s.name}` }, s.name, seatTag(s))))); + } + + for (const r of snap.rigs) { + view.push($('section', { class: 'wt-card wt-stack' }, + $('div', { class: 'wt-row wt-row--between' }, + $('h2', { class: 'wt-card-title' }, r.name), + $('span', { class: 'wt-muted' }, `${r.status} · ${r.uptime}`)), + r.seats.map((s) => $('a', { class: 'wt-row wt-row--between', href: `#/seat/${s.name}` }, + $('span', {}, s.name.split('@')[0]), + $('span', { class: 'wt-row' }, + s.work ? $('span', { class: 'wt-muted' }, `${s.work} opg.`) : null, + $('span', { class: 'wt-muted' }, ago(s.lastActivity)), + seatTag(s)))))); + } + + const open = snap.queue.filter((q) => !['done', 'closed', 'canceled'].includes(q.state) && q.summary); + const done = snap.queue.filter((q) => q.state === 'done' && q.summary).slice(0, 6); + if (open.length || done.length) { + view.push($('section', { class: 'wt-card wt-stack' }, + $('h2', { class: 'wt-card-title' }, 'Opgaver'), + [...open, ...done].map((q) => $('div', { class: 'wt-stack' }, + $('div', { class: 'wt-row wt-row--between' }, + $('span', { class: `wt-tag ${QSTATE[q.state] || ''}` }, q.state), + $('span', { class: 'wt-muted' }, `${q.dest} · ${ago(q.updated)}`)), + $('span', {}, q.summary))))); + } + + for (const repo of snap.repos) { + if (!repo.commits.length) continue; + view.push($('section', { class: 'wt-card wt-stack' }, + $('h2', { class: 'wt-card-title' }, repo.name), + repo.commits.slice(0, 8).map((c) => $('div', {}, + $('div', { class: 'wt-row wt-row--between' }, + $('code', {}, c.short), $('span', { class: 'wt-muted' }, ago(c.when))), + $('span', {}, c.subject))))); + } + return view; +} + +async function send(seat, payload) { + const r = await fetch('api/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Rig-Pocket': '1' }, + body: JSON.stringify({ seat, ...payload }), + }); + return r.ok; +} + +function seatView(name) { + const s = snap.rigs.flatMap((r) => r.seats).find((x) => x.name === name); + const pre = $('pre', { class: 'wt-pre' }, 'Henter skærm…'); + const refresh = async () => { + const r = await fetch(`api/capture/${encodeURIComponent(name)}`, { cache: 'no-store' }); + pre.textContent = r.ok ? (await r.json()).screen : 'Ukendt agent'; + pre.scrollTop = pre.scrollHeight; + }; + refresh(); + clearInterval(timer); + timer = setInterval(refresh, 5000); + + const input = $('textarea', { class: 'wt-input', rows: '3', placeholder: `Skriv til ${name}…` }); + const status = $('span', { class: 'wt-muted' }); + const doSend = async (payload) => { + status.textContent = 'Sender…'; + status.textContent = (await send(name, payload)) ? 'Sendt' : 'Fejl'; + setTimeout(refresh, 1200); + }; + return [ + $('a', { href: '#/', class: 'wt-muted' }, '← Oversigt'), + $('div', { class: 'wt-row wt-row--between' }, $('h2', {}, name), s ? seatTag(s) : null), + pre, + s && s.waiting ? $('div', { class: 'wt-row' }, + $('span', { class: 'wt-muted' }, 'Svar på prompten:'), + ['1', '2', '3'].map((k) => $('button', { class: 'wt-btn', onclick: () => doSend({ key: k }) }, k))) : null, + $('div', { class: 'wt-stack' }, input, + $('div', { class: 'wt-row wt-row--between' }, status, + $('button', { class: 'wt-btn wt-btn--primary', onclick: () => { if (input.value.trim()) { doSend({ text: input.value }); input.value = ''; } } }, 'Send'))), + ]; +} + +function render() { + if (!snap) return; + document.getElementById('updated').textContent = ago(snap.updated); + const m = location.hash.match(/^#\/seat\/(.+)$/); + const view = document.getElementById('view'); + // keep the open seat view (and anything typed) while the overview data refreshes + if (m && view.dataset.seat === m[1]) return; + view.dataset.seat = m ? m[1] : ''; + if (!m) clearInterval(timer); + view.replaceChildren(...(m ? seatView(decodeURIComponent(m[1])) : overview()).filter(Boolean)); +} + +window.addEventListener('hashchange', render); +document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); }); +load(); +setInterval(load, 15000); diff --git a/static/boot.js b/static/boot.js new file mode 100644 index 0000000..58f9f18 --- /dev/null +++ b/static/boot.js @@ -0,0 +1,3 @@ +// No-flash theme pick (from weirsoe-theme), as a file so the CSP needs no inline script. +try { document.documentElement.dataset.theme = localStorage.getItem('wt-theme') || 'dark'; } catch (e) { document.documentElement.dataset.theme = 'dark'; } +if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {}); diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..c1e538596873248a774aa138ebdb00cdb69420c7 GIT binary patch literal 9168 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 G5&l1|3U?v^ literal 0 HcmV?d00001 diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b5b8d92e949efac91d7acaa81ba0f7e09b7ee50d GIT binary patch literal 25837 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 literal 0 HcmV?d00001 diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..7d91494 --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..49d430f --- /dev/null +++ b/static/index.html @@ -0,0 +1,39 @@ + + + + + + Rig Pocket + + + + + + + + + + + + + +

+ +
+
+

Henter…

+
+ + diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..3bbccf4 --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "Rig Pocket", + "short_name": "Rig Pocket", + "description": "Status and replies for your OpenRig teams, on your phone", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#0b0f17", + "theme_color": "#0b0f17", + "icons": [ + { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml" }, + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..4f5a7c1 --- /dev/null +++ b/static/sw.js @@ -0,0 +1,16 @@ +// App shell offline, live data always from the network. +const CACHE = 'rig-pocket-v1'; +const SHELL = ['./', 'app.js', 'boot.js', 'manifest.webmanifest', 'icon.svg', 'icon-192.png']; + +self.addEventListener('install', (e) => e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()))); +self.addEventListener('activate', (e) => e.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()))); + +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url); + if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.includes('/api/')) return; + e.respondWith(fetch(e.request).then((r) => { + if (r.ok) { const copy = r.clone(); caches.open(CACHE).then((c) => c.put(e.request, copy)); } + return r; + }).catch(() => caches.match(e.request))); +}); diff --git a/test_server.py b/test_server.py new file mode 100644 index 0000000..249bb10 --- /dev/null +++ b/test_server.py @@ -0,0 +1,64 @@ +import unittest + +import server + + +def snap(commits=(), queue=(), waiting=False): + return { + "rigs": [{"name": "team", "seats": [{"name": "dev-qa@team", "waiting": waiting}]}], + "queue": list(queue), + "repos": [{"name": "repo", "commits": [{"hash": h, "subject": f"commit {h}"} for h in commits]}], + } + + +def q(qid, state, dest="orch-lead@team"): + return {"id": qid, "state": state, "dest": dest, "source": "operator-agent@kernel", "summary": f"task {qid}"} + + +class WatcherTest(unittest.TestCase): + def test_first_snapshot_only_seeds(self): + w = server.Watcher() + self.assertEqual(w.events(snap(commits=["a"], queue=[q("1", "in-progress")])), []) + + def test_new_commits_grouped_per_repo(self): + w = server.Watcher() + w.events(snap(commits=["a"])) + ev = w.events(snap(commits=["a", "b", "c"])) + self.assertEqual(len(ev), 1) + self.assertIn("2 ny commits", ev[0][0]) + + def test_lead_task_done_notifies_once(self): + w = server.Watcher() + w.events(snap(queue=[q("1", "in-progress"), q("2", "in-progress", "dev-build@team")])) + ev = w.events(snap(queue=[q("1", "done"), q("2", "done", "dev-build@team")])) + self.assertEqual([e[0] for e in ev], ["Opgave done: orch-lead@team"]) + self.assertEqual(w.events(snap(queue=[q("1", "done")])), []) + + def test_new_request_to_human_notifies(self): + w = server.Watcher() + w.events(snap()) + ev = w.events(snap(queue=[q("9", "in-progress", "operator-agent@kernel")])) + self.assertEqual(ev[0][0], "Teamet spørger dig") + + def test_prompt_must_survive_two_polls(self): + w = server.Watcher() + self.assertEqual(w.events(snap(waiting=True)), []) + ev = w.events(snap(waiting=True)) + self.assertEqual(ev[0][0], "dev-qa@team venter på svar") + self.assertEqual(w.events(snap(waiting=True)), []) + w.events(snap(waiting=False)) + w.events(snap(waiting=True)) + self.assertEqual(len(w.events(snap(waiting=True))), 1) + + def test_prompt_pattern(self): + self.assertTrue(server.PROMPT.search(" Do you want to proceed?\n ❯ 1. Yes")) + self.assertFalse(server.PROMPT.search("❯ yes, launch it")) + + def test_seat_names(self): + self.assertTrue(server.SEAT.match("dev-qa@ubl-workshop")) + self.assertFalse(server.SEAT.match("x; rm -rf /")) + self.assertFalse(server.SEAT.match("../etc@x/y")) + + +if __name__ == "__main__": + unittest.main()