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
This commit is contained in:
Asger Geel Weirsøe
2026-10-20 17:43:55 +02:00
commit 69cb255120
14 changed files with 674 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+21
View File
@@ -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.
+60
View File
@@ -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
+16
View File
@@ -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
+283
View File
@@ -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()
+146
View File
@@ -0,0 +1,146 @@
// Rig Pocket: one page, two views (#/ overview, #/seat/<name> 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);
+3
View File
@@ -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(() => {});
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#0b0f17"/>
<g fill="none" stroke-width="28" stroke-linecap="round">
<circle cx="256" cy="150" r="54" stroke="#38bdf8"/>
<circle cx="150" cy="350" r="54" stroke="#818cf8"/>
<circle cx="362" cy="350" r="54" stroke="#7dffa8"/>
<path d="M226 196 180 298M286 196 332 298M206 350h100" stroke="#97a3b6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 441 B

+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Rig Pocket</title>
<script src="boot.js"></script>
<link rel="stylesheet" href="{{THEME_CSS}}">
<script defer src="{{THEME_JS}}"></script>
<script defer src="app.js"></script>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Rig Pocket">
<meta name="theme-color" content="#0b0f17" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#f6f8fb" media="(prefers-color-scheme: light)">
</head>
<body>
<header class="wt-header">
<nav class="wt-nav">
<a class="wt-brand" href="./"><span class="wt-brand-dot"></span> Rig Pocket</a>
<div class="wt-row">
<span class="wt-muted" id="updated"></span>
<button class="theme-toggle" data-theme-toggle aria-label="Skift tema">
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<mask class="moon" id="moon-mask"><rect x="0" y="0" width="100%" height="100%" fill="white" /><circle cx="24" cy="10" r="6" fill="black" /></mask>
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor"><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /></g>
</svg>
</button>
</div>
</nav>
</header>
<main class="wt-main wt-container--narrow">
<div id="view" class="wt-stack"><p class="wt-muted">Henter…</p></div>
</main>
</body>
</html>
+15
View File
@@ -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" }
]
}
+16
View File
@@ -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)));
});
+64
View File
@@ -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()