← 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
A DW-Agents/vendor-command-center/public/programs.htmlM DW-Agents/vendor-command-center/server.js
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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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)">●</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 →