A turn that has run 15 minutes while producing under 50 tokens a minute is a stalled API stream: flagged on the page with an Esc button, and notified once. The container's memory use is shown in the header and notified near the limit. Claude-Session: https://claude.ai/code/session_01QhLkBGEip8wQdaZ54z4AqJ
194 lines
8.9 KiB
JavaScript
194 lines
8.9 KiB
JavaScript
// 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.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);
|