← back to Ads Dashboard
ads-dashboard v1: host-aware Node ad-ops dashboard (DW + Abrams), Basic-Auth, zero-dep
3ec677ec208fcdc9316a1f8e1e28d1f903ab26ef · 2026-08-02 06:47:44 -0700 · Steve Abrams
Files touched
A .gitignoreA data/campaigns.jsonA ecosystem.config.jsA package.jsonA public/index.htmlA server.js
Diff
commit 3ec677ec208fcdc9316a1f8e1e28d1f903ab26ef
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Aug 2 06:47:44 2026 -0700
ads-dashboard v1: host-aware Node ad-ops dashboard (DW + Abrams), Basic-Auth, zero-dep
---
.gitignore | 8 +++
data/campaigns.json | 1 +
ecosystem.config.js | 10 +++
package.json | 9 +++
public/index.html | 143 ++++++++++++++++++++++++++++++++++++++++++
server.js | 175 ++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 346 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/data/campaigns.json b/data/campaigns.json
new file mode 100644
index 0000000..4ac4fa0
--- /dev/null
+++ b/data/campaigns.json
@@ -0,0 +1 @@
+{ "campaigns": [] }
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 0000000..66661d9
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,10 @@
+module.exports = {
+ apps: [{
+ name: 'ads-dashboard',
+ script: 'server.js',
+ cwd: __dirname,
+ env: { PORT: 9764, ADS_USER: 'admin', ADS_PASS: 'DW2024!' },
+ max_restarts: 10,
+ autorestart: true,
+ }],
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..61fb912
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "ads-dashboard",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Host-aware internal ad-ops command center for ads.designerwallcoverings.com + ads.agentabrams.com",
+ "main": "server.js",
+ "scripts": { "start": "node server.js" },
+ "engines": { "node": ">=18" }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..9e1a835
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,143 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Ad-Ops</title>
+<style>
+ :root { --accent:#555; --bg:#0f1115; --card:#171a21; --line:#262b36; --fg:#e7eaf0; --mut:#8b94a7; }
+ @media (prefers-color-scheme: light){ :root{ --bg:#f5f6f8; --card:#fff; --line:#e4e7ec; --fg:#1a1d24; --mut:#68707f; } }
+ *{ box-sizing:border-box; }
+ body{ margin:0; font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--fg); }
+ header{ display:flex; align-items:center; gap:14px; padding:16px 22px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:5; }
+ header .dot{ width:12px; height:12px; border-radius:50%; background:var(--accent); flex:0 0 auto; }
+ header h1{ font-size:17px; margin:0; font-weight:650; letter-spacing:-.2px; }
+ header .sub{ color:var(--mut); font-size:13px; }
+ header .spacer{ flex:1; }
+ header a.host{ color:var(--mut); font-size:12px; text-decoration:none; }
+ main{ padding:22px; max-width:1200px; margin:0 auto; }
+ .grid{ display:grid; gap:16px; grid-template-columns:repeat(auto-fill,minmax(var(--min,240px),1fr)); }
+ .card{ background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px 18px; }
+ .card h2{ font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); margin:0 0 10px; }
+ .kpi{ font-size:30px; font-weight:680; letter-spacing:-.5px; }
+ .kpi a{ color:inherit; text-decoration:none; border-bottom:2px solid transparent; }
+ .kpi a:hover{ border-color:var(--accent); }
+ .when{ display:inline-block; margin-top:8px; font-size:11px; color:var(--mut); }
+ .controls{ display:flex; gap:12px; align-items:center; margin:22px 0 12px; flex-wrap:wrap; }
+ .controls label{ font-size:12px; color:var(--mut); }
+ select,input[type=range]{ accent-color:var(--accent); }
+ select{ background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:5px 8px; }
+ table{ width:100%; border-collapse:collapse; font-size:13px; }
+ th,td{ text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); }
+ th{ color:var(--mut); font-weight:600; cursor:pointer; user-select:none; }
+ td a{ color:var(--fg); text-decoration:none; border-bottom:1px dotted var(--mut); }
+ td a:hover{ border-color:var(--accent); }
+ .badge{ display:inline-block; padding:2px 8px; border-radius:99px; font-size:11px; border:1px solid var(--line); }
+ .empty{ color:var(--mut); font-size:13px; padding:16px 0; }
+ .section-h{ font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); margin:28px 0 6px; }
+</style>
+</head>
+<body>
+<header>
+ <span class="dot" id="dot"></span>
+ <div>
+ <h1 id="brandName">Ad-Ops</h1>
+ <div class="sub" id="brandSub">loading…</div>
+ </div>
+ <span class="spacer"></span>
+ <a class="host" id="hostChip" href="/healthz">/healthz</a>
+</header>
+<main>
+ <div class="grid" id="kpis"></div>
+
+ <div class="section-h">Campaigns</div>
+ <div class="controls">
+ <label>Sort <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="name">Name A→Z</option>
+ <option value="status">Status</option>
+ <option value="spend">Spend ↓</option>
+ </select></label>
+ <label>Density <input type="range" id="density" min="220" max="420" value="240"></label>
+ </div>
+ <div class="card">
+ <table id="campTable"><thead><tr>
+ <th data-k="name">Campaign</th><th data-k="domain">Domain</th>
+ <th data-k="status">Status</th><th data-k="spend">Spend</th><th data-k="created_at">Created</th>
+ </tr></thead><tbody id="campBody"></tbody></table>
+ <div class="empty" id="campEmpty" hidden>No campaigns wired yet — /yoloforever will populate this. Data source: /api/campaigns</div>
+ </div>
+
+ <div class="section-h">Competitor Ad Signals</div>
+ <div class="card">
+ <div id="signals"></div>
+ </div>
+</main>
+
+<script>
+const fmtDate = (iso) => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso||'—'; } };
+const $ = (id)=>document.getElementById(id);
+
+let campaigns = [];
+async function boot(){
+ const brand = await fetch('/api/brand').then(r=>r.json());
+ document.documentElement.style.setProperty('--accent', brand.accent || '#555');
+ $('brandName').textContent = brand.name + ' — Ad-Ops';
+ $('brandSub').textContent = (brand.domains||[]).join(' · ') || 'internal ad-operations command center';
+ $('hostChip').textContent = location.host;
+ document.title = brand.short || 'Ad-Ops';
+
+ const [ov, spend, camps] = await Promise.all([
+ fetch('/api/overview').then(r=>r.json()),
+ fetch('/api/spend').then(r=>r.json()),
+ fetch('/api/campaigns').then(r=>r.json()),
+ ]);
+
+ // KPIs — every number hrefs to its deeper source
+ const kpis = [
+ { h:'Active Campaigns', v:ov.counts.active, href:'/api/campaigns' },
+ { h:'Total Campaigns', v:ov.counts.campaigns, href:'/api/campaigns' },
+ { h:'Domains Covered', v:ov.counts.domains, href:'/api/brand' },
+ { h:'Spend (7d)', v: spend.present ? ('$'+spend.total_usd) : '$0', href:'/api/spend', when: spend.present?'from cost-ledger':'ledger not present' },
+ ];
+ $('kpis').innerHTML = kpis.map(k=>`<div class="card"><h2>${k.h}</h2>
+ <div class="kpi"><a href="${k.href}">${k.v}</a></div>
+ <span class="when" title="${new Date().toISOString()}">🕓 ${fmtDate(new Date().toISOString())}${k.when?' · '+k.when:''}</span></div>`).join('');
+
+ campaigns = camps.campaigns || [];
+ renderCampaigns();
+
+ // signals
+ const sig = await fetch('/api/signals').then(r=>r.json());
+ const items = sig.signals || [];
+ $('signals').innerHTML = items.length
+ ? items.map(s=>`<div><a href="${s.url||'#'}">${s.advertiser||s.title||'signal'}</a> <span class="badge">${s.platform||'—'}</span> <span class="when">🕓 ${fmtDate(s.seen_at||s.created_at)}</span></div>`).join('')
+ : `<div class="empty">No competitor ad signals captured yet — feeds from the advertising-signals skill. Source: /api/signals</div>`;
+}
+
+let sortKey='newest';
+function renderCampaigns(){
+ const b=$('campBody'), sorters={
+ newest:(a,c)=> (Date.parse(c.created_at||0))-(Date.parse(a.created_at||0)),
+ name:(a,c)=> (a.name||'').localeCompare(c.name||''),
+ status:(a,c)=> (a.status||'').localeCompare(c.status||''),
+ spend:(a,c)=> (c.spend||0)-(a.spend||0),
+ };
+ const rows=[...campaigns].sort(sorters[sortKey]||sorters.newest);
+ $('campEmpty').hidden = rows.length>0;
+ b.innerHTML = rows.map(c=>`<tr>
+ <td><a href="/api/campaigns">${c.name||'—'}</a></td>
+ <td><a href="https://${c.domain||''}">${c.domain||'—'}</a></td>
+ <td><span class="badge">${c.status||'—'}</span></td>
+ <td>${c.spend!=null?('$'+c.spend):'—'}</td>
+ <td><span class="when" title="${c.created_at||''}">🕓 ${fmtDate(c.created_at)}</span></td>
+ </tr>`).join('');
+}
+$('sort').onchange = e=>{ sortKey=e.target.value; renderCampaigns(); };
+$('density').oninput = e=>document.documentElement.style.setProperty('--min', e.target.value+'px');
+document.querySelectorAll('#campTable th').forEach(th=>th.onclick=()=>{ const m={name:'name',status:'status',spend:'spend',created_at:'newest'}; sortKey=m[th.dataset.k]||'newest'; $('sort').value=sortKey; renderCampaigns(); });
+
+boot().catch(e=>{ $('brandSub').textContent='error: '+e.message; });
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..f0eaad5
--- /dev/null
+++ b/server.js
@@ -0,0 +1,175 @@
+#!/usr/bin/env node
+/**
+ * ads-dashboard — host-aware internal ad-ops command center.
+ *
+ * One app serves two live hosts (barber.aa PUBLIC_HOSTS pattern):
+ * ads.designerwallcoverings.com -> DW ad-ops
+ * ads.agentabrams.com -> Abrams-portfolio ad-ops
+ *
+ * Zero external deps (node:http/fs/path/crypto only) so deploy = copy + `pm2 start`.
+ * Basic-Auth gates everything except /healthz; the 401 IS the healthy gate.
+ */
+'use strict';
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const PORT = parseInt(process.env.PORT || '9764', 10);
+const AUTH_USER = process.env.ADS_USER || 'admin';
+const AUTH_PASS = process.env.ADS_PASS || 'DW2024!';
+const PUBLIC_DIR = path.join(__dirname, 'public');
+const DATA_DIR = path.join(__dirname, 'data');
+
+// ── Host → brand map ────────────────────────────────────────────────────────
+// Add a hostname here (and it just works) — this is the PUBLIC_HOSTS registry.
+const BRANDS = {
+ 'ads.designerwallcoverings.com': {
+ key: 'dw',
+ name: 'Designer Wallcoverings',
+ short: 'DW Ad-Ops',
+ accent: '#8b7355',
+ domains: ['designerwallcoverings.com', 'apartmentwallpaper.com', 'philipperomano.com'],
+ },
+ 'ads.agentabrams.com': {
+ key: 'abrams',
+ name: 'Agent Abrams',
+ short: 'Abrams Ad-Ops',
+ accent: '#2c5f8a',
+ domains: ['agentabrams.com', 'rentv.agentabrams.com', 'lawyers.agentabrams.com'],
+ },
+};
+const DEFAULT_BRAND = {
+ key: 'default', name: 'Ad-Ops', short: 'Ad-Ops', accent: '#555', domains: [],
+};
+
+function brandForHost(hostHeader) {
+ const host = String(hostHeader || '').toLowerCase().split(':')[0];
+ return BRANDS[host] || DEFAULT_BRAND;
+}
+
+// ── helpers ─────────────────────────────────────────────────────────────────
+function readJSON(file, fallback) {
+ try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, file), 'utf8')); }
+ catch { return fallback; }
+}
+
+function timingSafeEqual(a, b) {
+ const ab = Buffer.from(a), bb = Buffer.from(b);
+ if (ab.length !== bb.length) return false;
+ return crypto.timingSafeEqual(ab, bb);
+}
+
+function checkAuth(req) {
+ const h = req.headers['authorization'] || '';
+ if (!h.startsWith('Basic ')) return false;
+ const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
+ return timingSafeEqual(u || '', AUTH_USER) && timingSafeEqual(p || '', AUTH_PASS);
+}
+
+function send(res, code, body, headers = {}) {
+ res.writeHead(code, { 'Cache-Control': 'no-store', ...headers });
+ res.end(body);
+}
+function sendJSON(res, code, obj) {
+ send(res, code, JSON.stringify(obj), { 'Content-Type': 'application/json' });
+}
+
+const MIME = {
+ '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
+ '.ico': 'image/x-icon', '.woff2': 'font/woff2',
+};
+
+// ── data endpoints (real sources where present, honest-empty otherwise) ───────
+// Spend: read the global cost-ledger if it exists; summarize last 7d by provider.
+function spendSummary() {
+ const ledgerPath = path.join(process.env.HOME || '/root', '.claude', 'cost-ledger.jsonl');
+ const out = { source: 'cost-ledger.jsonl', window_days: 7, total_usd: 0, by_provider: {}, present: false };
+ try {
+ const lines = fs.readFileSync(ledgerPath, 'utf8').trim().split('\n');
+ out.present = true;
+ const cutoff = fixedNowMs() - 7 * 864e5;
+ for (const ln of lines) {
+ let e; try { e = JSON.parse(ln); } catch { continue; }
+ const t = Date.parse(e.ts || e.time || e.date || 0);
+ if (t && t < cutoff) continue;
+ const usd = Number(e.usd || e.cost || e.amount || 0);
+ const prov = e.provider || e.service || 'unknown';
+ out.total_usd += usd;
+ out.by_provider[prov] = (out.by_provider[prov] || 0) + usd;
+ }
+ out.total_usd = Math.round(out.total_usd * 100) / 100;
+ } catch { /* honest-empty: present stays false */ }
+ return out;
+}
+
+// fixedNow: server boot time baseline (Date is fine at runtime on the server;
+// the workflow-script restriction does not apply to a running node process).
+function fixedNowMs() { return Date.now(); }
+
+function apiOverview(brand) {
+ const campaigns = readJSON(`campaigns.${brand.key}.json`, null)
+ || readJSON('campaigns.json', { campaigns: [] });
+ const list = Array.isArray(campaigns) ? campaigns : (campaigns.campaigns || []);
+ const active = list.filter(c => (c.status || '').toLowerCase() === 'active');
+ return {
+ brand: brand.key,
+ generated_at: new Date().toISOString(),
+ counts: { campaigns: list.length, active: active.length, domains: brand.domains.length },
+ domains: brand.domains,
+ };
+}
+
+// ── request handler ───────────────────────────────────────────────────────────
+const server = http.createServer((req, res) => {
+ const brand = brandForHost(req.headers.host);
+ const url = new URL(req.url, 'http://x');
+ const pathname = url.pathname;
+
+ // open health check (no auth) — the fleet probe reads this
+ if (pathname === '/healthz') {
+ return sendJSON(res, 200, {
+ ok: true, service: 'ads-dashboard', brand: brand.key,
+ host: (req.headers.host || '').split(':')[0], ts: new Date().toISOString(),
+ });
+ }
+
+ // everything else is gated
+ if (!checkAuth(req)) {
+ return send(res, 401, 'Authentication required', {
+ 'WWW-Authenticate': 'Basic realm="Ad-Ops"',
+ 'Content-Type': 'text/plain',
+ });
+ }
+
+ // API
+ if (pathname === '/api/overview') return sendJSON(res, 200, apiOverview(brand));
+ if (pathname === '/api/spend') return sendJSON(res, 200, spendSummary());
+ if (pathname === '/api/signals') {
+ return sendJSON(res, 200, readJSON(`signals.${brand.key}.json`, { source: 'advertising-signals', signals: [], present: false }));
+ }
+ if (pathname === '/api/campaigns') {
+ const c = readJSON(`campaigns.${brand.key}.json`, null) || readJSON('campaigns.json', { campaigns: [] });
+ return sendJSON(res, 200, Array.isArray(c) ? { campaigns: c } : c);
+ }
+ if (pathname === '/api/brand') return sendJSON(res, 200, brand);
+
+ // static
+ let rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
+ const file = path.join(PUBLIC_DIR, rel);
+ if (!file.startsWith(PUBLIC_DIR)) return send(res, 403, 'forbidden');
+ fs.readFile(file, (err, buf) => {
+ if (err) {
+ // SPA fallback
+ return fs.readFile(path.join(PUBLIC_DIR, 'index.html'), (e2, idx) =>
+ e2 ? send(res, 404, 'not found') : send(res, 200, idx, { 'Content-Type': MIME['.html'] }));
+ }
+ send(res, 200, buf, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
+ });
+});
+
+server.listen(PORT, () => {
+ console.log(`[ads-dashboard] listening on :${PORT} hosts=${Object.keys(BRANDS).join(', ')}`);
+});
(oldest)
·
back to Ads Dashboard
·
set prod port to 9766 (9764 taken on Kamatera) 429c5bd →