[object Object]

← back to Macstudio1 Dashboard

feat: engine bay live process table below gauges

3ef920f4ce94f24d28bd737328912a7ef3923a5f · 2026-05-07 23:16:50 -0700 · SteveStudio2

- /api/processes endpoint runs ps -Ao with explicit columns,
  parses to JSON {pid,user,cpu,mem,rss_mb,start,time,command}
- table rendered below odometer w/ chrome trim + leather backing
- sortable headers (click any col, second click reverses)
  default sort: CPU% desc; numeric cols default desc on first click
- hot rows (>=50% CPU or >=10% MEM) tinted red
- 2.5s poll cycle independent of gauge ticks
- Lobster Engine bay header in cream + gold counter

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3ef920f4ce94f24d28bd737328912a7ef3923a5f
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Thu May 7 23:16:50 2026 -0700

    feat: engine bay live process table below gauges
    
    - /api/processes endpoint runs ps -Ao with explicit columns,
      parses to JSON {pid,user,cpu,mem,rss_mb,start,time,command}
    - table rendered below odometer w/ chrome trim + leather backing
    - sortable headers (click any col, second click reverses)
      default sort: CPU% desc; numeric cols default desc on first click
    - hot rows (>=50% CPU or >=10% MEM) tinted red
    - 2.5s poll cycle independent of gauge ticks
    - Lobster Engine bay header in cream + gold counter
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/index.html | 174 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         |  29 +++++++++
 2 files changed, 203 insertions(+)

diff --git a/public/index.html b/public/index.html
index 672a746..0fa096c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -371,6 +371,92 @@ header.masthead {
   letter-spacing: 0.16em;
 }
 
