← 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) =>
  ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[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>