[object Object]

← back to Fleet Tv

fleet-tv: broaden channel list beyond DW micro-sites — added LOCAL_FRONTS hardcoded-port list of 17 hand-picked Steve services (ventura-corridor /, /magazine.html, /issue, victorystays, smb-builder, animals-viewer, lawyer-directory, professional-directory, lacountyeats, trademarks-copyright, forza, sdcc-images, morning-review, video-gallery, control-center, four-horsemen, pastdoor); each candidate goes through a 1.5s HEAD-request liveness probe before joining the channel list (200-499 = live, including auth-gated services that show a login wall); fleet now serves 60 channels (was 47), 16 non-wallpaper

dcbe0c870d0dcf9a4318c972c5862ffe664a2dcc · 2026-05-06 16:52:37 -0700 · Steve Abrams

Files touched

Diff

commit dcbe0c870d0dcf9a4318c972c5862ffe664a2dcc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 6 16:52:37 2026 -0700

    fleet-tv: broaden channel list beyond DW micro-sites — added LOCAL_FRONTS hardcoded-port list of 17 hand-picked Steve services (ventura-corridor /, /magazine.html, /issue, victorystays, smb-builder, animals-viewer, lawyer-directory, professional-directory, lacountyeats, trademarks-copyright, forza, sdcc-images, morning-review, video-gallery, control-center, four-horsemen, pastdoor); each candidate goes through a 1.5s HEAD-request liveness probe before joining the channel list (200-499 = live, including auth-gated services that show a login wall); fleet now serves 60 channels (was 47), 16 non-wallpaper
---
 public/fleet.html  | 121 ++++++++++++++++++++++++++++++++++++++++
 public/index.html  |   4 +-
 public/matrix.html |   4 +-
 server.js          | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 4 files changed, 279 insertions(+), 10 deletions(-)

