[object Object]

← back to Butlr

admin: /admin/status — live health snapshot UI

03e71d275af06d035c1bb2b294ae2c56a3f332ca · 2026-05-13 13:04:43 -0700 · SteveStudio2

Surfaces what scripts/status-snapshot.sh writes to ~/status, but live
per-request (no dependence on cron persistence). Auto-refreshes every
30s. Shows butlr + wallco-ai with HTTP, uptime, restarts, pm2 status,
memory, CPU. Reads recent ALERT-*.txt files if present.

Admin-only. URL: https://butlr.agentabrams.com/admin/status

Files touched

Diff

commit 03e71d275af06d035c1bb2b294ae2c56a3f332ca
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 13:04:43 2026 -0700

    admin: /admin/status — live health snapshot UI
    
    Surfaces what scripts/status-snapshot.sh writes to ~/status, but live
    per-request (no dependence on cron persistence). Auto-refreshes every
    30s. Shows butlr + wallco-ai with HTTP, uptime, restarts, pm2 status,
    memory, CPU. Reads recent ALERT-*.txt files if present.
    
    Admin-only. URL: https://butlr.agentabrams.com/admin/status
---
 routes/admin.js | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 105 insertions(+)

diff --git a/routes/admin.js b/routes/admin.js
index 2dce504..660f474 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -170,6 +170,111 @@ function escapeHtml(s) {
   return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
 }
 
+// ── /admin/status — live system snapshot ─────────────────────────────
+// On-demand health check of butlr + wallco-ai (the two pm2 processes
+// Steve monitors). Mirrors the launchd cron at
+// scripts/status-snapshot.sh — same shape, but live per-request.
+// Cron polls every 2min and persists; this is the "now" view.
+router.get('/admin/status', requireAdmin, async (req, res) => {
+  const [butlrHealth, wallcoHealth] = await Promise.all([
+    fetch('https://butlr.agentabrams.com/healthz', { signal: AbortSignal.timeout(5000) })
+      .then(r => r.status).catch(() => 0),
+    fetch('https://wallco.ai/health', { signal: AbortSignal.timeout(5000) })
+      .then(r => r.status).catch(() => 0),
+  ]);
+
+  // pm2 stats via local-host pm2 jlist (this server runs on prod next to wallco-ai's pm2)
+  let pm2Stats = {};
+  try {
+    const { execSync } = require('child_process');
+    const blob = execSync('pm2 jlist 2>/dev/null', { encoding: 'utf8', maxBuffer: 5_000_000 });
+    const list = JSON.parse(blob);
+    for (const name of ['butlr', 'wallco-ai']) {
+      const p = list.find(x => x.name === name);
+      if (!p) { pm2Stats[name] = null; continue; }
+      const e = p.pm2_env || {};
+      pm2Stats[name] = {
+        pid: p.pid,
+        uptime_s: Math.floor((Date.now() - (e.pm_uptime || 0)) / 1000),
+        restarts: e.restart_time || 0,
+        status: e.status || 'unknown',
+        memory_mb: Math.round((p.monit && p.monit.memory || 0) / 1024 / 1024 * 10) / 10,
+        cpu_pct: p.monit && p.monit.cpu || 0,
+      };
+    }
+  } catch (e) {
+    pm2Stats._error = e.message;
+  }
+
+  // Recent alerts from launchd cron (best-effort — only present if cron is on this host)
+  let recentAlerts = [];
+  try {
+    const fs = require('fs');
+    const path = require('path');
+    const alertDir = path.join(process.env.HOME || '/root', 'status');
+    if (fs.existsSync(alertDir)) {
+      recentAlerts = fs.readdirSync(alertDir)
+        .filter(f => f.startsWith('ALERT-'))
+        .sort().reverse().slice(0, 5)
+        .map(f => {
+          const body = fs.readFileSync(path.join(alertDir, f), 'utf8').slice(0, 400);
+          return { file: f, body };
+        });
+    }
+  } catch {}
+
+  const rowFor = (name, label, healthCode) => {
+    const s = pm2Stats[name];
+    const ok = healthCode >= 200 && healthCode < 300;
+    const pm2Ok = s && s.status === 'online';
+    const color = ok && pm2Ok ? '#047857' : '#b91c1c';
+    return `<tr>
+      <td><strong>${label}</strong></td>
+      <td style="color:${color};font-weight:600">${healthCode || 'ERR'}</td>
+      <td>${s ? s.uptime_s + 's' : '—'}</td>
+      <td>${s ? s.restarts : '—'}</td>
+      <td style="color:${pm2Ok ? '#047857' : '#b91c1c'}">${s ? s.status : 'not found'}</td>
+      <td>${s ? s.memory_mb + ' MB' : '—'}</td>
+      <td>${s ? s.cpu_pct + '%' : '—'}</td>
+    </tr>`;
+  };
+
+  res.type('html').send(`<!doctype html><html><head>
+    <meta charset="utf-8">
+    <title>Status — Butlr admin</title>
+    <meta http-equiv="refresh" content="30">
+    <link rel="stylesheet" href="/css/site.css">
+    <style>
+      body { font-family: 'Inter', system-ui, sans-serif; max-width: 920px; margin: 0 auto; padding: 24px; }
+      table { border-collapse: collapse; width: 100%; margin: 14px 0; background: #fff; }
+      th, td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; font-size: 14px; }
+      th { background: #f9fafb; font-weight: 600; }
+      .muted { color: #6b7280; font-size: 12px; }
+      .alert { background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; padding: 12px 14px; margin: 10px 0; }
+      .alert pre { font-size: 11px; margin: 6px 0 0; white-space: pre-wrap; }
+      .ok { background: #ecfdf5; border: 1px solid #047857; color: #047857; padding: 10px 14px; border-radius: 6px; font-size: 13px; }
+    </style>
+  </head><body>
+    <h1>Status</h1>
+    <p class="muted">Live snapshot · auto-refresh every 30s · ${new Date().toISOString().slice(0,19)}Z</p>
+
+    <table>
+      <thead><tr><th>Service</th><th>HTTP</th><th>Uptime</th><th>Restarts</th><th>pm2</th><th>Memory</th><th>CPU</th></tr></thead>
+      <tbody>
+        ${rowFor('butlr', 'butlr', butlrHealth)}
+        ${rowFor('wallco-ai', 'wallco-ai', wallcoHealth)}
+      </tbody>
+    </table>
+
+    <h2 style="font-size:18px;margin-top:24px">Recent alerts (last 5)</h2>
+    ${recentAlerts.length === 0
+      ? '<div class="ok">No alerts recorded.</div>'
+      : recentAlerts.map(a => `<div class="alert"><strong>${a.file.replace('ALERT-','').replace('.txt','')}</strong><pre>${a.body.replace(/</g, '&lt;')}</pre></div>`).join('')}
+
+    <p class="muted" style="margin-top:24px"><a href="/admin">← Admin dashboard</a></p>
+  </body></html>`);
+});
+
 router.get('/admin/orphan-recordings', requireAdmin, (req, res) => {
   let orphans;
   try { orphans = scanOrphans(); }

← 6db4767 sms: friendly GET handler on /twilio/sms-inbound (was 404 in  ·  back to Butlr  ·  Permissions-Policy: allow geolocation=(self) on wizard /new 0c6e41e →