← back to Fleet Tv
server.js
723 lines
// Fleet TV — live wall display cycling every Steve-owned site.
// Auto-builds the channel lineup from running pm2 processes + a curated
// "external" list (anything not in pm2 but worth showing).
//
// Only sites with REAL product inventory are aired — empty/stranded niches
// (those with /api/products total < MIN_PRODUCTS) are filtered out so the
// wall doesn't show empty grids.
const express = require('express');
const path = require('path');
const http = require('http');
const { execSync, execFile } = require('child_process');
const app = express();
const PORT = parseInt(process.env.PORT || '9913', 10);
const MIN_PRODUCTS = parseInt(process.env.MIN_PRODUCTS || '10', 10);
// ─── 404-guard: never serve editor leftovers / swap / backup files ────────
// Hard-block URLs that match common editor/backup junk patterns BEFORE the
// static handler can ever try to read them off disk. Pattern-matched on the
// URL path so it works even if a tracked file slips past .gitignore.
const JUNK_FILE_RE = /\.(bak|orig|rej|swp|swo)(?:\.[^/]*)?$|\.pre-[^/]*$|~$/i;
app.use((req, res, next) => {
if (JUNK_FILE_RE.test(req.path)) return res.status(404).end();
next();
});
// Allow widgets to be iframed from any origin; serve them via a static handler
// that also sets explicit frame-friendly headers BEFORE the general static mount.
const widgetStatic = express.static(path.join(__dirname, 'public/widget'), {
setHeaders(res) {
res.set('X-Frame-Options', 'ALLOWALL');
res.set('Content-Security-Policy', "frame-ancestors *");
res.set('Cache-Control', 'public, max-age=60');
},
});
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 = [
{ name: 'designerwallcoverings.com', url: 'https://designerwallcoverings.com', kind: 'parent', accent: '#c9a14b' },
{ name: 'philipperomano.com', url: 'https://philipperomano.com', kind: 'parent', accent: '#a89060' },
{ name: 'novasuede.com', url: 'https://novasuede.com', kind: 'parent', accent: '#6b7a8f' },
{ name: 'wholivedthere.com', url: 'https://wholivedthere.com', kind: 'archive', accent: '#7a5a3a' },
{ name: 'claimmyaddress.com', url: 'https://claimmyaddress.com', kind: 'archive', accent: '#5a7a3a' },
{ name: 'bubbesblock.com', url: 'https://bubbesblock.com', kind: 'archive', accent: '#7a3a5a' },
];
// Sites known to be auth-gated on the local mirror — skip in the rotator
// (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 {
pm2List = JSON.parse(execSync('pm2 jlist', { encoding: 'utf8' }));
} catch (e) {
console.warn('[fleet-tv] pm2 jlist failed:', e.message);
return [];
}
// 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}/`,
kind: 'dw-microsite',
accent: pickAccent(p.name),
pid: p.pid,
port,
});
}
// 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), ...extras];
}
function probeProductCount(channel) {
return new Promise((resolve) => {
const req = http.get(`${channel.url}api/products?limit=1`, { timeout: 1500 }, (res) => {
let body = '';
res.on('data', (c) => body += c);
res.on('end', () => {
try {
const j = JSON.parse(body);
// /api/products shape is { total, items: [...] } in DW micro-sites
const total = typeof j.total === 'number'
? j.total
: Array.isArray(j) ? j.length
: Array.isArray(j.items) ? j.items.length
: 0;
resolve({ ...channel, productCount: total });
} catch (e) {
resolve({ ...channel, productCount: 0 });
}
});
});
req.on('error', () => resolve({ ...channel, productCount: 0 }));
req.on('timeout', () => { req.destroy(); resolve({ ...channel, productCount: 0 }); });
});
}
function extractPortFromEnv(p) {
// pm2_env spreads the env block; PORT may live there directly or under env_pm2
return p.pm2_env.PORT || (p.pm2_env.env && p.pm2_env.env.PORT) || null;
}
function sniffDefaultPort(p) {
// Best-effort: read server.js for `process.env.PORT || NNNN`
try {
const fs = require('fs');
const src = fs.readFileSync(p.pm2_env.pm_exec_path, 'utf8').slice(0, 4000);
const m = src.match(/process\.env\.PORT\s*\|\|\s*(\d+)/);
return m ? parseInt(m[1], 10) : null;
} catch (e) {
return null;
}
}
function pickAccent(name) {
// Niche-aware accent palette — gives each channel a distinct ribbon color.
const palette = {
aged: '#8c6d4a', apartment: '#7a8a92', architectural: '#3e5161', block: '#3a4a3e',
cork: '#a07b54', embroidered: '#9a4665', fabric: '#5e7a4e', glitter: '#c0a060',
green: '#4a7a4a', handcrafted: '#8a6a4a', healthcare: '#6a8a9a', hollywood: '#b89060',
hospitality: '#9a7a4a', hotel: '#6a4a3a', jute: '#a08054', linen: '#a89060',
madagascar: '#6a8a4a', metallic: '#9a9aaa', mica: '#aaaa9a', museum: '#5a4a4a',
mylar: '#9aaaaa', natural: '#7a8a5a', pastel: '#dab8c8', raffia: '#a09060',
recycled: '#5a7a4a', restaurant: '#8a4a3a', restoration: '#6a5a4a', retro: '#d97a3a',
saloon: '#7a3a3a', screen: '#3a5a3a', selfadhesive: '#6a8aaa', silk: '#c8a8c8',
silver: '#aaaaaa', string: '#9a8060', suede: '#7a5a4a', textile: '#5a8a8a',
vinyl: '#3a3a5a', '1800': '#5a4a3a', '1890': '#6a5a4a', '1900': '#5a5a3a',
'1920': '#9a6a3a', '1930': '#8a7a3a', '1940': '#7a6a4a', '1950': '#cae0e0',
'1960': '#d96a8a', '1970': '#d97a3a', '1980': '#3acac9', wallpapersback: '#c0c0c0',
};
for (const k in palette) if (name.includes(k)) return palette[k];
return '#888';
}
app.get('/api/channels', async (req, res) => {
const dw = await buildPm2Channels();
const total = dw.length + EXTERNAL_CHANNELS.length;
res.json({
total,
minProducts: MIN_PRODUCTS,
generatedAt: new Date().toISOString(),
channels: [
...EXTERNAL_CHANNELS,
...dw.sort((a, b) => a.name.localeCompare(b.name)),
],
});
});
// Pinterest-style cross-fleet product mosaic: pulls N products from each
// real-inventory channel in parallel, returns a flattened randomized list
// for the /mosaic discover page.
app.get('/api/products-mosaic', async (req, res) => {
const perSite = Math.min(20, parseInt(req.query.per || '6', 10));
const dw = await buildPm2Channels(); // already filtered by MIN_PRODUCTS
const fetched = await Promise.all(dw.map(c => fetchProducts(c, perSite)));
const all = [];
for (const { channel, items } of fetched) {
for (const p of items) {
if (!p.image_url) continue;
all.push({
image: p.image_url,
title: p.title || p.handle || '',
vendor: p.vendor || '',
site: channel.name,
siteUrl: channel.url,
productUrl: p.product_url || channel.url,
accent: channel.accent,
});
}
}
// Fisher-Yates shuffle so the wall mixes vendors instead of clustering by site
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[all[i], all[j]] = [all[j], all[i]];
}
res.json({ total: all.length, perSite, generatedAt: new Date().toISOString(), items: all });
});
function fetchProducts(channel, n) {
return new Promise((resolve) => {
http.get(`${channel.url}api/products?limit=${n}`, { timeout: 2000 }, (response) => {
let body = '';
response.on('data', (c) => body += c);
response.on('end', () => {
try {
const j = JSON.parse(body);
const items = Array.isArray(j) ? j : Array.isArray(j.items) ? j.items : [];
resolve({ channel, items });
} catch (e) {
resolve({ channel, items: [] });
}
});
}).on('error', () => resolve({ channel, items: [] }))
.on('timeout', function () { this.destroy(); resolve({ channel, items: [] }); });
});
}
// Aggregate fleet stats — totals, rankings, vendor & aesthetic distribution.
app.get('/api/stats', async (req, res) => {
const dw = await buildPm2Channels(); // already filtered by MIN_PRODUCTS
// Pull a deeper sample (50 per site) for vendor/aesthetic distribution
const samples = await Promise.all(dw.map(c => fetchProducts(c, 50)));
const vendorCount = {};
const aestheticCount = {};
let totalSampled = 0;
for (const { items } of samples) {
for (const p of items) {
totalSampled++;
if (p.vendor) vendorCount[p.vendor] = (vendorCount[p.vendor] || 0) + 1;
if (p.aesthetic) aestheticCount[p.aesthetic] = (aestheticCount[p.aesthetic] || 0) + 1;
}
}
const ranked = [...dw].sort((a, b) => b.productCount - a.productCount);
const topVendors = Object.entries(vendorCount).sort((a, b) => b[1] - a[1]).slice(0, 12);
const topAesthetics = Object.entries(aestheticCount).sort((a, b) => b[1] - a[1]).slice(0, 12);
const totalProducts = dw.reduce((s, c) => s + c.productCount, 0);
res.json({
generatedAt: new Date().toISOString(),
minProducts: MIN_PRODUCTS,
fleet: {
sitesAired: dw.length,
totalProducts,
averagePerSite: dw.length ? Math.round(totalProducts / dw.length) : 0,
sampledForDistribution: totalSampled,
},
topSites: ranked.slice(0, 10).map(c => ({ name: c.name, productCount: c.productCount, accent: c.accent })),
bottomSites: ranked.slice(-5).map(c => ({ name: c.name, productCount: c.productCount, accent: c.accent })),
topVendors: topVendors.map(([vendor, count]) => ({ vendor, count })),
topAesthetics: topAesthetics.map(([aesthetic, count]) => ({ aesthetic, count })),
allSites: ranked.map(c => ({ name: c.name, productCount: c.productCount, accent: c.accent })),
});
});
// Cross-fleet search — fans out /api/products?q= to every aired channel,
// merges results with site provenance, deduplicates by image URL.
app.get('/api/search', async (req, res) => {
const q = (req.query.q || '').trim();
const limit = Math.min(40, parseInt(req.query.per || '8', 10));
if (!q) return res.json({ q: '', total: 0, sites: 0, items: [] });
const dw = await buildPm2Channels();
const results = await Promise.all(dw.map(c => searchChannel(c, q, limit)));
const seen = new Set();
const merged = [];
for (const { channel, items } of results) {
for (const p of items) {
if (!p.image_url) continue;
if (seen.has(p.image_url)) continue;
seen.add(p.image_url);
merged.push({
image: p.image_url,
title: p.title || p.handle || '',
vendor: p.vendor || '',
site: channel.name,
siteUrl: channel.url,
productUrl: p.product_url || channel.url,
accent: channel.accent,
});
}
}
res.json({
q,
total: merged.length,
sites: results.filter(r => r.items.length > 0).length,
sitesQueried: dw.length,
generatedAt: new Date().toISOString(),
items: merged,
});
});
function searchChannel(channel, q, limit) {
return new Promise((resolve) => {
const url = `${channel.url}api/products?q=${encodeURIComponent(q)}&limit=${limit}`;
http.get(url, { timeout: 1500 }, (response) => {
let body = '';
response.on('data', (c) => body += c);
response.on('end', () => {
try {
const j = JSON.parse(body);
const items = Array.isArray(j) ? j : Array.isArray(j.items) ? j.items : [];
resolve({ channel, items });
} catch (e) { resolve({ channel, items: [] }); }
});
}).on('error', () => resolve({ channel, items: [] }))
.on('timeout', function () { this.destroy(); resolve({ channel, items: [] }); });
});
}
// Single random product (or N) from the fleet — for embeddable widgets.
// Lightweight: caches the channel pool for 30s to avoid hammering /api/products
// on every widget tick.
let randomCache = { at: 0, items: [] };
async function getRandomPool() {
if (Date.now() - randomCache.at < 30_000 && randomCache.items.length > 0) return randomCache.items;
const dw = await buildPm2Channels();
const fetched = await Promise.all(dw.map(c => fetchProducts(c, 12)));
const all = [];
for (const { channel, items } of fetched) {
for (const p of items) {
if (!p.image_url) continue;
all.push({
image: p.image_url,
title: p.title || p.handle || '',
vendor: p.vendor || '',
site: channel.name,
siteUrl: channel.url,
productUrl: p.product_url || channel.url,
accent: channel.accent,
});
}
}
randomCache = { at: Date.now(), items: all };
return all;
}
app.get('/api/random', async (req, res) => {
const n = Math.min(20, Math.max(1, parseInt(req.query.count || '1', 10)));
const pool = await getRandomPool();
if (!pool.length) return res.json({ items: [] });
const out = [];
const used = new Set();
while (out.length < n && used.size < pool.length) {
const i = Math.floor(Math.random() * pool.length);
if (used.has(i)) continue;
used.add(i);
out.push(pool[i]);
}
res.set('Cache-Control', 'no-store');
res.json({ items: out, poolSize: pool.length, generatedAt: new Date().toISOString() });
});
// Daily-rotating editorial theme. Day of week → theme → keyword search across fleet.
// Deterministic per-day so all viewers see the same curated card.
const DAILY_THEMES = [
// Sun
{ name: 'Texture Sunday', subtitle: 'Hand-felt fibers and material weight.', keywords: ['linen','grass','jute','silk','suede','raffia','cork'], accent: '#a08054' },
// Mon
{ name: 'Bauhaus Monday', subtitle: 'Geometry, primary color, flat plane.', keywords: ['geometric','triangle','circle','grid','op art'], accent: '#3a4a6a' },
// Tue
{ name: 'Florals Tuesday', subtitle: 'Botanical motif, painted and printed.', keywords: ['floral','rose','peony','botanical','foliage','garden','flower'], accent: '#9a5a7a' },
// Wed
{ name: 'Maximalist Wednesday', subtitle: 'High pattern, high color, no apology.', keywords: ['paisley','damask','baroque','toile','chinoiserie','ornate'], accent: '#a04050' },
// Thu
{ name: 'Earth Tones Thursday', subtitle: 'Clay, mushroom, terracotta, sand.', keywords: ['terracotta','sand','clay','mushroom','earth','rust','warm'], accent: '#a87850' },
// Fri
{ name: 'Metallic Friday', subtitle: 'Foil, leaf, shimmer.', keywords: ['gold','silver','metallic','leaf','shimmer','mylar','glitter'], accent: '#c9a14b' },
// Sat
{ name: 'Eras Saturday', subtitle: 'A walk through twentieth-century walls.', keywords: ['vintage','retro','art deco','midcentury','hollywood','memphis','1970'], accent: '#d97a3a' },
];
function todayTheme(date = new Date()) {
const dow = date.getDay(); // 0 = Sunday … 6 = Saturday
const t = DAILY_THEMES[dow];
// Date-deterministic but also seed-shifted so the keyword sample rotates daily within the theme
const seed = date.toISOString().slice(0,10);
return { ...t, weekday: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][dow], seed };
}
app.get('/api/today', async (req, res) => {
const theme = todayTheme();
const dw = await buildPm2Channels();
// For each keyword, run a parallel cross-fleet search; merge dedup by image.
const queries = await Promise.all(theme.keywords.map(async kw => {
const probes = await Promise.all(dw.map(c => searchChannel(c, kw, 6)));
return { kw, probes };
}));
const seen = new Set();
const items = [];
for (const { kw, probes } of queries) {
for (const { channel, items: hits } of probes) {
for (const p of hits) {
if (!p.image_url) continue;
if (seen.has(p.image_url)) continue;
seen.add(p.image_url);
items.push({
image: p.image_url,
title: p.title || p.handle || '',
vendor: p.vendor || '',
site: channel.name,
siteUrl: channel.url,
productUrl: p.product_url || channel.url,
accent: channel.accent,
matchedKeyword: kw,
});
}
}
}
// Date-seeded shuffle so the daily order is stable but feels curated.
const seedNum = Number(theme.seed.replace(/-/g,''));
let s = seedNum;
for (let i = items.length - 1; i > 0; i--) {
s = (s * 9301 + 49297) % 233280;
const j = Math.floor((s / 233280) * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
res.json({
theme,
total: items.length,
items: items.slice(0, 24),
generatedAt: new Date().toISOString(),
});
});
// ──────────────────── ATLAS — full Steve-portfolio map ────────────────────
// Categorizes all running pm2 processes into themed groups for the /atlas.html
// visualization. Each project gets: status, restart_time, memory, uptime,
// category, and a one-liner description (curated below for known projects).
const ATLAS_DESCRIPTIONS = {
'fleet-tv': 'Live wall + mosaic + stats + search + today + widget across the DW fleet.',
'sf-orchestrator': 'Site Factory orchestrator — 11-stage automated site-build pipeline.',
'sf-viewer': 'Site Factory web viewer.',
'sf-critic': 'Site Factory critic agent.',
'sf-admin': 'Site Factory admin panel.',
'ai-factory-orchestrator': 'AI Factory — qwen3 + Claude critic loop, agent skills/subagents shipping.',
'ai-factory-viewer': 'AI Factory viewer.',
'visual-factory-orchestrator': 'Visual Factory — qwen3 → Playwright → llava → Claude critic loop.',
'hormuz-orchestrator': 'Hormuz / Four Horsemen orchestrator (Paper/Figma/Canva/21st).',
'animal-agent': 'Animals directory agent.',
'animals-viewer': 'Animals directory viewer.',
'animals-ingest': 'Animals data ingest.',
'animals-audit': 'Animals data audit.',
'animals-leads': 'Animals B2B leads dashboard.',
'pd-api': 'Professional Directory ("doctor") API.',
'pd-crawler': 'Professional Directory crawler.',
'pd-dedupe': 'Professional Directory dedupe.',
'pd-enrich': 'Professional Directory enrich.',
'pd-ingest': 'Professional Directory ingest.',
'pd-preview': 'Professional Directory mockup preview.',
'pd-web': 'Professional Directory public site.',
'george-gmail': 'DW outbound-Gmail agent — 3 mailboxes via Google Workspace.',
'claude-email-responder': 'Auto-reply to whitelisted Gmail senders via Claude CLI.',
'morning-review': 'Morning-review feed of overnight findings.',
'video-gallery': 'Persistent video gallery of build/demo videos.',
'commerce-claw': 'Commerce Claw vault for OAuth tokens (libsodium-gated).',
'commerce-claw-viewer': 'Commerce Claw viewer.',
'momentum-pricer': 'DW Momentum pricing agent (sliding-window leak fix shipped).',
'silas-sku-agent': 'DW SKU enrichment agent.',
'shopify-expert-agent': 'Shopify expertise agent.',
'vendor-discount-agent': 'DW vendor discount agent.',
'vendor-onboarding': 'DW vendor onboarding agent.',
'full-monty-agent': 'DW Full Monty enrichment pipeline.',
'launch-supervisor': 'DW launch supervisor.',
'timeout-agent': 'DW timeout monitor.',
'archive-agent': 'DW archive agent.',
'blog-agent': 'DW blog content agent.',
'claudette-agent': 'DW Claudette assistant.',
'color-search': 'DW color-search across catalog.',
'compliance-agent': 'DW comms compliance enforcer.',
'debate-ring': 'Multi-LLM debate ring.',
'dex-dedup-agent': 'DW deduplication agent.',
'garrett': 'DW Garrett agent.',
'hawke': 'DW process-hawk (kept alive everything).',
'artie-agent': 'DW Artie agent.',
'kamatera-tunnels': 'Kamatera SSH/PG tunnels.',
'norma-bluesky': "Norma social agent — Bluesky.",
'norma-facebook': "Norma social agent — Facebook.",
'norma-instagram': "Norma social agent — Instagram.",
'norma-price': 'Norma price reporter.',
'norma-twitter': 'Norma social agent — X / Twitter.',
'national-paper-hangers': 'NPH installer marketplace (luxury wallcovering installers).',
'pastdoor': 'pastdoor / stayclaim — address-first home history (Next.js).',
'site-bubbesblock': 'BubbesBlock — neighborhood story site.',
'site-claimmyaddress': 'ClaimMyAddress — homeowner self-claim flow.',
'site-wholivedthere': 'WhoLivedThere — historic resident lookup.',
'sister-sites-viewer': 'Sister-sites cross-link viewer.',
'sku-check-skill': 'SKU check skill backend.',
'slack-dm-viewer': 'Slack DM viewer.',
'smb-builder': 'SMB website builder.',
'wallpapersback': 'Wallpapers Back — vintage wallpaper relaunch.',
'yolo-agent': 'Yuri / YOLO Agent — autonomous task runner.',
'room-setting-agent': 'Room-setting image generation agent.',
'ventura-corridor': 'Ventura Corridor LA-area lifestyle directory.',
'bc-relay': 'Browser-Chrome relay.',
};
const ATLAS_CATEGORIES = [
{ id: 'fleet-tv', name: 'Fleet TV', accent: '#c9a14b', match: n => n === 'fleet-tv' },
{ id: 'dw-microsite', name: 'DW Micro-sites', accent: '#a89060', match: n => /wallpaper|wallcovering|wallcoverings|walls/.test(n) && n !== 'wallpapersback' },
{ id: 'dw-platform', name: 'DW Platform', accent: '#8a6a4a', match: n => /^(george|momentum|silas|shopify|vendor|full-monty|launch|timeout|archive|blog|claudette|color|compliance|dex|garrett|hawke|artie|kamatera|claude-email|debate-ring|wallpapersback|sku-check)/.test(n) },
{ id: 'site-factory', name: 'Site / AI / Visual Factories', accent: '#5a7a8a', match: n => /^(sf-|ai-factory|visual-factory|hormuz)/.test(n) },
{ id: 'norma', name: 'Norma (social)', accent: '#9a4a5a', match: n => /^norma-/.test(n) },
{ id: 'animals', name: 'Animals Directory', accent: '#4a7a4a', match: n => /^animal/.test(n) },
{ id: 'doctor', name: 'Professional Directory', accent: '#6a8a9a', match: n => /^pd-/.test(n) },
{ id: 'address', name: 'Address History', accent: '#7a5a3a', match: n => /^site-(bubbesblock|claimmyaddress|wholivedthere)$|^pastdoor$|^national-paper-hangers$/.test(n) },
{ id: 'directories', name: 'Other directories', accent: '#5a5a8a', match: n => /^(sister-sites|smb-builder|ventura-corridor)/.test(n) },
{ id: 'agents', name: 'Agents & Tools', accent: '#666', match: n => /^(yolo-agent|morning-review|video-gallery|room-setting|commerce-claw|slack-dm|bc-relay)/.test(n) },
];
function categorize(name) {
for (const c of ATLAS_CATEGORIES) if (c.match(name)) return c;
return { id: 'other', name: 'Other', accent: '#555' };
}
app.get('/api/atlas', async (req, res) => {
let pm2List;
try {
pm2List = JSON.parse(execSync('pm2 jlist', { encoding: 'utf8' }));
} catch (e) {
return res.json({ categories: [], error: e.message });
}
const projects = pm2List.map(p => {
const cat = categorize(p.name);
return {
name: p.name,
status: p.pm2_env.status,
restartTime: p.pm2_env.restart_time,
unstableRestarts: p.pm2_env.unstable_restarts,
uptimeMs: p.pm2_env.status === 'online' ? Date.now() - p.pm2_env.pm_uptime : 0,
memMb: p.monit && p.monit.memory ? Math.round(p.monit.memory / 1024 / 1024) : 0,
cpu: p.monit && typeof p.monit.cpu === 'number' ? p.monit.cpu : 0,
cwd: p.pm2_env.pm_cwd,
pid: p.pid,
categoryId: cat.id,
categoryName: cat.name,
categoryAccent: cat.accent,
description: ATLAS_DESCRIPTIONS[p.name] || null,
};
});
// Group by category, preserving canonical category order
const byCat = {};
for (const c of [...ATLAS_CATEGORIES, { id: 'other', name: 'Other', accent: '#555' }]) {
byCat[c.id] = { ...c, projects: [] };
}
for (const p of projects) byCat[p.categoryId].projects.push(p);
for (const id in byCat) byCat[id].projects.sort((a, b) => a.name.localeCompare(b.name));
const ordered = Object.values(byCat).filter(c => c.projects.length > 0);
// Health summary
const total = projects.length;
const online = projects.filter(p => p.status === 'online').length;
const errored = total - online;
const totalRestarts = projects.reduce((s, p) => s + p.restartTime, 0);
const totalMem = projects.reduce((s, p) => s + p.memMb, 0);
res.json({
generatedAt: new Date().toISOString(),
summary: { total, online, errored, totalRestarts, totalMemMb: totalMem },
categories: ordered,
});
});
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}`);
});