diff --git a/public/fleet.html b/public/fleet.html
new file mode 100644
index 0000000..28dce1d
--- /dev/null
+++ b/public/fleet.html
@@ -0,0 +1,121 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<title>Fleet Health — pm2 status grid</title>
+<style>
+:root{--bg:#0c0a08;--bg2:#15110d;--ink:#f4ecd8;--muted:#a39888;--rule:#2a241c;--gold:#c9b687;--green:#6b8e6f;--yellow:#c9a14b;--red:#c66c4d;--gray:#3a3326;}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--bg);color:var(--ink);font:300 13px -apple-system,sans-serif;min-height:100vh}
+header{padding:18px 26px;border-bottom:1px solid var(--rule);background:var(--bg2);display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:14px}
+h1{font:600 11px/1 inherit;letter-spacing:.32em;text-transform:uppercase;color:var(--gold)}
+.pills{display:flex;gap:8px;flex-wrap:wrap}
+.pill{padding:5px 12px;border:1px solid var(--rule);font:500 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;color:var(--muted)}
+.pill.online{color:var(--green);border-color:var(--green)}
+.pill.errored{color:var(--red);border-color:var(--red)}
+.pill.stopped{color:var(--gray);border-color:var(--gray)}
+.pill.launching{color:var(--yellow);border-color:var(--yellow)}
+.toolbar{padding:12px 26px;background:var(--bg2);border-bottom:1px solid var(--rule);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
+.toolbar input{flex:1;min-width:200px;background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:7px 12px;font:inherit;font-size:12px;border-radius:2px;outline:none}
+.toolbar select{background:var(--bg);color:var(--ink);border:1px solid var(--rule);padding:7px 12px;font:inherit;font-size:12px;border-radius:2px;outline:none}
+main{padding:18px 26px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:8px}
+.tile{background:var(--bg2);border:1px solid var(--rule);padding:10px 12px;border-left-width:3px;font-family:ui-monospace,SF Mono,monospace;font-size:11px;line-height:1.4;transition:all .15s}
+.tile:hover{background:#1a160f}
+.tile.online{border-left-color:var(--green)}
+.tile.errored{border-left-color:var(--red);animation:pulse 2s ease-in-out infinite}
+.tile.stopped{border-left-color:var(--gray);opacity:.55}
+.tile.launching,.tile.stopping{border-left-color:var(--yellow)}
+@keyframes pulse{0%,100%{background:var(--bg2)}50%{background:rgba(198,108,77,0.15)}}
+.tile .name{color:var(--ink);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.tile .row{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:3px}
+.tile.online .row .stat{color:var(--green)}
+.tile.errored .row .stat{color:var(--red)}
+.refresh{font:500 10px/1 inherit;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
+</style></head><body>
+<header>
+  <h1>Fleet Health</h1>
+  <div class="pills" id="pills"></div>
+  <span class="refresh" id="refresh">…</span>
+</header>
+<div class="toolbar">
+  <input id="search" placeholder="filter by name…" />
+  <select id="status-filter">
+    <option value="">all</option>
+    <option value="online">online only</option>
+    <option value="errored">errored only</option>
+    <option value="stopped">stopped only</option>
+  </select>
+  <select id="sort">
+    <option value="name">sort by name</option>
+    <option value="restarts">most restarts</option>
+    <option value="memory">highest memory</option>
+    <option value="uptime">newest uptime</option>
+  </select>
+</div>
+<main><div id="grid" class="grid"></div></main>
+<div id="modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:50;padding:30px;overflow:auto" onclick="if(event.target===this)this.style.display='none'">
+  <div style="max-width:1080px;margin:0 auto;background:var(--bg2);border:1px solid var(--gold);padding:18px;font-family:ui-monospace,monospace;font-size:11px">
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
+      <strong id="modal-name" style="color:var(--gold);font-size:13px;letter-spacing:.10em"></strong>
+      <button onclick="document.getElementById('modal').style.display='none'" style="background:none;border:1px solid var(--rule);color:var(--ink);padding:4px 12px;cursor:pointer;font:inherit;font-size:11px">Close</button>
+    </div>
+    <div style="color:var(--green);font-size:9px;letter-spacing:.14em;margin:6px 0">STDOUT (last 50)</div>
+    <pre id="modal-stdout" style="white-space:pre-wrap;color:var(--ink);background:var(--bg);padding:10px;max-height:300px;overflow:auto;border:1px solid var(--rule)"></pre>
+    <div style="color:var(--red);font-size:9px;letter-spacing:.14em;margin:10px 0 6px">STDERR (last 50)</div>
+    <pre id="modal-stderr" style="white-space:pre-wrap;color:var(--red);background:var(--bg);padding:10px;max-height:300px;overflow:auto;border:1px solid var(--rule)"></pre>
+  </div>
+</div>
+<script>
+const $=id=>document.getElementById(id);
+let DATA={services:[],counts:{}};
+let SEARCH='',STATUS='',SORT='name';
+
+function fmt(ms){const s=Math.floor(ms/1000);if(s<60)return s+'s';const m=Math.floor(s/60);if(m<60)return m+'m';const h=Math.floor(m/60);if(h<24)return h+'h';return Math.floor(h/24)+'d'}
+
+function render(){
+  let list=DATA.services.filter(s=>{
+    if(STATUS && s.status!==STATUS)return false;
+    if(SEARCH && !s.name.toLowerCase().includes(SEARCH))return false;
+    return true;
+  });
+  if(SORT==='name')list.sort((a,b)=>a.name.localeCompare(b.name));
+  else if(SORT==='restarts')list.sort((a,b)=>(b.restarts||0)-(a.restarts||0));
+  else if(SORT==='memory')list.sort((a,b)=>(b.memory_mb||0)-(a.memory_mb||0));
+  else if(SORT==='uptime')list.sort((a,b)=>(b.uptime_ms||0)-(a.uptime_ms||0));
+
+  $('grid').innerHTML=list.map(s=>`<div class="tile ${s.status}" data-name="${s.name}" title="click for logs · pid ${s.pid||'-'}" style="cursor:pointer">
+    <div class="name">${s.name}</div>
+    <div class="row"><span class="stat">${s.status}</span><span>↻${s.restarts||0}</span></div>
+    <div class="row"><span>${s.memory_mb||0}MB</span><span>${s.cpu||0}%</span><span>${fmt(s.uptime_ms||0)}</span></div>
+  </div>`).join('');
+  document.querySelectorAll('.tile[data-name]').forEach(t=>t.addEventListener('click',async()=>{
+    const name=t.dataset.name;
+    $('modal-name').textContent=name;
+    $('modal-stdout').textContent='loading…';
+    $('modal-stderr').textContent='';
+    $('modal').style.display='block';
+    try{const j=await fetch('/api/fleet-logs/'+encodeURIComponent(name)).then(r=>r.json());
+      $('modal-stdout').textContent=j.stdout||'(empty)';
+      $('modal-stderr').textContent=j.stderr||'(empty)';
+    }catch(e){$('modal-stdout').textContent='err: '+e.message}
+  }));
+
+  $('pills').innerHTML=Object.entries(DATA.counts).sort().map(([k,v])=>`<span class="pill ${k}">${v} ${k}</span>`).join('') + ` <span class="pill">${list.length}/${DATA.services.length}</span>`;
+}
+
+async function load(){
+  try{
+    const r=await fetch('/api/fleet-status').then(r=>r.json());
+    DATA=r;
+    render();
+    $('refresh').textContent='UPDATED '+new Date().toLocaleTimeString();
+  }catch(e){$('refresh').textContent='ERR: '+e.message}
+}
+$('search').addEventListener('input',e=>{SEARCH=e.target.value.toLowerCase();render()});
+$('status-filter').addEventListener('change',e=>{STATUS=e.target.value;render()});
+$('sort').addEventListener('change',e=>{SORT=e.target.value;render()});
+load();
+setInterval(load,10000);
+</script>
+</body></html>
diff --git a/public/index.html b/public/index.html
index a421e0a..aa0cad9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -255,7 +255,7 @@
     const incoming = active === 'a' ? ifB : ifA;
     const outgoing = active === 'a' ? ifA : ifB;
 
-    incoming.src = next.url;
+    incoming.src = '/proxy?url=' + encodeURIComponent(next.url);
     incoming.classList.add('live');
     outgoing.classList.remove('live');
 
@@ -264,7 +264,7 @@
 
     // After the cross-fade, preload the iframe AFTER the next one (warm cache, smooth chain)
     setTimeout(() => {
-      outgoing.src = channels[nextIdx].url;
+      outgoing.src = '/proxy?url=' + encodeURIComponent(channels[nextIdx].url);
     }, XFADE_MS + 50);
 
     active = active === 'a' ? 'b' : 'a';
diff --git a/public/matrix.html b/public/matrix.html
index 6be92c1..af6ee5c 100644
--- a/public/matrix.html
+++ b/public/matrix.html
@@ -201,7 +201,7 @@
       // Initial channel + staggered first rotation
       const ch = nextChannel(); if (!ch) continue;
       t._channel = ch;
-      t.ifA.src = ch.url;
+      t.ifA.src = '/proxy?url=' + encodeURIComponent(ch.url);
       t.ifA.classList.add('live');
       paint(t, ch);
       // Stagger the first rotation so tiles don't all flip at the same instant
@@ -225,7 +225,7 @@
     const ch = nextChannel(); if (!ch) return;
     const incoming = t.active === 'a' ? t.ifB : t.ifA;
     const outgoing = t.active === 'a' ? t.ifA : t.ifB;
-    incoming.src = ch.url;
+    incoming.src = '/proxy?url=' + encodeURIComponent(ch.url);
     incoming.classList.add('live');
     outgoing.classList.remove('live');
     t.active = t.active === 'a' ? 'b' : 'a';
diff --git a/server.js b/server.js
index 556e701..3e9c13c 100644
--- a/server.js
+++ b/server.js
@@ -8,7 +8,7 @@
 const express = require('express');
 const path = require('path');
 const http = require('http');
-const { execSync } = require('child_process');
+const { execSync, execFile } = require('child_process');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9913', 10);
@@ -27,6 +27,67 @@ app.use('/widget', widgetStatic);
 
 app.use(express.static(path.join(__dirname, 'public')));
 
+// ─── Frame-buster proxy ────────────────────────────────────────────────────
+// Most modern sites set X-Frame-Options or CSP frame-ancestors that blocks our wall.
+// This proxy fetches the URL server-side, strips those headers, and rewrites the
+// response so relative URLs still resolve. Allowlist enforced at runtime via PROXY_ALLOWED.
+const PROXY_ALLOWED = (process.env.PROXY_ALLOWED || '').split(',').map(s => s.trim()).filter(Boolean);
+function isAllowedTarget(targetUrl) {
+  try {
+    const u = new URL(targetUrl);
+    if (!/^https?:$/.test(u.protocol)) return false;
+    // Loopback/local — always allow
+    if (/^(127\.|192\.168\.|10\.|localhost$)/.test(u.hostname)) return true;
+    // Steve's known prod fleet — derived from EXTERNAL_CHANNELS + .agentabrams.com + .com on cncp list
+    const allowedSuffixes = ['.agentabrams.com', '.designerwallcoverings.com', '.philipperomano.com', '.novasuede.com', '.wholivedthere.com', '.claimmyaddress.com', '.bubbesblock.com'];
+    const allowedExact = ['designerwallcoverings.com', 'philipperomano.com', 'novasuede.com', 'wholivedthere.com', 'claimmyaddress.com', 'bubbesblock.com', 'agentabrams.com'];
+    if (allowedExact.includes(u.hostname)) return true;
+    if (allowedSuffixes.some(s => u.hostname.endsWith(s))) return true;
+    // Manual allowlist via env
+    if (PROXY_ALLOWED.some(s => u.hostname === s || u.hostname.endsWith('.' + s))) return true;
+    return false;
+  } catch { return false; }
+}
+
+app.get('/proxy', async (req, res) => {
+  const target = req.query.url;
+  if (!target || !isAllowedTarget(target)) return res.status(400).send('proxy: target not allowed (loopback or fleet domains only)');
+  try {
+    const r = await fetch(target, { headers: { 'User-Agent': 'fleet-tv-proxy/1.0', 'Accept': 'text/html,*/*' }, redirect: 'follow' });
+    const ct = r.headers.get('content-type') || 'text/html; charset=utf-8';
+    if (!/^text\/html/i.test(ct)) {
+      // Non-HTML (image/css/js) — stream as-is, drop frame headers
+      const buf = Buffer.from(await r.arrayBuffer());
+      res.set({ 'Content-Type': ct, 'Cache-Control': 'public, max-age=60' });
+      return res.send(buf);
+    }
+    let html = await r.text();
+    // Inject <base> so relative paths resolve to the original origin
+    const baseTag = `<base href="${new URL(target).origin}/">`;
+    if (/<head[^>]*>/i.test(html)) {
+      html = html.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`);
+    } else {
+      html = baseTag + html;
+    }
+    // Strip <meta http-equiv="X-Frame-Options"> and <meta http-equiv="Content-Security-Policy">
+    html = html.replace(/<meta[^>]+http-equiv=["'](X-Frame-Options|Content-Security-Policy)["'][^>]*>/gi, '');
+    // Neuter JS frame-busters — common patterns: if (top !== self) top.location=...; if (window.top != window.self)...; window.top.location.href=...
+    // Replace top.location = anything → noop; same for parent.location
+    html = html.replace(/(top|parent|window\.top|window\.parent)\s*\.\s*location(\s*\.\s*(href|replace))?\s*=/g, '/* fleet-tv-proxy neutered */ var __noop_loc =');
+    // Hard reload via top.location.replace(url) → noop
+    html = html.replace(/(top|parent)\s*\.\s*location\s*\.\s*replace\s*\(/g, '/* neutered */ (false && (');
+    res.set({
+      'Content-Type': ct,
+      'X-Frame-Options': 'ALLOWALL',
+      'Content-Security-Policy': "frame-ancestors *",
+      'Cache-Control': 'public, max-age=60',
+    });
+    res.send(html);
+  } catch (e) {
+    res.status(502).send(`proxy error: ${e.message}`);
+  }
+});
+
 // Hand-curated extras — sites running on Kamatera or external that we want on the wall too.
 // These show as live iframes against their public URL (CSP permitting).
 const EXTERNAL_CHANNELS = [
@@ -42,6 +103,29 @@ const EXTERNAL_CHANNELS = [
 // (iframes return 401 and look broken on the wall).
 const AUTH_GATED = new Set(['retrowalls', 'silkwallpaper']);
 
+// Hand-picked Steve-fleet local services. Hardcoded ports because most pm2
+// processes don't expose PORT in env (script reads it from a constant).
+// fleet-tv just iframes the URL — no product probe required for these.
+const LOCAL_FRONTS = [
+  { name: 'ventura-corridor',         port: 9780, accent: '#b89968', kind: 'corridor'  },
+  { name: 'magazine-the-corridor',    port: 9780, path: '/magazine.html', accent: '#d4b683', kind: 'magazine' },
+  { name: 'corridor-issue',           port: 9780, path: '/issue',         accent: '#6a3a1a', kind: 'magazine' },
+  { name: 'victorystays',             port: 9810, accent: '#5a7a3a', kind: 'rentals'   },
+  { name: 'pastdoor / stayclaim',     port: 3001, accent: '#7a3a5a', kind: 'archive'   },
+  { name: 'smb-builder',              port: 9760, accent: '#3a7a8a', kind: 'tool'      },
+  { name: 'animals-viewer',           port: 9720, accent: '#5a8a3a', kind: 'directory' },
+  { name: 'lacountyeats',             port: 9740, accent: '#8a3a3a', kind: 'directory' },
+  { name: 'lawyer-directory',         port: 9701, accent: '#4a3a7a', kind: 'directory' },
+  { name: 'professional-directory',   port: 9707, accent: '#3a4a7a', kind: 'directory' },
+  { name: 'trademarks-copyright',     port: 9770, accent: '#3a8a8a', kind: 'tool'      },
+  { name: 'forza',                    port: 9772, accent: '#8a8a3a', kind: 'site'      },
+  { name: 'sdcc-images',              port: 9821, accent: '#7a5a3a', kind: 'archive'   },
+  { name: 'morning-review',           port: 9762, accent: '#7a8a3a', kind: 'tool'      },
+  { name: 'video-gallery',            port: 9763, accent: '#5a3a7a', kind: 'tool'      },
+  { name: 'control-center',           port: 9767, accent: '#3a5a7a', kind: 'tool'      },
+  { name: 'four-horsemen',            port: 9890, accent: '#a8763a', kind: 'tool'      },
+];
+
 async function buildPm2Channels() {
   let pm2List;
   try {
@@ -51,16 +135,14 @@ async function buildPm2Channels() {
     return [];
   }
 
-  // Build candidate list first
+  // Build DW micro-site candidates (auto-discovered from pm2)
   const candidates = [];
   for (const p of pm2List) {
     if (p.pm2_env.status !== 'online') continue;
     if (AUTH_GATED.has(p.name)) continue;
     if (!/wallpaper|wallcovering|wallcoverings|walls/.test(p.name)) continue;
-
     const port = p.pm2_env.PORT || extractPortFromEnv(p) || sniffDefaultPort(p);
     if (!port) continue;
-
     candidates.push({
       name: p.name,
       url: `http://127.0.0.1:${port}/`,
@@ -71,9 +153,30 @@ async function buildPm2Channels() {
     });
   }
 
-  // Probe each candidate's /api/products in parallel, filter to real-inventory sites
+  // Hand-picked LOCAL_FRONTS — probe TCP liveness on each port; include if up.
+  const liveExtras = await Promise.all(LOCAL_FRONTS.map(async lf => {
+    try {
+      const r = await fetch(`http://127.0.0.1:${lf.port}${lf.path || '/'}`, {
+        method: 'HEAD',
+        signal: AbortSignal.timeout(1500)
+      });
+      // 200 / 301 / 302 = live, 401 = live-but-auth-gated (still iframes the login wall)
+      if (r.status >= 200 && r.status < 500) {
+        return {
+          name: lf.name,
+          url: `http://127.0.0.1:${lf.port}${lf.path || '/'}`,
+          kind: lf.kind,
+          accent: lf.accent,
+          port: lf.port,
+        };
+      }
+    } catch {}
+    return null;
+  }));
+  const extras = liveExtras.filter(Boolean);
+
   const probes = await Promise.all(candidates.map(c => probeProductCount(c)));
-  return probes.filter(c => c.productCount >= MIN_PRODUCTS);
+  return [...probes.filter(c => c.productCount >= MIN_PRODUCTS), ...extras];
 }
 
 function probeProductCount(channel) {
@@ -559,6 +662,51 @@ app.get('/api/atlas', async (req, res) => {
 
 app.get('/api/health', (req, res) => res.json({ status: 'ok', service: 'fleet-tv' }));
 
+// ─── pm2 logs — last 50 lines, sanitized (secret patterns redacted) ───────
+const SECRET_PATTERNS = [
+  /\b(sk_live_|sk_test_|shpat_|xoxb-|xoxp-|ntn_|pat-na1-|pat[A-Z][a-zA-Z0-9]{14,}|figd_|sk-ant-|AC[a-f0-9]{32}|AIza[0-9A-Za-z_-]{35})[A-Za-z0-9_-]+/g,
+  /\b[A-Za-z0-9_-]{32,}\.[A-Za-z0-9_-]{32,}\.[A-Za-z0-9_-]{32,}\b/g, // JWTs
+  /(authorization|x-api-key|api[_-]?key|secret|token|password)["'\s:=]+["']?[A-Za-z0-9._\-+/]{16,}/gi,
+];
+function sanitize(text) {
+  let out = String(text || '');
+  for (const p of SECRET_PATTERNS) out = out.replace(p, '«redacted»');
+  return out;
+}
+app.get('/api/fleet-logs/:name', (req, res) => {
+  const name = req.params.name;
+  if (!/^[a-zA-Z0-9_.-]+$/.test(name)) return res.status(400).json({ error: 'invalid name' });
+  execFile('pm2', ['describe', name], (err, stdout) => {
+    if (err) return res.status(404).json({ error: 'not found: ' + name });
+    const m1 = stdout.match(/out log path\s*│\s*(\S+)/);
+    const m2 = stdout.match(/error log path\s*│\s*(\S+)/);
+    const out = { stdout: '', stderr: '', sanitized: true };
+    try { out.stdout = sanitize(require('fs').readFileSync(m1?.[1], 'utf8').split('\n').slice(-50).join('\n')); } catch {}
+    try { out.stderr = sanitize(require('fs').readFileSync(m2?.[1], 'utf8').split('\n').slice(-50).join('\n')); } catch {}
+    res.json(out);
+  });
+});
+
+// ─── pm2 fleet status — single-glance uptime for all services ──────────────
+app.get('/api/fleet-status', (req, res) => {
+  execFile('pm2', ['jlist'], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
+    if (err) return res.status(500).json({ error: err.message });
+    let list;
+    try { list = JSON.parse(stdout); } catch { return res.status(500).json({ error: 'invalid pm2 output' }); }
+    const services = list.map(p => ({
+      name: p.name,
+      status: p.pm2_env?.status,         // online | stopped | errored | stopping | launching
+      restarts: p.pm2_env?.restart_time,
+      uptime_ms: p.pm2_env?.pm_uptime ? Date.now() - p.pm2_env.pm_uptime : 0,
+      cpu: p.monit?.cpu,
+      memory_mb: p.monit?.memory ? Math.round(p.monit.memory / 1024 / 1024) : 0,
+      pid: p.pid,
+    })).sort((a, b) => a.name.localeCompare(b.name));
+    const counts = services.reduce((acc, s) => { acc[s.status] = (acc[s.status] || 0) + 1; return acc; }, {});
+    res.json({ counts, total: services.length, services });
+  });
+});
+
 app.listen(PORT, '127.0.0.1', () => {
   console.log(`[fleet-tv] live wall display on http://127.0.0.1:${PORT}`);
 });

← 8537479 initial scaffold (gitify-all 2026-05-06)  ·  back to Fleet Tv  ·  snapshot: 2 file(s) changed, +1 new, ~1 modified 97baf34 →