// 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.stalled) return $('span', { class: 'wt-tag wt-tag--bad' }, `hænger (${s.stalled} min)`); 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 gb = (bytes) => (bytes / 2 ** 30).toFixed(1); 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'; } } async function post(path, payload) { const r = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Rig-Pocket': '1' }, body: JSON.stringify(payload), }); return r.ok; } // A reply box: textarea + button. onSend gets the text and returns a promise of success. function replyBox(placeholder, label, onSend) { const input = $('textarea', { class: 'wt-input', rows: '2', placeholder }); const status = $('span', { class: 'wt-muted' }); const go = async () => { const text = input.value.trim(); if (!text) return; status.textContent = 'Sender…'; if (await onSend(text)) { input.value = ''; status.textContent = 'Sendt'; setTimeout(load, 1500); } else status.textContent = 'Fejl'; }; return $('div', { class: 'wt-stack' }, input, $('div', { class: 'wt-row wt-row--between' }, status, $('button', { class: 'wt-btn wt-btn--primary', onclick: go }, label))); } // What a seat is asking, with the controls to answer it. function askBlock(s, after) { const a = s.ask; const done = (ok) => { if (after) after(); setTimeout(load, 1500); return ok; }; if (!a && s.stalled) { return $('div', { class: 'wt-stack' }, $('span', { class: 'wt-muted' }, `Samme tur i ${s.stalled} min uden at producere noget. Esc afbryder, bagefter kan du bede den fortsætte.`), $('button', { class: 'wt-btn', onclick: async () => done(await post('api/send', { seat: s.name, key: 'esc' })) }, 'Esc (afbryd turen)')); } if (!a) return null; if (a.kind === 'menu') { return $('div', { class: 'wt-stack' }, a.title ? $('span', {}, a.title) : null, $('div', { class: 'wt-stack' }, a.options.map((o) => $('button', { class: `wt-btn ${o.key === '1' ? 'wt-btn--primary' : ''}`, onclick: async (e) => { e.target.disabled = true; done(await post('api/send', { seat: s.name, key: o.key })); } }, `${o.key}. ${o.label}`)), $('button', { class: 'wt-btn', onclick: async () => done(await post('api/send', { seat: s.name, key: 'esc' })) }, 'Esc (annullér)'))); } return $('div', { class: 'wt-stack' }, $('blockquote', { class: 'wt-muted' }, a.text), replyBox('Dit svar…', 'Svar', async (text) => done(await post('api/send', { seat: s.name, text })))); } function overview() { const view = []; const mem = snap.memory; if (mem && mem.fraction >= 0.9) { view.push($('section', { class: 'wt-card wt-card--raised wt-stack' }, $('div', { class: 'wt-row wt-row--between' }, $('h2', { class: 'wt-card-title' }, 'Hukommelse'), $('span', { class: 'wt-tag wt-tag--bad' }, `${Math.round(mem.fraction * 100)} %`)), $('span', {}, `${gb(mem.used)} af ${gb(mem.limit)} GB brugt. Agenterne kan gå i stå; giv containeren mere RAM eller stop et team.`))); } const waiting = snap.rigs.flatMap((r) => r.seats.filter((s) => s.waiting || s.stalled)); const gates = snap.queue.filter((q) => q.gate); if (waiting.length || gates.length) { view.push($('section', { class: 'wt-card wt-card--raised wt-stack' }, $('h2', { class: 'wt-card-title' }, 'Venter på dig'), gates.map((q) => $('div', { class: 'wt-stack' }, $('div', { class: 'wt-row wt-row--between' }, $('span', { class: 'wt-tag wt-tag--warn' }, 'beslutning'), $('span', { class: 'wt-muted' }, `${q.source} · ${ago(q.updated)}`)), $('span', {}, q.summary || q.id), replyBox('Din beslutning…', 'Send beslutning', (text) => post('api/resolve', { id: q.id, text })))), waiting.map((s) => $('div', { class: 'wt-stack' }, $('a', { class: 'wt-row wt-row--between', href: `#/seat/${s.name}` }, $('strong', {}, s.name), seatTag(s)), askBlock(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; } 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); return [ $('a', { href: '#/', class: 'wt-muted' }, '← Oversigt'), $('div', { class: 'wt-row wt-row--between' }, $('h2', {}, name), s ? seatTag(s) : null), s && s.ask && s.ask.kind === 'menu' ? $('section', { class: 'wt-card wt-card--raised' }, askBlock(s, () => setTimeout(refresh, 1200))) : null, pre, replyBox(`Skriv til ${name}…`, 'Send', async (text) => { const ok = await post('api/send', { seat: name, text }); setTimeout(refresh, 1200); return ok; }), ]; } function render() { if (!snap) return; document.getElementById('updated').textContent = ago(snap.updated) + (snap.memory ? ` · ${gb(snap.memory.used)}/${gb(snap.memory.limit)} GB` : ''); const m = location.hash.match(/^#\/seat\/(.+)$/); const view = document.getElementById('view'); // keep the open seat view, and never wipe an answer being typed if (m && view.dataset.seat === m[1]) return; const typing = [...view.querySelectorAll('textarea')].some((t) => t.value || t === document.activeElement); if (!m && !view.dataset.seat && typing) 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);