ccsite-manager: fleet orchestrator, BTCPay auto-settlement, watchdog, dashboard

This commit is contained in:
drjones
2026-08-02 22:36:59 -07:00
commit 4477d56b30
15 changed files with 2754 additions and 0 deletions

229
templates/btcpay.html Normal file
View File

@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BTCPay Manager — CCSite Fleet</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #080812; --card: #12121f; --accent: #6c63ff; --accent2: #00d4aa;
--text: #e0e0e0; --muted: #777; --danger: #ff4757; --success: #2ed573; --warning: #ffa502;
--btc: #f7931a;
--radius: 12px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'SF Mono', monospace; background: var(--bg); color: var(--text); min-height: 100vh; }
header { background: var(--card); border-bottom: 1px solid rgba(108,99,255,0.2); padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 100; }
header h1 { font-size: 1.2rem; font-weight: 800; background: linear-gradient(135deg, var(--btc), var(--accent2)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header nav a { color: var(--muted); text-decoration: none; font-size: 0.75rem; margin-left: 1rem; padding: 0.3rem 0.7rem; border-radius: 4px; border: 1px solid rgba(108,99,255,0.2); transition: all 0.2s; }
header nav a:hover, header nav a.active { color: var(--accent2); border-color: var(--accent2); }
.container { max-width: 1400px; margin: 0 auto; padding: 1.5rem; }
.card { background: var(--card); padding: 1.5rem; border-radius: var(--radius); border: 1px solid rgba(108,99,255,0.1); margin-bottom: 1rem; }
.card h3 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); margin-bottom: 1rem; }
.big-number { font-size: 3rem; font-weight: 800; font-family: 'SF Mono', monospace; }
.label-sm { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 1.5px; color: var(--muted); }
.progress-bar { height: 12px; background: rgba(255,255,255,0.05); border-radius: 6px; overflow: hidden; margin: 0.5rem 0; }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--btc), #ffd700); border-radius: 6px; transition: width 30s ease; }
table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
th { text-align: left; padding: 0.7rem 0.5rem; border-bottom: 1px solid rgba(108,99,255,0.1); font-size: 0.6rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); }
td { padding: 0.6rem 0.5rem; border-bottom: 1px solid rgba(108,99,255,0.05); }
tr:hover { background: rgba(108,99,255,0.03); }
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 3px; font-size: 0.65rem; font-weight: 600; }
.badge-ok { background: rgba(46,213,115,0.15); color: var(--success); }
.badge-warn { background: rgba(255,165,2,0.15); color: var(--warning); }
.badge-err { background: rgba(255,71,87,0.15); color: var(--danger); }
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.chart-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
.chart-box { background: var(--card); padding: 1.5rem; border-radius: var(--radius); border: 1px solid rgba(108,99,255,0.1); }
.chart-box canvas { max-height: 250px; }
.pulse { animation: pulse 2s infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
@media (max-width: 768px) { .chart-row { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header>
<div>
<h1>₿ BTCPay Server Manager</h1>
<span style="font-size:0.7rem;color:var(--muted);">10.30.20.140 · <span id="clock">--</span></span>
</div>
<nav>
<a href="/">Dashboard</a>
<a href="/master">Master Panel</a>
<a href="/btcpay" class="active">₿ BTCPay</a>
</nav>
</header>
<div class="container">
<!-- Sync Status -->
<div class="card" style="border:1px solid rgba(243,156,18,0.3);">
<div style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;">
<div>
<div class="label-sm">Sync Progress</div>
<div class="big-number" style="color:var(--btc);" id="sync-pct">--</div>
</div>
<div style="flex:1;min-width:250px;">
<div class="progress-bar"><div class="progress-fill" id="sync-bar" style="width:0%"></div></div>
<div style="font-size:0.7rem;color:var(--muted);display:flex;justify-content:space-between;">
<span id="blocks-display">--</span>
<span id="headers-display">--</span>
</div>
<div style="margin-top:0.5rem;font-size:0.7rem;">
<span class="badge" id="sync-badge">Syncing</span>
<span style="color:var(--muted);margin-left:0.5rem;">v<span id="btcpay-version">--</span></span>
</div>
</div>
<div style="text-align:right;">
<div class="label-sm">Wallets Ready</div>
<div class="big-number" id="wallets-ready" style="color:var(--warning);">0/10</div>
<div class="label-sm">Total Invoices: <span id="total-invoices" style="color:var(--btc);">0</span></div>
</div>
</div>
</div>
<!-- Stats Row -->
<div class="stat-row" id="stat-row"></div>
<!-- Charts -->
<div class="chart-row">
<div class="chart-box">
<h3>📈 Sync Progress (24h)</h3>
<canvas id="sync-chart" height="200"></canvas>
</div>
<div class="chart-box">
<h3>🏪 Per-Store Invoice Count</h3>
<canvas id="invoice-chart" height="200"></canvas>
</div>
</div>
<!-- Store Table -->
<div class="card">
<h3>📋 Store Status</h3>
<table>
<thead><tr><th>#</th><th>Site</th><th>Store ID</th><th>Wallet</th><th>Invoices</th><th>Webhook</th><th>Status</th></tr></thead>
<tbody id="store-table"></tbody>
</table>
</div>
<!-- Webhook Health -->
<div class="card">
<h3>🔗 Webhook Status</h3>
<div id="webhook-status" style="font-size:0.75rem;color:var(--muted);">Loading...</div>
</div>
</div>
<script>
const EM = {1:'⚡',2:'💳',3:'👤',4:'🔑',5:'₿',6:'🎁',7:'⭐',8:'🔥',9:'🎮',10:'🏆'};
let syncChart, invoiceChart;
function initCharts() {
syncChart = new Chart(document.getElementById('sync-chart').getContext('2d'), {
type: 'line',
data: { labels: [], datasets: [{ label: 'Sync %', data: [], borderColor: '#f7931a', backgroundColor: 'rgba(247,147,26,0.1)', fill: true, tension: 0.3, pointRadius: 0 }] },
options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } },
scales: { x: { ticks: { color: '#777', font: { size: 9 } } }, y: { min: 0, max: 100, ticks: { color: '#777', callback: v => v+'%' } } } }
});
invoiceChart = new Chart(document.getElementById('invoice-chart').getContext('2d'), {
type: 'bar',
data: { labels: [], datasets: [{ label: 'Invoices', data: [], backgroundColor: '#f7931a', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: true, plugins: { legend: { display: false } },
scales: { x: { ticks: { color: '#777', font: { size: 8 } } }, y: { ticks: { color: '#777' }, min: 0 } } }
});
}
async function refresh() {
try {
const r = await fetch('/api/state');
const data = await r.json();
const bp = data.btcpay || {};
const sites = data.sites || {};
// Sync status
document.getElementById('sync-pct').textContent = (bp.sync_pct||0).toFixed(1) + '%';
document.getElementById('sync-bar').style.width = (bp.sync_pct||0) + '%';
document.getElementById('blocks-display').textContent = (bp.blocks||0).toLocaleString() + ' blocks synced';
document.getElementById('headers-display').textContent = 'Chain height: ' + (bp.headers||'?').toLocaleString();
document.getElementById('btcpay-version').textContent = bp.version || '?';
const badge = document.getElementById('sync-badge');
if (bp.synced) {
badge.textContent = '✅ Synced'; badge.className = 'badge badge-ok';
} else {
badge.textContent = '⏳ Syncing'; badge.className = 'badge badge-warn';
}
const wallets = bp.stores_with_wallet || 0;
const total = bp.total_stores || 10;
const wel = document.getElementById('wallets-ready');
wel.textContent = wallets + '/' + total;
wel.style.color = wallets === total ? 'var(--success)' : 'var(--warning)';
document.getElementById('total-invoices').textContent = bp.total_invoices || 0;
// Stats row
document.getElementById('stat-row').innerHTML = `
<div class="card"><div class="label-sm">BTCPay Server</div><div class="big-number" style="font-size:1.5rem;color:var(--accent2);">v${bp.version||'?'}</div></div>
<div class="card"><div class="label-sm">Total Stores</div><div class="big-number" style="font-size:1.5rem;">${total}</div></div>
<div class="card"><div class="label-sm">Wallets Ready</div><div class="big-number" style="font-size:1.5rem;color:${wallets===total?'var(--success)':'var(--warning)'};">${wallets}/${total}</div></div>
<div class="card"><div class="label-sm">Total Invoices</div><div class="big-number" style="font-size:1.5rem;color:var(--btc);">${bp.total_invoices||0}</div></div>
`;
// Store table
const table = document.getElementById('store-table');
table.innerHTML = '';
for (let i=1; i<=10; i++) {
const s = bp.stores?.[i] || {};
const site = sites[i] || {};
table.innerHTML += `
<tr>
<td style="color:var(--muted);">#${i}</td>
<td>${EM[i]} ${site.name||'?'}</td>
<td style="font-family:'SF Mono',monospace;font-size:0.65rem;color:var(--muted);">${s.store_id||'?'}</td>
<td><span class="badge ${s.has_wallet?'badge-ok':'badge-warn'}">${s.has_wallet?'Ready':'Pending'}</span></td>
<td>${s.invoice_count||0}</td>
<td><span class="badge badge-ok">Active</span></td>
<td><a href="http://${site.ip}:5000" target="_blank" style="color:var(--accent);font-size:0.65rem;">Visit →</a></td>
</tr>
`;
}
// Invoice chart
const labels = [], invCounts = [];
for (let i=1; i<=10; i++) {
labels.push(EM[i]);
invCounts.push(bp.stores?.[i]?.invoice_count || 0);
}
invoiceChart.data.labels = labels;
invoiceChart.data.datasets[0].data = invCounts;
invoiceChart.update();
// Sync chart
if (data.btcpay_history && data.btcpay_history.length > 1) {
syncChart.data.labels = data.btcpay_history.map(h => new Date(h.ts*1000).toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'}));
syncChart.data.datasets[0].data = data.btcpay_history.map(h => h.sync_pct);
syncChart.update();
}
// Webhook summary
document.getElementById('webhook-status').innerHTML = `
All 10 stores have webhooks configured → <code style="color:var(--btc);">http://10.30.20.{210-221}:5000/webhook/btcpay</code><br>
<span style="color:var(--muted);">Webhooks fire on: InvoiceReceivedPayment, InvoiceProcessing, InvoiceSettled</span><br>
<span style="color:var(--muted);">Auto-redelivery: enabled on all stores</span>
`;
} catch(e) { console.error(e); }
}
setInterval(() => document.getElementById('clock').textContent = new Date().toLocaleTimeString(), 1000);
initCharts();
refresh();
setInterval(refresh, 8000);
</script>
</body>
</html>

324
templates/dashboard.html Normal file
View File

@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FLEET OPS — CCSite Mission Control</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg:#07090f; --panel:#0d1117; --panel2:#11161f; --line:#1c2432;
--txt:#e6edf6; --muted:#7d8aa0; --dim:#4a5668;
--green:#2ea043; --green2:#3fb950; --red:#f85149; --amber:#d29922;
--cyan:#39c5cf; --purple:#a371f7; --pink:#f778ba; --orange:#ff9f43;
}
body { background:var(--bg); color:var(--txt); font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif; padding:18px 22px 60px; }
.mono { font-family:'SF Mono',Menlo,monospace; }
/* ── header ── */
header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin-bottom:18px; }
.brand { display:flex; align-items:center; gap:12px; }
.brand .logo { width:38px;height:38px;border-radius:10px;background:linear-gradient(135deg,#1f6feb,#a371f7);display:flex;align-items:center;justify-content:center;font-size:20px; }
.brand h1 { font-size:1.25rem; letter-spacing:2px; font-weight:800; }
.brand h1 span { color:var(--cyan); }
.brand .sub { font-size:0.68rem; color:var(--muted); letter-spacing:1.5px; text-transform:uppercase; }
.hdr-right { display:flex; align-items:center; gap:14px; font-size:0.75rem; color:var(--muted); }
.pulse { width:8px;height:8px;border-radius:50%;background:var(--green2);display:inline-block;margin-right:6px;animation:pl 2s infinite; }
@keyframes pl { 0%,100%{box-shadow:0 0 0 0 rgba(63,185,80,.5)} 50%{box-shadow:0 0 0 6px rgba(63,185,80,0)} }
.nav { display:flex; gap:8px; margin-left:10px; }
.nav a { font-size:0.7rem; color:var(--muted); text-decoration:none; padding:4px 10px; border:1px solid var(--line); border-radius:6px; }
.nav a:hover { color:var(--txt); border-color:var(--cyan); }
/* ── KPI strip ── */
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:10px; margin-bottom:14px; }
.kpi { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; position:relative; overflow:hidden; }
.kpi::after { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:var(--kc,var(--cyan)); opacity:.7; }
.kpi .l { font-size:0.62rem; color:var(--muted); text-transform:uppercase; letter-spacing:1.2px; margin-bottom:5px; }
.kpi .v { font-size:1.45rem; font-weight:800; font-variant-numeric:tabular-nums; }
.kpi .d { font-size:0.66rem; color:var(--dim); margin-top:3px; }
.kpi .d b { color:var(--green2); font-weight:600; }
/* ── charts row ── */
.charts { display:grid; grid-template-columns:2fr 1.4fr 1fr 0.9fr; gap:10px; margin-bottom:14px; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 14px; }
.panel h3 { font-size:0.68rem; color:var(--muted); text-transform:uppercase; letter-spacing:1.4px; margin-bottom:10px; display:flex; justify-content:space-between; }
.panel h3 .tag { color:var(--dim); font-weight:400; letter-spacing:0; text-transform:none; }
canvas { display:block; width:100%; }
/* ── main grid ── */
.main { display:grid; grid-template-columns:1fr 340px; gap:12px; margin-bottom:14px; }
.sites { display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:10px; }
.site { background:var(--panel2); border:1px solid var(--line); border-radius:12px; padding:12px; transition:border-color .2s; position:relative; }
.site:hover { border-color:#2f3d52; }
.site.off { opacity:.55; }
.site .hd { display:flex; align-items:center; gap:8px; margin-bottom:9px; }
.site .em { font-size:1.3rem; }
.site .nm { font-weight:700; font-size:0.9rem; }
.site .ip { font-size:0.62rem; color:var(--dim); font-family:Menlo,monospace; }
.dot { width:8px;height:8px;border-radius:50%;margin-left:auto; }
.dot.on { background:var(--green2); box-shadow:0 0 8px var(--green2); }
.dot.off { background:var(--red); box-shadow:0 0 8px var(--red); }
.site .nums { display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px; text-align:center; }
.site .nums .n { font-size:0.95rem; font-weight:700; font-variant-numeric:tabular-nums; }
.site .nums .t { font-size:0.56rem; color:var(--dim); text-transform:uppercase; letter-spacing:.8px; }
.site .ft { display:flex; justify-content:space-between; align-items:center; margin-top:9px; font-size:0.64rem; color:var(--muted); }
.badge-pend { background:rgba(210,153,34,.15); color:var(--amber); border:1px solid rgba(210,153,34,.35); padding:1px 7px; border-radius:10px; font-size:0.62rem; font-weight:600; }
.badge-none { color:var(--dim); font-size:0.62rem; }
/* ── right rail feeds ── */
.rail { display:flex; flex-direction:column; gap:12px; }
.feed { max-height:300px; overflow-y:auto; scrollbar-width:thin; scrollbar-color:var(--line) transparent; }
.fi { display:flex; gap:9px; padding:7px 2px; border-bottom:1px solid var(--line); font-size:0.74rem; align-items:baseline; }
.fi:last-child { border-bottom:none; }
.fi .ic { flex-shrink:0; }
.fi .tt { color:var(--dim); font-size:0.62rem; margin-left:auto; flex-shrink:0; font-family:Menlo,monospace; }
.fi .amt { color:var(--green2); font-weight:700; font-variant-numeric:tabular-nums; }
.fi .st-p { color:var(--amber); } .fi .st-d { color:var(--green2); }
.fi .mb { font-size:0.6rem; padding:1px 6px; border-radius:8px; background:rgba(163,113,247,.15); color:var(--purple); border:1px solid rgba(163,113,247,.3); }
/* ── bottom tables ── */
.bottom { display:grid; grid-template-columns:1.6fr 1fr 1fr; gap:12px; }
table { width:100%; border-collapse:collapse; font-size:0.74rem; }
th { text-align:left; color:var(--dim); font-size:0.6rem; text-transform:uppercase; letter-spacing:1px; padding:5px 8px; border-bottom:1px solid var(--line); }
td { padding:6px 8px; border-bottom:1px solid var(--line); font-variant-numeric:tabular-nums; }
tr:last-child td { border-bottom:none; }
td.num, th.num { text-align:right; }
.rank { color:var(--dim); font-family:Menlo,monospace; width:22px; }
.bar { height:4px; border-radius:2px; background:linear-gradient(90deg,var(--cyan),var(--purple)); margin-top:4px; }
.funnel { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.fstep { flex:1; min-width:90px; text-align:center; background:var(--panel2); border:1px solid var(--line); border-radius:10px; padding:10px 6px; }
.fstep .n { font-size:1.2rem; font-weight:800; }
.fstep .l { font-size:0.58rem; color:var(--muted); text-transform:uppercase; letter-spacing:1px; margin-top:2px; }
.farrow { color:var(--dim); font-size:1rem; }
.stores { display:flex; flex-wrap:wrap; gap:6px; }
.store-chip { font-size:0.64rem; padding:3px 9px; border-radius:8px; border:1px solid var(--line); background:var(--panel2); }
.store-chip b { color:var(--green2); }
.store-chip .nw { color:var(--red); }
.empty { color:var(--dim); font-size:0.72rem; padding:14px 4px; text-align:center; }
@media (max-width:1100px) { .charts { grid-template-columns:1fr 1fr; } .main { grid-template-columns:1fr; } .bottom { grid-template-columns:1fr; } }
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">🛰️</div>
<div>
<h1>FLEET <span>OPS</span></h1>
<div class="sub">CCSite Mission Control</div>
</div>
<div class="nav">
<a href="/master">Master</a><a href="/btcpay">BTCPay</a><a href="/watchdog">Watchdog</a><a href="/recon">Recon</a>
</div>
</div>
<div class="hdr-right">
<span><span class="pulse"></span>live · poll <span id="poll-age"></span></span>
<span class="mono" id="clock"></span>
</div>
</header>
<div class="kpis" id="kpis"></div>
<div class="charts">
<div class="panel"><h3>Revenue — fleet history <span class="tag" id="rev-total"></span></h3><canvas id="ch-rev" height="150"></canvas></div>
<div class="panel"><h3>Activity — 14 days <span class="tag">orders vs signups</span></h3><canvas id="ch-act" height="150"></canvas></div>
<div class="panel"><h3>Users by site</h3><canvas id="ch-users" height="150"></canvas></div>
<div class="panel"><h3>BTC node</h3><canvas id="ch-btc" height="150"></canvas><div id="btc-sub" style="font-size:.64rem;color:var(--muted);text-align:center;margin-top:4px"></div></div>
</div>
<div class="main">
<div>
<div class="panel" style="margin-bottom:12px;"><h3>Fleet — 10 storefronts <span class="tag" id="fleet-tag"></span></h3>
<div class="sites" id="sites"></div>
</div>
<div class="panel"><h3>Conversion funnel</h3><div class="funnel" id="funnel"></div></div>
</div>
<div class="rail">
<div class="panel"><h3>💸 Live orders <span class="tag" id="ord-count"></span></h3><div class="feed" id="orders"></div></div>
<div class="panel"><h3>👤 Signups <span class="tag" id="usr-count"></span></h3><div class="feed" id="signups"></div></div>
<div class="panel"><h3>⚡ Events</h3><div class="feed" id="events"></div></div>
</div>
</div>
<div class="bottom">
<div class="panel"><h3>🏆 Site leaderboard — by revenue</h3><table id="board"></table></div>
<div class="panel"><h3>🔥 Top products</h3><table id="top-prod"></table></div>
<div class="panel"><h3>₿ BTCPay stores <span class="tag" id="btcpay-tag"></span></h3>
<div class="stores" id="stores"></div>
<h3 style="margin-top:14px;">Recent chain activity</h3>
<div id="btc-recent" style="font-size:.7rem"></div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const money = n => '$' + Number(n||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
const ago = iso => {
if (!iso) return '—';
const t = (Date.now() - new Date(iso.replace('T',' ').replace(/\+.*/,'') + (iso.includes('Z')||iso.includes('+')?'':' UTC'))/1000)|0;
// ISO dates from shops are naive UTC
const s = isNaN(t) || t < 0 ? 0 : t;
if (s < 60) return s + 's'; if (s < 3600) return (s/60|0) + 'm';
if (s < 86400) return (s/3600|0) + 'h'; return (s/86400|0) + 'd';
};
const agoTs = ts => ago(new Date(ts*1000).toISOString());
setInterval(() => { $('clock').textContent = new Date().toLocaleTimeString(); }, 1000);
/* ── tiny canvas chart kit ── */
function setup(cv){ const dpr = devicePixelRatio||1, r = cv.getBoundingClientRect();
cv.width = r.width*dpr; cv.height = r.height*dpr;
const x = cv.getContext('2d'); x.scale(dpr,dpr); return [x, r.width, r.height]; }
function area(cv, pts, color){
if(!pts.length){ return; } const [x,W,H] = setup(cv), P=8;
const max = Math.max(...pts.map(p=>p.v), 1);
const bw = (W-2*P)/Math.max(pts.length-1,1);
x.clearRect(0,0,W,H);
const grad = x.createLinearGradient(0,0,0,H);
grad.addColorStop(0, color+'55'); grad.addColorStop(1, color+'00');
x.beginPath(); x.moveTo(P, H-P);
pts.forEach((p,i)=> x.lineTo(P+i*bw, H-P-(p.v/max)*(H-2*P-14)));
x.lineTo(P+(pts.length-1)*bw, H-P); x.closePath(); x.fillStyle = grad; x.fill();
x.beginPath(); pts.forEach((p,i)=> i? x.lineTo(P+i*bw, H-P-(p.v/max)*(H-2*P-14)) : x.moveTo(P, H-P-(p.v/max)*(H-2*P-14)));
x.strokeStyle = color; x.lineWidth = 1.8; x.stroke();
const lp = pts[pts.length-1];
x.fillStyle = color; x.beginPath(); x.arc(P+(pts.length-1)*bw, H-P-(lp.v/max)*(H-2*P-14), 3, 0, 7); x.fill();
x.fillStyle = '#7d8aa0'; x.font = '10px Menlo'; x.fillText(money(lp.v), P+4, 12);
}
function bars(cv, labels, seriesA, seriesB){
const [x,W,H] = setup(cv), P=8, n = labels.length; if(!n) return;
const max = Math.max(...seriesA, ...seriesB, 1), bw = (W-2*P)/n;
x.clearRect(0,0,W,H);
labels.forEach((l,i)=>{
const a = seriesA[i], b = seriesB[i];
x.fillStyle = '#39c5cf';
const ha = (a/max)*(H-2*P-12); x.fillRect(P+i*bw+1, H-P-ha, bw*0.55, ha);
x.fillStyle = '#a371f7';
const hb = (b/max)*(H-2*P-12); x.fillRect(P+i*bw+bw*0.58, H-P-hb, bw*0.35, hb);
if(i%2===0){ x.fillStyle='#4a5668'; x.font='8px Menlo'; x.fillText(l.slice(5), P+i*bw, H-1); }
});
}
function donut(cv, items){
const [x,W,H] = setup(cv); x.clearRect(0,0,W,H);
const total = items.reduce((s,i)=>s+i.v,0); if(!total){ x.fillStyle='#4a5668'; x.font='11px Menlo'; x.fillText('no users yet', 10, H/2); return; }
const cols = ['#39c5cf','#a371f7','#f778ba','#ff9f43','#3fb950','#d29922','#f85149','#58a6ff','#7ee787','#e3b341'];
const cx = W*0.32, cy = H/2, r = Math.min(W,H)/2 - 10; let a = -Math.PI/2;
items.forEach((it,i)=>{ const sw = it.v/total*2*Math.PI;
x.beginPath(); x.arc(cx,cy,r,a,a+sw); x.strokeStyle = cols[i%cols.length]; x.lineWidth = 13; x.stroke(); a += sw; });
x.fillStyle = '#e6edf6'; x.font = 'bold 15px Menlo'; x.textAlign='center'; x.fillText(total, cx, cy+4); x.textAlign='left';
items.slice(0,6).forEach((it,i)=>{ x.fillStyle = cols[i%cols.length]; x.fillRect(W*0.58, 14+i*20, 8, 8);
x.fillStyle = '#7d8aa0'; x.font = '9.5px Menlo'; x.fillText(it.k+' ('+it.v+')', W*0.58+13, 21+i*20); });
}
function gauge(cv, pct, label){
const [x,W,H] = setup(cv); x.clearRect(0,0,W,H);
const cx = W/2, cy = H*0.62, r = Math.min(W,H)*0.42;
x.lineWidth = 11; x.lineCap = 'round';
x.beginPath(); x.arc(cx,cy,r,Math.PI,2*Math.PI); x.strokeStyle = '#1c2432'; x.stroke();
x.beginPath(); x.arc(cx,cy,r,Math.PI,Math.PI+Math.PI*Math.min(pct,100)/100);
x.strokeStyle = pct>=100 ? '#3fb950' : '#d29922'; x.stroke();
x.fillStyle = '#e6edf6'; x.font = 'bold 17px Menlo'; x.textAlign='center';
x.fillText(pct.toFixed(0)+'%', cx, cy-6);
x.fillStyle = '#7d8aa0'; x.font = '9px Menlo'; x.fillText(label, cx, cy+12); x.textAlign='left';
}
/* ── renderers ── */
async function refresh(){
const [state, orders, signups, board, top, act, fun] = await Promise.all([
fetch('/api/state').then(r=>r.json()), fetch('/api/orders?limit=30').then(r=>r.json()),
fetch('/api/signups?limit=30').then(r=>r.json()), fetch('/api/leaderboard').then(r=>r.json()),
fetch('/api/top-products').then(r=>r.json()), fetch('/api/activity').then(r=>r.json()),
fetch('/api/funnel').then(r=>r.json()),
]);
const t = state.totals||{}, sites = Object.values(state.sites||{});
const revToday = sites.reduce((s,x)=>s+(x.revenue_today||0),0);
const ordToday = sites.reduce((s,x)=>s+(x.orders_today||0),0);
const sgnToday = sites.reduce((s,x)=>s+(x.signups_today||0),0);
/* KPIs */
const k = [
['Fleet Revenue', money(fun.revenue), `+${money(revToday)} today`, 'var(--green2)'],
['Orders', fun.orders, `+${ordToday} today · AOV ${money(fun.aov)}`, 'var(--cyan)'],
['Pending Delivery', fun.pending, fun.pending?'⚠ needs fulfillment':'all clear', fun.pending?'var(--amber)':'var(--green2)'],
['Total Users', fun.users, `+${sgnToday} today · ${fun.conversion}% buy`, 'var(--purple)'],
['Sites Online', (t.online||0)+'/10', t.offline? t.offline+' DOWN':'all green', t.offline?'var(--red)':'var(--green2)'],
['Products Live', t.products||0, 'across fleet', 'var(--pink)'],
['Deposits', money(fun.deposits), 'store balances', 'var(--orange)'],
['Unique Buyers', fun.unique_customers, 'by email', 'var(--cyan)'],
];
$('kpis').innerHTML = k.map(x=>`<div class="kpi" style="--kc:${x[3]}"><div class="l">${x[0]}</div><div class="v">${x[1]}</div><div class="d">${x[2]}</div></div>`).join('');
/* charts */
const hist = (state.history||[]).map(h=>({v:h.revenue}));
area($('ch-rev'), hist, '#39c5cf');
$('rev-total').textContent = hist.length? 'now '+money(hist[hist.length-1].v) : '';
const days = [...new Set([...Object.keys(act.daily||{})])].sort();
bars($('ch-act'), days, days.map(d=>(act.daily[d]||{}).orders||0), days.map(d=>(act.daily[d]||{}).signups||0));
donut($('ch-users'), Object.entries(signups.per_site||{}).map(([sid,v])=>({k:(state.sites[sid]||{}).name||sid, v})).sort((a,b)=>b.v-a.v));
const bp = state.btcpay||{};
gauge($('ch-btc'), bp.sync_pct||0, 'blocks '+(bp.blocks||'?'));
$('btc-sub').textContent = bp.synced ? 'fully synced · v'+(bp.version||'').split('+')[0] : 'syncing…';
/* sites grid */
$('fleet-tag').textContent = (t.online||0)+' online · '+(t.users||0)+' users';
$('sites').innerHTML = sites.sort((a,b)=>a.site_id-b.site_id).map(s=>`
<div class="site ${s.status!=='online'?'off':''}">
<div class="hd"><span class="em">${s.emoji}</span>
<div><div class="nm">${s.name}</div><div class="ip">${s.ip} · ${s.response_ms}ms</div></div>
<span class="dot ${s.status==='online'?'on':'off'}"></span></div>
<div class="nums">
<div><div class="n">${s.users}</div><div class="t">users</div></div>
<div><div class="n">${s.orders}</div><div class="t">orders</div></div>
<div><div class="n">${money(s.revenue)}</div><div class="t">revenue</div></div>
</div>
<div class="ft"><span>${s.products} products</span>
${s.details&&s.details.pending_orders? `<span class="badge-pend">${s.details.pending_orders} pending</span>` : '<span class="badge-none">✓ clear</span>'}
</div></div>`).join('');
/* funnel */
const fs = [['Users',fun.users],['Buyers',fun.buyers],['Orders',fun.orders],['Delivered',fun.delivered],['Revenue',money(fun.revenue)]];
$('funnel').innerHTML = fs.map((f,i)=>`${i?'<span class="farrow">→</span>':''}<div class="fstep"><div class="n">${f[1]}</div><div class="l">${f[0]}</div></div>`).join('');
/* feeds */
$('ord-count').textContent = orders.length? orders.length+' total':'';
$('orders').innerHTML = orders.length? orders.map(o=>`
<div class="fi"><span class="ic">${o.emoji}</span>
<span><b>${o.product.slice(0,34)}</b> <span class="amt">${money(o.amount)}</span><br>
<span style="color:var(--dim)">${o.site} · <span class="st-${o.status==='delivered'?'d':'p'}">${o.status}</span> · ${o.email.split('@')[0]}@…</span></span>
<span class="tt">${ago(o.date)}</span></div>`).join('') : '<div class="empty">no orders yet</div>';
$('usr-count').textContent = Object.values(signups.tiers||{}).map((v,k)=>k+':'+v).join(' · ')||'';
$('signups').innerHTML = signups.recent.length? signups.recent.map(u=>`
<div class="fi"><span class="ic">${u.emoji}</span>
<span><b>${u.username}</b> ${u.membership!=='free'?`<span class="mb">${u.membership}</span>`:''}<br>
<span style="color:var(--dim)">${u.site} · ${u.orders} orders · ${money(u.balance)} bal</span></span>
<span class="tt">${ago(u.joined)}</span></div>`).join('') : '<div class="empty">no signups yet</div>';
$('events').innerHTML = (state.events||[]).slice(0,20).map(e=>`
<div class="fi"><span>${e.detail}</span><span class="tt">${agoTs(e.timestamp)}</span></div>`).join('') || '<div class="empty">quiet…</div>';
/* leaderboard */
const maxRev = Math.max(...board.map(b=>b.revenue),1);
$('board').innerHTML = `<tr><th></th><th>Site</th><th class="num">Revenue</th><th class="num">Orders</th><th class="num">24h</th><th class="num">AOV</th><th class="num">Users</th><th class="num">Pend</th></tr>` +
board.map((b,i)=>`<tr><td class="rank">${i+1}</td><td>${b.emoji} ${b.site}<div class="bar" style="width:${(b.revenue/maxRev*100)|0}%"></div></td>
<td class="num"><b>${money(b.revenue)}</b></td><td class="num">${b.orders}</td><td class="num">${b.orders_24h}</td>
<td class="num">${money(b.aov)}</td><td class="num">${b.users}</td>
<td class="num">${b.pending?`<span class="badge-pend">${b.pending}</span>`:'—'}</td></tr>`).join('');
$('top-prod').innerHTML = `<tr><th>Product</th><th class="num">Sold</th><th class="num">Revenue</th><th class="num">Sites</th></tr>` +
(top.length? top.map(p=>`<tr><td>${p.product.slice(0,30)}</td><td class="num">${p.orders}</td><td class="num">${money(p.revenue)}</td><td class="num">${p.sites}</td></tr>`).join('') : '<tr><td class="empty">no sales yet</td></tr>');
/* btcpay stores */
const stores = bp.stores||{};
$('btcpay-tag').textContent = (bp.stores_with_wallet||0)+'/'+(bp.total_stores||10)+' wallets · '+(bp.total_invoices||0)+' invoices';
$('stores').innerHTML = Object.entries(stores).map(([sid,st])=>{
const nm = (state.sites[sid]||{}).emoji||''; const inv = st.invoices_by_status||{};
return `<span class="store-chip">${nm} ${st.has_wallet?'<b>●</b>':'<span class="nw">○</span>'} ${st.invoice_count||0}inv <span style="color:var(--dim)">${inv.Settled?inv.Settled+'✓ ':''}${inv.New?inv.New+'⏳':''}</span></span>`;
}).join('');
const br = (state.btc_stats||{}).recent||[];
$('btc-recent').innerHTML = br.length? br.map(x=>`<div class="fi"><span>₿ <span class="amt">${(x.btc||0).toLocaleString()}</span> sats-equiv<br><span style="color:var(--dim)" class="mono">${x.txid}</span></span><span class="tt">${agoTs(x.ts)}</span></div>`).join('') : '<div class="empty">no chain activity</div>';
$('poll-age').textContent = state.last_poll||'—';
}
refresh();
setInterval(refresh, 20000);
</script>
</body>
</html>

257
templates/master.html Normal file
View File

@@ -0,0 +1,257 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Master Control — CCSite Fleet</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root{--bg:#080812;--card:#12121f;--accent:#6c63ff;--accent2:#00d4aa;--text:#e0e0e0;--muted:#777;--danger:#ef4444;--success:#22c55e;--warning:#f59e0b;--radius:10px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
header{background:var(--card);border-bottom:1px solid rgba(108,99,255,0.2);padding:0.8rem 2rem;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:100}
header h1{font-size:1.1rem;font-weight:800;background:linear-gradient(135deg,var(--accent),var(--accent2));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.tabs{display:flex;gap:0;border-bottom:1px solid rgba(255,255,255,0.06);background:var(--card);padding:0 2rem}
.tab{padding:0.7rem 1.3rem;cursor:pointer;font-size:0.8rem;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;transition:all 0.15s;text-transform:uppercase;letter-spacing:0.5px}
.tab:hover{color:var(--text)}
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.container{max-width:1500px;margin:0 auto;padding:1.5rem}
.stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:0.75rem;margin-bottom:1.5rem}
.stat-card{background:var(--card);padding:1rem;border-radius:var(--radius);border:1px solid rgba(255,255,255,0.03);text-align:center}
.stat-card .sv{font-size:1.4rem;font-weight:800;color:var(--accent2)}
.stat-card .sl{font-size:0.6rem;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-top:0.15rem}
.panel{display:none}.panel.active{display:block}
table{width:100%;border-collapse:collapse;font-size:0.78rem}
th,td{padding:0.45rem 0.6rem;text-align:left;border-bottom:1px solid rgba(255,255,255,0.04)}
th{color:var(--muted);font-size:0.65rem;text-transform:uppercase;letter-spacing:0.5px;position:sticky;top:0;background:var(--bg)}
tr:hover{background:rgba(108,99,255,0.03)}
.badge{padding:0.15rem 0.45rem;border-radius:10px;font-size:0.6rem;font-weight:700}
.badge-ok{background:rgba(34,197,94,0.15);color:var(--success)}
.badge-pending{background:rgba(245,158,11,0.15);color:var(--warning)}
.badge-danger{background:rgba(239,68,68,0.15);color:var(--danger)}
input,select{padding:0.45rem 0.7rem;border-radius:6px;border:1px solid rgba(255,255,255,0.08);background:var(--card);color:var(--text);font-size:0.78rem;outline:none}
input:focus,select:focus{border-color:var(--accent)}
.search-bar{display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap}
.btn{padding:0.4rem 0.9rem;border-radius:6px;border:none;cursor:pointer;font-weight:600;font-size:0.75rem;background:var(--accent);color:#fff}
.btn:hover{filter:brightness(1.15)}
.table-wrap{max-height:60vh;overflow-y:auto;border-radius:var(--radius);border:1px solid rgba(255,255,255,0.04)}
</style>
</head>
<body>
<header>
<h1>🖥️ Master Control Panel</h1>
<div style="font-size:0.7rem;color:var(--muted);"><a href="/" style="color:var(--accent);text-decoration:none;">← Dashboard</a> | <span id="clock">--</span></div>
</header>
<div class="tabs">
<div class="tab active" onclick="switchTab('overview')">📊 Overview</div>
<div class="tab" onclick="switchTab('orders')">📦 Orders</div>
<div class="tab" onclick="switchTab('users')">👥 Users</div>
<div class="tab" onclick="switchTab('products')">🏷️ Products</div>
<div class="tab" onclick="switchTab('transactions')">💳 Transactions</div>
</div>
<div class="container">
<div id="panel-overview" class="panel active">
<div class="stat-row" id="overview-stats"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
<div style="background:var(--card);padding:1rem;border-radius:var(--radius);">
<h3 style="font-size:0.75rem;color:var(--muted);margin-bottom:0.8rem;">📦 Recent Orders</h3>
<div id="recent-orders"></div>
</div>
<div style="background:var(--card);padding:1rem;border-radius:var(--radius);">
<h3 style="font-size:0.75rem;color:var(--muted);margin-bottom:0.8rem;">👥 Recent Signups</h3>
<div id="recent-users"></div>
</div>
</div>
</div>
<div id="panel-orders" class="panel">
<div class="search-bar">
<input type="text" id="order-search" placeholder="Search orders..." oninput="renderOrders()">
<select id="order-site" onchange="renderOrders()"><option value="">All Sites</option></select>
<select id="order-status" onchange="renderOrders()"><option value="">All Status</option><option value="pending">Pending</option><option value="completed">Completed</option></select>
</div>
<div class="table-wrap"><table id="orders-table"><thead><tr><th>Site</th><th>Order</th><th>Product</th><th>Email</th><th>Amount</th><th>Status</th><th>Date</th></tr></thead><tbody></tbody></table></div>
</div>
<div id="panel-users" class="panel">
<div class="search-bar">
<input type="text" id="user-search" placeholder="Search users..." oninput="renderUsers()">
<select id="user-site" onchange="renderUsers()"><option value="">All Sites</option></select>
</div>
<div class="table-wrap"><table id="users-table"><thead><tr><th>Site</th><th>User</th><th>Email</th><th>Balance</th><th>Orders</th><th>Joined</th></tr></thead><tbody></tbody></table></div>
</div>
<div id="panel-products" class="panel">
<div class="search-bar">
<input type="text" id="product-search" placeholder="Search products..." oninput="renderProducts()">
<select id="product-site" onchange="renderProducts()"><option value="">All Sites</option></select>
</div>
<div class="table-wrap"><table id="products-table"><thead><tr><th>Site</th><th>Product</th><th>Category</th><th>Price</th><th>Stock</th></tr></thead><tbody></tbody></table></div>
</div>
<div id="panel-transactions" class="panel">
<div class="search-bar">
<input type="text" id="txn-search" placeholder="Search transactions..." oninput="renderTransactions()">
<select id="txn-site" onchange="renderTransactions()"><option value="">All Sites</option></select>
</div>
<div class="table-wrap"><table id="txns-table"><thead><tr><th>Site</th><th>User</th><th>Type</th><th>Method</th><th>Amount</th><th>Date</th></tr></thead><tbody></tbody></table></div>
</div>
</div>
<script>
const SITE_IPS = {1:'10.30.20.210',2:'10.30.20.211',3:'10.30.20.212',4:'10.30.20.213',5:'10.30.20.214',6:'10.30.20.217',7:'10.30.20.218',8:'10.30.20.219',9:'10.30.20.220',10:'10.30.20.221'};
let ALL_DATA = {};
async function loadAll() {
const promises = Object.entries(SITE_IPS).map(async ([id,ip]) => {
try {
const r = await fetch(`http://${ip}:5000/api/dashboard-data`);
const d = await r.json();
d._site_id = parseInt(id);
d._ip = ip;
return d;
} catch(e) { return null; }
});
const results = await Promise.all(promises);
ALL_DATA = results.filter(r => r);
// Populate site dropdowns
const siteOpts = ALL_DATA.map(d => `<option value="${d._site_id}">${d.site}</option>`).join('');
['order-site','user-site','product-site','txn-site'].forEach(id => {
document.getElementById(id).innerHTML = '<option value="">All Sites</option>' + siteOpts;
});
updateOverview();
}
function updateOverview() {
let totalOrders=0, totalUsers=0, totalRevenue=0, pendingOrders=0;
ALL_DATA.forEach(d => {
totalOrders += d.orders.length;
totalUsers += d.users.length;
totalRevenue += d.orders.filter(o=>o.status==='completed').reduce((s,o)=>s+o.amount,0);
pendingOrders += d.orders.filter(o=>o.status==='pending').length;
});
document.getElementById('overview-stats').innerHTML = `
<div class="stat-card"><div class="sv">${totalUsers}</div><div class="sl">Total Users</div></div>
<div class="stat-card"><div class="sv">${totalOrders}</div><div class="sl">Total Orders</div></div>
<div class="stat-card"><div class="sv">$${totalRevenue.toFixed(0)}</div><div class="sl">Revenue</div></div>
<div class="stat-card"><div class="sv" style="color:var(--warning);">${pendingOrders}</div><div class="sl">Pending</div></div>
<div class="stat-card"><div class="sv">${ALL_DATA.length}</div><div class="sl">Sites Online</div></div>
`;
// Recent orders
const allOrders = ALL_DATA.flatMap(d => d.orders.map(o => ({...o, site:d.site, sid:d._site_id})));
allOrders.sort((a,b) => b.date.localeCompare(a.date));
document.getElementById('recent-orders').innerHTML = allOrders.slice(0,10).map(o =>
`<div style="padding:0.3rem 0;border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.75rem;">
<span style="color:var(--accent);">${o.site}</span> · ${o.product?.slice(0,30)} ·
<span style="color:var(--accent2);">$${o.amount?.toFixed(2)}</span>
<span class="badge ${o.status==='completed'?'badge-ok':'badge-pending'}">${o.status}</span>
</div>`
).join('') || '<div style="color:var(--muted);">No orders yet</div>';
// Recent users
const allUsers = ALL_DATA.flatMap(d => d.users.map(u => ({...u, site:d.site})));
allUsers.sort((a,b) => b.joined.localeCompare(a.joined));
document.getElementById('recent-users').innerHTML = allUsers.slice(0,10).map(u =>
`<div style="padding:0.3rem 0;border-bottom:1px solid rgba(255,255,255,0.03);font-size:0.75rem;">
<span style="color:var(--accent);">${u.site}</span> · ${u.username} · ${u.email}
</div>`
).join('') || '<div style="color:var(--muted);">No users yet</div>';
}
function renderOrders() {
const q = (document.getElementById('order-search').value||'').toLowerCase();
const sid = parseInt(document.getElementById('order-site').value)||0;
const status = document.getElementById('order-status').value;
let orders = ALL_DATA.flatMap(d => d.orders.map(o => ({...o, site:d.site, sid:d._site_id})));
if(sid) orders = orders.filter(o => o.sid===sid);
if(status) orders = orders.filter(o => o.status===status);
if(q) orders = orders.filter(o => (o.product+o.email+o.code).toLowerCase().includes(q));
orders.sort((a,b) => b.date.localeCompare(a.date));
document.getElementById('orders-table').querySelector('tbody').innerHTML = orders.slice(0,200).map(o =>
`<tr>
<td style="color:var(--accent);">${o.site}</td>
<td>#${o.id} <span style="font-size:0.65rem;color:var(--muted);">${o.code||''}</span></td>
<td>${o.product?.slice(0,35)}</td>
<td>${o.email}</td>
<td style="color:var(--accent2);">$${o.amount?.toFixed(2)}</td>
<td><span class="badge ${o.status==='completed'?'badge-ok':'badge-pending'}">${o.status}</span></td>
<td style="font-size:0.7rem;color:var(--muted);">${new Date(o.date).toLocaleDateString()}</td>
</tr>`
).join('');
}
function renderUsers() {
const q = (document.getElementById('user-search').value||'').toLowerCase();
const sid = parseInt(document.getElementById('user-site').value)||0;
let users = ALL_DATA.flatMap(d => d.users.map(u => ({...u, site:d.site, sid:d._site_id})));
if(sid) users = users.filter(u => u.sid===sid);
if(q) users = users.filter(u => (u.username+u.email).toLowerCase().includes(q));
document.getElementById('users-table').querySelector('tbody').innerHTML = users.slice(0,200).map(u =>
`<tr>
<td style="color:var(--accent);">${u.site}</td>
<td><strong>${u.username}</strong></td>
<td>${u.email}</td>
<td style="color:var(--accent2);">$${u.balance?.toFixed(2)}</td>
<td>${u.orders}</td>
<td style="font-size:0.7rem;color:var(--muted);">${new Date(u.joined).toLocaleDateString()}</td>
</tr>`
).join('');
}
function renderProducts() {
const q = (document.getElementById('product-search').value||'').toLowerCase();
const sid = parseInt(document.getElementById('product-site').value)||0;
let prods = ALL_DATA.flatMap(d => d.products.map(p => ({...p, site:d.site, sid:d._site_id})));
if(sid) prods = prods.filter(p => p.sid===sid);
if(q) prods = prods.filter(p => p.name.toLowerCase().includes(q));
document.getElementById('products-table').querySelector('tbody').innerHTML = prods.slice(0,200).map(p =>
`<tr>
<td style="color:var(--accent);">${p.site}</td>
<td>${p.name}</td>
<td>${p.category}</td>
<td style="color:var(--accent2);">$${p.price?.toFixed(2)}</td>
<td>${p.stock}</td>
</tr>`
).join('');
}
function renderTransactions() {
const q = (document.getElementById('txn-search').value||'').toLowerCase();
const sid = parseInt(document.getElementById('txn-site').value)||0;
let txns = ALL_DATA.flatMap(d => d.transactions.map(t => ({...t, site:d.site, sid:d._site_id})));
if(sid) txns = txns.filter(t => t.sid===sid);
if(q) txns = txns.filter(t => (t.user+t.method).toLowerCase().includes(q));
document.getElementById('txns-table').querySelector('tbody').innerHTML = txns.slice(0,200).map(t =>
`<tr>
<td style="color:var(--accent);">${t.site}</td>
<td>${t.user}</td>
<td><span class="badge ${t.type==='deposit'?'badge-ok':'badge-danger'}">${t.type}</span></td>
<td>${t.method||'-'}</td>
<td style="color:${t.amount>0?'var(--success)':'var(--danger)'};">${t.amount>0?'+':''}$${t.amount?.toFixed(2)}</td>
<td style="font-size:0.7rem;color:var(--muted);">${new Date(t.date).toLocaleDateString()}</td>
</tr>`
).join('');
}
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
const tabMap = {'overview':1,'orders':2,'users':3,'products':4,'transactions':5};
document.querySelector('.tab:nth-child(' + tabMap[tab] + ')').classList.add('active');
document.getElementById('panel-'+tab).classList.add('active');
if(tab==='orders') renderOrders();
if(tab==='users') renderUsers();
if(tab==='products') renderProducts();
if(tab==='transactions') renderTransactions();
}
setInterval(() => document.getElementById('clock').textContent = new Date().toLocaleTimeString(), 1000);
loadAll();
setInterval(loadAll, 30000);
</script>
</body>
</html>

116
templates/watchdog.html Normal file
View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Watchdog — CCSite Fleet</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0a0f;color:#e0e0e0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;padding:1.5rem}
h1{font-size:1.5rem;margin-bottom:0.3rem}
h1 span{color:#22c55e}
.sub{color:#666;font-size:0.8rem;margin-bottom:1.5rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:1rem}
.card{background:#111118;border-radius:10px;padding:1.2rem;border:1px solid #1a1a24}
.card.pass{border-color:#22c55e33}
.card.warn{border-color:#f59e0b33}
.card.fail{border-color:#ef444433}
.card .header{display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem}
.card .name{font-weight:700;font-size:0.9rem}
.card .status{font-size:0.7rem;padding:0.2rem 0.6rem;border-radius:4px;font-weight:700;text-transform:uppercase}
.status.pass{background:#22c55e22;color:#22c55e}
.status.warn{background:#f59e0b22;color:#f59e0b}
.status.fail{background:#ef444422;color:#ef4444}
.card .detail{font-size:0.75rem;color:#666;font-family:monospace}
.summary{display:flex;gap:1.5rem;margin-bottom:1.5rem;flex-wrap:wrap}
.summary .stat{text-align:center}
.summary .stat .n{font-size:2rem;font-weight:900}
.summary .stat .l{font-size:0.65rem;color:#666;text-transform:uppercase;letter-spacing:1px}
.summary .stat .n.green{color:#22c55e}
.summary .stat .n.yellow{color:#f59e0b}
.summary .stat .n.red{color:#ef4444}
.issues{background:#ef444411;border:1px solid #ef444433;border-radius:10px;padding:1rem;margin-bottom:1.5rem}
.issues h3{font-size:0.85rem;color:#ef4444;margin-bottom:0.5rem}
.issues li{font-size:0.8rem;color:#fca5a5;margin-left:1.2rem;line-height:1.6}
.healing{background:#f59e0b11;border:1px solid #f59e0b33;border-radius:10px;padding:1rem;margin-bottom:1.5rem}
.healing h3{font-size:0.85rem;color:#f59e0b;margin-bottom:0.5rem}
.healing li{font-size:0.8rem;color:#fcd34d;margin-left:1.2rem;line-height:1.6}
.actions{margin-top:1rem;display:flex;gap:0.5rem}
button{background:#1a1a2e;border:1px solid #333;color:#ccc;padding:0.5rem 1rem;border-radius:6px;cursor:pointer;font-size:0.8rem}
button:hover{background:#222;border-color:#555}
.refresh{color:#666;font-size:0.7rem;margin-top:0.5rem}
</style>
</head>
<body>
<h1>🛡️ <span>CCSite Watchdog</span></h1>
<p class="sub">Self-healing health monitor — checks every 60s</p>
<div id="summary" class="summary"></div>
<div id="issues"></div>
<div id="healing"></div>
<div class="actions">
<button onclick="runScan()">🔍 Run Scan Now</button>
<button onclick="location.reload()">🔄 Refresh</button>
</div>
<div class="grid" id="checks"></div>
<p class="refresh" id="last"></p>
<script>
function icon(s) { return s==='pass'?'✅':s==='warn'?'⚠️':'❌' }
async function load() {
try {
const r = await fetch('/api/watchdog');
const d = await r.json();
// Summary
const s = d.summary || {};
const statusEmoji = s.status === 'healthy' ? '🟢' : s.status === 'degraded' ? '🟡' : '🔴';
document.getElementById('summary').innerHTML =
`<div class="stat"><div class="n green">${s.pass||0}</div><div class="l">Passing</div></div>
<div class="stat"><div class="n yellow">${s.warn||0}</div><div class="l">Warnings</div></div>
<div class="stat"><div class="n red">${s.fail||0}</div><div class="l">Failing</div></div>
<div class="stat"><div class="n">${statusEmoji}</div><div class="l">${(s.status||'unknown').toUpperCase()}</div></div>`;
// Issues
const issues = d.issues || [];
document.getElementById('issues').innerHTML = issues.length ?
`<div class="issues"><h3>🚨 ${issues.length} Issue(s)</h3><ul>${issues.map(i=>`<li>${i}</li>`).join('')}</ul></div>` : '';
// Healing
const healing = d.healing_actions || [];
document.getElementById('healing').innerHTML = healing.length ?
`<div class="healing"><h3>🔧 Self-Healing Actions</h3><ul>${healing.map(h=>`<li>${h.success?'✅':'❌'} ${h.action}: ${h.detail}</li>`).join('')}</ul></div>` : '';
// Checks
const checks = d.checks || [];
document.getElementById('checks').innerHTML = checks.map(c =>
`<div class="card ${c.status}">
<div class="header">
<span class="name">${c.check}</span>
<span class="status ${c.status}">${c.status}</span>
</div>
<div class="detail">${c.detail}</div>
</div>`
).join('');
document.getElementById('last').textContent = `Last check: ${d.last_check || 'never'}`;
} catch(e) {
document.getElementById('summary').innerHTML = '<div class="stat"><div class="n red">⚠️</div><div class="l">Watchdog unavailable</div></div>';
}
}
async function runScan() {
document.querySelector('button').textContent = 'Scanning...';
await fetch('/api/watchdog/run', {method:'POST'});
await load();
document.querySelector('button').textContent = '🔍 Run Scan Now';
}
load();
setInterval(load, 30000);
</script>
</body>
</html>