+/* Engine bay process table — chrome trim, leather background, brass header */
+.proc-table {
+  margin-top: 32px;
+  background: linear-gradient(180deg, var(--leather-light) 0%, var(--leather) 100%);
+  border: 4px solid var(--chrome-2);
+  border-radius: 6px;
+  padding: 16px 18px 18px;
+  box-shadow:
+    inset 0 0 0 2px var(--chrome-1),
+    inset 0 0 16px rgba(0, 0, 0, 0.5),
+    0 4px 10px rgba(0, 0, 0, 0.4);
+}
+.proc-head {
+  display: flex; justify-content: space-between; align-items: baseline;
+  border-bottom: 2px solid var(--chrome-2);
+  padding-bottom: 10px; margin-bottom: 10px;
+}
+.proc-head h3 {
+  margin: 0;
+  font-family: 'Lobster', cursive;
+  font-size: 26px;
+  color: var(--cream);
+  text-shadow: 0 0 10px rgba(212, 182, 131, 0.5);
+}
+.proc-count {
+  font-family: 'Special Elite', monospace;
+  font-size: 11px;
+  letter-spacing: 0.28em;
+  color: var(--gold);
+  text-transform: uppercase;
+}
+.proc-table table {
+  width: 100%;
+  border-collapse: collapse;
+  font-family: 'JetBrains Mono', monospace;
+  font-size: 12px;
+  color: var(--cream);
+}
+.proc-table thead th {
+  position: sticky; top: 0;
+  text-align: left;
+  padding: 8px 10px;
+  font-family: 'Special Elite', monospace;
+  font-size: 10px;
+  letter-spacing: 0.22em;
+  text-transform: uppercase;
+  color: var(--gold);
+  border-bottom: 1px solid var(--chrome-3);
+  background: var(--leather);
+  cursor: pointer;
+  user-select: none;
+  white-space: nowrap;
+}
+.proc-table thead th:hover { color: var(--cream); }
+.proc-table thead th.active { color: var(--turq); }
+.proc-table thead th.active::after {
+  content: ' ▾';
+  color: var(--turq);
+}
+.proc-table thead th.active.asc::after { content: ' ▴'; }
+.proc-table tbody tr {
+  border-bottom: 1px dotted rgba(212, 182, 131, 0.18);
+  transition: background 0.15s;
+}
+.proc-table tbody tr:hover { background: rgba(74, 189, 197, 0.12); }
+.proc-table tbody td {
+  padding: 5px 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 280px;
+}
+.proc-table td.num {
+  text-align: right;
+  font-variant-numeric: tabular-nums;
+  color: var(--gold);
+}
+.proc-table tbody td.cmd {
+  font-family: 'Special Elite', monospace;
+  color: var(--cream);
+  max-width: 380px;
+}
+.proc-table tbody tr.hot {
+  background: rgba(200, 36, 58, 0.18);
+}
+
 footer {
   text-align: center;
   padding: 32px 24px 64px;
@@ -459,6 +545,28 @@ footer a { color: var(--gold); text-decoration: none; }
     <span class="digit" id="odo-calls">0000</span>
   </div>
 
+  <!-- Live process list -->
+  <section class="proc-table">
+    <div class="proc-head">
+      <h3>Engine bay · live processes</h3>
+      <span class="proc-count" id="proc-count">— running</span>
+    </div>
+    <table id="proc">
+      <thead>
+        <tr>
+          <th data-sort="pid">PID</th>
+          <th data-sort="user">USER</th>
+          <th data-sort="cpu" class="active desc">CPU %</th>
+          <th data-sort="mem">MEM %</th>
+          <th data-sort="rss_mb">RSS (MB)</th>
+          <th data-sort="time">TIME</th>
+          <th data-sort="command">COMMAND</th>
+        </tr>
+      </thead>
+      <tbody></tbody>
+    </table>
+  </section>
+
 </main>
 
 <footer>
@@ -564,6 +672,72 @@ async function trigger(mode, btn) {
 
 tick();
 setInterval(tick, 1200);
+
+// ─── Live process table ────────────────────────────────────────────────
+let PROC_ROWS = [];
+let PROC_SORT = { col: 'cpu', dir: 'desc' };
+
+function fmtNum(n, dec) { return Number(n).toFixed(dec === undefined ? 1 : dec); }
+
+function renderProcs() {
+  const tbody = document.querySelector('#proc tbody');
+  if (!tbody) return;
+  const rows = [...PROC_ROWS];
+  rows.sort((a, b) => {
+    let va = a[PROC_SORT.col], vb = b[PROC_SORT.col];
+    if (typeof va === 'string' && typeof vb === 'string') {
+      return PROC_SORT.dir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
+    }
+    return PROC_SORT.dir === 'asc' ? (va - vb) : (vb - va);
+  });
+  tbody.innerHTML = rows.map(r => {
+    const hot = (r.cpu >= 50 || r.mem >= 10) ? ' class="hot"' : '';
+    const escCmd = String(r.command).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
+    return `<tr${hot}>
+      <td class="num">${r.pid}</td>
+      <td>${r.user}</td>
+      <td class="num">${fmtNum(r.cpu)}</td>
+      <td class="num">${fmtNum(r.mem)}</td>
+      <td class="num">${fmtNum(r.rss_mb, 0)}</td>
+      <td>${r.time}</td>
+      <td class="cmd" title="${escCmd}">${escCmd}</td>
+    </tr>`;
+  }).join('');
+
+  // Sort indicators on headers
+  document.querySelectorAll('#proc thead th').forEach(th => {
+    th.classList.remove('active', 'asc', 'desc');
+    if (th.dataset.sort === PROC_SORT.col) {
+      th.classList.add('active', PROC_SORT.dir);
+    }
+  });
+}
+
+document.querySelectorAll('#proc thead th').forEach(th => {
+  th.addEventListener('click', () => {
+    const col = th.dataset.sort;
+    if (!col) return;
+    if (PROC_SORT.col === col) {
+      PROC_SORT.dir = PROC_SORT.dir === 'asc' ? 'desc' : 'asc';
+    } else {
+      PROC_SORT.col = col;
+      PROC_SORT.dir = (col === 'pid' || col === 'user' || col === 'command') ? 'asc' : 'desc';
+    }
+    renderProcs();
+  });
+});
+
+async function tickProcs() {
+  try {
+    const r = await fetch('/api/processes').then(r => r.json());
+    PROC_ROWS = r.rows || [];
+    document.getElementById('proc-count').textContent = `${PROC_ROWS.length} running`;
+    renderProcs();
+  } catch (e) { /* network blip */ }
+}
+
+tickProcs();
+setInterval(tickProcs, 2500);
 </script>
 
 </body>
diff --git a/server.js b/server.js
index 7ab074a..a466962 100644
--- a/server.js
+++ b/server.js
@@ -157,6 +157,35 @@ app.post('/api/control/idle', async (_req, res) => {
   res.json({ ok: true, mode: 'idle', detail: out });
 });
 
+// Live process list — parsed ps output. Top 60 by CPU desc by default.
+// Columns: pid, user, cpu, mem, rss_mb, start, time, command
+app.get('/api/processes', async (_req, res) => {
+  try {
+    // -A all, -o explicit columns, sort by -%cpu (descending)
+    // comm = full command (truncated by ps to ~256 chars)
+    const r = await execCmd("ps -Ao pid=,user=,%cpu=,%mem=,rss=,etime=,time=,comm= -r 2>/dev/null | head -200");
+    const rows = [];
+    for (const line of r.stdout.split('\n')) {
+      const m = line.match(/^\s*(\d+)\s+(\S+)\s+(\d+\.\d+)\s+(\d+\.\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$/);
+      if (!m) continue;
+      const [, pid, user, cpu, mem, rss, start, time, command] = m;
+      rows.push({
+        pid: parseInt(pid, 10),
+        user,
+        cpu: parseFloat(cpu),
+        mem: parseFloat(mem),
+        rss_mb: +(parseInt(rss, 10) / 1024).toFixed(1),
+        start, time,
+        command: command.slice(0, 80)
+      });
+    }
+    res.set('Cache-Control', 'no-store');
+    res.json({ count: rows.length, rows: rows.slice(0, 60) });
+  } catch (e) {
+    res.status(500).json({ error: String(e.message || e) });
+  }
+});
+
 app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }));
 
 app.listen(PORT, '0.0.0.0', () => {

← 574b249 feat: macstudio1-dashboard v0.1 — 57 Bel Air aesthetic  ·  back to Macstudio1 Dashboard  ·  fix: no-store HTML so new sections appear on reload 2afd088 →