[object Object]

← back to Factory Floor

initial scaffold: factory-floor unified live-status grid on :9700

1a2720be4a5cab3955ae0e7024ed34e1251e04ad · 2026-05-10 20:53:20 -0700 · Steve Abrams

Files touched

Diff

commit 1a2720be4a5cab3955ae0e7024ed34e1251e04ad
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 20:53:20 2026 -0700

    initial scaffold: factory-floor unified live-status grid on :9700
---
 .gitignore        |  10 ++++
 package.json      |  10 ++++
 public/index.html | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++
 services.json     |  28 ++++++++++
 5 files changed, 360 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bc67126
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env
+.env.*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+state.cache.json
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9ca1f20
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "factory-floor",
+  "version": "0.1.0",
+  "description": "Unified live-status grid for Steve's apps, agents, models, and pipelines.",
+  "main": "server.js",
+  "type": "module",
+  "scripts": {
+    "start": "node server.js"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..8ddf39f
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,162 @@
+<!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;
+    --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{color:var(--bad)}
+  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)}
+  .ratio{font-family:var(--mono);color:var(--mute);font-size:11px}
+  .bar{height:4px;background:var(--rule);border-radius:2px;overflow:hidden;margin-top:6px}
+  .bar>span{display:block;height:100%;background:var(--gold)}
+</style>
+</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>Host</h2><div id="host">…</div></div>
+</main>
+<div class="footer">~/Projects/factory-floor · auto-refresh every 10s · POST /api/refresh to force · serving on :9700</div>
+
+<script>
+const $ = (id) => document.getElementById(id);
+const fmt = {
+  ms: (n) => n == null ? '—' : `${n}ms`,
+  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>`,
+};
+
+async function tick() {
+  let s;
+  try { s = await fetch('/api/state').then(r => r.json()); }
+  catch (e) { $('meta').textContent = 'fetch failed: ' + e.message; 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}`;
+
+  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 = ((h.mem_gb_total - h.mem_gb_free) / h.mem_gb_total) * 100;
+  html += `<div class="card third"><h2>Host</h2>
+    <div class="row"><span class="l">${h.host}</span><span class="r">${h.cores} cores</span></div>
+    <div class="row"><span class="l">Load avg</span><span class="r">${h.load?.join(' / ')}</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>`;
+
+  // OLLAMA / INFERENCE BRAINS
+  const ol = s.ollama || {};
+  const inf = (groups['inference'] || []);
+  html += `<div class="card third"><h2>Inference brains <span class="count">${ol.installed?.length||0} installed · ${ol.loaded?.length||0} loaded</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">${m.name}${loaded ? '<span class="pill ok">HOT</span>' : ''}</div>
+        <div class="sub">${m.params || ''} · ${m.q || ''} · ${m.size_gb}GB</div>
+      </div>`;
+    }).join('') || '<div class="empty">no models</div>'}</div>
+  </div>`;
+
+  // GIT ACTIVITY
+  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:55%">${g.project}: <span style="color:var(--ink)">${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>`;
+
+  // SERVICES — grouped tiles
+  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="${s.url}" target="_blank">
+        <div class="name">${s.name} ${fmt.pill(s.ok)}</div>
+        <div class="sub">${s.url.replace(/^https?:\\/\\//, '')} · ${s.ok ? `${s.status} · ${s.ms}ms` : (s.error || s.status)}</div>
+      </a>`).join('')
+    }</div>`;
+  }
+  html += `</div>`;
+
+  // PM2
+  const pm2items = s.pm2.items || [];
+  html += `<div class="card third"><h2>PM2 agents <span class="count">${pm2items.length}</span></h2>
+    <div style="max-height:380px;overflow-y:auto"><table>
+      <thead><tr><th>name</th><th>state</th><th class="r">mem</th><th class="r">cpu</th></tr></thead>
+      <tbody>${pm2items.map(p => `<tr>
+        <td class="mono">${p.name}</td>
+        <td class="s ${p.status !== 'online' ? p.status : ''}">${p.status}</td>
+        <td class="r">${p.mem_mb}M</td>
+        <td class="r">${p.cpu}%</td>
+      </tr>`).join('')}</tbody>
+    </table></div>
+  </div>`;
+
+  $('main').innerHTML = html;
+}
+
+tick();
+setInterval(tick, 10000);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..68610af
--- /dev/null
+++ b/server.js
@@ -0,0 +1,150 @@
+// factory-floor — single-page live coordinator for Steve's stack.
+// Polls pm2, Ollama, known service URLs, recent git commits. Caches in-memory, refreshes
+// every POLL_MS. Exposes GET /api/state and serves /public/.
+import http from 'node:http';
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { execFile } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PORT = +(process.env.PORT || 9700);
+const POLL_MS = +(process.env.POLL_MS || 10_000);
+const PROJECTS_DIR = path.join(os.homedir(), 'Projects');
+
+const SERVICES = JSON.parse(fs.readFileSync(path.join(__dirname, 'services.json'), 'utf8')).services;
+
+const run = (cmd, args, opts = {}) => new Promise((res) => {
+  execFile(cmd, args, { timeout: 8000, maxBuffer: 16 * 1024 * 1024, ...opts }, (err, stdout, stderr) =>
+    res({ err, stdout: stdout?.toString() || '', stderr: stderr?.toString() || '' })
+  );
+});
+
+async function pollPm2() {
+  const r = await run('pm2', ['jlist']);
+  if (r.err) return { error: r.err.message, items: [] };
+  try {
+    const j = JSON.parse(r.stdout);
+    const items = j.map(p => ({
+      name: p.name,
+      pid: p.pid,
+      status: p.pm2_env?.status,
+      cpu: p.monit?.cpu ?? 0,
+      mem_mb: Math.round((p.monit?.memory ?? 0) / 1024 / 1024),
+      uptime_s: p.pm2_env?.pm_uptime ? Math.round((Date.now() - p.pm2_env.pm_uptime) / 1000) : 0,
+      restarts: p.pm2_env?.restart_time ?? 0,
+      port: p.pm2_env?.PORT ?? p.pm2_env?.env?.PORT ?? null,
+    })).sort((a, b) => a.name.localeCompare(b.name));
+    return { items };
+  } catch (e) { return { error: e.message, items: [] }; }
+}
+
+async function pollOllama() {
+  try {
+    const [tags, ps] = await Promise.all([
+      fetch('http://127.0.0.1:11434/api/tags').then(r => r.json()).catch(() => null),
+      fetch('http://127.0.0.1:11434/api/ps').then(r => r.json()).catch(() => null),
+    ]);
+    return {
+      installed: (tags?.models || []).map(m => ({
+        name: m.name,
+        size_gb: +(m.size / 1024 ** 3).toFixed(2),
+        q: m.details?.quantization_level,
+        params: m.details?.parameter_size,
+      })),
+      loaded: (ps?.models || []).map(m => ({ name: m.name, size_mb: Math.round((m.size_vram || m.size) / 1024 / 1024) })),
+    };
+  } catch (e) { return { error: e.message, installed: [], loaded: [] }; }
+}
+
+async function pollServices() {
+  return Promise.all(SERVICES.map(async (s) => {
+    const t0 = Date.now();
+    try {
+      const ctrl = AbortSignal.timeout(3500);
+      const r = await fetch(s.url, { signal: ctrl, redirect: 'manual' });
+      const ms = Date.now() - t0;
+      let payload = null;
+      if (s.match) {
+        try {
+          const j = await r.clone().json();
+          const found = JSON.stringify(j).toLowerCase().includes(s.match.toLowerCase());
+          payload = { matched: found };
+        } catch {}
+      }
+      return { ...s, ok: r.status < 500, status: r.status, ms, payload };
+    } catch (e) {
+      return { ...s, ok: false, status: 0, ms: Date.now() - t0, error: e.code || e.message };
+    }
+  }));
+}
+
+async function pollGit() {
+  const dirs = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
+    .filter(d => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_'))
+    .slice(0, 200);
+  const rows = await Promise.all(dirs.map(async (d) => {
+    const repo = path.join(PROJECTS_DIR, d.name);
+    if (!fs.existsSync(path.join(repo, '.git'))) return null;
+    const r = await run('git', ['-C', repo, 'log', '-1', '--format=%cI%x09%h%x09%s'], { timeout: 3000 });
+    if (r.err || !r.stdout) return null;
+    const [iso, sha, ...msg] = r.stdout.trim().split('\t');
+    return { project: d.name, iso, sha, msg: msg.join('\t'), age_s: Math.round((Date.now() - new Date(iso).getTime()) / 1000) };
+  }));
+  return rows.filter(Boolean).sort((a, b) => b.iso.localeCompare(a.iso)).slice(0, 20);
+}
+
+async function pollHost() {
+  const load = os.loadavg();
+  const total = os.totalmem(), free = os.freemem();
+  return {
+    host: os.hostname(),
+    cores: os.cpus().length,
+    load: load.map(n => +n.toFixed(2)),
+    mem_gb_total: +(total / 1024 ** 3).toFixed(1),
+    mem_gb_free: +(free / 1024 ** 3).toFixed(1),
+    uptime_h: +(os.uptime() / 3600).toFixed(1),
+  };
+}
+
+let STATE = { generated_at: null };
+
+async function refresh() {
+  const t0 = Date.now();
+  const [pm2, ollama, services, git, host] = await Promise.all([
+    pollPm2(), pollOllama(), pollServices(), pollGit(), pollHost(),
+  ]);
+  STATE = { generated_at: new Date().toISOString(), poll_ms: Date.now() - t0, pm2, ollama, services, git, host };
+}
+
+await refresh();
+setInterval(() => { refresh().catch(e => console.error('refresh err', e.message)); }, POLL_MS);
+
+const server = http.createServer(async (req, res) => {
+  try {
+    if (req.url === '/api/state') {
+      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
+      return res.end(JSON.stringify(STATE));
+    }
+    if (req.url === '/api/refresh' && req.method === 'POST') {
+      await refresh();
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      return res.end(JSON.stringify({ ok: true, generated_at: STATE.generated_at }));
+    }
+    let urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
+    const filePath = path.join(__dirname, 'public', urlPath);
+    if (!filePath.startsWith(path.join(__dirname, 'public'))) { res.writeHead(403); return res.end(); }
+    if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { res.writeHead(404); return res.end('not found'); }
+    const ext = path.extname(filePath).slice(1);
+    const types = { html: 'text/html; charset=utf-8', js: 'text/javascript', css: 'text/css', svg: 'image/svg+xml', json: 'application/json' };
+    res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream', 'Cache-Control': 'no-store' });
+    fs.createReadStream(filePath).pipe(res);
+  } catch (e) {
+    res.writeHead(500); res.end(e.message);
+  }
+});
+
+server.listen(PORT, () => {
+  console.log(`factory-floor listening on http://127.0.0.1:${PORT}  (poll every ${POLL_MS}ms)`);
+});
diff --git a/services.json b/services.json
new file mode 100644
index 0000000..99c1bdf
--- /dev/null
+++ b/services.json
@@ -0,0 +1,28 @@
+{
+  "services": [
+    { "name": "Hermes-3-8B", "url": "http://127.0.0.1:11434/api/tags", "match": "hermes3", "group": "inference" },
+    { "name": "Qwen3:14b",   "url": "http://127.0.0.1:11434/api/tags", "match": "qwen3:14b", "group": "inference" },
+    { "name": "Gemma3:12b",  "url": "http://127.0.0.1:11434/api/tags", "match": "gemma3:12b", "group": "inference" },
+
+    { "name": "Claude Control Center", "url": "http://127.0.0.1:9767",  "group": "factory-floor" },
+    { "name": "Morning Review",        "url": "http://127.0.0.1:9762",  "group": "factory-floor" },
+    { "name": "Video Gallery",         "url": "http://127.0.0.1:9763",  "group": "factory-floor" },
+    { "name": "Plannator Viewer",      "url": "http://127.0.0.1:9772",  "group": "factory-floor" },
+    { "name": "Four Horsemen",         "url": "http://45.61.58.125:9801", "group": "factory-floor" },
+
+    { "name": "AbramsOS",          "url": "http://127.0.0.1:9931",  "group": "apps" },
+    { "name": "Lawyer Directory",  "url": "http://127.0.0.1:9701",  "group": "apps" },
+    { "name": "Doctor (pd-web)",   "url": "http://127.0.0.1:9706",  "group": "apps" },
+    { "name": "Animals",           "url": "http://127.0.0.1:9720",  "group": "apps" },
+    { "name": "NPH",               "url": "http://127.0.0.1:9765",  "group": "apps" },
+    { "name": "VCL",               "url": "http://127.0.0.1:9789",  "group": "apps" },
+
+    { "name": "George (Gmail)",    "url": "http://100.107.67.67:9850", "group": "comms" },
+    { "name": "Norma (Instagram)", "url": "http://127.0.0.1:9810",  "group": "comms" },
+
+    { "name": "Visual Factory",    "url": "http://127.0.0.1:9892",  "group": "factories" },
+    { "name": "The AI Factory",    "url": "http://127.0.0.1:9890",  "group": "factories" },
+    { "name": "Site Factory",      "url": "http://127.0.0.1:9880",  "group": "factories" },
+    { "name": "SF Viewer",         "url": "http://127.0.0.1:9881",  "group": "factories" }
+  ]
+}

(oldest)  ·  back to Factory Floor  ·  fix: non-blocking initial poll + 30s timeout (box was at loa e87d5a8 →