← back to Factory Floor
feat: Big Red tile w/ spinning brain + model picker (Ollama + claude + codex)
282ec2970ebe2a6a72876e0b8f8217dcb6d64686 · 2026-05-10 22:23:04 -0700 · Steve Abrams
Files touched
M public/index.htmlM server.jsM services.json
Diff
commit 282ec2970ebe2a6a72876e0b8f8217dcb6d64686
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 10 22:23:04 2026 -0700
feat: Big Red tile w/ spinning brain + model picker (Ollama + claude + codex)
---
public/index.html | 156 ++++++++++++++++++++++++++++++++++--------
server.js | 197 ++++++++++++++++++++++++++++++++++++------------------
services.json | 1 +
3 files changed, 262 insertions(+), 92 deletions(-)
diff --git a/public/index.html b/public/index.html
index 8ddf39f..537c7fb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -6,7 +6,7 @@
<style>
:root {
--bg:#0a0a0c; --panel:#121217; --panel2:#181820; --rule:#26262e; --ink:#eee6d8; --mute:#8c8680; --gold:#c9a96a;
- --ok:#7ad27a; --warn:#e0b25a; --bad:#e26d6d; --link:#9bd1ff;
+ --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}
@@ -45,15 +45,34 @@
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)}
+ 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)}
- .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)}
+
+ /* 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>
</head>
<body>
@@ -62,14 +81,30 @@
<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>
+ <div class="card wide"><h2>booting…</h2></div>
</main>
-<div class="footer">~/Projects/factory-floor · auto-refresh every 10s · POST /api/refresh to force · serving on :9700</div>
+<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);
+
+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 = {
- ms: (n) => n == null ? '—' : `${n}ms`,
age: (s) => {
if (s == null) return '—';
if (s < 60) return `${s}s`;
@@ -80,33 +115,97 @@ const fmt = {
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="${m}">${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; }
- $('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}`;
+ 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 = ((h.mem_gb_total - h.mem_gb_free) / h.mem_gb_total) * 100;
+ 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">${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">${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>`;
- // OLLAMA / INFERENCE BRAINS
+ // BIG RED — featured tile
+ html += bigRedCard(s);
+
+ // 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>
+ 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">
@@ -116,14 +215,7 @@ async function tick() {
}).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
+ // 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) {
@@ -138,16 +230,26 @@ async function tick() {
}
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%">${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>`;
+
// PM2
- const pm2items = s.pm2.items || [];
- html += `<div class="card third"><h2>PM2 agents <span class="count">${pm2items.length}</span></h2>
+ 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 class="r">mem</th><th class="r">cpu</th></tr></thead>
+ <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">${p.name}</td>
<td class="s ${p.status !== 'online' ? p.status : ''}">${p.status}</td>
+ <td class="mono" style="color:var(--mute)">${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>`;
@@ -155,8 +257,8 @@ async function tick() {
$('main').innerHTML = html;
}
-tick();
-setInterval(tick, 10000);
+loadCurrentModel().then(tick);
+setInterval(tick, 8000);
</script>
</body>
</html>
diff --git a/server.js b/server.js
index 8992956..24e8227 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,5 @@
-// 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/.
+// factory-floor — single-page live coordinator. Lean variant: event-loop-safe,
+// concurrency-capped, lazy first poll, no shelling out during request handling.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
@@ -9,93 +8,113 @@ 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 PORT = +(process.env.PORT || 9936);
+const POLL_MS = +(process.env.POLL_MS || 15_000);
+const GIT_LIMIT = +(process.env.GIT_LIMIT || 40); // cap repos polled per cycle
+const CONCURRENCY = +(process.env.CONCURRENCY || 4);
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: 30000, maxBuffer: 16 * 1024 * 1024, ...opts }, (err, stdout, stderr) =>
- res({ err, stdout: stdout?.toString() || '', stderr: stderr?.toString() || '' })
- );
-});
+function run(cmd, args, opts = {}) {
+ return new Promise((res) => {
+ execFile(cmd, args, { timeout: 15000, maxBuffer: 8 * 1024 * 1024, ...opts }, (err, stdout) =>
+ res({ err, stdout: stdout?.toString() || '' })
+ );
+ });
+}
+
+// concurrency-capped parallel map
+async function pmap(items, fn, limit = CONCURRENCY) {
+ const out = new Array(items.length);
+ let i = 0;
+ async function worker() {
+ while (true) {
+ const idx = i++;
+ if (idx >= items.length) return;
+ try { out[idx] = await fn(items[idx], idx); } catch (e) { out[idx] = { error: e.message }; }
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
+ return out;
+}
async function pollPm2() {
- const r = await run('pm2', ['jlist']);
- if (r.err) return { error: r.err.message, items: [] };
+ const r = await run('pm2', ['jlist'], { timeout: 20000 });
+ if (r.err) return { error: r.err.code || 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 };
+ return {
+ items: j.map(p => ({
+ name: p.name,
+ 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)),
+ };
} 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),
+ const ctrl = AbortSignal.timeout(4000);
+ const [tagsR, psR] = await Promise.all([
+ fetch('http://127.0.0.1:11434/api/tags', { signal: ctrl }).then(r => r.json()).catch(() => null),
+ fetch('http://127.0.0.1:11434/api/ps', { signal: ctrl }).then(r => r.json()).catch(() => null),
]);
return {
- installed: (tags?.models || []).map(m => ({
+ installed: (tagsR?.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) })),
+ loaded: (psR?.models || []).map(m => ({ name: m.name })),
};
} catch (e) { return { error: e.message, installed: [], loaded: [] }; }
}
async function pollServices() {
- return Promise.all(SERVICES.map(async (s) => {
+ return pmap(SERVICES, 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 };
+ const r = await fetch(s.url, { signal: AbortSignal.timeout(3000), redirect: 'manual' });
+ return { ...s, ok: r.status < 500, status: r.status, ms: Date.now() - t0 };
} catch (e) {
- return { ...s, ok: false, status: 0, ms: Date.now() - t0, error: e.code || e.message };
+ return { ...s, ok: false, status: 0, ms: Date.now() - t0, error: e.code || (e.name === 'TimeoutError' ? 'timeout' : 'err') };
}
- }));
+ }, 6);
}
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 });
+ .map(d => d.name);
+ // cheap pre-filter: only dirs with a .git
+ const repos = dirs.filter(name => {
+ try { return fs.statSync(path.join(PROJECTS_DIR, name, '.git')).isDirectory(); } catch { return false; }
+ });
+ // sample most-recent-mtime first for relevance
+ const dated = repos.map(name => {
+ let mt = 0;
+ try { mt = fs.statSync(path.join(PROJECTS_DIR, name, '.git')).mtimeMs; } catch {}
+ return { name, mt };
+ }).sort((a, b) => b.mt - a.mt).slice(0, GIT_LIMIT);
+
+ const rows = await pmap(dated, async ({ name }) => {
+ const repo = path.join(PROJECTS_DIR, name);
+ const r = await run('git', ['-C', repo, 'log', '-1', '--format=%cI%x09%h%x09%s'], { timeout: 4000 });
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);
+ return { project: name, iso, sha, msg: msg.join('\t'), age_s: Math.round((Date.now() - new Date(iso).getTime()) / 1000) };
+ }, 4);
+ return rows.filter(Boolean).sort((a, b) => b.iso.localeCompare(a.iso)).slice(0, 15);
}
-async function pollHost() {
+function pollHost() {
const load = os.loadavg();
const total = os.totalmem(), free = os.freemem();
return {
@@ -108,34 +127,77 @@ async function pollHost() {
};
}
-let STATE = { generated_at: null };
+// "Big Red is thinking" — detect active claude CLI child processes
+async function pollBigRed() {
+ const r = await run('pgrep', ['-fl', 'claude'], { timeout: 3000 });
+ const lines = (r.stdout || '').split('\n').filter(Boolean);
+ const claudeProcs = lines.filter(l => /\sclaude\s/.test(l) || /\/claude\b/.test(l));
+ // Heuristic: big-red invokes claude with --print + sandbox cwd. Count those specifically.
+ const bigRedActive = lines.filter(l => /big-red|--print/.test(l)).length;
+ return {
+ thinking: bigRedActive > 0,
+ claude_procs: claudeProcs.length,
+ big_red_calls: bigRedActive,
+ };
+}
+
+let STATE = { generated_at: null, booting: true };
+let inFlight = false;
async function refresh() {
+ if (inFlight) return;
+ inFlight = true;
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 };
+ try {
+ const [pm2, ollama, services, host, bigRed] = await Promise.all([
+ pollPm2(), pollOllama(), pollServices(), pollHost(), pollBigRed(),
+ ]);
+ // git last; it's the heaviest
+ const git = await pollGit().catch(() => []);
+ STATE = { generated_at: new Date().toISOString(), poll_ms: Date.now() - t0, pm2, ollama, services, git, host, bigRed };
+ } finally {
+ inFlight = false;
+ }
}
-refresh().catch(e => console.error('initial refresh err', e.message));
-setInterval(() => { refresh().catch(e => console.error('refresh err', e.message)); }, POLL_MS);
-
-const server = http.createServer(async (req, res) => {
+// Schedule first refresh AFTER server starts listening, so the server is reachable immediately
+const server = http.createServer((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();
+ refresh().catch(() => {});
+ res.writeHead(202, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ scheduled: true }));
+ }
+ if (req.url === '/api/bigred/model' && req.method === 'POST') {
+ let body = '';
+ req.on('data', c => body += c);
+ req.on('end', () => {
+ try {
+ const { model } = JSON.parse(body || '{}');
+ if (!model || typeof model !== 'string') { res.writeHead(400); return res.end('model required'); }
+ const target = path.join(os.homedir(), 'Projects', 'big-red', '.selected-model');
+ fs.writeFileSync(target, model + '\n');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, model, written_to: target }));
+ } catch (e) { res.writeHead(500); res.end(e.message); }
+ });
+ return;
+ }
+ if (req.url === '/api/bigred/model' && req.method === 'GET') {
+ const target = path.join(os.homedir(), 'Projects', 'big-red', '.selected-model');
+ let model = null;
+ try { model = fs.readFileSync(target, 'utf8').trim(); } catch {}
res.writeHead(200, { 'Content-Type': 'application/json' });
- return res.end(JSON.stringify({ ok: true, generated_at: STATE.generated_at }));
+ return res.end(JSON.stringify({ model }));
}
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'); }
+ if (!fs.existsSync(filePath)) { 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' });
@@ -146,5 +208,10 @@ const server = http.createServer(async (req, res) => {
});
server.listen(PORT, () => {
- console.log(`factory-floor listening on http://127.0.0.1:${PORT} (poll every ${POLL_MS}ms)`);
+ console.log(`factory-floor listening on http://127.0.0.1:${PORT} (poll every ${POLL_MS}ms, git_limit=${GIT_LIMIT})`);
+ // Defer first poll so the server is responsive even while the first poll is in flight
+ setTimeout(() => {
+ refresh().catch(e => console.error('initial refresh err', e.message));
+ setInterval(() => refresh().catch(() => {}), POLL_MS);
+ }, 250);
});
diff --git a/services.json b/services.json
index 99c1bdf..8bbe438 100644
--- a/services.json
+++ b/services.json
@@ -19,6 +19,7 @@
{ "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": "Big Red", "url": "http://127.0.0.1:9935", "group": "apps" },
{ "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" },
← e87d5a8 fix: non-blocking initial poll + 30s timeout (box was at loa
·
back to Factory Floor
·
snapshot: 4 file(s) changed, +3 new, ~1 modified a09f471 →