← back to Factory Floor
public/index.html
271 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Factory Floor — Steve's coordinator</title>
<style>
:root {
--bg:#0a0a0c; --panel:#121217; --panel2:#181820; --rule:#26262e; --ink:#eee6d8; --mute:#8c8680; --gold:#c9a96a;
--ok:#7ad27a; --warn:#e0b25a; --bad:#e26d6d; --link:#9bd1ff; --red:#e94545; --redglow:#ff6b6b;
--mono:'SF Mono','Menlo',monospace; --sans:-apple-system,'SF Pro Text','Inter',sans-serif; --serif:'SF Pro Display','Cormorant Garamond',serif;
}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--sans)}
header{padding:18px 28px;display:flex;align-items:baseline;justify-content:space-between;border-bottom:1px solid var(--rule);background:var(--panel)}
header h1{margin:0;font:300 28px/1 var(--serif);letter-spacing:.04em}
header h1 b{color:var(--gold);font-weight:500}
.meta{font-family:var(--mono);font-size:11px;color:var(--mute);letter-spacing:.04em}
.meta .dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--ok);margin-right:6px;vertical-align:1px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
main{padding:24px 28px;display:grid;grid-template-columns:repeat(12,1fr);gap:18px}
.card{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:16px;min-height:120px;overflow:hidden}
.card.wide{grid-column:span 12}
.card.half{grid-column:span 6}
.card.third{grid-column:span 4}
.card.two-thirds{grid-column:span 8}
@media(max-width:1100px){.card.half,.card.third,.card.two-thirds{grid-column:span 12}}
.card h2{margin:0 0 12px;font:500 13px/1 var(--sans);text-transform:uppercase;letter-spacing:.14em;color:var(--gold);display:flex;justify-content:space-between;align-items:baseline}
.card h2 .count{color:var(--mute);font:400 11px var(--mono);letter-spacing:0;text-transform:none}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px}
.tile{background:var(--panel2);border:1px solid var(--rule);border-radius:4px;padding:10px 12px;display:flex;flex-direction:column;gap:3px;text-decoration:none;color:inherit;transition:border-color .15s}
.tile:hover{border-color:var(--gold)}
.tile .name{font:500 13px var(--sans);letter-spacing:.01em;display:flex;justify-content:space-between;align-items:center;gap:6px}
.tile .sub{font:400 11px var(--mono);color:var(--mute);letter-spacing:.02em}
.pill{display:inline-block;font:600 9px/1 var(--mono);text-transform:uppercase;letter-spacing:.08em;padding:3px 5px;border-radius:2px}
.pill.ok{background:rgba(122,210,122,.12);color:var(--ok);border:1px solid rgba(122,210,122,.3)}
.pill.warn{background:rgba(224,178,90,.12);color:var(--warn);border:1px solid rgba(224,178,90,.3)}
.pill.bad{background:rgba(226,109,109,.12);color:var(--bad);border:1px solid rgba(226,109,109,.3)}
.row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dotted var(--rule);font-size:13px}
.row:last-child{border:none}
.row .l{color:var(--mute)}
.row .r{font-family:var(--mono);font-size:12px}
table{width:100%;border-collapse:collapse;font-size:12px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--rule);vertical-align:top}
th{font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--mute)}
td.mono{font-family:var(--mono)}
td.r{text-align:right;font-family:var(--mono);color:var(--mute)}
td.s{color:var(--ok)}
td.s.stopped,td.s.errored{color:var(--bad)}
.group-head{font:600 10px var(--sans);text-transform:uppercase;letter-spacing:.14em;color:var(--mute);margin:14px 0 6px}
.group-head:first-child{margin-top:0}
.empty{color:var(--mute);font-style:italic;font-size:13px}
.footer{padding:18px 28px;color:var(--mute);font-size:11px;font-family:var(--mono);text-align:center;border-top:1px solid var(--rule)}
.bar{height:4px;background:var(--rule);border-radius:2px;overflow:hidden;margin-top:6px}
.bar>span{display:block;height:100%;background:var(--gold)}
/* BIG RED tile */
.bigred{position:relative;background:linear-gradient(180deg,#1a0e0e 0%,#160a0a 100%);border:1px solid #3a1818}
.bigred h2{color:var(--redglow)}
.bigred-stage{display:flex;align-items:center;gap:18px;padding:12px 0}
.brain{width:88px;height:88px;flex-shrink:0;position:relative;display:flex;align-items:center;justify-content:center}
.brain svg{width:88px;height:88px;display:block;filter:drop-shadow(0 0 12px rgba(233,69,69,.4))}
.brain.thinking svg{animation:brainspin 1.6s linear infinite}
.brain.thinking::before{content:'';position:absolute;inset:-8px;border:2px solid rgba(255,107,107,.4);border-top-color:transparent;border-radius:50%;animation:brainspin 0.9s linear infinite}
.brain.thinking::after{content:'';position:absolute;inset:-18px;border:1px solid rgba(255,107,107,.18);border-bottom-color:transparent;border-radius:50%;animation:brainspin 2.2s linear infinite reverse}
@keyframes brainspin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
.brain-info{flex:1}
.brain-state{font:600 11px var(--mono);text-transform:uppercase;letter-spacing:.18em;color:var(--mute);margin-bottom:4px}
.brain-state.live{color:var(--redglow);animation:pulse 1.2s infinite}
.brain-meta{font-family:var(--mono);font-size:11px;color:var(--mute);margin-bottom:8px}
.model-row{display:flex;gap:8px;align-items:center;margin-top:6px}
.model-row label{font:600 10px var(--mono);text-transform:uppercase;letter-spacing:.1em;color:var(--mute)}
.model-row select{flex:1;background:#0e0708;border:1px solid #3a1818;color:var(--ink);padding:6px 8px;border-radius:3px;font-family:var(--mono);font-size:12px}
.model-row select:focus{outline:none;border-color:var(--redglow)}
.model-row .saved{font:600 10px var(--mono);color:var(--ok);opacity:0;transition:opacity .2s}
.model-row .saved.show{opacity:1}
</style>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<header>
<h1>FACTORY <b>FLOOR</b></h1>
<div class="meta"><span class="dot"></span><span id="meta">connecting…</span></div>
</header>
<main id="main">
<div class="card wide"><h2>booting…</h2></div>
</main>
<div class="footer">~/Projects/factory-floor · auto-refresh every 8s · POST /api/refresh to force · serving on :9936</div>
<script>
const $ = (id) => document.getElementById(id);
// Escape text before injecting into innerHTML — names/messages come from
// local processes (pm2, ollama, git) but are still untrusted strings.
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) =>
({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]));
const BRAIN_SVG = `
<svg viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="rg" cx="50%" cy="45%" r="55%">
<stop offset="0%" stop-color="#ff7a7a"/>
<stop offset="65%" stop-color="#c92626"/>
<stop offset="100%" stop-color="#5b0a0a"/>
</radialGradient>
</defs>
<path fill="url(#rg)" stroke="#3a0808" stroke-width="1.2"
d="M50 14c-9 0-16 5-18 12-7 1-12 7-12 14 0 4 2 8 5 11-1 2-1 4-1 6 0 7 5 13 12 14 1 7 8 13 14 13s12-3 14-9c2 1 4 1 6 1 7 0 13-5 13-12 0-2 0-3-1-5 4-2 6-7 6-12 0-7-5-13-12-14-2-7-9-12-18-12z"/>
<g stroke="#3a0808" stroke-width="1.5" fill="none" stroke-linecap="round">
<path d="M30 38c4-2 8-2 12 0M58 38c4-2 8-2 12 0M28 56c5 1 10-1 14-4M58 52c4 3 9 5 14 4M40 70c4 2 12 2 16 0M40 26c4 2 12 2 16 0M50 14v72"/>
</g>
</svg>`;
const fmt = {
age: (s) => {
if (s == null) return '—';
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s/60)}m`;
if (s < 86400) return `${Math.round(s/3600)}h`;
return `${Math.round(s/86400)}d`;
},
pill: (ok) => `<span class="pill ${ok?'ok':'bad'}">${ok?'UP':'DOWN'}</span>`,
};
let LAST_MODEL = null;
async function setBigRedModel(model) {
try {
const r = await fetch('/api/bigred/model', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ model })
});
if (r.ok) {
const el = document.querySelector('.model-row .saved');
if (el) { el.classList.add('show'); setTimeout(() => el.classList.remove('show'), 1500); }
LAST_MODEL = model;
}
} catch (e) { console.error(e); }
}
function bigRedCard(state) {
const br = state.bigRed || {};
const ollama = (state.ollama?.installed || []).map(m => m.name);
const allModels = [
...ollama,
'claude:haiku', 'claude:sonnet', 'claude:opus',
'codex',
];
const current = LAST_MODEL || 'claude:haiku';
const thinkingClass = br.thinking ? 'thinking' : '';
const stateLabel = br.thinking ? 'THINKING' : (br.error ? 'OFFLINE' : 'IDLE');
const stateClass = br.thinking ? 'live' : '';
return `
<div class="card third bigred">
<h2>Big Red <span class="count">:9935</span></h2>
<div class="bigred-stage">
<div class="brain ${thinkingClass}">${BRAIN_SVG}</div>
<div class="brain-info">
<div class="brain-state ${stateClass}">${stateLabel}</div>
<div class="brain-meta">${br.big_red_calls ?? 0} active call(s) · ${br.claude_procs ?? 0} claude proc(s)</div>
<div class="model-row">
<label>brain</label>
<select id="brModel" onchange="setBigRedModel(this.value)">
${allModels.map(m => `<option ${m===current?'selected':''} value="${esc(m)}">${esc(m)}</option>`).join('')}
</select>
<span class="saved">saved ✓</span>
</div>
</div>
</div>
</div>`;
}
async function loadCurrentModel() {
try {
const r = await fetch('/api/bigred/model').then(r => r.json());
if (r.model) LAST_MODEL = r.model;
} catch {}
}
async function tick() {
let s;
try { s = await fetch('/api/state').then(r => r.json()); }
catch (e) { $('meta').textContent = 'fetch failed: ' + e.message; return; }
if (!s.generated_at) {
$('meta').textContent = 'first poll in flight…';
return;
}
$('meta').innerHTML = `polled ${new Date(s.generated_at).toLocaleTimeString()} · ${s.poll_ms}ms · pm2 ${s.pm2?.items?.length||0} · ollama ${s.ollama?.installed?.length||0} · load ${s.host?.load?.[0]??'—'}`;
const groups = {};
for (const svc of (s.services || [])) (groups[svc.group] ||= []).push(svc);
let html = '';
// HOST
const h = s.host || {};
const memUsed = (h.mem_gb_total - h.mem_gb_free).toFixed(1);
const memPct = Math.min(100, ((h.mem_gb_total - h.mem_gb_free) / h.mem_gb_total) * 100);
const loadColor = h.load?.[0] > 100 ? 'var(--bad)' : (h.load?.[0] > 20 ? 'var(--warn)' : 'var(--ok)');
html += `<div class="card third"><h2>Host</h2>
<div class="row"><span class="l">${esc(h.host || '—')}</span><span class="r">${h.cores} cores</span></div>
<div class="row"><span class="l">Load (1m)</span><span class="r" style="color:${loadColor}">${h.load?.[0] ?? '—'}</span></div>
<div class="row"><span class="l">Memory</span><span class="r">${memUsed} / ${h.mem_gb_total} GB</span></div>
<div class="bar"><span style="width:${memPct}%"></span></div>
<div class="row"><span class="l">Uptime</span><span class="r">${h.uptime_h}h</span></div>
</div>`;
// BIG RED — featured tile
html += bigRedCard(s);
// INFERENCE BRAINS
const ol = s.ollama || {};
html += `<div class="card third"><h2>Inference brains <span class="count">${ol.installed?.length||0} on disk · ${ol.loaded?.length||0} hot</span></h2>
<div class="tiles">${(ol.installed || []).map(m => {
const loaded = (ol.loaded || []).some(L => L.name === m.name);
return `<div class="tile">
<div class="name">${esc(m.name)}${loaded ? '<span class="pill ok">HOT</span>' : ''}</div>
<div class="sub">${esc(m.params || '')} · ${esc(m.q || '')} · ${m.size_gb}GB</div>
</div>`;
}).join('') || '<div class="empty">no models</div>'}</div>
</div>`;
// SERVICES
const groupOrder = ['factory-floor', 'apps', 'factories', 'comms', 'inference'];
html += `<div class="card two-thirds"><h2>Services <span class="count">live HTTP check</span></h2>`;
for (const g of groupOrder) {
const list = groups[g] || [];
if (!list.length) continue;
html += `<div class="group-head">${g.replace('-', ' ')}</div><div class="tiles">${
list.map(s => `<a class="tile" href="${esc(s.url)}" target="_blank" rel="noopener noreferrer">
<div class="name">${esc(s.name)} ${fmt.pill(s.ok)}</div>
<div class="sub">${esc(s.url.replace(/^https?:\/\//, ''))} · ${s.ok ? `${s.status} · ${s.ms}ms` : esc(s.error || String(s.status))}</div>
</a>`).join('')
}</div>`;
}
html += `</div>`;
// GIT
html += `<div class="card third"><h2>Recent commits <span class="count">across ~/Projects</span></h2>
<div style="max-height:280px;overflow-y:auto">${(s.git || []).slice(0,12).map(g =>
`<div class="row"><span class="l" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%">${esc(g.project)}: <span style="color:var(--ink)">${esc((g.msg||'').slice(0,50))}</span></span><span class="r">${fmt.age(g.age_s)}</span></div>`
).join('') || '<div class="empty">no repos</div>'}</div>
</div>`;
// PM2
const pm2items = s.pm2?.items || [];
const onlinePm2 = pm2items.filter(p => p.status === 'online').length;
html += `<div class="card two-thirds"><h2>PM2 agents <span class="count">${onlinePm2}/${pm2items.length} online</span></h2>
<div style="max-height:380px;overflow-y:auto"><table>
<thead><tr><th>name</th><th>state</th><th>port</th><th class="r">mem</th><th class="r">cpu</th><th class="r">↻</th></tr></thead>
<tbody>${pm2items.map(p => `<tr>
<td class="mono">${esc(p.name)}</td>
<td class="s ${p.status !== 'online' ? esc(p.status) : ''}">${esc(p.status)}</td>
<td class="mono" style="color:var(--mute)">${esc(p.port || '')}</td>
<td class="r">${p.mem_mb}M</td>
<td class="r">${p.cpu}%</td>
<td class="r">${p.restarts}</td>
</tr>`).join('')}</tbody>
</table></div>
</div>`;
$('main').innerHTML = html;
}
loadCurrentModel().then(tick);
setInterval(tick, 8000);
</script>
</body>
</html>