mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 05:56:21 -10:00
A person looking at a rig's floor can now see its finished work, with each item's answer first in plain words, and find an item by id. Coverage is honest: a bounded or unreadable read says so instead of claiming there's nothing. An artifact reference wraps without changing its text. Six files (+291/-8): the floor page and server, the runtime contract, and tests. Source-only entry; no tag, pin, release or deploy.
367 lines
21 KiB
HTML
367 lines
21 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Rig Floor</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #0b0e13;
|
|
--panel: #121722;
|
|
--line: #252c3a;
|
|
--edge2: #2c3444;
|
|
--text: #f3f5f8;
|
|
--bone: #f3f5f8;
|
|
--body: #cbd1db;
|
|
--muted: #929cad;
|
|
--faint: #929cad;
|
|
--working: #47d18c;
|
|
--idle: #6d9eff;
|
|
--blocked: #ffb44a;
|
|
--stalled: #ff795f;
|
|
--down: #77808f;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
background: radial-gradient(circle at 80% 0%, #172032 0, transparent 34%), var(--bg);
|
|
color: var(--text);
|
|
font: 14px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
}
|
|
main { width: min(1040px, 100%); margin: 0 auto; padding: 28px; }
|
|
header { display: flex; align-items: end; justify-content: space-between; gap: 18px; margin-bottom: 24px; }
|
|
.eyebrow { color: var(--working); font-size: 11px; letter-spacing: .16em; text-transform: uppercase; }
|
|
h1 { margin: 4px 0 0; font: 600 clamp(28px, 5vw, 46px)/1.05 system-ui, sans-serif; letter-spacing: -.04em; }
|
|
.connection { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
|
.pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--blocked); box-shadow: 0 0 0 4px #ffb44a18; }
|
|
.connection.live .pulse { background: var(--working); box-shadow: 0 0 0 4px #47d18c18; }
|
|
.connection.polling .pulse { background: var(--idle); box-shadow: 0 0 0 4px #6d9eff18; }
|
|
|
|
.metrics, .table-wrap, .notice { border: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 94%, transparent); border-radius: 8px; }
|
|
.metrics { display: flex; flex-wrap: wrap; align-items: center; margin-bottom: 20px; overflow: hidden; }
|
|
.metric { display: flex; align-items: baseline; gap: 8px; padding: 10px 14px; border-right: 1px solid var(--line); }
|
|
.metric:last-child { border-right: 0; }
|
|
.metric b { font: 650 17px/1 system-ui, sans-serif; }
|
|
.metric span, .empty { color: var(--muted); font-size: 11px; }
|
|
section { margin-top: 24px; }
|
|
h2 { margin: 0 0 10px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; }
|
|
.welcome { border: 1px solid var(--edge2); border-radius: 3px; padding: 18px 20px; margin-bottom: 22px;
|
|
background: rgba(255,255,255,.02); }
|
|
.welcome h2 { margin: 0 0 8px; font-size: 15px; letter-spacing: .01em; }
|
|
.welcome p { margin: 0 0 12px; color: var(--body); font-size: 12.5px; line-height: 1.65; max-width: 68ch; }
|
|
.welcome .wfoot { margin: 12px 0 0; color: var(--faint); font-size: 11.5px; }
|
|
.wagents { display: flex; flex-wrap: wrap; gap: 10px; }
|
|
.wagent { display: flex; flex-direction: column; gap: 3px; text-align: left; cursor: pointer;
|
|
background: rgba(255,255,255,.04); border: 1px solid var(--edge2); border-radius: 3px;
|
|
padding: 10px 14px; color: var(--body); font: inherit; min-width: 210px; }
|
|
.wagent:hover { border-color: rgba(255,255,255,.4); color: var(--bone); }
|
|
.wagent b { font-size: 12.5px; }
|
|
.wagent span { font-size: 11px; color: var(--faint); }
|
|
.table-wrap { overflow-x: auto; }
|
|
table { width: 100%; min-width: 720px; border-collapse: collapse; text-align: left; }
|
|
th { padding: 8px 12px; border-bottom: 1px solid var(--line); color: var(--muted); background: rgba(255,255,255,.018); font-size: 9px; font-weight: 650; letter-spacing: .16em; text-transform: uppercase; white-space: nowrap; }
|
|
td { padding: 9px 12px; border-bottom: 1px solid var(--line); color: #cbd1db; font-size: 11px; vertical-align: middle; }
|
|
tbody tr:last-child td { border-bottom: 0; }
|
|
tbody tr:hover td { background: rgba(255,255,255,.018); }
|
|
.seat-name { color: var(--text); font-weight: 650; overflow-wrap: anywhere; }
|
|
.state-tag { --state: var(--down); display: inline-flex; align-items: center; gap: 7px; color: var(--state); font-size: 10px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
|
|
.state-pip { width: 7px; height: 7px; border-radius: 50%; background: var(--state); box-shadow: 0 0 0 3px color-mix(in srgb, var(--state) 12%, transparent); }
|
|
.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
.working { --state: var(--working); }
|
|
.idle { --state: var(--idle); }
|
|
.blocked { --state: var(--blocked); }
|
|
.stalled { --state: var(--stalled); }
|
|
.down { --state: var(--down); }
|
|
.queue-title { color: var(--text); font-weight: 600; }
|
|
.queue-state { color: var(--idle); font-size: 10px; font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
|
.route { color: var(--muted); overflow-wrap: anywhere; }
|
|
.item-id { display: block; margin-top: 3px; color: var(--muted); font: 10px ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
|
.result { max-width: 40ch; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
.artifact { color: var(--muted); overflow-wrap: break-word; }
|
|
.unset { color: var(--muted); font-style: italic; }
|
|
.coverage { margin: -4px 0 10px; color: var(--muted); font-size: 11px; }
|
|
/* An element marked hidden stays hidden, whatever display a class gives it (the find box is display:flex). */
|
|
[hidden] { display: none !important; }
|
|
.find { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0 0 18px; }
|
|
.find input { flex: 1 1 260px; max-width: 420px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.find output { color: var(--muted); font-size: 11px; }
|
|
.notice { padding: 18px; color: var(--blocked); }
|
|
footer { margin-top: 22px; color: var(--muted); font-size: 11px; }
|
|
@media (max-width: 600px) {
|
|
main { padding: 18px; }
|
|
header { align-items: start; flex-direction: column; }
|
|
.metric { flex: 1 1 auto; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header>
|
|
<div><div class="eyebrow">Live activity</div><h1 id="rig">Rig floor</h1></div>
|
|
<div id="connection" class="connection"><span class="pulse"></span><span>connecting</span></div>
|
|
</header>
|
|
<div class="find" id="findbox" role="search" hidden><input id="find" type="search" placeholder="Find work by id (qitem-…)" aria-label="Find work by id" autocomplete="off" spellcheck="false"><output id="found" aria-live="polite"></output></div>
|
|
<div id="content"><div class="notice">Reading the runtime contract…</div></div>
|
|
<footer id="updated"></footer>
|
|
</main>
|
|
|
|
<script>
|
|
const content = document.querySelector('#content');
|
|
const rig = document.querySelector('#rig');
|
|
const updated = document.querySelector('#updated');
|
|
const connection = document.querySelector('#connection');
|
|
let events;
|
|
let pollTimer;
|
|
let reconnectTimer;
|
|
|
|
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, char => ({
|
|
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
|
})[char]);
|
|
|
|
function setConnection(mode, label) {
|
|
connection.className = `connection ${mode}`;
|
|
connection.lastElementChild.textContent = label;
|
|
}
|
|
|
|
function render(state) {
|
|
if (!state.ok) {
|
|
content.innerHTML = `<div class="notice">Data degraded: ${escapeHtml(state.degraded || 'unknown reason')}</div>`;
|
|
rig.textContent = 'Rig floor';
|
|
updated.textContent = '';
|
|
return;
|
|
}
|
|
|
|
// NOT ATTACHED is a different fact from ATTACHED-AND-QUIET, and rendering
|
|
// them the same way is how a box with no rig showed an empty floor under a
|
|
// green live signal — the fiction this surface exists to stop. The
|
|
// generator distinguishes three causes; a viewer can only act on the
|
|
// difference if the floor says which one it is.
|
|
if (state.attached === false) {
|
|
const why = {
|
|
'no-rig': 'No rig is running on this box.',
|
|
'no-rig-cli': 'The rig CLI is not installed on this box.',
|
|
'rig-error': 'The rig is installed but could not be read.',
|
|
}[state.reason] || 'This box is not attached to a rig.';
|
|
rig.textContent = 'Not attached';
|
|
content.innerHTML = `<div class="notice"><strong>${escapeHtml(why)}</strong>` +
|
|
(state.detail ? `<br/><span>${escapeHtml(state.detail)}</span>` : '') +
|
|
`<br/><span>This is not an idle rig — there is nothing attached to report on.</span></div>`;
|
|
updated.textContent = state.generatedAt ? `checked ${new Date(state.generatedAt).toLocaleTimeString()}` : '';
|
|
setConnection('down', state.reason === 'rig-error' ? 'rig unreadable' : 'no rig');
|
|
return;
|
|
}
|
|
|
|
rig.textContent = state.rig;
|
|
const working = state.seats.filter(seat => seat.state === 'working').length;
|
|
const attention = state.seats.filter(seat => ['blocked', 'stalled', 'down'].includes(seat.state)).length;
|
|
const pending = state.queue.length;
|
|
const seats = state.seats.map(seat => `
|
|
<tr>
|
|
<td class="seat-name">${escapeHtml(seat.seat)}</td>
|
|
<td>${escapeHtml(seat.pod)} / ${escapeHtml(seat.member)}</td>
|
|
<td><span class="state-tag ${escapeHtml(seat.state)}"><span class="state-pip"></span>${escapeHtml(seat.state)}</span></td>
|
|
<td>${escapeHtml(seat.lifecycle)}</td>
|
|
<td class="numeric">${seat.ageMinutes == null ? 'unknown' : `${escapeHtml(seat.ageMinutes)}m`}</td>
|
|
<td class="numeric">${escapeHtml(seat.pendingWork)}</td>
|
|
</tr>`).join('') || '<tr><td class="empty" colspan="6">No seats reported.</td></tr>';
|
|
// Ids and the find box come with finished work: a generator that sends no `done` gets the floor exactly as before.
|
|
const showIds = !!(state.done && Array.isArray(state.done.rows));
|
|
document.querySelector('#findbox').hidden = !showIds;
|
|
const queue = state.queue.map(item => `
|
|
<tr${showIds ? ` data-id="${escapeHtml(item.id)}"` : ''}>
|
|
<td class="queue-title">${escapeHtml(item.title)}${showIds ? `<span class="item-id">${escapeHtml(item.id)}</span>` : ''}</td>
|
|
<td class="queue-state">${escapeHtml(item.state)}</td>
|
|
<td class="route">${escapeHtml(item.source)}</td>
|
|
<td class="route">${escapeHtml(item.destination)}</td>
|
|
</tr>`).join('') || '<tr><td class="empty" colspan="4">Queue is clear.</td></tr>';
|
|
// While a find is on, a table with no matching row says so instead of standing empty under its count.
|
|
const queueRows = queue + (showIds && state.queue.length ? '<tr id="nomatch-open" hidden><td class="empty" colspan="4">No match in the queue.</td></tr>' : '');
|
|
|
|
// Work that finished, from a provider that sends `done` (an older one doesn't, and then this section isn't drawn). The answer
|
|
// a seat closed the work with is its result; a field the work doesn't carry reads "not recorded", and a result the provider
|
|
// couldn't read says so instead of looking empty.
|
|
const unset = (text) => `<span class="unset">${text}</span>`;
|
|
// A closure in plain words; the queue's own word stays in the tooltip.
|
|
const SAY = { 'no-follow-on': 'Done, nothing follows', handed_off_to: 'Handed off to', blocked_on: 'Blocked on', denied: 'Denied', canceled: 'Canceled', escalation: 'Escalated' };
|
|
const decisionOf = (d) => !d?.reason ? unset('not recorded')
|
|
: `<span title="${escapeHtml(d.reason)}">${escapeHtml(SAY[d.reason] || d.reason)}${d.target ? `${SAY[d.reason] && /to$|on$/.test(SAY[d.reason]) ? ' ' : ': '}${escapeHtml(d.target)}` : ''}</span>`;
|
|
// An artifact reference may break after "@", ":", "/" or "#", not inside a word.
|
|
// Split the raw text first and escape each part: inserting after escaping would cut an entity (') apart.
|
|
const breakable = (t) => String(t).split(/(?<=[@:/#])/).map(escapeHtml).join('<wbr>');
|
|
const done = state.done && Array.isArray(state.done.rows) ? state.done : null;
|
|
const doneRows = done ? done.rows.map(item => {
|
|
if (!item || typeof item !== 'object') return '';
|
|
const decision = decisionOf(item.decision);
|
|
const answer = item.result ?? item.resolution;
|
|
const result = item.resultRead === false ? unset("couldn't be read") : typeof answer === 'string' && answer !== '' ? escapeHtml(answer) : unset('not recorded');
|
|
const closed = Date.parse(item.closedAt || '');
|
|
return `
|
|
<tr data-id="${escapeHtml(item.id)}">
|
|
<td class="queue-title">${escapeHtml(item.title)}<span class="item-id">${escapeHtml(item.id)}</span></td>
|
|
<td class="result">${result}</td>
|
|
<td class="route">${decision}</td>
|
|
<td class="artifact">${item.artifact ? breakable(item.artifact) : unset('not recorded')}</td>
|
|
<td class="numeric">${Number.isFinite(closed) ? escapeHtml(new Date(closed).toLocaleString(undefined, { timeZoneName: 'short' })) : unset('unknown')}</td>
|
|
</tr>`; }).join('') || `<tr><td class="empty" colspan="5">None of this rig's work among the newest ${escapeHtml(done.coverage?.looked ?? 0)} finished items on this box.</td></tr>` : '';
|
|
const cov = done?.coverage || {};
|
|
const doneSection = !done ? '' : cov.unreadable
|
|
? `<section><h2>Done recently</h2><div class="notice">Finished work couldn't be read: ${escapeHtml(cov.unreadable)}.</div></section>`
|
|
: `<section>
|
|
<h2>Done recently · ${done.rows.length}</h2>
|
|
<p class="coverage">The newest ${escapeHtml(done.rows.length)} of this rig's finished work, from the newest ${escapeHtml(cov.looked ?? 0)} finished items on this box.</p>
|
|
<div class="table-wrap"><table aria-label="Finished work">
|
|
<thead><tr><th>Work</th><th>Result</th><th>Decision</th><th>Artifact</th><th class="numeric">Closed</th></tr></thead>
|
|
<tbody>${doneRows}${done.rows.length ? '<tr id="nomatch-done" hidden><td class="empty" colspan="5">No match in finished work.</td></tr>' : ''}</tbody>
|
|
</table></div>
|
|
</section>`;
|
|
|
|
content.innerHTML = `
|
|
${welcomeHtml()}
|
|
<div class="metrics" aria-label="Rig summary">
|
|
<div class="metric"><b>${working}</b><span>working now</span></div>
|
|
<div class="metric"><b>${attention}</b><span>need attention</span></div>
|
|
<div class="metric"><b>${pending}</b><span>queue items</span></div>
|
|
</div>
|
|
<section>
|
|
<h2>Seats · ${state.seats.length}</h2>
|
|
<div class="table-wrap"><table aria-label="Rig seats">
|
|
<thead><tr><th>Seat</th><th>Pod / member</th><th>State</th><th>Lifecycle</th><th class="numeric">Age</th><th class="numeric">Pending</th></tr></thead>
|
|
<tbody>${seats}</tbody>
|
|
</table></div>
|
|
</section>
|
|
<section>
|
|
<h2>Queue · ${state.queue.length}</h2>
|
|
<div class="table-wrap"><table aria-label="Rig queue">
|
|
<thead><tr><th>Work</th><th>State</th><th>Source</th><th>Destination</th></tr></thead>
|
|
<tbody>${queueRows}</tbody>
|
|
</table></div>
|
|
</section>${doneSection}`;
|
|
lastState = state;
|
|
findWork();
|
|
updated.textContent = `Runtime snapshot · ${new Date(state.generatedAt).toLocaleString()}`;
|
|
}
|
|
|
|
// Find by id: only among the work this floor already holds (this rig's open work and its newest finished work); it reads
|
|
// nothing else. Kept outside #content so a refresh doesn't clear what was typed, and applied again after every refresh.
|
|
let lastState = null;
|
|
const findInput = document.querySelector('#find'), found = document.querySelector('#found');
|
|
// "This rig's" work is work with one of this floor's seats at either end: a generator that still sends other rigs' open work
|
|
// can't make the find box call that ours.
|
|
function findWork() {
|
|
const q = findInput.value.trim();
|
|
const seats = new Set((lastState?.seats || []).map(s => s.seat));
|
|
const ours = item => item && (seats.has(item.source) || seats.has(item.destination));
|
|
const hit = item => ours(item) && String(item.id).includes(q);
|
|
const openHits = (lastState?.queue || []).filter(hit), doneHits = (lastState?.done?.rows || []).filter(hit);
|
|
const shown = new Set([...openHits, ...doneHits].map(item => String(item.id)));
|
|
for (const row of content.querySelectorAll('tr[data-id]')) row.hidden = !!q && !shown.has(row.dataset.id);
|
|
for (const [sel, hits] of [['#nomatch-open', openHits], ['#nomatch-done', doneHits]]) { const r = document.querySelector(sel); if (r) r.hidden = !q || hits.length > 0; }
|
|
if (!q) { found.textContent = ''; return; }
|
|
const done = lastState?.done;
|
|
found.textContent = openHits.length || doneHits.length ? `${openHits.length} open, ${doneHits.length} finished`
|
|
: done?.coverage?.unreadable ? "Not in this rig's open work. Finished work couldn't be read, so this can't say whether it finished."
|
|
: `No work with that id in this rig's open work or its newest ${done?.rows?.length ?? 0} finished items (older finished work isn't searched).`;
|
|
}
|
|
findInput.addEventListener('input', findWork);
|
|
|
|
// The box's own introduction, declared in its manifest. Absent on a box
|
|
// that declares none, which is the normal case — this surface ships to
|
|
// every install and must not assume it is anybody's landing page.
|
|
let welcome = null;
|
|
function welcomeHtml() {
|
|
if (!welcome) return '';
|
|
const agents = (welcome.agents || []).filter(a => a && a.seat).map(a => `
|
|
<button class="wagent" data-seat="${escapeHtml(a.seat)}">
|
|
<b>${escapeHtml(a.label || a.seat)}</b>
|
|
<span>${escapeHtml(a.hint || '')}</span>
|
|
</button>`).join('');
|
|
return `<section class="welcome">
|
|
${welcome.title ? `<h2>${escapeHtml(welcome.title)}</h2>` : ''}
|
|
${welcome.body ? `<p>${escapeHtml(welcome.body)}</p>` : ''}
|
|
${agents ? `<div class="wagents">${agents}</div>` : ''}
|
|
${welcome.footer ? `<p class="wfoot">${escapeHtml(welcome.footer)}</p>` : ''}
|
|
</section>`;
|
|
}
|
|
// Summoning a seat is a CONTRACT message (open-agent), not a private
|
|
// channel invented here — the shell owns the sidebar and this surface only
|
|
// asks. Delegated so it survives the innerHTML rewrite on every refresh.
|
|
content.addEventListener('click', (e) => {
|
|
const b = e.target.closest('.wagent');
|
|
if (b) parent.postMessage({ t: 'open-agent', seat: b.dataset.seat }, '*');
|
|
});
|
|
|
|
async function loadWelcome() {
|
|
try {
|
|
const r = await fetch('/surfaces.json', { cache: 'no-store' });
|
|
const d = await r.json();
|
|
welcome = d && typeof d.welcome === 'object' ? d.welcome : null;
|
|
} catch { welcome = null; }
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
const response = await fetch('/api/factory/state', { cache: 'no-store' });
|
|
const state = await response.json();
|
|
render(state);
|
|
} catch (error) {
|
|
content.innerHTML = `<div class="notice">Unable to read floor state: ${escapeHtml(error.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
clearInterval(pollTimer);
|
|
pollTimer = setInterval(refresh, 15000);
|
|
setConnection('polling', 'polling fallback');
|
|
}
|
|
|
|
function connectEvents() {
|
|
clearTimeout(reconnectTimer);
|
|
if (events) events.close();
|
|
events = new EventSource('/api/events');
|
|
events.onopen = () => {
|
|
clearInterval(pollTimer);
|
|
setConnection('live', 'live signal');
|
|
refresh();
|
|
};
|
|
events.onmessage = () => refresh();
|
|
events.addEventListener('degraded', () => {
|
|
events.close();
|
|
startPolling();
|
|
reconnectTimer = setTimeout(connectEvents, 15000);
|
|
});
|
|
events.onerror = () => {
|
|
events.close();
|
|
startPolling();
|
|
reconnectTimer = setTimeout(connectEvents, 15000);
|
|
};
|
|
}
|
|
|
|
async function boot() {
|
|
try {
|
|
const response = await fetch('/api/contract', { cache: 'no-store' });
|
|
const contract = await response.json();
|
|
const needed = ['observe.factory-state', 'stream.events'];
|
|
const missing = needed.filter(capability => !contract.capabilities.includes(capability));
|
|
// contract-meta compatibility rule: capabilities present + same major.
|
|
// (Exact-match rejects contract-compatible minor runtimes.)
|
|
const major = String(contract.contractVersion ?? '').split('.')[0];
|
|
if (major !== '0' || missing.length) {
|
|
throw new Error(`Unsupported runtime contract ${contract.contractVersion}${missing.length ? `; missing ${missing.join(', ')}` : ''}`);
|
|
}
|
|
await loadWelcome();
|
|
await refresh();
|
|
connectEvents();
|
|
} catch (error) {
|
|
content.innerHTML = `<div class="notice">Cannot start: ${escapeHtml(error.message)}</div>`;
|
|
startPolling();
|
|
}
|
|
}
|
|
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|