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:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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);
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user