commit 69cb255120c1f27074640507c8a5f81114ef0c06 Author: Asger Geel Weirsøe Date: Tue Oct 20 17:43:55 2026 +0200 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 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 0000000..c1e5385 Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..b5b8d92 Binary files /dev/null and b/static/icon-512.png differ 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()