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
+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)));
});