← 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}`);
});