[object Object]

← back to Designer Wallcoverings

vcc: add /programs 'All DW Programs' hub (TK-11765)

fce6b836503e1af1bfad2919abd44b079260fc8a · 2026-09-15 11:41:59 -0700 · Steve

- gated /api/programs: pm2 jlist + internal-gateway route deep-links (cached routes.json, graceful on missing pm2/file)
- new public/programs.html: fully HTML-escaped grid, sort + density slider, created-date chip, 30s live refresh

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc3tC98ExHfU5P3zptiXZK

Files touched

Diff

commit fce6b836503e1af1bfad2919abd44b079260fc8a
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 15 11:41:59 2026 -0700

    vcc: add /programs 'All DW Programs' hub (TK-11765)
    
    - gated /api/programs: pm2 jlist + internal-gateway route deep-links (cached routes.json, graceful on missing pm2/file)
    - new public/programs.html: fully HTML-escaped grid, sort + density slider, created-date chip, 30s live refresh
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Jc3tC98ExHfU5P3zptiXZK
---
 .../vendor-command-center/public/programs.html     | 228 +++++++++++++++++++++
 DW-Agents/vendor-command-center/server.js          |  58 ++++++
 2 files changed, 286 insertions(+)

diff --git a/DW-Agents/vendor-command-center/public/programs.html b/DW-Agents/vendor-command-center/public/programs.html
new file mode 100644
index 00000000..1bb285e7
--- /dev/null
+++ b/DW-Agents/vendor-command-center/public/programs.html
@@ -0,0 +1,228 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>All DW Programs — VCC</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+:root{
+  --bg-primary:#0a0a14;--bg-card:#13152a;--bg-card-hover:#1a1d35;--bg-input:#1e2140;
+  --border:#2a2d45;--text-primary:#e8e8f0;--text-secondary:#8888a8;--text-muted:#55557a;
+  --accent:#7c3aed;--accent-hover:#6d28d9;--accent-light:rgba(124,58,237,0.15);
+  --green:#22c55e;--green-bg:rgba(34,197,94,0.12);--red:#ef4444;--red-bg:rgba(239,68,68,0.12);
+  --yellow:#f59e0b;--yellow-bg:rgba(245,158,11,0.12);--blue:#3b82f6;--blue-bg:rgba(59,130,246,0.12);
+  --cols:5;
+}
+body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg-primary);color:var(--text-primary);min-height:100vh}
+a{color:var(--blue);text-decoration:none}
+.hdr{position:sticky;top:0;z-index:100;background:linear-gradient(135deg,#191033 0%,#0a0a14 100%);border-bottom:1px solid var(--border);padding:12px 20px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+.hdr h1{font-size:17px;font-weight:600;white-space:nowrap}
+.hdr h1 span{color:var(--accent);margin-right:6px}
+.hdr .home{font-size:12px;color:var(--text-secondary);border:1px solid var(--border);padding:4px 10px;border-radius:6px}
+.hdr .home:hover{border-color:var(--accent);color:var(--text-primary)}
+.hdr .count{font-size:13px;color:var(--text-secondary);margin-left:auto;white-space:nowrap}
+.hdr .count b{color:var(--text-primary)}
+
+.ctrl{padding:12px 20px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--border);background:var(--bg-card)}
+.ctrl label{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.04em;margin-right:2px}
+.ctrl select,.ctrl input[type=text]{background:var(--bg-input);border:1px solid var(--border);color:var(--text-primary);padding:6px 10px;border-radius:6px;font-size:13px}
+.ctrl input[type=text]{flex:1;min-width:180px;max-width:360px}
+.ctrl input[type=range]{vertical-align:middle;width:130px;accent-color:var(--accent)}
+.ctrl .sep{width:1px;height:24px;background:var(--border)}
+.chipbtn{background:var(--bg-input);border:1px solid var(--border);color:var(--text-secondary);padding:6px 10px;border-radius:6px;font-size:12px;cursor:pointer}
+.chipbtn.active{background:var(--accent);border-color:var(--accent);color:#fff}
+.chipbtn:hover{border-color:var(--accent)}
+
+.grid{padding:14px 20px;display:grid;grid-template-columns:repeat(var(--cols),minmax(0,1fr));gap:10px}
+.card{display:block;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:12px 13px;transition:border-color .15s,background .15s;position:relative;overflow:hidden}
+.card:hover{border-color:var(--accent);background:var(--bg-card-hover)}
+.card .top{display:flex;align-items:center;gap:8px;margin-bottom:6px}
+.card .dot{width:9px;height:9px;border-radius:50%;flex-shrink:0}
+.dot.online{background:var(--green);box-shadow:0 0 6px var(--green)}
+.dot.stopped,.dot.errored,.dot.launching,.dot.unknown{background:var(--red)}
+.dot.stopping,.dot.one-launch-status,.dot.waiting{background:var(--yellow)}
+.card .name{font-size:13.5px;font-weight:600;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1}
+.card .chips{display:flex;flex-wrap:wrap;gap:5px;margin:4px 0 7px}
+.chip{font-size:10.5px;padding:1px 6px;border-radius:4px;border:1px solid var(--border);color:var(--text-secondary);white-space:nowrap}
+.chip.port{background:var(--blue-bg);border-color:transparent;color:#93c5fd;font-family:ui-monospace,monospace}
+.chip.st-online{background:var(--green-bg);border-color:transparent;color:#86efac}
+.chip.st-bad{background:var(--red-bg);border-color:transparent;color:#fca5a5}
+.chip.st-warn{background:var(--yellow-bg);border-color:transparent;color:#fcd34d}
+.chip.dwlink{background:var(--accent-light);border-color:transparent;color:#c4b5fd}
+.card .when{font-size:10.5px;color:var(--text-muted);display:flex;align-items:center;gap:4px}
+.card .cwd{font-size:10px;color:var(--text-muted);font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:5px;direction:rtl;text-align:left}
+.card .links{display:flex;gap:6px;margin-top:9px}
+.card .links a{font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid var(--border);color:var(--text-secondary)}
+.card .links a.primary{background:var(--accent);border-color:var(--accent);color:#fff}
+.card .links a:hover{border-color:var(--accent);color:var(--text-primary)}
+.card .links a.primary:hover{background:var(--accent-hover);color:#fff}
+
+.loader{text-align:center;padding:48px 20px;color:var(--text-secondary);font-size:14px}
+.loader .spin{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite;margin-right:8px;vertical-align:middle}
+@keyframes spin{to{transform:rotate(360deg)}}
+.empty{text-align:center;padding:56px 20px;color:var(--text-muted);font-size:15px}
+.err{margin:20px;padding:14px 16px;background:var(--red-bg);border:1px solid var(--red);border-radius:8px;color:#fca5a5;font-size:13px}
+</style>
+</head>
+<body>
+<div class="hdr">
+  <h1><span>◆</span>All DW Programs</h1>
+  <a class="home" href="/">← VCC dashboard</a>
+  <div class="count" id="count">loading…</div>
+  <div class="count" id="upd" style="opacity:.65;font-weight:400"></div>
+</div>
+
+<div class="ctrl">
+  <input type="text" id="q" placeholder="Search name / port / path…" autocomplete="off">
+  <span class="sep"></span>
+  <label for="sort">Sort</label>
+  <select id="sort">
+    <option value="name">Name A→Z</option>
+    <option value="status">Status (online first)</option>
+    <option value="recent">Recently started</option>
+    <option value="oldest">Oldest running</option>
+    <option value="port">Port</option>
+  </select>
+  <span class="sep"></span>
+  <button class="chipbtn" id="fOnline">Online only</button>
+  <button class="chipbtn" id="fLinked">DW-linked only</button>
+  <span class="sep"></span>
+  <label for="density">Density</label>
+  <input type="range" id="density" min="2" max="8" step="1" value="5">
+</div>
+
+<div id="wrap"><div class="loader"><span class="spin"></span>Loading programs…</div></div>
+
+<script>
+var LS='vcc_programs_prefs_v1';
+var prefs={sort:'name',density:5,online:false,linked:false,q:''};
+try{var s=JSON.parse(localStorage.getItem(LS)||'{}');Object.assign(prefs,s||{});}catch(e){}
+function savePrefs(){try{localStorage.setItem(LS,JSON.stringify(prefs));}catch(e){}}
+
+var GW_PORT=8090;            // dw-internal-gateway
+var DATA=[];
+
+function statusClass(st){
+  if(st==='online')return 'online';
+  if(st==='stopping'||st==='waiting'||st==='launching')return 'stopping';
+  if(st==='stopped'||st==='errored'||st==='unknown')return 'stopped';
+  return 'unknown';
+}
+function fmtWhen(ts){
+  if(!ts)return {label:'—',iso:''};
+  var d=new Date(Number(ts));
+  if(isNaN(d))return {label:'—',iso:''};
+  return {
+    label:d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}),
+    iso:d.toISOString()
+  };
+}
+function fmtUptime(ms){
+  if(!ms||ms<0)return '';
+  var s=Math.floor(ms/1000),d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
+  if(d>0)return d+'d '+h+'h';
+  if(h>0)return h+'h '+m+'m';
+  if(m>0)return m+'m';
+  return s+'s';
+}
+function esc(x){return String(x==null?'':x).replace(/[&<>"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];});}
+
+function programLinks(p){
+  var out=[];
+  if(p.internal_slug){
+    out.push('<a class="primary" href="//'+esc(p.internal_slug)+'.internal.dw:'+GW_PORT+'/" title="Open via internal gateway">internal.dw ↗</a>');
+  }
+  if(p.port){
+    var host=location.hostname||'127.0.0.1';
+    out.push('<a class="'+(p.internal_slug?'':'primary')+'" href="http://'+esc(host)+':'+p.port+'/" title="Open on its port">:'+p.port+' ↗</a>');
+  }
+  if(!out.length)out.push('<a href="javascript:void 0" style="cursor:default;opacity:.5" title="No HTTP port exposed">no web UI</a>');
+  return out.join('');
+}
+
+function render(){
+  var wrap=document.getElementById('wrap');
+  var q=(prefs.q||'').trim().toLowerCase();
+  var rows=DATA.filter(function(p){
+    if(prefs.online && p.status!=='online')return false;
+    if(prefs.linked && !p.internal_slug)return false;
+    if(q){
+      var hay=(p.name+' '+(p.port||'')+' '+(p.cwd||'')+' '+(p.internal_slug||'')).toLowerCase();
+      if(hay.indexOf(q)<0)return false;
+    }
+    return true;
+  });
+  var sortRank={online:0,launching:1,waiting:1,stopping:2,stopped:3,errored:3,unknown:4};
+  rows.sort(function(a,b){
+    switch(prefs.sort){
+      case 'status':{var r=(sortRank[a.status]||9)-(sortRank[b.status]||9);return r||a.name.localeCompare(b.name);}
+      case 'recent':return (b.created_at||0)-(a.created_at||0);
+      case 'oldest':return (a.created_at||0)-(b.created_at||0);
+      case 'port':return (a.port||1e9)-(b.port||1e9)||a.name.localeCompare(b.name);
+      default:return a.name.localeCompare(b.name);
+    }
+  });
+  document.getElementById('count').innerHTML='<b>'+rows.length+'</b> of '+DATA.length+' programs';
+  if(!rows.length){wrap.innerHTML='<div class="empty">No programs match.</div>';return;}
+  var html='<div class="grid">';
+  rows.forEach(function(p){
+    var sc=statusClass(p.status);
+    var stChip=p.status==='online'?'st-online':(sc==='stopping'?'st-warn':'st-bad');
+    var w=fmtWhen(p.created_at);
+    var up=fmtUptime(p.uptime_ms);
+    html+='<div class="card">'
+      +'<div class="top"><span class="dot '+sc+'"></span><span class="name" title="'+esc(p.name)+'">'+esc(p.name)+'</span></div>'
+      +'<div class="chips">'
+        +'<span class="chip '+stChip+'">'+esc(p.status)+'</span>'
+        +(p.port?'<span class="chip port">:'+p.port+'</span>':'')
+        +(up?'<span class="chip">↑ '+esc(up)+'</span>':'')
+        +(p.internal_slug?'<span class="chip dwlink">'+esc(p.internal_slug)+'.internal.dw</span>':'')
+      +'</div>'
+      +'<div class="when" title="'+esc(w.iso)+'">🕓 '+esc(w.label)+'</div>'
+      +(p.cwd?'<div class="cwd" title="'+esc(p.cwd)+'">'+esc(p.cwd)+'</div>':'')
+      +'<div class="links">'+programLinks(p)+'</div>'
+    +'</div>';
+  });
+  html+='</div>';
+  wrap.innerHTML=html;
+}
+
+function applyPrefsToUI(){
+  document.getElementById('sort').value=prefs.sort;
+  document.getElementById('density').value=prefs.density;
+  document.documentElement.style.setProperty('--cols',prefs.density);
+  document.getElementById('q').value=prefs.q||'';
+  document.getElementById('fOnline').classList.toggle('active',!!prefs.online);
+  document.getElementById('fLinked').classList.toggle('active',!!prefs.linked);
+}
+
+document.getElementById('sort').addEventListener('change',function(e){prefs.sort=e.target.value;savePrefs();render();});
+document.getElementById('density').addEventListener('input',function(e){prefs.density=+e.target.value;document.documentElement.style.setProperty('--cols',prefs.density);savePrefs();});
+document.getElementById('q').addEventListener('input',function(e){prefs.q=e.target.value;savePrefs();render();});
+document.getElementById('fOnline').addEventListener('click',function(){prefs.online=!prefs.online;this.classList.toggle('active',prefs.online);savePrefs();render();});
+document.getElementById('fLinked').addEventListener('click',function(){prefs.linked=!prefs.linked;this.classList.toggle('active',prefs.linked);savePrefs();render();});
+
+applyPrefsToUI();
+function loadPrograms(){
+  fetch('/api/programs',{credentials:'same-origin'})
+    .then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();})
+    .then(function(d){
+      if(d&&d.error)throw new Error(d.error);
+      DATA=Array.isArray(d)?d:[];
+      render();
+      var u=document.getElementById('upd');
+      if(u)u.textContent='updated '+new Date().toLocaleTimeString();
+    })
+    .catch(function(e){
+      document.getElementById('wrap').innerHTML='<div class="err">Failed to load programs: '+esc(e.message)+'</div>';
+      document.getElementById('count').textContent='error';
+      var u=document.getElementById('upd');
+      if(u)u.textContent='update failed '+new Date().toLocaleTimeString();
+    });
+}
+loadPrograms();
+setInterval(loadPrograms, 30000);  // live refresh every 30s (was a one-shot fetch — contrarian TK-11765)
+</script>
+</body>
+</html>
diff --git a/DW-Agents/vendor-command-center/server.js b/DW-Agents/vendor-command-center/server.js
index 71e2655b..f6927cd7 100644
--- a/DW-Agents/vendor-command-center/server.js
+++ b/DW-Agents/vendor-command-center/server.js
@@ -203,6 +203,63 @@ app.get('/healthz-deep', async (req, res) => {
 // Apply auth to all other routes
 app.use(requireAuth);
 
+// --- All DW Programs hub (TK-11765) -------------------------------------------
+// Surfaces every pm2-managed DW program so the VCC is the single control center.
+// $0 local: shells `pm2 jlist` and cross-references the internal-gateway routes so
+// the UI can build //<slug>.internal.dw:8090/ deep-links. Gated (below requireAuth).
+const { execFile } = require('child_process');
+let _igwRoutes = null, _igwRoutesAt = 0;
+function igwRoutes() {
+  // Cache the gateway route map for 5 min; missing/unparseable file → {} (no links, never throw).
+  if (_igwRoutes && Date.now() - _igwRoutesAt < 5 * 60 * 1000) return _igwRoutes;
+  try {
+    const raw = require('fs').readFileSync(require('os').homedir() + '/Projects/dw-internal-gateway/routes.json', 'utf8');
+    _igwRoutes = (JSON.parse(raw).routes) || {};
+  } catch (_) { _igwRoutes = {}; }
+  _igwRoutesAt = Date.now();
+  return _igwRoutes;
+}
+app.get('/api/programs', (req, res) => {
+  // pm2 jlist can print startup noise before the JSON; slice from the first '['.
+  execFile('pm2', ['jlist'], { maxBuffer: 32 * 1024 * 1024, timeout: 10000 }, (err, stdout) => {
+    if (err) return res.status(500).json({ error: 'pm2 jlist failed: ' + err.message });
+    let list;
+    try {
+      const s = stdout.indexOf('[');
+      list = JSON.parse(s >= 0 ? stdout.slice(s) : stdout);
+    } catch (e) { return res.status(500).json({ error: 'pm2 jlist parse failed: ' + e.message }); }
+    const routes = igwRoutes();
+    // Build slug→true set once. A program is deep-linkable via the gateway if its
+    // name (or name minus a common suffix) matches a routes.json key.
+    const routeKeys = new Set(Object.keys(routes));
+    const matchSlug = (name) => {
+      if (routeKeys.has(name)) return name;
+      const stripped = name.replace(/-(viewer|internal|line-viewer|dashboard|app|site|local)$/, '');
+      if (routeKeys.has(stripped)) return stripped;
+      return null;
+    };
+    const out = (Array.isArray(list) ? list : []).map((p) => {
+      const e = p.pm2_env || {};
+      const port = (e.env && e.env.PORT) || e.PORT || null;
+      const up = e.pm_uptime || null;
+      return {
+        name: p.name,
+        port: port ? Number(port) : null,
+        status: e.status || 'unknown',
+        uptime_ms: up ? Date.now() - up : null,
+        created_at: e.created_at || up || null,
+        cwd: e.pm_cwd || null,
+        internal_slug: matchSlug(p.name),
+      };
+    });
+    res.json(out);
+  });
+});
+app.get('/programs', (req, res) => {
+  res.sendFile(__dirname + '/public/programs.html');
+});
+// -----------------------------------------------------------------------------
+
 // --- API: Get all vendors (with agent health status + Nora mailer counts) ---
 app.get('/api/vendors', async (req, res) => {
   try {
@@ -6147,6 +6204,7 @@ th[data-sort].sort-asc, th[data-sort].sort-desc { color: var(--accent); }
       <span style="color:var(--text-muted);font-size:11px;margin:0 6px;">|</span>
       <span style="color:var(--text-secondary);font-size:11px;">Last Updated: <span id="lastUpdatedTs">${new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true })}</span></span>
       <button onclick="syncNow()" id="syncNowBtn" style="background:var(--accent);color:#fff;border:none;padding:3px 10px;border-radius:4px;font-size:11px;cursor:pointer;font-weight:600;">Sync Now</button>
+      <a href="/programs" style="background:var(--blue);color:#fff;text-decoration:none;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;">All DW Programs</a>
       <span style="color:var(--green)">&#9679;</span>
       <span>Port 9660</span>
     </div>

← 8b226463 auto-data-snapshot: 2026-09-15T10:53:13 (4 data files) — DW-  ·  back to Designer Wallcoverings  ·  harden: fail loud on bare run across dry-by-default scripts 995a82ea →