sunnyware / static /admin-v2.html
shamiur's picture
Part 694 fix: Rename tools-test endpoint to avoid route collision
6c100e0
Raw History Blame Contribute Delete
251 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sunnyware - Admin</title>
<style>
:root {
--bg: #0b0d12; --sidebar: #12161f; --panel: #12161f; --border: #232a38;
--text: #e7ecf3; --muted: #8892a6; --accent: #5b8def; --danger: #ef5b5b;
--success: #2ecc71; --warn: #f1c40f;
}
* { box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, sans-serif; margin: 0; display: flex; height: 100vh; overflow: hidden; }
/* Sidebar */
.sidebar { width: 240px; background: var(--sidebar); border-right: 1px solid var(--border); overflow-y: auto; flex-shrink: 0; }
.logo { padding: 20px 24px; font-size: 16px; font-weight: 700; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.logo small { font-size: 11px; color: var(--muted); font-weight: 400; }
.nav-group { padding: 12px 0; border-bottom: 1px solid var(--border); }
.nav-group-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; padding: 0 24px 6px; }
.nav-item { padding: 8px 24px; color: var(--muted); cursor: pointer; border-left: 3px solid transparent; transition: 0.15s; font-size: 13px; }
.nav-item:hover { background: rgba(255,255,255,0.03); color: var(--text); }
.nav-item.active { color: var(--text); border-left-color: var(--accent); background: rgba(91, 141, 239, 0.12); font-weight: 500; }
/* Main */
.main { flex: 1; overflow-y: auto; padding: 24px 32px; min-width: 0; }
.main-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; gap: 12px; flex-wrap: wrap; }
.main-header h1 { margin: 0; font-size: 22px; }
.header-actions { display: flex; gap: 8px; align-items: center; font-size: 13px; }
.header-actions .who { color: var(--muted); font-size: 12px; }
.hidden { display: none !important; }
h2 { font-size: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; margin: 24px 0 14px; }
h2:first-child { margin-top: 0; }
/* Cards */
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
.card .title { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; }
.card .value { font-size: 24px; font-weight: 700; }
.card .value.ok { color: var(--success); }
.card .value.bad { color: var(--danger); }
/* Tables & Forms */
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; background: var(--panel); border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; }
th { background: rgba(0,0,0,0.2); color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
tr:hover td { background: rgba(255,255,255,0.02); }
input, select, textarea {
background: #0b0d12; border: 1px solid var(--border); color: var(--text);
padding: 8px 10px; border-radius: 6px; font: inherit; width: 100%; font-size: 13px;
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
label { display: block; font-size: 11px; color: var(--muted); margin: 10px 0 4px; text-transform: uppercase; letter-spacing: 0.5px; }
button {
background: var(--accent); color: #fff; border: 0; padding: 8px 14px;
border-radius: 6px; font: inherit; font-weight: 500; cursor: pointer; font-size: 13px; transition: 0.15s;
}
button:hover { opacity: 0.85; }
button.danger { background: var(--danger); }
button.success { background: var(--success); }
button.small { padding: 4px 8px; font-size: 11px; }
button.ghost { background: rgba(255,255,255,0.05); color: var(--text); border: 1px solid var(--border); }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; align-items: end; margin-bottom: 14px; }
.form-row button { height: 36px; }
.form-row label { margin-bottom: 3px; }
.ledger { max-height: 300px; overflow-y: auto; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; background: rgba(91, 141, 239, 0.15); color: var(--accent); }
.tag.ok { background: rgba(46, 204, 113, 0.15); color: var(--success); }
.tag.bad { background: rgba(239, 91, 91, 0.15); color: var(--danger); }
/* Toast */
#toast {
position: fixed; bottom: 20px; right: 20px; padding: 10px 18px;
border-radius: 6px; color: #fff; font-weight: 500; opacity: 0;
transform: translateY(10px); transition: all 0.25s; z-index: 1000; font-size: 13px;
}
#toast.show { opacity: 1; transform: translateY(0); }
#toast.error { background: var(--danger); }
#toast.success { background: var(--success); }
/* Print */
@media print {
.sidebar, .header-actions, .nav-item, button, .form-row { display: none !important; }
body { display: block; background: #fff; color: #000; }
.main { padding: 0; }
.card, table { border-color: #ccc; }
th, td { border-color: #ddd; color: #000; }
th { background: #f5f5f5; }
}
/* Part 688: Provider dashboard enhancements */
.llm-sparkline { display: inline-flex; align-items: flex-end; gap: 1px; height: 18px; }
.llm-sparkline-bar { width: 3px; background: #5b8def; border-radius: 1px; opacity: 0.7; }
.llm-sparkline-bar:hover { opacity: 1; }
.health-pause-btn {
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text);
cursor: pointer;
font-size: 11px;
}
.health-pause-btn.paused { background: #e67e22; border-color: #e67e22; color: white; }
</style>
<style>
.toggle-switch { position: relative; display: inline-block; width: 40px; height: 22px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #444; transition: .2s; border-radius: 22px; }
.toggle-slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: .2s; border-radius: 50%; }
input:checked + .toggle-slider { background-color: var(--accent); }
input:checked + .toggle-slider:before { transform: translateX(18px); }
</style>
</head>
<body>
<div class="sidebar" id="sidebar" style="display:none;">
<div class="logo">Sunnyware <small>v2</small></div>
<div class="nav-group">
<div class="nav-group-label">Overview</div>
<div class="nav-item" data-tab="dashboard">Dashboard</div>
<div class="nav-item" data-tab="organizations">Organizations</div>
<div class="nav-item" data-tab="users">Users (SSO)</div>
<div class="nav-item" data-tab="workos-users">WorkOS Users</div>
<div class="nav-item" data-tab="approvals">HITL Approvals</div>
<div class="nav-item" data-tab="hitl-config">HITL Config</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Finance</div>
<div class="nav-item" data-tab="customers">Customers</div>
<div class="nav-item" data-tab="plans">Plans</div>
<div class="nav-item" data-tab="invoices">Invoices</div>
<div class="nav-item" data-tab="paddle-live">Paddle Live</div>
<div class="nav-item" data-tab="overage">Overage Billing</div>
<div class="nav-item" data-tab="accounting">Accounting</div>
<div class="nav-item" data-tab="reports">Reports</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Operations</div>
<div class="nav-item" data-tab="coupons">Coupons</div>
<div class="nav-item" data-tab="dunning">Dunning</div>
<div class="nav-item" data-tab="compliance">Compliance</div>
<div class="nav-item" data-tab="budget">Budget</div>
<div class="nav-item" data-tab="capital">Capital</div>
<div class="nav-item" data-tab="alerts">Alerts</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Team and Access</div>
<div class="nav-item" data-tab="access">Plan Access</div>
<div class="nav-item" data-tab="health">Account Health</div>
<div class="nav-item" data-tab="team">Team</div>
<div class="nav-item" data-tab="calculators">Calculators</div>
<div class="nav-item" data-tab="cfo">CFO Agent</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Monitoring</div>
<div class="nav-item" data-tab="analytics">User Analytics</div>
<div class="nav-item" data-tab="llm-dashboard">LLM Dashboard</div>
<div class="nav-item" data-tab="email">Email</div>
<div class="nav-item" data-tab="api-keys">API Keys</div>
<div class="nav-item" data-tab="webhooks">Webhooks</div>
<div class="nav-item" data-tab="chat-audit">Chat Audit</div>
<div class="nav-item" data-tab="scheduled-tasks">Scheduled Tasks</div>
<div class="nav-item" data-tab="rag-admin">RAG Management</div>
<div class="nav-item" data-tab="security">Security</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Settings</div>
<div class="nav-item" data-tab="settings">Settings</div>
<div class="nav-item" data-tab="backup">Backup &amp; Restore</div>
<div class="nav-item" data-tab="impersonate">Impersonate</div>
<div class="nav-item" data-tab="feature-flags">Feature Flags</div>
<div class="nav-item" data-tab="slack-bot">Slack Bot</div>
<div class="nav-item" data-tab="feedback">Feedback</div>
<div style="padding: 12px 16px 4px 16px; font-size: 10px; font-weight: 600; color: var(--muted); letter-spacing: 0.5px; text-transform: uppercase;">Resources</div>
<a href="/about" target="_blank" class="nav-item" style="display: block; text-decoration: none; color: inherit;">About</a>
<a href="/docs" target="_blank" class="nav-item" style="display: block; text-decoration: none; color: inherit;">API Docs</a>
</div>
</div>
<div class="main" id="main-content">
<div id="login-section">
<h1 style="text-align:center;margin-bottom:8px;">Sunnyware Admin</h1>
<p style="text-align:center;color:var(--muted);font-size:13px;margin-bottom:24px;">Sign in with your team email or Google account.</p>
<div class="card" style="max-width: 420px; margin: 0 auto;">
<div id="login-error" style="color:var(--danger);font-size:12px;margin-bottom:12px;text-align:center;min-height:16px;"></div>
<button onclick="loginWithGoogle()" style="width:100%;padding:12px;font-size:14px;display:flex;align-items:center;justify-content:center;gap:10px;background:#fff;color:#333;border:1px solid #ddd;border-radius:6px;cursor:pointer;font-weight:600;">
<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Sign in with Google
</button>
<div style="text-align:center;color:var(--muted);font-size:12px;margin:16px 0;">or login with email</div>
<input id="admin-email" type="email" placeholder="you@company.com" style="width:100%;padding:12px;margin-bottom:12px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:13px;">
<button onclick="loginWithEmail()" style="width:100%;padding:12px;font-size:14px;">Continue with Email</button>
<div style="margin-top:16px;text-align:center;">
<button class="ghost small" onclick="document.getElementById('login-section').style.display='none'; showAdminLoginKey()" style="font-size:11px;opacity:0.7;">Use Master Key Instead</button>
</div>
</div>
</div>
<div id="admin-section" class="hidden">
<div id="alert-banner" style="display:none;background:#3a1c1c;border:1px solid #e74c3c;border-radius:6px;padding:12px 16px;margin-bottom:16px;color:#e74c3c;font-size:13px;">
<strong>Warning:</strong> Some providers are auto-disabled. <span id="alert-providers"></span>
<button onclick="testAllProviders(this)" style="margin-left:12px;background:#e74c3c;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;">Test All Now</button>
</div>
<div class="main-header">
<h1 id="page-title">Dashboard</h1>
<div class="header-actions">
<span class="who" id="who"></span>
<button class="ghost small" onclick="logout()">Log out</button>
</div>
</div>
<div id="content"></div>
</div>
</div>
<div id="toast"></div>
<script>
// ═══════════════════════════════════════════════════════════
// Module-level helpers (accessible from all render functions)
// ═══════════════════════════════════════════════════════════
function _fmtNum(n) {
return n >= 1000000 ? (n/1000000).toFixed(1)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n || 0);
}
function _fmtAgo(ts) {
if (!ts) return '-';
const s = Math.floor(Date.now()/1000 - ts);
if (s < 60) return 'just now';
if (s < 3600) return Math.floor(s/60)+'m ago';
if (s < 86400) return Math.floor(s/3600)+'h ago';
return Math.floor(s/86400)+'d ago';
}
function _fmtDate(ts) {
if (!ts) return '-';
const d = new Date(ts * 1000);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}
function _fmtDuration(seconds) {
if (!seconds) return '0m';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return h + 'h ' + m + 'm';
return m + 'm';
}
function _isOnline(lastActiveTs) {
return lastActiveTs && (Date.now()/1000 - lastActiveTs) < 300;
}
// ═════════════════════════════════════════════════════════════════
// CORE STATE
// ═════════════════════════════════════════════════════════════════
let API_KEY = localStorage.getItem('sunnyware_admin_key') || '';
// Part 234: Auto-login via invite_token
const urlParams = new URLSearchParams(window.location.search);
const inviteToken = urlParams.get('invite_token');
if (inviteToken) {
localStorage.setItem('sunnyware_admin_key', inviteToken);
API_KEY = inviteToken;
window.history.replaceState({}, document.title, "/admin");
setTimeout(() => window.location.reload(), 100);
}
let CURRENT_TAB = 'dashboard';
let CACHE = {}; // { customers: [], plans: [], invoices: [], ledger: [], ... }
let ACC_STATE = null;
// ═════════════════════════════════════════════════════════════════
// HELPERS
// ═════════════════════════════════════════════════════════════════
function $(id) { return document.getElementById(id); }
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function money(n) {
const sym = (ACC_STATE && ACC_STATE.sym) || '$';
const num = parseFloat(n) || 0;
return (num < 0 ? '-' : '') + sym + Math.abs(num).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2});
}
function num(n) { return (parseFloat(n) || 0).toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2}); }
function fmtDate(ts) {
if (!ts) return '—';
const d = new Date(ts * 1000 || ts);
return d.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
}
let prevAutoDisabled = {}; // Part 666: Track state changes for toasts
function toast(msg, type) {
const t = $('toast');
t.textContent = msg;
t.className = type + ' show';
setTimeout(() => t.className = '', 2800);
}
// ═════════════════════════════════════════════════════════════════
// API CALLS
// ═════════════════════════════════════════════════════════════════
async function api(path, method = 'GET', body = null) {
const opts = {
method,
headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json' }
};
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
if (res.status === 401) { logout(); throw new Error('Unauthorized'); }
if (res.status === 403) { toast('Forbidden — admin role required', 'error'); throw new Error('Forbidden'); }
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || res.statusText);
}
return res.json();
}
// ═════════════════════════════════════════════════════════════════
// AUTH
// ═════════════════════════════════════════════════════════════════
function saveKey() {
const k = $('api-key').value.trim();
if (!k) return toast('Key required', 'error');
API_KEY = k;
localStorage.setItem('sunnyware_admin_key', k);
init();
}
function logout() {
API_KEY = '';
localStorage.removeItem('sunnyware_admin_key');
$('sidebar').style.display = 'none';
$('login-section').style.display = 'block';
$('admin-section').classList.add('hidden');
}
// ═════════════════════════════════════════════════════════════════
// NAVIGATION
// ═════════════════════════════════════════════════════════════════
function nav(tab) {
CURRENT_TAB = tab;
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active', el.dataset.tab === tab);
});
const titles = {
dashboard: 'Dashboard',
organizations: 'Organizations',
users: 'Users (SSO)',
'workos-users': 'WorkOS Users',
approvals: 'HITL Approvals',
'hitl-config': 'HITL Config',
customers: 'Customers',
plans: 'Plans',
invoices: 'Invoices',
'paddle-live': 'Paddle Live (Sandbox API)',
accounting: 'Accounting',
reports: 'Reports',
coupons: 'Coupons',
dunning: 'Dunning',
compliance: 'Compliance',
budget: 'Budget',
capital: 'Capital',
alerts: 'Alerts',
access: 'Plan Access',
health: 'Account Health',
team: 'Team',
calculators: 'Calculators',
cfo: 'CFO Agent',
settings: 'Settings'
};
$('page-title').textContent = titles[tab] || tab;
const renderers = {
dashboard: renderDashboard,
organizations: renderOrganizations,
users: renderUsers,
'workos-users': renderWorkOSUsers,
'paddle-live': renderPaddleLive,
approvals: renderApprovals,
'hitl-config': renderHitlConfig,
customers: renderCustomers,
plans: renderPlans,
invoices: renderInvoices,
accounting: renderAccounting,
reports: renderReports,
coupons: renderCoupons,
dunning: renderDunning,
compliance: renderCompliance,
budget: renderBudget,
capital: renderCapital,
alerts: renderAlerts,
access: renderAccess,
health: renderHealth,
team: renderTeam,
calculators: renderCalculators,
cfo: renderCFO,
settings: renderSettings
};
const fn = renderers[tab];
if (!fn) return;
$('content').innerHTML = '<p style="color:var(--muted);">Loading...</p>';
Promise.resolve(fn()).catch(err => {
console.error(err);
$('content').innerHTML = '<p style="color:var(--danger);">Error: ' + esc(err.message) + '</p>';
});
}
// ═════════════════════════════════════════════════════════════════
// DASHBOARD
// ═════════════════════════════════════════════════════════════════
async function renderWorkOSUsers() {
$('content').innerHTML = '<p style="color:var(--muted);">Loading WorkOS users...</p>';
try {
const d = await api('/api/admin/workos/users');
const users = d.users || [];
if (!users.length) {
$('content').innerHTML = '<h2>WorkOS Users</h2><p style="color:var(--muted);">No users found in WorkOS.</p>';
return;
}
$('content').innerHTML = `
<h2>WorkOS Users (${users.length})</h2>
<p style="color:var(--muted); margin-bottom: 15px;">Users synced from WorkOS AuthKit (Google, Microsoft, etc.)</p>
<table>
<thead><tr><th>Email</th><th>First Name</th><th>Last Name</th><th>ID</th><th>Created</th><th>Status</th><th>PDF</th></tr></thead>
<tbody>
${users.map(u => `
<tr>
<td><strong>${esc(u.email || '')}</strong></td>
<td>${esc(u.first_name || '—')}</td>
<td>${esc(u.last_name || '—')}</td>
<td><code style="font-size:11px">${esc(u.id || '')}</code></td>
<td>${u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'}</td>
<td><span class="tag ${u.email_verified ? 'ok' : 'warn'}">${u.email_verified ? 'Verified' : 'Unverified'}</span></td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
$('content').innerHTML = `<h2>WorkOS Users</h2><p style="color:var(--danger);">Error loading users: ${esc(e.message)}</p>`;
}
}
async function renderDashboard() {
const [saas, acc] = await Promise.all([
api('/api/saas/dashboard'),
api('/api/acc/dashboard')
]);
const billing = await api('/api/saas/billing/status');
$('content').innerHTML = `
<div class="grid">
<div class="card"><div class="title">MRR</div><div class="value">${money(saas.mrr)}</div></div>
<div class="card"><div class="title">ARR</div><div class="value">${money(saas.arr)}</div></div>
<div class="card"><div class="title">Active Orgs</div><div class="value">${saas.active_orgs} / ${saas.total_orgs}</div></div>
<div class="card"><div class="title">Total Revenue</div><div class="value">${money(saas.total_revenue)}</div></div>
<div class="card"><div class="title">Invoices</div><div class="value">${saas.total_invoices}</div></div>
</div>
<h2>Financial Overview</h2>
<div class="grid">
<div class="card"><div class="title">Revenue (this month)</div><div class="value">${money(acc.revenue)}</div></div>
<div class="card"><div class="title">AI Cost</div><div class="value">${money(acc.ai_cost)}</div></div>
<div class="card"><div class="title">Gateway Fees</div><div class="value">${money(acc.fee)}</div></div>
<div class="card"><div class="title">Expenses</div><div class="value">${money(acc.expenses)}</div></div>
<div class="card"><div class="title">Gross Margin</div><div class="value ${acc.gross_margin >= 70 ? 'ok' : 'bad'}">${acc.gross_margin.toFixed(1)}%</div></div>
<div class="card"><div class="title">Net Profit</div><div class="value ${acc.net >= 0 ? 'ok' : 'bad'}">${money(acc.net)}</div></div>
</div>
<h2>Paddle Billing</h2>
<div class="card">
<p style="margin:0;">${
billing.mode === 'live'
? '<strong style="color:var(--success);">Live Mode</strong> — Paddle Connected (Real Payments)'
: billing.mode === 'sandbox'
? '<strong style="color:var(--warn);">Sandbox Mode</strong> — Paddle Sandbox (Test Payments Only)'
: '<strong style="color:var(--danger);">Simulated</strong> — Add PADDLE_API_KEY to enable billing'
}</p>
<p style="margin:8px 0 0 0;font-size:12px;color:var(--muted);">Current mode: <code>${billing.mode || 'unknown'}</code> · Provider: <code>${billing.provider || '?'}</code></p>
</div>
`;
}
// =============================================================
// PADDLE LIVE (Part 211) - pulls from Paddle API, not local DB
// =============================================================
function _fmtIsoDate(iso) {
if (!iso) return '-';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, {year:'numeric', month:'short', day:'numeric'});
} catch(e) { return iso; }
}
async function renderPaddleLive() {
$('content').innerHTML = '<p style="color:var(--muted);">Loading live data from Paddle API...</p>';
try {
const d = await api('/api/saas/paddle/live');
if (!d || d.ok === false) {
$('content').innerHTML = [
'<div class="card">',
'<p style="color:var(--danger);margin:0;"><strong>Paddle API error:</strong> ' + esc(d.error || 'unknown') + '</p>',
'<p style="color:var(--muted);font-size:12px;margin:8px 0 0 0;">Check that <code>PADDLE_API_KEY</code> is set and valid for the ' + esc(d.mode || 'sandbox') + ' environment.</p>',
'</div>',
'<div style="margin-top:16px;"><button onclick="renderPaddleLive()">Retry</button></div>'
].join('');
return;
}
const modeBadge = d.mode === 'live'
? '<strong style="color:var(--success);">Live</strong>'
: '<strong style="color:var(--warn);">Sandbox</strong>';
const subsHtml = (d.subscriptions || []).length
? d.subscriptions.map(s => [
'<tr>',
'<td><code style="font-size:11px;">' + esc(s.id) + '</code></td>',
'<td>' + esc(s.customer_email) + '</td>',
'<td>' + esc(s.plan_name) + '</td>',
'<td><span style="color:' + (s.status === 'active' ? 'var(--success)' : 'var(--muted)') + ';">' + esc(s.status) + '</span></td>',
'<td class="n">$' + num(s.amount) + '</td>',
'<td>' + _fmtIsoDate(s.next_billed_at) + '</td>',
'</tr>'
].join('')).join('')
: '<tr><td colspan="6" style="color:var(--muted);">No active subscriptions in Paddle. Create a real checkout to populate this.</td></tr>';
const upcomingHtml = (d.upcoming_payments || []).length
? d.upcoming_payments.map(u => [
'<tr>',
'<td>' + _fmtIsoDate(u.date) + '</td>',
'<td>' + esc(u.customer_email) + '</td>',
'<td class="n">$' + num(u.amount) + '</td>',
'<td><code style="font-size:11px;">' + esc(u.subscription_id) + '</code></td>',
'</tr>'
].join('')).join('')
: '<tr><td colspan="4" style="color:var(--muted);">No upcoming payments scheduled.</td></tr>';
$('content').innerHTML = [
'<div class="grid">',
'<div class="card"><div class="title">MRR (live)</div><div class="value">$' + num(d.mrr) + '</div></div>',
'<div class="card"><div class="title">ARR (live)</div><div class="value">$' + num(d.arr) + '</div></div>',
'<div class="card"><div class="title">Active Subs</div><div class="value">' + d.active_subscriptions + ' / ' + d.total_subscriptions + '</div></div>',
'<div class="card"><div class="title">Customers</div><div class="value">' + d.customer_count + '</div></div>',
'<div class="card"><div class="title">Mode</div><div class="value" style="font-size:15px;">' + modeBadge + '</div></div>',
'</div>',
'<h2>Live Subscriptions (from Paddle API)</h2>',
'<table>',
'<thead><tr><th>Subscription</th><th>Customer</th><th>Plan</th><th>Status</th><th class="n">Amount</th><th>Next Billed</th></tr></thead>',
'<tbody>' + subsHtml + '</tbody>',
'</table>',
'<h2>Upcoming Payments</h2>',
'<table>',
'<thead><tr><th>Date</th><th>Customer</th><th class="n">Amount</th><th>Subscription</th></tr></thead>',
'<tbody>' + upcomingHtml + '</tbody>',
'</table>',
'<div style="margin-top:20px;display:flex;gap:8px;"><button onclick="renderPaddleLive()">Refresh from Paddle</button></div>',
'<p style="margin-top:20px;font-size:12px;color:var(--muted);line-height:1.6;"><strong>Note:</strong> This page shows data pulled directly from the Paddle API - not our local database. If a customer was upgraded via the sandbox auto-approval flow, they will appear in the Organizations tab (upgraded locally) but <em>not</em> here (no real Paddle subscription). Once a real checkout completes, the customer will appear in both places.</p>'
].join('');
} catch(e) {
$('content').innerHTML = '<div class="card"><p style="color:var(--danger);margin:0;"><strong>Error:</strong> ' + esc(e.message) + '</p></div>';
}
}
// ═════════════════════════════════════════════════════════════════
// ORGANIZATIONS (existing)
// ═════════════════════════════════════════════════════════════════
async function renderOrganizations() {
const d = await api('/api/saas/orgs');
const usageMap = {};
try {
const u = await api('/api/saas/usage/summary');
for (const row of (u.orgs || [])) { usageMap[row.org_id] = row; }
} catch(e) {}
$('content').innerHTML = `
<h2>Create Organization</h2>
<div class="form-row">
<div><label>Name</label><input id="new-org-name" placeholder="Company Name"></div>
<div><label>Owner Email</label><input id="new-org-email" type="email" placeholder="owner@company.com"></div>
<div><label>Plan</label>
<select id="new-org-plan">
<option value="free">Free</option>
<option value="pro">Pro ($49)</option>
<option value="enterprise">Enterprise ($299)</option>
</select>
</div>
<button onclick="createOrg()">Create Org</button>
</div>
<h2>Organizations (${d.count})</h2>
<table>
<thead><tr><th>Org ID</th><th>Name</th><th>Plan</th><th>Token Usage</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
${d.orgs.map(o => `
<tr>
<td><code style="font-size:11px">${esc(o.id)}</code></td>
<td>${esc(o.name)}</td>
<td>
<select onchange="updatePlan('${o.id}', this.value)" style="width:auto;padding:4px;font-size:12px">
<option value="free" ${o.plan==='free'?'selected':''}>Free</option>
<option value="pro" ${o.plan==='pro'?'selected':''}>Pro</option>
<option value="enterprise" ${o.plan==='enterprise'?'selected':''}>Enterprise</option>
</select>
</td>
<td>${(() => {
const u = usageMap[o.id];
if (!u || !u.tokens) return '<span style="color:var(--muted);font-size:11px;">—</span>';
const tk = u.tokens;
const used = tk.used || 0;
const limit = tk.limit || 0;
const pct = limit > 0 ? Math.min(100, Math.round(used / limit * 100)) : 0;
const color = limit > 0 && pct >= 90 ? '#e74c3c' : (limit > 0 && pct >= 70 ? '#f39c12' : '#2ecc71');
const fmt = n => n >= 1e9 ? (n/1e9).toFixed(1)+'B' : (n >= 1e6 ? (n/1e6).toFixed(1)+'M' : (n >= 1e3 ? (n/1e3).toFixed(1)+'K' : n));
if (limit < 0) return `<span style="color:var(--muted);font-size:11px;">unlimited (${fmt(used)} used)</span>`;
return `<div style="font-size:11px;">${fmt(used)} / ${fmt(limit)} <span style="color:${color};">(${pct}%)</span></div>
<div style="height:4px;background:var(--bg-tertiary);border-radius:2px;margin-top:3px;overflow:hidden;">
<div style="height:100%;width:${pct}%;background:${color};"></div>
</div>`;
})()}</td>
<td><span class="tag ${o.status==='active'?'ok':'bad'}">${o.status}</span></td>
<td>
<button class="small" onclick="checkout('${o.id}', '${o.plan}')">Pay</button>
<button class="ghost small" onclick="deleteOrg('${o.id}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function createOrg() {
const name = $('new-org-name').value.trim();
const email = $('new-org-email').value.trim();
const plan = $('new-org-plan').value;
if (!name) return toast('Name required', 'error');
await api('/api/saas/orgs', 'POST', { name, owner_email: email, plan });
toast('Organization created', 'success');
nav('organizations');
}
async function updatePlan(orgId, plan) {
await api('/api/saas/orgs/' + orgId, 'PATCH', { plan });
toast('Plan updated to ' + plan, 'success');
}
async function deleteOrg(id) {
if (!confirm('Delete this organization?')) return;
await api('/api/saas/orgs/' + id, 'DELETE');
toast('Deleted', 'success');
nav('organizations');
}
async function checkout(orgId, planId) {
const res = await api('/api/saas/checkout', 'POST', { org_id: orgId, plan_id: planId });
if (res.ok && res.url) window.location.href = res.url;
else toast('Checkout error: ' + (res.error || 'unknown'), 'error');
}
// ═════════════════════════════════════════════════════════════════
// USERS (existing)
// ═════════════════════════════════════════════════════════════════
async function renderUsers() {
const d = await api('/sso/users');
$('content').innerHTML = `
<h2>SSO Users (${d.count || d.users.length})</h2>
<table>
<thead><tr><th>Email</th><th>Role</th><th>Key Preview</th><th>RPM Limit</th><th>Created</th><th></th></tr></thead>
<tbody>
${d.users.map(u => `
<tr>
<td>${esc(u.email)}</td>
<td><span class="tag ${u.role==='admin'?'ok':''}">${u.role}</span></td>
<td><code style="font-size:11px">${esc(u.api_key_preview || '—')}</code></td>
<td>${fmtDate(u.created_at)}</td>
<td><button class="ghost small" onclick="deleteUser('${esc(u.email)}')">Delete</button></td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function deleteUser(email) {
if (!confirm('Delete ' + email + '?')) return;
await api('/sso/users/delete', 'POST', { email });
toast('User deleted', 'success');
nav('users');
}
// ═════════════════════════════════════════════════════════════════
// APPROVALS (existing)
// ═════════════════════════════════════════════════════════════════
async function renderApprovals() {
const d = await api('/api/approvals');
$('content').innerHTML = `
<h2>Pending Approvals (${d.count})</h2>
${d.count === 0 ? '<p style="color:var(--muted)">No pending approvals.</p>' : `
<table>
<thead><tr><th>Tool</th><th>Args</th><th>Time</th><th></th></tr></thead>
<tbody>
${d.approvals.map(a => `
<tr>
<td><strong>${esc(a.tool)}</strong></td>
<td><code style="font-size:11px">${esc(JSON.stringify(a.args).slice(0,80))}</code></td>
<td>${new Date(a.created_at*1000).toLocaleTimeString()}</td>
<td>
<button class="success small" onclick="approveReq('${a.id}')">Approve</button>
<button class="danger small" onclick="rejectReq('${a.id}')">Reject</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
// ═══════════════════════════════════════════════════════════════
// PART 199: HITL Config
// ═══════════════════════════════════════════════════════════════
async function renderHitlConfig() {
const d = await api('/api/hitl/tools');
const tools = d.tools || [];
const globalMap = d.global || {};
const overrides = d.overrides || {};
const effective = d.effective || {};
const selectedUser = window._hitlUser || '';
const rows = tools.map(toolName => {
let decision;
if (selectedUser) {
decision = (overrides[selectedUser] && overrides[selectedUser][toolName]) || globalMap[toolName] || effective[toolName] || 'auto';
} else {
decision = globalMap[toolName] || effective[toolName] || 'auto';
}
const isOverride = selectedUser && overrides[selectedUser] && overrides[selectedUser][toolName];
return `<tr>
<td><strong>${esc(toolName)}</strong>${isOverride ? ' <span style="color:var(--accent);font-size:10px;">(override)</span>' : ''}</td>
<td>
<select data-tool="${esc(toolName)}" onchange="setHitlDecision('${esc(toolName)}', this.value)">
<option value="auto" ${decision==='auto'?'selected':''}>Auto</option>
<option value="approve" ${decision==='approve'?'selected':''}>Approve</option>
<option value="deny" ${decision==='deny'?'selected':''}>Deny</option>
</select>
</td>
</tr>`;
}).join('');
$('content').innerHTML = `
<h2>HITL Config</h2>
<p style="color:var(--muted);margin-bottom:16px;">
Control which tools require manual approval. Safe read-only tools default to <strong>Auto</strong>.
${selectedUser ? `<span style="color:var(--accent);">Editing overrides for <strong>${esc(selectedUser)}</strong></span>` : ''}
</p>
<div class="card" style="margin-bottom:16px;">
<div class="title">Bulk Actions (${selectedUser ? 'for ' + esc(selectedUser) : 'Global'})</div>
<button class="success small" onclick="bulkHitl('auto')">Set All → Auto</button>
<button class="small" onclick="bulkHitl('approve')">Set All → Approve</button>
<button class="danger small" onclick="bulkHitl('deny')">Set All → Deny</button>
<button class="ghost small" onclick="resetHitl()" style="margin-left:8px;">Reset All</button>
</div>
<div class="card" style="margin-bottom:16px;">
<div class="title">Per-User Override</div>
<p style="font-size:12px;color:var(--muted);margin:0 0 8px 0;">
Enter a user key (e.g. <code>user:alice@company.com</code>) to set rules for one user only.
</p>
<input id="hitl-user-input" placeholder="user:email@company.com" value="${esc(selectedUser)}" style="width:100%;margin-bottom:8px;">
<button class="small" onclick="applyUserFilter()">Apply Filter</button>
<button class="ghost small" onclick="clearUserFilter()">Clear (Global)</button>
</div>
<div class="card">
<div class="title">Tools (${tools.length} total)</div>
<div style="max-height:500px;overflow-y:auto;">
<table>
<thead><tr><th>Tool</th><th style="width:150px;">Decision</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
`;
}
async function setHitlDecision(tool, decision) {
const userKey = window._hitlUser || null;
await api('/api/hitl/config', 'POST', { tool, decision, user_key: userKey });
toast(tool + ' → ' + decision, 'success');
}
async function bulkHitl(decision) {
const userKey = window._hitlUser || null;
const target = userKey ? 'for ' + userKey : 'globally';
if (!confirm('Set ALL tools to "' + decision + '" ' + target + '?')) return;
await api('/api/hitl/config/bulk', 'POST', { decision, user_key: userKey });
toast('All tools → ' + decision, 'success');
nav('hitl-config');
}
async function resetHitl() {
if (!confirm('Reset all HITL config to defaults?')) return;
await api('/api/hitl/config/reset', 'POST');
toast('Reset complete', 'success');
nav('hitl-config');
}
function applyUserFilter() {
let u = (document.getElementById('hitl-user-input').value || '').trim();
if (u && !u.startsWith('user:') && u.includes('@')) u = 'user:' + u;
window._hitlUser = u;
nav('hitl-config');
}
function clearUserFilter() {
window._hitlUser = '';
nav('hitl-config');
}
async function approveReq(id) {
await api('/api/approvals/' + id + '/approve', 'POST');
toast('Approved', 'success');
nav('approvals');
}
async function rejectReq(id) {
await api('/api/approvals/' + id + '/reject', 'POST');
toast('Rejected', 'success');
nav('approvals');
}
// ═════════════════════════════════════════════════════════════════
// INIT
// ═════════════════════════════════════════════════════════════════
async function init() {
// Part 222: check if 2FA is required for this user
if (API_KEY) {
try {
const statusRes = await fetch('/api/2fa/status', { headers: { 'X-API-Key': API_KEY } });
if (statusRes.ok) {
const s = await statusRes.json();
if (s.enabled) {
const existingSess = localStorage.getItem('sunnyware_2fa_session');
let valid = false;
if (existingSess) {
const check = await fetch('/api/2fa/check-session', {
headers: { 'X-API-Key': API_KEY, 'X-2FA-Session': existingSess }
});
if (check.ok) {
const cd = await check.json();
valid = !!cd.valid;
}
}
if (!valid) {
show2FAChallenge(s.user);
return;
}
}
}
} catch (e) {
console.warn('[2fa-init] check failed:', e);
}
}
// Part 201: handle Paddle redirect params
const params = new URLSearchParams(window.location.search);
const checkoutStatus = params.get('checkout');
const ptxn = params.get('ptxn') || params.get('_ptxn'); // Paddle uses both
const simulated = params.get('simulated');
if (checkoutStatus === 'success' || ptxn || simulated) {
// Clean URL so refresh doesn't re-trigger
window.history.replaceState({}, '', window.location.pathname);
// Show toast after a short delay
setTimeout(() => {
if (checkoutStatus === 'cancel') {
toast('Checkout cancelled', 'error');
} else if (simulated) {
toast('Checkout simulated (Paddle not configured)', 'success');
} else {
toast('Payment successful — subscription active', 'success');
}
}, 500);
}
if (!API_KEY) {
$('login-section').style.display = 'block';
$('admin-section').classList.add('hidden');
$('sidebar').style.display = 'none';
return;
}
try {
await api('/api/saas/dashboard');
$('login-section').style.display = 'none';
$('admin-section').classList.remove('hidden');
$('sidebar').style.display = 'block';
$('who').textContent = 'Admin';
try { ACC_STATE = await api('/api/acc/state'); } catch(e) {}
nav('dashboard');
} catch(e) {
console.error('[init] dashboard failed:', e);
// Only force login if the key is truly invalid
if (e.message === 'Unauthorized' || e.message === 'Forbidden') {
$('login-section').style.display = 'block';
$('admin-section').classList.add('hidden');
$('sidebar').style.display = 'none';
} else {
// Transient error — show login but preserve key
toast('Connection issue: ' + e.message + ' (click Authenticate to retry)', 'error');
$('login-section').style.display = 'block';
$('admin-section').classList.add('hidden');
$('sidebar').style.display = 'none';
}
}
}
document.querySelectorAll('.nav-item').forEach(el => {
el.addEventListener('click', () => nav(el.dataset.tab));
});
// ═════════════════════════════════════════════════════════════════
// CUSTOMERS
// ═════════════════════════════════════════════════════════════════
async function renderCustomers() {
const d = await api('/api/acc/customers');
const plans = (ACC_STATE && ACC_STATE.plans) || [];
const planName = id => { const p = plans.find(x => x.id === id); return p ? p.name : id; };
const planPrice = id => { const p = plans.find(x => x.id === id); return p ? p.price : 0; };
$('content').innerHTML = `
<h2>Add Customer</h2>
<div class="form-row">
<div><label>Name</label><input id="cust-name" placeholder="Acme Inc"></div>
<div><label>Plan</label>
<select id="cust-plan">
${plans.length ? plans.map(p => `<option value="${esc(p.id)}">${esc(p.name)} (${money(p.price)})</option>`).join('') : '<option value="">No plans — create one first</option>'}
</select>
</div>
<div><label>Status</label>
<select id="cust-status">
<option value="trial">Trial</option>
<option value="active">Active</option>
<option value="churned">Churned</option>
</select>
</div>
<button onclick="addCustomer()">Add Customer</button>
</div>
<h2>Customers (${d.customers.length})</h2>
${d.customers.length === 0 ? '<p style="color:var(--muted)">No customers yet.</p>' : `
<table>
<thead><tr><th>Name</th><th>Plan</th><th>Status</th><th class="n">Monthly</th><th></th></tr></thead>
<tbody>
${d.customers.map(c => `
<tr>
<td>${esc(c.name)}</td>
<td>
<select onchange="updateCustomer(${c.id}, {plan: this.value})" style="width:auto;padding:4px;font-size:12px">
${plans.map(pp => `<option value="${esc(pp.id)}" ${pp.id===c.plan?'selected':''}>${esc(pp.name)}</option>`).join('')}
</select>
</td>
<td>
<select onchange="updateCustomer(${c.id}, {st: this.value})" style="width:auto;padding:4px;font-size:12px">
<option value="trial" ${c.st==='trial'?'selected':''}>Trial</option>
<option value="active" ${c.st==='active'?'selected':''}>Active</option>
<option value="churned" ${c.st==='churned'?'selected':''}>Churned</option>
</select>
</td>
<td class="n">${money(planPrice(c.plan))}</td>
<td><button class="ghost small" onclick="deleteCustomer(${c.id})">Delete</button></td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function addCustomer() {
const name = $('cust-name').value.trim();
const plan = $('cust-plan').value;
const status = $('cust-status').value;
if (!name) return toast('Name required', 'error');
if (!plan) return toast('Create a plan first', 'error');
await api('/api/acc/customers', 'POST', { name, plan, status });
toast('Customer added', 'success');
ACC_STATE = await api('/api/acc/state');
nav('customers');
}
async function updateCustomer(id, patch) {
await api('/api/acc/customers/' + id, 'PATCH', patch);
toast('Updated', 'success');
ACC_STATE = await api('/api/acc/state');
}
async function deleteCustomer(id) {
if (!confirm('Delete customer?')) return;
await api('/api/acc/customers/' + id, 'DELETE');
toast('Deleted', 'success');
ACC_STATE = await api('/api/acc/state');
nav('customers');
}
// ═════════════════════════════════════════════════════════════════
// PLANS
// ═════════════════════════════════════════════════════════════════
async function renderPlans() {
// Part 208b: Fetch from the SaaS store, not accounting
const d = await api('/api/saas/plans');
$('content').innerHTML = `
<h2>Add Plan</h2>
<div class="form-row">
<div><label>Plan Name</label><input id="plan-name" placeholder="Starter"></div>
<div><label>Price / month ($)</label><input id="plan-price" type="number" min="0" step="1" value="29"></div>
<div><label>AI Tokens (M)</label><input id="plan-tokens" type="number" min="0" value="5"></div>
<div><label>Trial Days</label><input id="plan-trial" type="number" min="0" value="0"></div>
<div><label>Annual Disc %</label><input id="plan-disc" type="number" min="0" value="10"></div>
<button onclick="addPlan()">Add Plan</button>
</div>
<h2>Plans (${d.plans.length})</h2>
${d.plans.length === 0 ? '<p style="color:var(--muted)">No plans yet.</p>' : `
<table>
<thead><tr><th>Name</th><th class="n">Price/mo</th><th class="n">AI Tokens (M)</th><th class="n">Trial</th><th class="n">Annual Disc</th><th class="n">Annual Price</th><th>Paddle ID</th><th></th></tr></thead>
<tbody>
${d.plans.map(p => `
<tr>
<td><input value="${esc(p.name)}" onchange="updatePlanData('${esc(p.id)}', 'name', this.value)" style="width:130px;padding:4px;font-size:12px"></td>
<td class="n"><input type="number" value="${p.price_monthly||0}" onchange="updatePlanData('${esc(p.id)}', 'price_monthly', parseFloat(this.value)||0)" style="width:90px;padding:4px;font-size:12px;text-align:right"></td>
<td class="n"><input type="number" value="${p.token_limit_millions||0}" onchange="updatePlanData('${esc(p.id)}', 'token_limit_millions', parseFloat(this.value)||0)" style="width:70px;padding:4px;font-size:12px;text-align:right"></td>
<td class="n"><input type="number" value="${p.trial_days||0}" onchange="updatePlanData('${esc(p.id)}', 'trial_days', parseFloat(this.value)||0)" style="width:70px;padding:4px;font-size:12px;text-align:right"></td>
<td class="n"><input type="number" value="${p.annual_discount_pct||0}" onchange="updatePlanData('${esc(p.id)}', 'annual_discount_pct', parseFloat(this.value)||0)" style="width:70px;padding:4px;font-size:12px;text-align:right"></td>
<td class="n">${money((p.price_monthly||0)*12*(1-(p.annual_discount_pct||0)/100))}</td>
<td><code style="font-size:10px">${esc(p.paddle_price_id||'No Paddle ID')}</code></td>
<td><button class="ghost small" onclick="deletePlan('${esc(p.id)}')">Delete</button></td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function addPlan() {
const name = $('plan-name').value.trim();
if (!name) return toast('Name required', 'error');
const data = {
name,
price: parseFloat($('plan-price').value) || 0,
tokens: parseFloat($('plan-tokens').value) || 0,
trial: parseFloat($('plan-trial').value) || 0,
disc: parseFloat($('plan-disc').value) || 0,
};
try {
toast('Creating plan in Paddle...', 'info');
await api('/api/saas/plans', 'POST', data);
toast('Plan created with Paddle Price', 'success');
nav('plans');
} catch(e) {
toast('Error: ' + e.message, 'error');
}
}
async function updatePlanData(id, field, value) {
const payload = {};
payload[field] = value;
await api('/api/saas/plans/' + id, 'PATCH', payload);
toast('Plan updated', 'success');
nav('plans'); // Re-render to reflect changes
}
async function deletePlan(id) {
if (!confirm('Delete this plan?')) return;
await api('/api/saas/plans/' + id, 'DELETE');
toast('Deleted', 'success');
nav('plans');
}
// ═════════════════════════════════════════════════════════════════
// INVOICES
// ═════════════════════════════════════════════════════════════════
async function renderInvoices() {
const d = await api('/api/acc/invoices');
// Part 202: also fetch SaaS (Paddle) subscription invoices
const saasInv = await api('/api/saas/invoices').catch(() => ({invoices: []}));
const saasInvoices = saasInv.invoices || [];
const custs = (ACC_STATE && ACC_STATE.cust) || [];
const cname = id => { const c = custs.find(x => x.id === id); return c ? c.name : `#${id}`; };
$('content').innerHTML = `
<h2>Create Invoice</h2>
<div class="form-row">
<div><label>Customer</label>
<select id="inv-cust">
${custs.length ? custs.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('') : '<option value="">No customers</option>'}
</select>
</div>
<div><label>Amount ($)</label><input id="inv-amt" type="number" value="99" min="0" step="0.01"></div>
<div><label>Tax %</label><input id="inv-tax" type="number" value="18" min="0" step="0.1"></div>
<div><label>Status</label>
<select id="inv-status">
<option value="due">Due</option>
<option value="paid">Paid (auto-posts ledger)</option>
</select>
</div>
<button onclick="addInvoice()">Create Invoice</button>
</div>
<h2>Invoices (${d.invoices.length})</h2>
${d.invoices.length === 0 ? '<p style="color:var(--muted)">No invoices yet.</p>' : `
<table>
<thead><tr><th>No.</th><th>Customer</th><th class="n">Amount</th><th class="n">Tax</th><th class="n">Total</th><th>Status</th><th></th></tr></thead>
<tbody>
${d.invoices.map(v => {
const tax = v.amt * v.tax / 100;
const total = v.amt + tax;
const tagCls = v.st === 'paid' ? 'ok' : (v.st === 'refunded' ? 'bad' : '');
const actions = v.st === 'due'
? `<button class="success small" onclick="markInvoicePaid('${esc(v.no)}')">Mark Paid</button>`
: (v.st === 'paid' ? `<button class="ghost small" onclick="refundInvoice('${esc(v.no)}')">Refund</button>` : '');
return `
<tr>
<td>${esc(v.no)}</td>
<td>${esc(cname(v.c))}</td>
<td class="n">${money(v.amt)}</td>
<td class="n">${money(tax)}</td>
<td class="n">${money(total)}</td>
<td><span class="tag ${tagCls}">${esc(v.st)}</span></td>
<td>${actions}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`}
<h2>Subscription Invoices (Paddle) (${saasInvoices.length})</h2>
${saasInvoices.length === 0 ? '<p style="color:var(--muted)">No subscription invoices yet.</p>' : `
<table>
<thead><tr><th>Date</th><th>Org</th><th class="n">Amount</th><th>Description</th><th>Status</th><th>PDF</th></tr></thead>
<tbody>
${saasInvoices.map(v => {
const amt = (v.amount_cents || 0) / 100;
const dt = new Date((v.created_at || 0) * 1000).toLocaleDateString();
const tagCls = v.status === 'paid' ? 'ok' : (v.status === 'refunded' ? 'bad' : '');
return `
<tr>
<td>${dt}</td>
<td><code style="font-size:11px">${esc(v.org_id || '')}</code></td>
<td class="n">${money(amt)}</td>
<td>${esc(v.description || '')}</td>
<td><span class="tag ${tagCls}">${esc(v.status || '')}</span></td>
<td><button class="ghost small" onclick="downloadInvoicePdf('${esc(v.id)}')">PDF</button></td>
</tr>
`;
}).join('')}
</tbody>
</table>
`}
`;
}
async function addInvoice() {
const custId = parseInt($('inv-cust').value);
const amt = parseFloat($('inv-amt').value) || 0;
const tax = parseFloat($('inv-tax').value) || 0;
const status = $('inv-status').value;
if (!custId) return toast('Create a customer first', 'error');
if (!amt) return toast('Amount required', 'error');
await api('/api/acc/invoices', 'POST', {
customer_id: custId, amount: amt, tax, status
});
toast('Invoice created', 'success');
ACC_STATE = await api('/api/acc/state');
nav('invoices');
}
async function markInvoicePaid(no) {
await api('/api/acc/invoices/' + encodeURIComponent(no) + '/paid', 'POST');
toast('Invoice marked paid', 'success');
ACC_STATE = await api('/api/acc/state');
nav('invoices');
}
async function refundInvoice(no) {
if (!confirm('Refund this invoice?')) return;
await api('/api/acc/invoices/' + encodeURIComponent(no) + '/refund', 'POST');
toast('Invoice refunded', 'success');
ACC_STATE = await api('/api/acc/state');
nav('invoices');
}
// ==== ACCOUNTING (P&L + Ledger) ====
async function renderAccounting() {
const [pnl, ledger] = await Promise.all([
api('/api/acc/pnl'),
api('/api/acc/ledger')
]);
$('content').innerHTML = `
<h2>Profit & Loss (this month)</h2>
<div class="grid">
<div class="card"><div class="title">Revenue</div><div class="value">${money(pnl.revenue)}</div></div>
<div class="card"><div class="title">Gateway Fees</div><div class="value">${money(-pnl.fee)}</div></div>
<div class="card"><div class="title">AI Cost</div><div class="value">${money(-pnl.ai_cost)}</div></div>
<div class="card"><div class="title">Operating Expenses</div><div class="value">${money(-pnl.expenses)}</div></div>
<div class="card"><div class="title">Net Profit</div><div class="value ${pnl.net>=0?'ok':'bad'}">${money(pnl.net)}</div></div>
<div class="card"><div class="title">Tax Collected</div><div class="value">${money(pnl.tax_collected)}</div></div>
</div>
<h2>Add Ledger Entry</h2>
<div class="form-row">
<div><label>Type</label>
<select id="led-type">
<option value="Expense">Expense</option>
<option value="AI cost">AI Cost</option>
<option value="Revenue">Revenue</option>
</select>
</div>
<div><label>Description</label><input id="led-desc" placeholder="What is this for?"></div>
<div><label>Amount ($)</label><input id="led-amt" type="number" min="0" step="0.01" value="100"></div>
<button onclick="addLedgerEntry()">Add Entry</button>
</div>
<h2>Ledger (${ledger.entries.length})</h2>
${ledger.entries.length === 0 ? '<p style="color:var(--muted)">No entries yet.</p>' : `
<table class="ledger">
<thead><tr><th>Date</th><th>Type</th><th>Description</th><th class="n">Amount</th></tr></thead>
<tbody>
${ledger.entries.slice().reverse().map(l => `
<tr>
<td>${esc(l.d)}</td>
<td><span class="tag">${esc(l.t)}</span></td>
<td>${esc(l.desc)}</td>
<td class="n" style="color:var(--${l.amt>=0?'success':'danger'})">${money(l.amt)}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function addLedgerEntry() {
const type = $('led-type').value;
const desc = $('led-desc').value.trim();
const amount = parseFloat($('led-amt').value) || 0;
if (!amount) return toast('Amount required', 'error');
await api('/api/acc/ledger', 'POST', { type, desc: desc || type, amount });
toast('Entry added', 'success');
ACC_STATE = await api('/api/acc/state');
nav('accounting');
}
// ==== REPORTS (printable financial statements) ====
async function renderReports() {
const [dash, pnl, invoices, customers, ledger] = await Promise.all([
api('/api/acc/dashboard'),
api('/api/acc/pnl'),
api('/api/acc/invoices'),
api('/api/acc/customers'),
api('/api/acc/ledger')
]);
const acc = ACC_STATE || {};
const co = acc.co || 'Sunnyware';
const thisMonth = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
$('content').innerHTML = `
<div class="form-row" style="margin-bottom:20px;">
<button onclick="window.print()" class="ghost">Print / Save as PDF</button>
</div>
<div class="card" style="max-width:820px;margin:0 auto;color:#111;background:#fff;padding:30px;">
<div style="display:flex;justify-content:space-between;border-bottom:2px solid #111;padding-bottom:12px;margin-bottom:16px;">
<div>
<h2 style="margin:0;font-size:18px;color:#111;border:none;padding:0;">${esc(co)}</h2>
<p style="margin:4px 0 0;font-size:12px;color:#555;">Financial Statements &middot; ${esc(thisMonth)}</p>
</div>
<div style="text-align:right;">
<p style="margin:0;font-size:11px;color:#555;">Report ID: RPT-${Date.now().toString(36).toUpperCase()}</p>
<p style="margin:4px 0 0;font-size:11px;color:#555;">Generated: ${new Date().toLocaleString()}</p>
</div>
</div>
<h3 style="color:#111;border:none;padding:0;margin:16px 0 8px;">Income Statement</h3>
<table style="background:#fff;">
<tr><td>Revenue</td><td class="n" style="color:#111;">${money(pnl.revenue)}</td></tr>
<tr><td>Gateway Fees</td><td class="n" style="color:#111;">${money(-pnl.fee)}</td></tr>
<tr><td>AI Cost</td><td class="n" style="color:#111;">${money(-pnl.ai_cost)}</td></tr>
<tr><td>Operating Expenses</td><td class="n" style="color:#111;">${money(-pnl.expenses)}</td></tr>
<tr style="font-weight:700;border-top:2px solid #111;"><td>Net Profit</td><td class="n" style="color:#111;">${money(pnl.net)}</td></tr>
<tr><td>Sales Tax Collected</td><td class="n" style="color:#111;">${money(pnl.tax_collected)}</td></tr>
</table>
<h3 style="color:#111;border:none;padding:0;margin:24px 0 8px;">Key Metrics</h3>
<table style="background:#fff;">
<tr><td>Monthly Recurring Revenue (MRR)</td><td class="n" style="color:#111;">${money(dash.mrr)}</td></tr>
<tr><td>Annual Recurring Revenue (ARR)</td><td class="n" style="color:#111;">${money(dash.arr)}</td></tr>
<tr><td>Gross Margin</td><td class="n" style="color:#111;">${dash.gross_margin.toFixed(1)}%</td></tr>
<tr><td>Active Customers</td><td class="n" style="color:#111;">${customers.customers.filter(c=>c.st==='active').length}</td></tr>
<tr><td>Paid Invoices</td><td class="n" style="color:#111;">${invoices.invoices.filter(v=>v.st==='paid').length}</td></tr>
<tr><td>Outstanding (Due)</td><td class="n" style="color:#111;">${money(invoices.invoices.filter(v=>v.st==='due').reduce((s,v)=>s+v.amt*(1+v.tax/100),0))}</td></tr>
</table>
<h3 style="color:#111;border:none;padding:0;margin:24px 0 8px;">Recent Ledger Activity</h3>
<table style="background:#fff;">
<thead><tr><th style="color:#111;background:#f5f5f5;">Date</th><th style="color:#111;background:#f5f5f5;">Description</th><th class="n" style="color:#111;background:#f5f5f5;">Amount</th></tr></thead>
<tbody>
${ledger.entries.slice(-10).reverse().map(l => `
<tr>
<td>${esc(l.d)}</td>
<td>${esc(l.desc)}</td>
<td class="n" style="color:#111;">${money(l.amt)}</td>
</tr>
`).join('')}
</tbody>
</table>
<p style="margin-top:32px;border-top:1px solid #ccc;padding-top:12px;font-size:11px;color:#555;">
Prepared by Sunnyware Admin &middot; ${new Date().toLocaleDateString()} &middot; Unaudited management figures.
</p>
</div>
`;
}
// ==== SETTINGS ====
async function renderSettings() {
const acc = ACC_STATE || {};
$('content').innerHTML = `
<h2>Company Settings</h2>
<div class="form-row" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));">
<div><label>Company Name</label><input id="set-co" value="${esc(acc.co || 'Sunnyware')}"></div>
<div><label>Currency Symbol</label>
<select id="set-sym">
${['$','€','£','₹','¥'].map(s => `<option ${s===acc.sym?'selected':''}>${s}</option>`).join('')}
</select>
</div>
<div><label>Sales Tax % (new invoices)</label><input id="set-tax" type="number" step="0.1" value="${acc.tax||18}"></div>
<div><label>Income Tax % (estimate)</label><input id="set-itx" type="number" step="0.1" value="${acc.itx||21}"></div>
</div>
<div style="margin-top:10px;">
<button onclick="saveSettings()">Save Settings</button>
</div>
<h2 style="margin-top:32px;">System Info</h2>
<div class="card">
<table style="background:transparent;">
<tr><td>Backend</td><td>FastAPI on Hugging Face Spaces</td></tr>
<tr><td>Auth</td><td>API Keys (X-API-Key header)</td></tr>
<tr><td>Billing</td><td>Paddle Sandbox</td></tr>
<tr><td>SSO</td><td>WorkOS</td></tr>
<tr><td>Financial data</td><td>accounting.json</td></tr>
</table>
</div>
<h2 style="margin-top:32px;">Danger Zone</h2>
<div class="card">
<p style="color:var(--muted);margin-top:0;">Reset all financial data (customers, invoices, ledger, plans). Organizations and Users are NOT affected.</p>
<button class="danger" onclick="resetAccounting()">Reset Financial Data</button>
</div>
`;
}
async function saveSettings() {
const patch = {
co: $('set-co').value.trim() || 'Sunnyware',
sym: $('set-sym').value,
tax: parseFloat($('set-tax').value) || 0,
itx: parseFloat($('set-itx').value) || 0,
};
await api('/api/acc/settings', 'PATCH', patch);
ACC_STATE = await api('/api/acc/state');
toast('Settings saved', 'success');
nav('settings');
}
async function resetAccounting() {
if (!confirm('Reset ALL financial data? This cannot be undone.')) return;
if (!confirm('Are you absolutely sure?')) return;
await api('/api/acc/reset', 'POST');
ACC_STATE = await api('/api/acc/state');
toast('Financial data reset', 'success');
nav('dashboard');
}
// ═════════════════════════════════════════════════════════════════
// COUPONS
// ═════════════════════════════════════════════════════════════════
async function renderCoupons() {
const d = await api('/api/acc/coupons');
const plans = (ACC_STATE && ACC_STATE.plans) || [];
$('content').innerHTML = `
<h2>Create Coupon</h2>
<div class="form-row">
<div><label>Code</label><input id="cp-code" placeholder="WELCOME20"></div>
<div><label>Type</label>
<select id="cp-type">
<option value="pct">Percent off</option>
<option value="flat">Fixed amount</option>
</select>
</div>
<div><label>Value</label><input id="cp-val" type="number" value="20" min="0"></div>
<div><label>Max uses (0=unlimited)</label><input id="cp-max" type="number" value="100" min="0"></div>
<div><label>Expires</label><input id="cp-exp" type="date" value="2027-12-31"></div>
<button onclick="addCoupon()">Create Coupon</button>
</div>
<h2>Coupons (${d.coupons.length})</h2>
${d.coupons.length === 0 ? '<p style="color:var(--muted)">No coupons yet.</p>' : `
<table>
<thead><tr><th>Code</th><th>Discount</th><th>Used / Max</th><th>Expires</th><th>Status</th><th></th></tr></thead>
<tbody>
${d.coupons.map(c => {
const used = c.used || 0;
const max = c.max || 0;
const isExpired = c.exp && c.exp < new Date().toISOString().slice(0,10);
const isUsed = max && used >= max;
const status = !c.on ? 'Disabled' : isExpired ? 'Expired' : isUsed ? 'Used up' : 'Active';
const tagCls = status === 'Active' ? 'ok' : 'bad';
return `
<tr>
<td><strong>${esc(c.code)}</strong></td>
<td>${c.type==='pct' ? c.val+'% off' : money(c.val)+' off'}</td>
<td>${used} / ${max || 'unlimited'}</td>
<td>${esc(c.exp || 'Never')}</td>
<td><span class="tag ${tagCls}">${status}</span></td>
<td>
<button class="ghost small" onclick="toggleCoupon('${esc(c.code)}')">${c.on ? 'Disable' : 'Enable'}</button>
<button class="danger small" onclick="deleteCoupon('${esc(c.code)}')">Delete</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`}
`;
}
async function addCoupon() {
const code = $('cp-code').value.trim().toUpperCase();
const type = $('cp-type').value;
const val = parseFloat($('cp-val').value) || 0;
const max = parseInt($('cp-max').value) || 0;
const exp = $('cp-exp').value || '';
if (!code) return toast('Code required', 'error');
if (!val) return toast('Value required', 'error');
await api('/api/acc/coupons', 'POST', {
code, type, val, plans: [], max, exp
});
toast('Coupon created', 'success');
ACC_STATE = await api('/api/acc/state');
nav('coupons');
}
async function toggleCoupon(code) {
await api('/api/acc/coupons/' + encodeURIComponent(code) + '/toggle', 'POST');
toast('Coupon toggled', 'success');
ACC_STATE = await api('/api/acc/state');
nav('coupons');
}
async function deleteCoupon(code) {
if (!confirm('Delete coupon ' + code + '?')) return;
await api('/api/acc/coupons/' + encodeURIComponent(code), 'DELETE');
toast('Coupon deleted', 'success');
ACC_STATE = await api('/api/acc/state');
nav('coupons');
}
// ═════════════════════════════════════════════════════════════════
// DUNNING (failed payment recovery)
// ═════════════════════════════════════════════════════════════════
async function renderDunning() {
const d = await api('/api/acc/dunning');
const custs = (ACC_STATE && ACC_STATE.cust) || [];
const cname = id => { const c = custs.find(x => x.id === id); return c ? c.name : `#${id}`; };
const STEPS = ['Retry day 0', 'Email day 3', 'Retry day 5', 'Final notice day 7', 'Suspend day 10'];
$('content').innerHTML = `
<h2>Failed Payments (${d.failed.length})</h2>
${d.failed.length === 0 ? '<p style="color:var(--muted)">No failed payments. All customers paying successfully.</p>' : `
<table>
<thead><tr><th>Customer</th><th>Reason</th><th class="n">Amount</th><th>Next step</th><th></th></tr></thead>
<tbody>
${d.failed.map((f, i) => {
const step = f.st || 1;
const nextStep = step >= 4 ? 'Lapsed - moved to free tier' : STEPS[step] || STEPS[0];
const tagCls = step >= 4 ? 'bad' : '';
return `
<tr>
<td>${esc(cname(f.c))}</td>
<td>${esc(f.why)}</td>
<td class="n">${money(f.amt)}</td>
<td><span class="tag ${tagCls}">${esc(nextStep)}</span></td>
<td>
<button class="success small" onclick="retryDunning(${i})">Retry via Paddle</button>
<button class="ghost small" onclick="advanceDunning(${i})">Advance step</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`}
<h2>Retry Schedule</h2>
<div class="card">
<p style="margin:0;font-size:13px;">${STEPS.join(' -> ')}</p>
<p style="margin:8px 0 0;color:var(--muted);font-size:12px;">Retries are simulated. Live retries require Paddle webhook integration.</p>
</div>
`;
}
async function retryDunning(idx) {
const d = await api('/api/acc/dunning/' + idx + '/retry', 'POST');
if (d.result && d.result.recovered) {
toast('Payment recovered: ' + money(d.result.amount), 'success');
} else {
toast('Retry failed, advanced to next step', 'error');
}
ACC_STATE = await api('/api/acc/state');
nav('dunning');
}
async function advanceDunning(idx) {
await api('/api/acc/dunning/' + idx + '/advance', 'POST');
toast('Advanced to next step', 'success');
ACC_STATE = await api('/api/acc/state');
nav('dunning');
}
// ═════════════════════════════════════════════════════════════════
// COMPLIANCE (filings calendar)
// ═════════════════════════════════════════════════════════════════
async function renderCompliance() {
const d = await api('/api/acc/compliance');
const today = new Date().toISOString().slice(0,10);
const daysUntil = s => Math.round((new Date(s) - new Date(today)) / 86400000);
const sorted = d.items.slice().sort((a,b) => a.due < b.due ? -1 : 1);
const overdue = sorted.filter(x => daysUntil(x.due) < 0).length;
$('content').innerHTML = `
<h2>Add Compliance Item</h2>
<div class="form-row">
<div><label>Name</label><input id="cmp-name" placeholder="Quarterly tax filing"></div>
<div><label>Area</label>
<select id="cmp-area">
<option>Tax</option>
<option>Legal</option>
<option>Payroll</option>
<option>Audit</option>
<option>Corporate</option>
<option>Accounting</option>
</select>
</div>
<div><label>Repeats</label>
<select id="cmp-freq">
<option value="month">Monthly</option>
<option value="quarter">Quarterly</option>
<option value="year" selected>Yearly</option>
<option value="once">One time</option>
</select>
</div>
<div><label>Next due</label><input id="cmp-due" type="date" value="2027-12-31"></div>
<button onclick="addCompliance()">Add Item</button>
</div>
<h2>Filings (${sorted.length}) ${overdue ? `<span style="color:var(--danger);font-size:12px;">(${overdue} overdue)</span>` : ''}</h2>
${sorted.length === 0 ? '<p style="color:var(--muted)">No compliance items yet. Add tax filings, legal reviews, etc.</p>' : `
<table>
<thead><tr><th>Item</th><th>Area</th><th>Repeats</th><th>Next due</th><th>Status</th><th></th></tr></thead>
<tbody>
${sorted.map(c => {
const days = daysUntil(c.due);
const status = days < 0 ? 'Overdue' : days <= 30 ? `Due in ${days} days` : 'Upcoming';
const tagCls = days < 0 ? 'bad' : days <= 30 ? '' : 'ok';
return `
<tr>
<td>${esc(c.name)}</td>
<td>${esc(c.area)}</td>
<td>${esc(c.f)}</td>
<td>${esc(c.due)}</td>
<td><span class="tag ${tagCls}">${status}</span></td>
<td>
<button class="success small" onclick="markFiled(${c.id})">Mark Filed</button>
<button class="danger small" onclick="deleteCompliance(${c.id})">Delete</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`}
<div class="card" style="margin-top:24px;">
<h3 style="margin-top:0;color:var(--accent);">Audit-Ready Folder</h3>
<p style="margin:0;font-size:13px;color:var(--muted);">Keep these ready for auditors, lenders, or investors:</p>
<ul style="margin:8px 0 0;padding-left:20px;font-size:13px;">
<li>Bank statements and reconciliations</li>
<li>Ledger and the three statements from Reports</li>
<li>Invoices and payment records</li>
<li>Contracts, loan agreements, and cap table</li>
<li>Tax filings and payment receipts</li>
<li>Payroll records</li>
<li>Asset and depreciation schedule</li>
</ul>
</div>
`;
}
async function addCompliance() {
const name = $('cmp-name').value.trim();
const area = $('cmp-area').value;
const freq = $('cmp-freq').value;
const due = $('cmp-due').value;
if (!name) return toast('Name required', 'error');
if (!due) return toast('Due date required', 'error');
await api('/api/acc/compliance', 'POST', { name, area, freq, due });
toast('Compliance item added', 'success');
ACC_STATE = await api('/api/acc/state');
nav('compliance');
}
async function markFiled(id) {
await api('/api/acc/compliance/' + id + '/filed', 'POST');
toast('Marked as filed, next cycle scheduled', 'success');
ACC_STATE = await api('/api/acc/state');
nav('compliance');
}
async function deleteCompliance(id) {
if (!confirm('Delete this compliance item?')) return;
await api('/api/acc/compliance/' + id, 'DELETE');
toast('Deleted', 'success');
ACC_STATE = await api('/api/acc/state');
nav('compliance');
}
// ==== BUDGET ====
async function renderBudget() {
const [state, ledger] = await Promise.all([
api('/api/acc/state'),
api('/api/acc/ledger')
]);
const cats = state.cats || [];
const bud = state.bud || {};
const costs = state.costs || [];
const thisMonth = new Date().toISOString().slice(0,7);
const actuals = {};
ledger.entries.filter(l => l.d.startsWith(thisMonth) && l.amt < 0).forEach(l => {
const cat = l.t === 'AI cost' ? 'AI API' : l.t === 'Expense' ? 'Other' : l.t;
actuals[cat] = (actuals[cat] || 0) + Math.abs(l.amt);
});
costs.forEach(c => {
if (c.s === thisMonth) actuals[c.cat] = (actuals[c.cat] || 0) + c.amt;
});
const totalBud = cats.reduce((s, k) => s + (bud[k] || 0), 0);
const totalAct = cats.reduce((s, k) => s + (actuals[k] || 0), 0);
$('content').innerHTML = `
<h2>Budget Overview (${thisMonth})</h2>
<div class="grid">
<div class="card"><div class="title">Total Budget</div><div class="value">${money(totalBud)}</div></div>
<div class="card"><div class="title">Actual Spend</div><div class="value">${money(totalAct)}</div></div>
<div class="card"><div class="title">Remaining</div><div class="value ${totalBud-totalAct>=0?'ok':'bad'}">${money(totalBud-totalAct)}</div></div>
<div class="card"><div class="title">Utilization</div><div class="value">${totalBud ? ((totalAct/totalBud)*100).toFixed(0) : 0}%</div></div>
</div>
<h2>Budget vs Actual by Category</h2>
<table>
<thead><tr><th>Category</th><th class="n">Monthly Budget</th><th class="n">Actual</th><th class="n">Left</th><th class="n">% Used</th></tr></thead>
<tbody>
${cats.map(k => {
const b = bud[k] || 0;
const a = actuals[k] || 0;
const pct = b ? Math.min(100, (a/b)*100) : 0;
return `
<tr>
<td>${esc(k)}</td>
<td class="n"><input type="number" value="${b}" onchange="updateBudget('${esc(k)}', parseFloat(this.value)||0)" style="width:110px;padding:4px;font-size:12px;text-align:right"></td>
<td class="n">${money(a)}</td>
<td class="n" style="color:var(--${b-a>=0?'success':'danger'})">${money(b-a)}</td>
<td class="n">${pct.toFixed(0)}%</td>
</tr>
`;
}).join('')}
</tbody>
</table>
<h2>Recurring Costs (${costs.length})</h2>
${costs.length === 0 ? '<p style="color:var(--muted)">No costs yet. Add payroll, hosting, subscriptions, etc.</p>' : `
<table>
<thead><tr><th>Name</th><th>Category</th><th class="n">Amount</th><th>Repeats</th><th>Starts</th><th>Life (mo)</th><th></th></tr></thead>
<tbody>
${costs.map(c => `
<tr>
<td>${esc(c.name)}</td>
<td>${esc(c.cat)}</td>
<td class="n">${money(c.amt)}</td>
<td>${esc(c.f)}</td>
<td>${esc(c.s)}</td>
<td class="n">${c.life || 0}</td>
<td><button class="ghost small" onclick="deleteCost(${c.id})">Remove</button></td>
</tr>
`).join('')}
</tbody>
</table>
`}
<h2>Add Recurring Cost</h2>
<div class="form-row">
<div><label>Name</label><input id="cost-name" placeholder="Server hosting"></div>
<div><label>Category</label>
<select id="cost-cat">${cats.map(k => `<option>${esc(k)}</option>`).join('')}</select>
</div>
<div><label>Amount</label><input id="cost-amt" type="number" min="0" step="0.01" value="100"></div>
<div><label>Repeats</label>
<select id="cost-freq">
<option value="month">Monthly</option>
<option value="quarter">Quarterly</option>
<option value="year">Yearly</option>
<option value="once">One time</option>
</select>
</div>
<div><label>Starts</label><input id="cost-start" type="month" value="${thisMonth}"></div>
<div><label>Life (mo, 0=expense)</label><input id="cost-life" type="number" min="0" value="0"></div>
<button onclick="addCost()">Add Cost</button>
</div>
`;
}
async function updateBudget(cat, value) {
const state = ACC_STATE || await api('/api/acc/state');
const bud = state.bud || {};
bud[cat] = value;
await api('/api/acc/settings', 'PATCH', { bud });
toast('Budget updated', 'success');
}
async function addCost() {
const name = $('cost-name').value.trim();
const cat = $('cost-cat').value;
const amt = parseFloat($('cost-amt').value) || 0;
const freq = $('cost-freq').value;
const start = $('cost-start').value;
const life = parseInt($('cost-life').value) || 0;
if (!name) return toast('Name required', 'error');
if (!amt) return toast('Amount required', 'error');
await api('/api/acc/costs', 'POST', { name, cat, amount: amt, freq, start, life });
toast('Cost added', 'success');
ACC_STATE = await api('/api/acc/state');
nav('budget');
}
async function deleteCost(id) {
if (!confirm('Remove this cost?')) return;
await api('/api/acc/costs/' + id, 'DELETE');
toast('Cost removed', 'success');
ACC_STATE = await api('/api/acc/state');
nav('budget');
}
// ==== CAPITAL ====
async function renderCapital() {
const d = await api('/api/acc/capital');
const types = {
capital: 'Owner capital',
invest: 'Investor funding',
loan: 'Loan received',
repay: 'Loan repayment',
draw: 'Owner withdrawal',
taxpay: 'Tax payment',
};
const total = (type) => d.entries.filter(x => x.t === type).reduce((s, x) => s + x.amt, 0);
const capitalIn = total('capital');
const investIn = total('invest');
const loanIn = total('loan');
const repayOut = total('repay');
const drawOut = total('draw');
const taxPay = total('taxpay');
const loanBalance = loanIn - repayOut;
const equity = capitalIn + investIn - drawOut;
$('content').innerHTML = `
<h2>Capital Overview</h2>
<div class="grid">
<div class="card"><div class="title">Paid-in Capital</div><div class="value">${money(capitalIn + investIn)}</div></div>
<div class="card"><div class="title">Owner Withdrawals</div><div class="value">${money(-drawOut)}</div></div>
<div class="card"><div class="title">Loan Balance</div><div class="value">${money(loanBalance)}</div></div>
<div class="card"><div class="title">Equity</div><div class="value">${money(equity)}</div></div>
<div class="card"><div class="title">Tax Paid</div><div class="value">${money(taxPay)}</div></div>
</div>
<h2>Funding and Owner Transactions (${d.entries.length})</h2>
${d.entries.length === 0 ? '<p style="color:var(--muted)">No capital transactions yet.</p>' : `
<table>
<thead><tr><th>Date</th><th>Type</th><th>Name</th><th class="n">Amount</th><th class="n">Interest %</th><th></th></tr></thead>
<tbody>
${d.entries.slice().sort((a,b) => a.d < b.d ? -1 : 1).map(x => `
<tr>
<td>${esc(x.d)}</td>
<td>${esc(types[x.t] || x.t)}</td>
<td>${esc(x.name || '')}</td>
<td class="n">${money(x.amt)}</td>
<td class="n">${x.t === 'loan' ? x.r + '%' : '-'}</td>
<td><button class="ghost small" onclick="deleteCapital(${x.id})">Remove</button></td>
</tr>
`).join('')}
</tbody>
</table>
`}
<h2>Add Capital Transaction</h2>
<div class="form-row">
<div><label>Type</label>
<select id="cap-type">
${Object.entries(types).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
</select>
</div>
<div><label>Name / Note</label><input id="cap-name" placeholder="Seed round, Bank loan, etc"></div>
<div><label>Amount</label><input id="cap-amt" type="number" min="0" step="0.01" value="10000"></div>
<div><label>Date</label><input id="cap-date" type="date" value="${new Date().toISOString().slice(0,10)}"></div>
<div><label>Interest % (loans)</label><input id="cap-rate" type="number" min="0" step="0.1" value="0"></div>
<button onclick="addCapital()">Add Transaction</button>
</div>
<div class="card" style="margin-top:24px;">
<h3 style="margin-top:0;color:var(--accent);">Quick Guide</h3>
<ul style="margin:0;padding-left:20px;font-size:13px;color:var(--muted);">
<li><strong>Owner capital</strong>: money you put into the business (increases equity)</li>
<li><strong>Investor funding</strong>: money from outside investors (dilutes ownership)</li>
<li><strong>Loan received</strong>: borrowed money (must be repaid, not equity)</li>
<li><strong>Loan repayment</strong>: paying back principal (reduces loan balance)</li>
<li><strong>Owner withdrawal</strong>: taking profit out (reduces equity, not an expense)</li>
<li><strong>Tax payment</strong>: paying collected sales tax or income tax</li>
</ul>
</div>
`;
}
async function addCapital() {
const type = $('cap-type').value;
const name = $('cap-name').value.trim();
const amount = parseFloat($('cap-amt').value) || 0;
const date = $('cap-date').value;
const rate = parseFloat($('cap-rate').value) || 0;
if (!amount) return toast('Amount required', 'error');
await api('/api/acc/capital', 'POST', { type, name, amount, date, rate });
toast('Capital transaction added', 'success');
ACC_STATE = await api('/api/acc/state');
nav('capital');
}
async function deleteCapital(id) {
if (!confirm('Delete this transaction?')) return;
await api('/api/acc/capital/' + id, 'DELETE');
toast('Deleted', 'success');
ACC_STATE = await api('/api/acc/state');
nav('capital');
}
// ==== ALERTS ====
async function renderAlerts() {
const [dash, pnl, invoices, dunning] = await Promise.all([
api('/api/acc/dashboard'),
api('/api/acc/pnl'),
api('/api/acc/invoices'),
api('/api/acc/dunning')
]);
const thr = (ACC_STATE && ACC_STATE.thr) || { gm: 70, ai: 30 };
const aiPct = pnl.revenue ? (pnl.ai_cost / pnl.revenue * 100) : 0;
const overdueCount = invoices.invoices.filter(v => v.st === 'due').length;
const failedCount = dunning.failed.length;
const alerts = [
{ name: 'Gross margin below ' + thr.gm + '%', firing: dash.gross_margin < thr.gm, value: dash.gross_margin.toFixed(1) + '%' },
{ name: 'AI cost above ' + thr.ai + '% of revenue', firing: aiPct > thr.ai, value: aiPct.toFixed(1) + '%' },
{ name: 'Unpaid invoices', firing: overdueCount > 0, value: overdueCount },
{ name: 'Failed payments', firing: failedCount > 0, value: failedCount },
{ name: 'Net profit negative', firing: pnl.net < 0, value: money(pnl.net) },
{ name: 'Zero MRR', firing: dash.mrr === 0, value: money(dash.mrr) },
];
const firingCount = alerts.filter(a => a.firing).length;
$('content').innerHTML = `
<h2>Alerts (${firingCount} of ${alerts.length} firing)</h2>
<div class="grid">
${alerts.map(a => `
<div class="card">
<div class="title">${esc(a.name)}</div>
<div class="value ${a.firing ? 'bad' : 'ok'}">${esc(String(a.value))}</div>
<div style="margin-top:8px;font-size:11px;color:var(--muted);">${a.firing ? 'FIRING' : 'OK'}</div>
</div>
`).join('')}
</div>
<h2>Thresholds</h2>
<div class="form-row">
<div><label>Minimum gross margin %</label><input id="thr-gm" type="number" value="${thr.gm}" min="0"></div>
<div><label>Max AI cost % of revenue</label><input id="thr-ai" type="number" value="${thr.ai}" min="0"></div>
<button onclick="saveThresholds()">Save</button>
</div>
<p style="color:var(--muted);font-size:12px;">Alerts check when you open this tab. Sending to email/Slack needs a backend automation.</p>
`;
}
async function saveThresholds() {
const thr = {
gm: parseFloat($('thr-gm').value) || 0,
ai: parseFloat($('thr-ai').value) || 0,
};
await api('/api/acc/settings', 'PATCH', { thr });
ACC_STATE = await api('/api/acc/state');
toast('Thresholds saved', 'success');
nav('alerts');
}
// ==== PLAN ACCESS ====
async function renderAccess() {
const [state, plans] = await Promise.all([api('/api/acc/state'), api('/api/acc/plans')]);
const feat = state.feat || [];
const ent = state.ent || {};
const plist = plans.plans || [];
$('content').innerHTML = `
<h2>Plan Entitlements Matrix</h2>
<p style="color:var(--muted);font-size:12px;">Checkboxes = on/off, numbers = quota.</p>
<table>
<thead><tr><th>Feature</th>${plist.map(pp => `<th class="n">${esc(pp.name)}</th>`).join('')}</tr></thead>
<tbody>
${feat.map(f => `
<tr>
<td><strong>${esc(f.n)}</strong></td>
${plist.map(pp => {
const val = (ent[pp.id] && ent[pp.id][f.k]) || 0;
const input = f.t === 'b'
? `<input type="checkbox" ${val ? 'checked' : ''} onchange="updateEntitlement('${esc(pp.id)}', '${esc(f.k)}', this.checked ? 1 : 0)">`
: `<input type="number" value="${val}" min="0" style="width:80px;padding:4px;font-size:12px;text-align:right" onchange="updateEntitlement('${esc(pp.id)}', '${esc(f.k)}', parseInt(this.value)||0)">`;
return `<td class="n">${input}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
<h2>Quick Access Test</h2>
<div class="card">
<select onchange="showPlanAccess(this.value)" style="max-width:300px;">
<option value="">-- Select plan --</option>
${plist.map(pp => `<option value="${esc(pp.id)}">${esc(pp.name)}</option>`).join('')}
</select>
<div id="access-preview" style="margin-top:12px;font-size:13px;"></div>
</div>
`;
}
async function updateEntitlement(planId, featKey, value) {
await api('/api/acc/entitlements', 'POST', { plan_id: planId, feat_key: featKey, value });
toast('Entitlement updated', 'success');
ACC_STATE = await api('/api/acc/state');
}
function showPlanAccess(planId) {
if (!planId) { $('access-preview').innerHTML = ''; return; }
const state = ACC_STATE || {};
const feat = state.feat || [];
const ent = (state.ent || {})[planId] || {};
const plans = state.plans || [];
const plan = plans.find(p => p.id === planId);
$('access-preview').innerHTML = `
<strong style="color:var(--accent);">${esc(plan ? plan.name : planId)}</strong>
<ul style="margin:8px 0 0;padding-left:20px;">
${feat.map(f => {
const v = ent[f.k] || 0;
const display = f.t === 'b' ? (v ? 'ON' : 'OFF') : v;
return `<li>${esc(f.n)}: <strong>${display}</strong></li>`;
}).join('')}
<li>AI tokens/mo: <strong>${plan ? plan.tokens : 0}M</strong></li>
</ul>
`;
}
// ==== ACCOUNT HEALTH ====
async function renderHealth() {
const [customers, dunning] = await Promise.all([api('/api/acc/customers'), api('/api/acc/dunning')]);
const dunningIds = dunning.failed.map(f => f.c);
const usage = (ACC_STATE && ACC_STATE.use) || {};
const rows = customers.customers.filter(c => c.st !== 'churned').map(c => {
const u = usage[c.id] || { tr: 0, lg: 3, f: 3, tk: 0 };
const score = Math.max(0, Math.round(
0.4 * (u.tr >= 0 ? 100 : Math.max(0, 100 + u.tr * 2))
+ 0.2 * Math.min(100, u.lg * 20)
+ 0.25 * ((u.f / 5) * 100)
+ 0.15 * Math.max(0, 100 - u.tk * 25)
- (dunningIds.includes(c.id) ? 15 : 0)
));
return { c, u, score };
}).sort((a, b) => a.score - b.score);
$('content').innerHTML = `
<h2>Account Health (${rows.length})</h2>
<p style="color:var(--muted);font-size:12px;">Lowest scores first. Weights: usage 40%, features 25%, logins 20%, tickets 15%.</p>
${rows.length === 0 ? '<p style="color:var(--muted)">No active customers to score.</p>' : `
<table>
<thead><tr><th>Customer</th><th class="n">Score</th><th>Status</th><th class="n">Trend</th><th class="n">Logins/wk</th><th class="n">Features</th><th class="n">Tickets</th></tr></thead>
<tbody>
${rows.map(r => {
const cls = r.score >= 70 ? 'ok' : r.score >= 40 ? '' : 'bad';
const label = r.score >= 70 ? 'Healthy' : r.score >= 40 ? 'At risk' : 'Red alert';
return `
<tr>
<td>${esc(r.c.name)}</td>
<td class="n"><strong>${r.score}</strong></td>
<td><span class="tag ${cls}">${label}</span></td>
<td class="n">${r.u.tr >= 0 ? '+' : ''}${r.u.tr}%</td>
<td class="n">${r.u.lg}</td>
<td class="n">${r.u.f} / 5</td>
<td class="n">${r.u.tk}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`}
<div class="card" style="margin-top:20px;">
<h3 style="margin-top:0;color:var(--accent);">How Scores Work</h3>
<ul style="margin:0;padding-left:20px;font-size:13px;color:var(--muted);">
<li><strong>Healthy (70+)</strong>: active, engaged, no issues</li>
<li><strong>At risk (40-69)</strong>: declining usage or low adoption</li>
<li><strong>Red alert (&lt;40)</strong>: about to churn, needs outreach</li>
<li>Failed payments subtract 15 points automatically</li>
</ul>
</div>
`;
}
// ==== TEAM ====
async function renderTeam() {
const [membersData, activityData] = await Promise.all([
api('/api/team/members').catch(() => ({ members: [] })),
api('/api/team/activity?limit=100').catch(() => ({ activity: [] }))
]);
const members = membersData.members || [];
const activity = activityData.activity || [];
const fmtTs = (ts) => {
if (!ts) return '—';
const d = new Date(ts * 1000);
const diffMin = Math.floor((Date.now() - d) / 60000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return diffMin + 'm ago';
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return diffHr + 'h ago';
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
};
const roleOptions = ['Owner', 'Finance', 'Support', 'Viewer'];
const ROLE_DESC = {
Owner: 'Full access to everything',
Finance: 'Plans, Invoices, Accounting, Budget, Capital, Reports',
Support: 'Customers, Access, Health, view-only sessions',
Viewer: 'Read-only: Dashboard, Customers, Plans, Health, Alerts',
};
const actionBadge = (action) => {
const colors = {
login: '#2ecc71', logout: '#7c5cff',
member_added: '#00d4ff', member_deleted: '#e74c3c',
role_changed: '#f39c12',
};
const c = colors[action] || '#666';
return '<span style="display:inline-block;padding:2px 8px;border-radius:99px;font-size:10px;font-weight:600;background:' + c + '22;color:' + c + ';">' + esc(action) + '</span>';
};
$('content').innerHTML = `
<h2>Team Members (${members.length})</h2>
<p style="color:var(--muted);margin-bottom:12px;">Manage who has access to this workspace.</p>
${members.length === 0 ? '<p style="color:var(--muted)">No team members yet. Add the first one below.</p>' : `
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th>Last Logout</th><th></th></tr></thead>
<tbody>
${members.map(m => `
<tr data-mid="${esc(m.id)}">
<td><strong>${esc(m.name || '—')}</strong></td>
<td>${esc(m.email)}</td>
<td>
<select onchange="changeMemberRole('${esc(m.id)}', this.value)" style="width:auto;padding:4px;font-size:12px">
${roleOptions.map(r => '<option value="' + r + '"' + (r === m.role ? ' selected' : '') + '>' + r + '</option>').join('')}
</select>
</td>
<td><span class="tag ${m.status === 'active' ? 'ok' : ''}">${esc(m.status || 'active')}</span></td>
<td>${fmtTs(m.last_login)}</td>
<td>${fmtTs(m.last_logout)}</td>
<td><button class="ghost small" onclick="deleteTeamMember('${esc(m.id)}', '${esc(m.email)}')">Delete</button></td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div class="card" style="margin-top:20px;">
<h3 style="margin-top:0;color:var(--accent);">Add Team Member</h3>
<div class="form-row">
<div><label>Name</label><input id="tm-name" placeholder="Jane Doe"></div>
<div><label>Email</label><input id="tm-email" type="email" placeholder="jane@company.com"></div>
<div><label>Role</label>
<select id="tm-role">
${roleOptions.map(r => '<option value="' + r + '"' + (r === 'Viewer' ? ' selected' : '') + '>' + r + '</option>').join('')}
</select>
</div>
<button onclick="addTeamMember()">Add Member</button>
</div>
</div>
<h2 style="margin-top:32px;">Role Permissions</h2>
<table>
<thead><tr><th>Role</th><th>Access</th></tr></thead>
<tbody>
${Object.entries(ROLE_DESC).map(([r, desc]) => `
<tr><td><strong>${esc(r)}</strong></td><td>${esc(desc)}</td></tr>
`).join('')}
</tbody>
</table>
<h2 style="margin-top:32px;">Activity Log (${activity.length})</h2>
<p style="color:var(--muted);margin-bottom:12px;">Logins, logouts, role changes, and member additions.</p>
${activity.length === 0 ? '<p style="color:var(--muted)">No activity yet.</p>' : `
<table>
<thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
<tbody>
${activity.map(a => `
<tr>
<td>${fmtTs(a.ts)}</td>
<td>${esc(a.user)}</td>
<td>${actionBadge(a.action)}</td>
<td style="font-size:12px;color:var(--muted);">${esc(a.details || '')}</td>
</tr>
`).join('')}
</tbody>
</table>
<div style="margin-top:12px;">
<button class="ghost small" onclick="clearTeamActivity()">Clear Activity Log</button>
</div>
`}
`;
}
async function addTeamMember() {
const name = (document.getElementById('tm-name')?.value || '').trim();
const email = (document.getElementById('tm-email')?.value || '').trim();
const role = (document.getElementById('tm-role')?.value || 'Viewer').trim();
if (!email || !email.includes('@')) return toast('Valid email required', 'error');
try {
const res = await api('/api/team/members', 'POST', { name, email, role });
if (res.ok) {
const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:10000;';
modal.innerHTML = `
<div class="card" style="max-width:500px;width:90%;position:relative;">
<button style="position:absolute;top:10px;right:10px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer;" onclick="this.closest('div').parentElement.remove()">&times;</button>
<h3 style="margin-top:0;color:var(--success);">Member Added Successfully</h3>
<p style="font-size:13px;">${res.email_sent ? 'An email with the invite link has been sent.' : 'Email could not be sent, please copy the link manually.'}</p>
<div style="background:var(--bg);padding:12px;border-radius:6px;font-family:monospace;font-size:12px;word-break:break-all;user-select:all;margin:16px 0;">
${esc(res.accept_url)}
</div>
<div style="display:flex;gap:8px;">
<button onclick="navigator.clipboard.writeText('${esc(res.accept_url)}').then(() => toast('Copied!'))" style="flex:1;">Copy Link</button>
<button class="ghost" onclick="this.closest('div').parentElement.parentElement.remove(); nav('team');" style="flex:1;">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
} else {
toast('Error: ' + (res.error || 'unknown'), 'error');
}
} catch(e) {
toast('Error: ' + e.message, 'error');
}
}
async function deleteTeamMember(memberId, email) {
if (!confirm('Remove ' + email + ' from the team?')) return;
try {
await api('/api/team/members/' + memberId, 'DELETE');
toast('Member removed', 'success');
nav('team');
} catch(e) {
toast('Error: ' + e.message, 'error');
}
}
async function changeMemberRole(memberId, newRole) {
try {
await api('/api/team/members/' + memberId, 'PATCH', { role: newRole });
toast('Role updated to ' + newRole, 'success');
nav('team');
} catch(e) {
toast('Error: ' + e.message, 'error');
nav('team');
}
}
async function clearTeamActivity() {
if (!confirm('Clear the entire activity log? This cannot be undone.')) return;
try {
await api('/api/team/activity', 'DELETE');
toast('Activity log cleared', 'success');
nav('team');
} catch(e) {
toast('Error: ' + e.message, 'error');
}
}
async function logAudit() {
const user = $('audit-user').value.trim() || 'admin';
const action = $('audit-action').value.trim();
if (!action) return toast('Action required', 'error');
await api('/api/acc/audit', 'POST', { user, action });
toast('Logged', 'success');
nav('team');
}
// ==== CALCULATORS ====
async function renderCalculators() {
$('content').innerHTML = `
<h2>Business Calculators</h2>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px;">
<div class="card">
<h3 style="margin-top:0;color:var(--accent);">Subscription Metrics</h3>
<div><label>Customers at start</label><input id="c1" type="number" value="200"></div>
<div><label>Churned this month</label><input id="c2" type="number" value="8"></div>
<div><label>ARPU per month ($)</label><input id="c3" type="number" value="99"></div>
<div><label>CAC per customer ($)</label><input id="c4" type="number" value="240"></div>
<button onclick="calcSubs()" style="margin-top:10px;">Calculate</button>
<div id="r1" style="margin-top:12px;padding:10px;background:var(--bg);border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-line;"></div>
</div>
<div class="card">
<h3 style="margin-top:0;color:var(--accent);">AI Usage Margin</h3>
<div><label>Price per 1M tokens ($)</label><input id="a1" type="number" value="12"></div>
<div><label>Model cost per 1M tokens ($)</label><input id="a2" type="number" value="4.5"></div>
<div><label>Tokens/month (millions)</label><input id="a3" type="number" value="60"></div>
<button onclick="calcAI()" style="margin-top:10px;">Calculate</button>
<div id="r2" style="margin-top:12px;padding:10px;background:var(--bg);border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-line;"></div>
</div>
<div class="card">
<h3 style="margin-top:0;color:var(--accent);">Tax & Gateway Fee</h3>
<div><label>Price ($)</label><input id="t1" type="number" value="100"></div>
<div><label>Tax rate %</label><input id="t2" type="number" value="18"></div>
<div><label>Gateway fee %</label><input id="t3" type="number" value="2.9"></div>
<div><label>Fixed fee ($)</label><input id="t4" type="number" value="0.3"></div>
<button onclick="calcFee()" style="margin-top:10px;">Calculate</button>
<div id="r3" style="margin-top:12px;padding:10px;background:var(--bg);border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-line;"></div>
</div>
<div class="card">
<h3 style="margin-top:0;color:var(--accent);">Runway Calculator</h3>
<div><label>Cash on hand ($)</label><input id="rw1" type="number" value="100000"></div>
<div><label>Monthly burn ($)</label><input id="rw2" type="number" value="12000"></div>
<button onclick="calcRunway()" style="margin-top:10px;">Calculate</button>
<div id="r4" style="margin-top:12px;padding:10px;background:var(--bg);border-radius:6px;font-family:monospace;font-size:12px;white-space:pre-line;"></div>
</div>
</div>
`;
}
function calcSubs() {
const s = parseFloat($('c1').value) || 0;
const c = parseFloat($('c2').value) || 0;
const p = parseFloat($('c3').value) || 0;
const a = parseFloat($('c4').value) || 0;
const churn = s ? c / s : 0;
const life = churn ? 1 / churn : 0;
const ltv = p * life;
const ratio = a ? ltv / a : 0;
const payback = p ? a / p : 0;
$('r1').textContent =
'Churn rate: ' + (churn * 100).toFixed(2) + '%\n' +
'Avg lifetime: ' + life.toFixed(1) + ' months\n' +
'LTV: $' + ltv.toFixed(2) + '\n' +
'LTV : CAC ratio: ' + ratio.toFixed(1) + ' : 1\n' +
'CAC payback: ' + payback.toFixed(1) + ' months';
}
function calcAI() {
const price = parseFloat($('a1').value) || 0;
const cost = parseFloat($('a2').value) || 0;
const tokens = parseFloat($('a3').value) || 0;
const rev = price * tokens;
const c = cost * tokens;
const margin = rev ? ((rev - c) / rev * 100) : 0;
$('r2').textContent =
'Revenue: $' + rev.toFixed(2) + '\n' +
'AI cost: $' + c.toFixed(2) + '\n' +
'Profit: $' + (rev - c).toFixed(2) + '\n' +
'Margin: ' + margin.toFixed(1) + '%';
}
function calcFee() {
const p = parseFloat($('t1').value) || 0;
const tx = p * ((parseFloat($('t2').value) || 0) / 100);
const tot = p + tx;
const fee = tot * ((parseFloat($('t3').value) || 0) / 100) + (parseFloat($('t4').value) || 0);
$('r3').textContent =
'Customer pays: $' + tot.toFixed(2) + '\n' +
'Tax collected: $' + tx.toFixed(2) + '\n' +
'Gateway fee: $' + fee.toFixed(2) + '\n' +
'You keep: $' + (p - fee).toFixed(2) + ' (' + (p ? ((p-fee)/p*100).toFixed(1) : '0') + '%)';
}
function calcRunway() {
const cash = parseFloat($('rw1').value) || 0;
const burn = parseFloat($('rw2').value) || 0;
const months = burn > 0 ? cash / burn : Infinity;
$('r4').textContent =
'Cash: $' + cash.toFixed(2) + '\n' +
'Burn: $' + burn.toFixed(2) + '/mo\n' +
'Runway: ' + (isFinite(months) ? months.toFixed(1) + ' months' : 'Self-funding');
}
// ==== CFO AGENT (AI ACCOUNTANT CHAT) ====
let CFO_HISTORY = []; // persists across navigation
async function renderCFO() {
// Load pending drafts
let draftsData = { drafts: [], count: 0 };
let agentStatus = { enabled: false };
try {
const [d, s] = await Promise.all([
api('/api/acc/drafts'),
api('/api/acc/agent/status')
]);
draftsData = d;
agentStatus = s;
} catch(e) {}
const statusBadge = agentStatus.enabled
? '<span class="tag ok">Agent Ready</span> <small style="color:var(--muted)">' + esc(agentStatus.model || '') + '</small>'
: '<span class="tag bad">Agent Offline</span> <small style="color:var(--muted)">Add LLM_API_KEY to HF Secrets</small>';
$('content').innerHTML = `
<h2>CFO Agent <span style="float:right;font-size:12px;font-weight:400;">${statusBadge}</span></h2>
<p style="color:var(--muted);font-size:12px;margin-bottom:16px;">
Ask the AI accountant to add costs, withdraw funds, categorize expenses, or generate reports.
Every entry is drafted first and requires your approval before posting.
</p>
<div class="card" style="padding:0;overflow:hidden;max-width:900px;margin:0 auto;">
<div id="cfo-chat-log" style="max-height:420px;overflow-y:auto;padding:16px;">
${CFO_HISTORY.length === 0 ? `
<div style="text-align:center;padding:40px 20px;color:var(--muted);font-size:13px;">
<p style="margin:0 0 12px;font-size:15px;color:var(--text);">Try a sample request:</p>
<p style="margin:6px 0;"><button class="ghost small" onclick="cfoQuick('Add a cost of $500 for Facebook ads today')">Add a cost of $500 for Facebook ads today</button></p>
<p style="margin:6px 0;"><button class="ghost small" onclick="cfoQuick('Withdraw $1000 for owner draw')">Withdraw $1000 for owner draw</button></p>
<p style="margin:6px 0;"><button class="ghost small" onclick="cfoQuick('Paid $50 for team lunch')">Paid $50 for team lunch</button></p>
<p style="margin:6px 0;"><button class="ghost small" onclick="cfoQuick('Add a $120 software cost for GitHub today')">Add a $120 software cost for GitHub today</button></p>
</div>
` : CFO_HISTORY.map(m => cfoRenderMessage(m)).join('')}
</div>
<div style="border-top:1px solid var(--border);padding:12px;display:flex;gap:8px;">
<input id="cfo-input" placeholder="e.g. Add a $500 cost for Facebook ads today..." style="flex:1;margin:0;" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();cfoSend();}">
<button onclick="cfoSend()" id="cfo-send-btn">Send</button>
</div>
</div>
<h2 style="margin-top:32px;">Pending Drafts (${draftsData.count})</h2>
${draftsData.count === 0 ? '<p style="color:var(--muted);font-size:13px;">No pending drafts. All entries are approved.</p>' : `
<table>
<thead><tr><th>Date</th><th>Description</th><th>Debit</th><th>Credit</th><th class="n">Amount</th><th>Confidence</th><th></th></tr></thead>
<tbody>
${draftsData.drafts.map(dr => `
<tr>
<td>${esc(dr.d)}</td>
<td>${esc(dr.desc)}</td>
<td><code style="font-size:11px">${esc(dr.debit_account || '')}</code></td>
<td><code style="font-size:11px">${esc(dr.credit_account || '')}</code></td>
<td class="n">${money(Math.abs(dr.amt))}</td>
<td><span class="tag ${dr.confidence === 'high' ? 'ok' : dr.confidence === 'low' ? 'bad' : ''}">${esc(dr.confidence || 'med')}</span></td>
<td>
<button class="success small" onclick="cfoApprove('${esc(dr.id)}')">Approve</button>
<button class="danger small" onclick="cfoReject('${esc(dr.id)}')">Reject</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
// Scroll to bottom
const log = $('cfo-chat-log');
if (log) log.scrollTop = log.scrollHeight;
// Focus input
const inp = $('cfo-input');
if (inp) inp.focus();
}
function cfoRenderMessage(m) {
if (m.role === 'user') {
return `<div style="display:flex;justify-content:flex-end;margin-bottom:12px;">
<div style="background:var(--accent);color:#fff;padding:10px 14px;border-radius:12px 12px 2px 12px;max-width:75%;font-size:13px;white-space:pre-wrap;">${esc(m.content)}</div>
</div>`;
}
if (m.role === 'assistant') {
const draftsHtml = (m.drafts || []).map(dr => `
<div style="margin-top:12px;padding:12px;background:var(--bg);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:6px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<strong style="color:var(--accent);font-size:12px;">DRAFT #${esc((dr.id || '').replace('draft_',''))}</strong>
<span class="tag ${dr.confidence === 'high' ? 'ok' : dr.confidence === 'low' ? 'bad' : ''}">${esc(dr.confidence || 'med')}</span>
</div>
<div style="font-size:13px;margin-bottom:8px;">${esc(dr.desc)}</div>
<table style="background:transparent;margin:0 0 8px 0;font-size:12px;">
<tr><td style="padding:2px 8px 2px 0;color:var(--muted);border:none;">Date</td><td style="padding:2px 0;border:none;">${esc(dr.d)}</td></tr>
<tr><td style="padding:2px 8px 2px 0;color:var(--muted);border:none;">Debit</td><td style="padding:2px 0;border:none;"><code>${esc(dr.debit_account || '')}</code></td></tr>
<tr><td style="padding:2px 8px 2px 0;color:var(--muted);border:none;">Credit</td><td style="padding:2px 0;border:none;"><code>${esc(dr.credit_account || '')}</code></td></tr>
<tr><td style="padding:2px 8px 2px 0;color:var(--muted);border:none;">Amount</td><td style="padding:2px 0;border:none;"><strong>${money(Math.abs(dr.amt))}</strong></td></tr>
${dr.reasoning ? `<tr><td style="padding:2px 8px 2px 0;color:var(--muted);border:none;">Reasoning</td><td style="padding:2px 0;border:none;font-size:11px;color:var(--muted);">${esc(dr.reasoning)}</td></tr>` : ''}
</table>
<button class="success small" onclick="cfoApprove('${esc(dr.id)}')">Approve</button>
<button class="danger small" onclick="cfoReject('${esc(dr.id)}')">Reject</button>
</div>
`).join('');
return `<div style="display:flex;justify-content:flex-start;margin-bottom:12px;">
<div style="background:var(--panel);color:var(--text);padding:12px 16px;border-radius:12px 12px 12px 2px;max-width:85%;font-size:13px;border:1px solid var(--border);">
<div style="white-space:pre-wrap;">${esc(m.content || '')}</div>
${draftsHtml}
</div>
</div>`;
}
return '';
}
function cfoQuick(text) {
const inp = $('cfo-input');
if (inp) inp.value = text;
cfoSend();
}
async function cfoSend() {
const inp = $('cfo-input');
const btn = $('cfo-send-btn');
if (!inp || !inp.value.trim()) return;
const message = inp.value.trim();
inp.value = '';
btn.disabled = true;
btn.textContent = 'Thinking...';
CFO_HISTORY.push({ role: 'user', content: message });
const log = $('cfo-chat-log');
if (log) {
if (CFO_HISTORY.length === 1) log.innerHTML = '';
log.innerHTML += cfoRenderMessage({ role: 'user', content: message });
log.innerHTML += `<div id="cfo-loading" style="display:flex;justify-content:flex-start;margin-bottom:12px;"><div style="background:var(--panel);color:var(--muted);padding:10px 14px;border-radius:12px;font-size:12px;border:1px solid var(--border);">CFO is thinking...</div></div>`;
log.scrollTop = log.scrollHeight;
}
try {
// Send only last 10 history entries to keep context small
const hist = CFO_HISTORY.slice(-10, -1).map(m => ({ role: m.role, content: m.content }));
const res = await api('/api/acc/chat', 'POST', { message, history: hist });
const loading = $('cfo-loading');
if (loading) loading.remove();
CFO_HISTORY.push({ role: 'assistant', content: res.reply || res.error || '(no response)', drafts: res.drafts || [] });
if (log) {
log.innerHTML += cfoRenderMessage({ role: 'assistant', content: res.reply || res.error || '(no response)', drafts: res.drafts || [] });
log.scrollTop = log.scrollHeight;
}
} catch(e) {
const loading = $('cfo-loading');
if (loading) loading.remove();
const errMsg = 'Error: ' + e.message;
CFO_HISTORY.push({ role: 'assistant', content: errMsg });
if (log) {
log.innerHTML += cfoRenderMessage({ role: 'assistant', content: errMsg });
log.scrollTop = log.scrollHeight;
}
} finally {
btn.disabled = false;
btn.textContent = 'Send';
if (inp) inp.focus();
}
}
async function cfoApprove(draftId) {
try {
await api('/api/acc/drafts/' + encodeURIComponent(draftId) + '/approve', 'POST');
toast('Entry approved and posted to ledger', 'success');
// Update the draft card in chat to show it's approved
CFO_HISTORY = CFO_HISTORY.map(m => {
if (m.drafts) {
m.drafts = m.drafts.map(d => d.id === draftId ? { ...d, status: 'posted' } : d);
}
return m;
});
nav('cfo');
} catch(e) {
toast('Approval failed: ' + e.message, 'error');
}
}
async function cfoReject(draftId) {
if (!confirm('Reject this draft?')) return;
try {
await api('/api/acc/drafts/' + encodeURIComponent(draftId) + '/reject', 'POST');
toast('Draft rejected', 'success');
CFO_HISTORY = CFO_HISTORY.map(m => {
if (m.drafts) {
m.drafts = m.drafts.filter(d => d.id !== draftId);
}
return m;
});
nav('cfo');
} catch(e) {
toast('Reject failed: ' + e.message, 'error');
}
}
// ═══ START ═══
init();
// Part 212: USER ANALYTICS page
async function renderAnalytics() {
const overview = await api('/api/analytics/overview?days=30').catch(() => ({}));
const usersData = await api('/api/analytics/users?days=30').catch(() => ({ users: [] }));
const userList = usersData.users || [];
const isOnline = _isOnline;
const fmtDate = _fmtDate;
const fmtDuration = _fmtDuration;
const fmtNum = _fmtNum;
const fmtAgo = _fmtAgo;
$('content').innerHTML = `
<h2>User Analytics</h2>
<p style="color:var(--muted);margin-bottom:16px;">Per-user usage & activity - last 30 days.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:24px;">
<div class="card"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;">Unique Users</div><div style="font-size:22px;font-weight:700;">${overview.unique_users || 0}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;">Total Tokens</div><div style="font-size:22px;font-weight:700;">${fmtNum(overview.total_tokens || 0)}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;">Chats</div><div style="font-size:22px;font-weight:700;">${fmtNum(overview.total_chats || 0)}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;">Tool Calls</div><div style="font-size:22px;font-weight:700;">${fmtNum(overview.total_tools || 0)}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;">Errors</div><div style="font-size:22px;font-weight:700;">${overview.total_errors || 0}</div></div>
</div>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;">Usage Overview (Last 7 Days)</h3>
<div id="usageChart-container" style="min-height:200px;"></div>
</div>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;">Top Token Users (Last 30 Days)</h3>
<div id="tokenUsersBar-container" style="min-height:200px;"></div>
</div>
<h3>Users (${userList.length})</h3>
${userList.length === 0 ? '<p style="color:var(--muted)">No activity yet. Chat or login to generate data.</p>' : `
<table>
<thead>
<tr>
<th>User Email</th>
<th>Status</th>
<th>Today (Tok / Chats)</th>
<th>Week (Tok / Chats)</th>
<th>Month (Tok / Chats)</th><th>Cost (Month)</th>
<th>Last Login</th>
<th>Devices / IPs</th><th>Last Active</th>
<th>Total Active</th>
<th></th>
</tr>
</thead>
<tbody>
${userList.map(u => {
const online = isOnline(u.last_active);
const safeEmail = esc(u.user).split(String.fromCharCode(39)).join('&#39;');
return `
<tr>
<td>
<strong>${esc(u.user)}</strong>
<div style="font-size:10px;color:var(--muted);">Sessions: ${u.total_sessions || 0}</div>
</td>
<td>
<span style="display:inline-block;padding:2px 8px;border-radius:99px;font-size:11px;font-weight:600;background:${online ? '#2ecc7122' : '#66666622'};color:${online ? '#2ecc71' : '#888'};">
${online ? 'Online' : 'Offline'}
</span>
</td>
<td>${fmtNum(u.today.tokens)} tok / ${u.today.chats} c</td>
<td>${fmtNum(u.week.tokens)} tok / ${u.week.chats} c</td>
<td>${fmtNum(u.month.tokens)} tok / ${u.month.chats} c</td>
<td style="font-size:11px;">$${((u.month.cost_cents||0)/100).toFixed(4)}</td>
<td style="font-size:11px;">${fmtAgo(u.last_active)}</td>
<td style="font-size:11px;">
${(u.unique_devices||[]).length} dev / ${(u.unique_ips||[]).length} IP
</td>
<td><button class="ghost small" onclick="showUserDetail('${safeEmail}')">Details</button></td>
</tr>
`}).join('')}
</tbody>
</table>
`}
<div id="user-detail-panel" style="margin-top:24px;"></div>
`;
// Draw Chart (pure SVG, no external dependencies)
try {
const chartRes = await api('/api/analytics/charts?days=7');
if (!chartRes || !Array.isArray(chartRes.data)) {
throw new Error('Invalid chart data');
}
_renderSimpleChart('usageChart-container', chartRes.data);
// Part 267: Top Token Users Bar Chart
const users = usersData.users || [];
const topUsers = users
.map(u => ({ user: u.user, tokens: u.month.tokens }))
.sort((a, b) => b.tokens - a.tokens)
.slice(0, 5);
_renderBarChart('tokenUsersBar-container', topUsers);
} catch(e) {
console.error('[chart] Error:', e);
const c = document.getElementById('usageChart-container');
if (c) c.innerHTML = '<p style="color:var(--muted);font-size:12px;">Chart unavailable: ' + (e.message || 'unknown') + '</p>';
}
}
async function showUserDetail(email) {
const panel = $('user-detail-panel');
if (!panel) return;
// Alias helpers so they're accessible in this scope
const fmtNum = _fmtNum, fmtAgo = _fmtAgo, fmtDate = _fmtDate, fmtDuration = _fmtDuration;
panel.innerHTML = '<p style="color:var(--muted)">Loading details...</p>';
let detail;
try {
detail = await api('/api/analytics/user/' + encodeURIComponent(email) + '?days=30');
} catch(err) {
panel.innerHTML = '<p style="color:var(--danger)">API error: ' + (err.message || 'unknown') + '</p>';
return;
}
if (!detail) { panel.innerHTML = '<p style="color:var(--danger)">Failed to load: empty response.</p>'; return; }
const eventsData = await api('/api/analytics/events?user=' + encodeURIComponent(email) + '&limit=100').catch(() => ({ events: [] }));
const daily = detail.daily_usage || [];
const evtList = eventsData.events || [];
const maxTok = Math.max(1, ...daily.map(d => d.tokens || 0));
panel.innerHTML = `
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="margin:0;color:var(--accent);">${esc(email)}</h3>
<button class="ghost small" onclick="document.getElementById(\x27user-detail-panel\x27).innerHTML=\x27\x27">Close</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-bottom:20px;">
<div><div style="font-size:11px;color:var(--muted);">Month Tokens</div><div style="font-size:20px;font-weight:700;">${fmtNum(detail.month.tokens)}</div></div>
<div><div style="font-size:11px;color:var(--muted);">Month Chats</div><div style="font-size:20px;font-weight:700;">${detail.month.chats}</div></div>
<div><div style="font-size:11px;color:var(--muted);">Month Tools</div><div style="font-size:20px;font-weight:700;">${detail.month.tools}</div></div>
<div><div style="font-size:11px;color:var(--muted);">Total Active Time</div><div style="font-size:20px;font-weight:700;">${fmtDuration(detail.total_active_seconds)}</div></div>
<div><div style="font-size:11px;color:var(--muted);">Total Sessions</div><div style="font-size:20px;font-weight:700;">${detail.total_sessions || 0}</div></div>
</div>
<h4 style="margin-top:20px;">Device Hardware Profile</h4>
${detail.hardware ? `
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:20px;background:var(--bg);padding:16px;border-radius:8px;border:1px solid var(--border);">
<div><span style="font-size:11px;color:var(--muted);">OS / Browser</span><br><strong>${esc(detail.hardware.os)} / ${esc(detail.hardware.browser)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">Platform</span><br><strong>${esc(detail.hardware.platform)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">CPU Cores</span><br><strong>${esc(detail.hardware.cpuCores)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">RAM (Approx)</span><br><strong>${esc(detail.hardware.ramGB)} GB</strong></div>
<div><span style="font-size:11px;color:var(--muted);">Timezone</span><br><strong>${esc(detail.hardware.timezone)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">Language</span><br><strong>${esc(detail.hardware.language)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">Screen</span><br><strong>${esc(detail.hardware.screenRes)} @ ${esc(detail.hardware.pixelRatio)}x (${esc(detail.hardware.colorDepth)}-bit)</strong></div>
<div><span style="font-size:11px;color:var(--muted);">Touch Support</span><br><strong>${detail.hardware.touch ? 'Yes' : 'No'}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">GPU Vendor</span><br><strong>${esc(detail.hardware.gpuVendor)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">GPU Model</span><br><strong>${esc(detail.hardware.gpuRenderer)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">Max Texture</span><br><strong>${esc(detail.hardware.maxTexture)}</strong></div>
<div><span style="font-size:11px;color:var(--muted);">Network</span><br><strong>${esc(detail.hardware.downlink)} Mbps / ${esc(detail.hardware.rtt)} ms (${esc(detail.hardware.netType)})</strong></div>
<div style="grid-column:1/-1;border-top:1px solid var(--border);padding-top:12px;margin-top:4px;">
<span style="font-size:11px;color:var(--muted);">Synthetic Performance Score (Higher is Better)</span><br>
<strong style="font-size:20px;color:${(detail.hardware.benchmarkScore || 0) < 1000 ? 'var(--danger)' : (detail.hardware.benchmarkScore || 0) < 5000 ? 'var(--warn)' : 'var(--success)'};">
${esc(detail.hardware.benchmarkScore)} ops/sec
</strong>
<span style="font-size:11px;color:var(--muted);"> (${esc(detail.hardware.benchmarkMs)} ms)</span>
</div>
<div style="grid-column:1/-1;font-size:10px;color:var(--muted);margin-top:8px;">
<strong>User Agent:</strong> ${esc(detail.hardware.userAgent)}
</div>
</div>
` : '<p style="color:var(--muted);font-size:12px;margin-bottom:20px;">No hardware data collected yet. (User needs to open the chat after Part 238 deploy).</p>'}
<h4>Daily Usage (last 7 days)</h4>
<div style="display:flex;gap:6px;height:90px;align-items:flex-end;margin-bottom:20px;padding:0 4px;">
${daily.slice(-7).map(d => `
<div style="flex:1;text-align:center;" title="${d.date}: ${d.tokens} tokens, ${d.chats} chats, ${d.tools} tools">
<div style="height:${Math.max(2, ((d.tokens||0)/maxTok)*75)}px;background:var(--accent);border-radius:4px 4px 0 0;opacity:${d.tokens > 0 ? 1 : 0.2};"></div>
<div style="font-size:9px;color:var(--muted);margin-top:4px;">${d.date.slice(5)}</div>
</div>
`).join('')}
</div>
<h4>Activity Timeline (last 100 events)</h4>
${evtList.length === 0 ? '<p style="color:var(--muted)">No events yet.</p>' : `
<table style="font-size:12px;">
<thead><tr><th>Time</th><th>Type</th><th>Detail</th><th>Status</th></tr></thead>
<tbody>
${evtList.map(e => `
<tr>
<td style="white-space:nowrap;">${new Date(e.ts*1000).toLocaleString()}</td>
<td>${esc(e.type)}</td>
<td>${esc(
e.type === 'chat' ? (e.provider + '/' + e.model + ' - ' + e.tokens + ' tok') :
e.type === 'tool' ? (e.tool_name + ' - ' + Math.round(e.latency_ms) + 'ms') :
e.details || '-'
)}</td>
<td style="color:${e.ok ? '#2ecc71' : 'var(--danger)'}">${e.ok ? 'ok' : 'err: ' + esc((e.error||'').slice(0,40))}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
</div>
`;
}
// Part 212: LLM DASHBOARD page
let _llmRefreshTimer = null;
async function renderLlmDashboard() {
if (_llmRefreshTimer) { clearInterval(_llmRefreshTimer); _llmRefreshTimer = null; }
const data = await api('/api/llm-dashboard/providers').catch(() => ({ providers: [] }));
const providers = data.providers || [];
const orch = await api('/api/llm-dashboard/orchestrator').catch(() => ({}));
$('content').innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<h2>LLM Dashboard</h2>
<p style="color:var(--muted);margin-bottom:16px;">Advanced orchestrator controls &amp; monitoring.</p>
</div>
<button class="ghost small" onclick="renderLlmDashboard()">Refresh</button>
</div>
<div class="card" style="margin-bottom:24px;border:1px solid ${orch.disable_all ? 'var(--danger)' : 'var(--accent)'};">
<h3 style="margin-top:0;color:${orch.disable_all ? 'var(--danger)' : 'var(--accent)'};">Orchestrator Control Panel</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;align-items:center;margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:12px;">
<label class="toggle-switch">
<input type="checkbox" id="orch-disable" ${orch.disable_all ? 'checked' : ''} onchange="saveOrch('disable_all', this.checked)">
<span class="toggle-slider"></span>
</label>
<div>
<div style="font-weight:600;color:${orch.disable_all ? 'var(--danger)' : 'inherit'};">Global Kill Switch</div>
<div style="font-size:11px;color:var(--muted);">Stop all LLM calls immediately</div>
</div>
</div>
<div>
<label style="font-size:11px;color:var(--muted);">Routing Strategy</label>
<select id="orch-strategy" onchange="saveOrch('routing_strategy', this.value)" style="width:100%;margin-top:4px;">
<option value="fallback" ${orch.routing_strategy === 'fallback' ? 'selected' : ''}>Fallback (Priority)</option>
<option value="round_robin" ${orch.routing_strategy === 'round_robin' ? 'selected' : ''}>Round Robin</option>
<option value="fastest" ${orch.routing_strategy === 'fastest' ? 'selected' : ''}>Fastest Response</option>
</select>
</div>
<div>
<label style="font-size:11px;color:var(--muted);">Global Rate Limit (req/min)</label>
<input type="number" value="${orch.global_rate_limit || 60}" style="width:100%;margin-top:4px;" onblur="saveOrch('global_rate_limit', parseInt(this.value))">
</div>
</div>
<div>
<label style="font-size:11px;color:var(--muted);">System Prompt Addon</label>
<input type="text" value="${esc(orch.system_prompt_addon || '')}" placeholder="e.g. Always answer in JSON" style="width:100%;margin-top:4px;" onblur="saveOrch('system_prompt_addon', this.value)">
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<h3 style="margin:0;">Providers (${providers.length})</h3>
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;">
<span style="font-size:11px;color:var(--muted);">Routing:</span>
<select id="routingStrategy" style="padding:4px 8px;font-size:12px;background:var(--bg-tertiary);color:var(--text);border:1px solid var(--border);border-radius:4px;" onchange="setRoutingStrategy(this.value)">
<option value="fallback">Fallback (default order)</option>
<option value="fastest">Fastest (lowest latency)</option>
<option value="round_robin">Round Robin</option>
</select>
</div>
</div>
${providers.length === 0 ? '<p style="color:var(--muted)">No providers configured. Set LLM_PROVIDERS env var.</p>' : `
<table>
<thead><tr><th>#</th><th>Provider</th><th>Model</th><th>API Key</th><th>Status</th><th>Limit/min</th><th>Limit/hr</th><th>Limit/day</th><th>Cooldown</th><th>Actions</th></tr></thead>
<tbody>
${providers.map((p, i) => `
<tr>
<td style="white-space:nowrap;">
<button class="ghost small" onclick="moveProvider('${esc(p.name)}', -1)" title="Move up" ${i===0?'disabled':''}>^</button>
<button class="ghost small" onclick="moveProvider('${esc(p.name)}', 1)" title="Move down" ${i===providers.length-1?'disabled':''}>v</button>
</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<label class="toggle-switch" style="transform:scale(0.8);transform-origin:left;">
<input type="checkbox" ${p.config.enabled !== false ? 'checked' : ''} onchange="saveProviderConfig('${esc(p.name)}', 'enabled', this.checked)">
<span class="toggle-slider"></span>
</label>
<strong>${esc(p.name)}</strong>
</div>
<span style="font-size:10px;color:var(--muted);margin-left:42px;">${esc(p.provider_type)}</span>
</td>
<td style="font-size:11px;">
<input type="text" value="${esc(p.config.model_override || p.model || '')}" placeholder="${esc(p.model)}" style="width:130px;padding:4px;font-size:11px;" onblur="saveProviderConfig('${esc(p.name)}', 'model_override', this.value)">
</td>
<td style="font-size:11px;">
<input type="password" value="${esc(p.config.api_key || '')}" placeholder="API Key (masked)" style="width:120px;padding:4px;font-size:11px;" onblur="saveApiKey('${esc(p.name)}', this.value)">
</td>
<td>${p.is_cooling
? '<span style="color:#f39c12;font-weight:600;">Cooling</span>'
: '<span style="color:#2ecc71;font-weight:600;">Active</span>'}</td>
<td><input type="number" value="${p.config.limit_per_min}" style="width:60px;padding:4px;font-size:12px;" onblur="saveProviderConfig('${esc(p.name)}', 'limit_per_min', this.value)"></td>
<td><input type="number" value="${p.config.limit_per_hour}" style="width:70px;padding:4px;font-size:12px;" onblur="saveProviderConfig('${esc(p.name)}', 'limit_per_hour', this.value)"></td>
<td><input type="number" value="${p.config.limit_per_day}" style="width:80px;padding:4px;font-size:12px;" onblur="saveProviderConfig('${esc(p.name)}', 'limit_per_day', this.value)"></td>
<td>${p.is_cooling ? '<span style="color:#f39c12;">' + Math.ceil(p.cooldown_remaining) + 's</span>' : '-'}</td>
<td><button class="ghost small" onclick="resetCooldown('${esc(p.name)}')">Reset</button>
<button class="ghost small" onclick="testProvider('${esc(p.name)}', this)">Test</button>
<button class="ghost small" style="background:#e67e22; color:#fff;" onclick="forceRecover('${esc(p.name)}')">Recover</button></td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;">
<h3 style="margin:0;">Usage Analytics (7d)</h3>
<div>
<button class="ghost small" onclick="refreshLlmUsage()">Refresh</button>
</div>
</div>
<p style="color:var(--muted);font-size:12px;">Tokens &amp; estimated cost · auto-refresh 30s</p>
<div id="llm-usage-container"><p style="color:var(--muted)">Loading...</p></div>
<div style="background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:20px;margin-bottom:24px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="margin:0;">Tools Testing</h3>
<button class="ghost small" onclick="testAllTools(this)">Test All Tools</button>
</div>
<p style="color:var(--muted);font-size:12px;margin:0 0 12px;">Runs safe canned tests on all 83 tools. Tools requiring external keys or uploads are skipped.</p>
<div id="tools-test-results"><span style="color:var(--muted);font-size:12px;">Click "Test All Tools" to start.</span></div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;">
<h3 style="margin:0;">Provider Health</h3>
<div>
<button class="ghost small" onclick="testAllProviders(this)" style="margin-right:8px;">Test All</button>
<button class="ghost small health-pause-btn" id="healthPauseBtn" onclick="toggleHealthPause()" style="margin-right:8px;">Pause</button>
<button class="ghost small" onclick="refreshLlmHealth()">Refresh</button>
</div>
</div>
<p style="color:var(--muted);font-size:12px;">1h + 24h windows · auto-refresh 15s</p>
<div id="llm-health-container"><p style="color:var(--muted)">Loading...</p></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:24px;">
<h3 style="margin:0;">Live LLM Log</h3>
<div>
<button class="ghost small" onclick="clearLlmLogs()">Clear Log</button>
<button class="ghost small" onclick="refreshLlmLogs()">Refresh</button>
</div>
</div>
<p style="color:var(--muted);font-size:12px;">Auto-refreshes every 5 seconds.</p>
<div id="llm-logs-container"><p style="color:var(--muted)">Loading...</p></div>
`;
refreshLlmLogs();
refreshLlmHealth();
refreshLlmUsage();
_llmRefreshTimer = setInterval(refreshLlmLogs, 5000);
}
async function testAllProviders(btn) {
const orig = btn.innerText;
btn.innerText = "Testing...";
btn.disabled = true;
try {
const data = await api('/api/llm-dashboard/providers');
const allProvs = (data.providers || []).map(p => p.name);
const cloudProvs = allProvs.filter(n => n !== 'local');
const hasLocal = allProvs.includes('local');
if (cloudProvs.length === 0) {
toast('No cloud providers configured', 'error');
return;
}
const results = [];
// Part 655: sequential with 150ms delay to avoid rate-limit bursts
for (let i = 0; i < cloudProvs.length; i++) {
const name = cloudProvs[i];
btn.innerText = `Testing ${i + 1}/${cloudProvs.length}...`;
const start = Date.now();
try {
const r = await api('/api/llm-dashboard/test-provider/' + encodeURIComponent(name), 'POST');
results.push({
name, ok: r.ok,
latency: r.latency_ms || (Date.now() - start),
error: (r.error || '').substring(0, 40),
});
} catch (e) {
results.push({ name, ok: false, latency: Date.now() - start, error: e.message.substring(0, 40) });
}
if (i < cloudProvs.length - 1) await new Promise(res => setTimeout(res, 150));
}
const okCount = results.filter(r => r.ok).length;
const okSummary = results.filter(r => r.ok).sort((a, b) => a.latency - b.latency)
.map(r => `${r.name} ${Math.round(r.latency)}ms`).join(' · ');
const failSummary = results.filter(r => !r.ok)
.map(r => `${r.name} (${r.error || 'failed'})`).join(' · ');
const parts = [`${okCount}/${results.length} OK`];
if (okSummary) parts.push(okSummary);
if (failSummary) parts.push(`FAIL: ${failSummary}`);
if (hasLocal) parts.push('(local skipped)');
toast(parts.join(' · '), okCount === results.length ? 'success' : 'error');
refreshLlmHealth();
} catch (e) {
toast(`Test all failed: ${e.message}`, 'error');
} finally {
btn.innerText = orig;
btn.disabled = false;
}
}
async function refreshLlmUsage() {
const container = document.getElementById('llm-usage-container');
if (!container) {
if (window._llmUsageTimer) { clearInterval(window._llmUsageTimer); window._llmUsageTimer = null; }
return;
}
const data = await api('/api/llm-dashboard/usage').catch(() => null);
if (!data) {
container.innerHTML = '<p style="color:var(--muted)">Failed to load usage data.</p>';
return;
}
const totals = data.totals || {};
const daily = data.daily || [];
const maxTok = Math.max(1, ...daily.map(d => d.tokens));
const spark = daily.map(d => {
const h = Math.max(2, Math.round((d.tokens / maxTok) * 40));
return '<div title="' + d.date + ': ' + d.tokens + ' tokens, ' + d.calls + ' calls" style="display:inline-block;width:28px;height:' + h + 'px;background:var(--accent);border-radius:2px 2px 0 0;margin-right:4px;vertical-align:bottom;"></div>';
}).join('');
const provRows = (data.providers || []).map(p => `
<tr>
<td><strong>${esc(p.provider)}</strong></td>
<td>${(p.tok_24h || 0).toLocaleString()}</td>
<td>${(p.tok_7d || 0).toLocaleString()} <div style="background:#2ecc71; height:4px; width:${Math.min(100, ((p.tok_7d || 0) / 500) * 100)}%; border-radius:2px; margin-top:4px;"></div></td>
<td>${(p.calls_7d || 0).toLocaleString()}</td>
<td>${p.cost_7d_cents > 0 ? '$' + (p.cost_7d_cents / 100).toFixed(4) : '—'}</td>
</tr>
`).join('');
const userRows = (data.users || []).slice(0, 5).map(u => `
<tr>
<td>${esc(u.user || 'anonymous')}</td>
<td>${(u.tok_24h || 0).toLocaleString()}</td>
<td>${(u.tok_7d || 0).toLocaleString()} <div style="background:#9b59b6; height:4px; width:${Math.min(100, ((u.tok_7d || 0) / 500) * 100)}%; border-radius:2px; margin-top:4px;"></div></td>
<td>${(u.calls_7d || 0).toLocaleString()}</td>
</tr>
`).join('');
container.innerHTML = `
<div style="display:flex;gap:24px;align-items:flex-end;margin:12px 0;padding:16px;background:rgba(255,255,255,0.02);border-radius:6px;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;">
<div style="font-size:11px;color:var(--muted);margin-bottom:8px;">Last 7 days (hover for details)</div>
<div style="height:48px;line-height:0;">${spark}</div>
</div>
<div>
<div style="font-size:11px;color:var(--muted);">24h Tokens</div>
<div style="font-size:20px;font-weight:700;">${(totals.tokens_24h || 0).toLocaleString()}</div>
</div>
<div>
<div style="font-size:11px;color:var(--muted);">7d Tokens</div>
<div style="font-size:20px;font-weight:700;">${(totals.tokens_7d || 0).toLocaleString()}</div>
</div>
<div>
<div style="font-size:11px;color:var(--muted);">7d Calls</div>
<div style="font-size:20px;font-weight:700;">${(totals.calls_7d || 0).toLocaleString()}</div>
</div>
</div>
<h4 style="margin:16px 0 8px;font-size:13px;">Tokens by Provider</h4>
<table style="font-size:12px;">
<thead><tr><th>Provider</th><th>Tokens 24h</th><th>Tokens 7d</th><th>Calls 7d</th><th>Est. Cost 7d</th></tr></thead>
<tbody>${provRows || '<tr><td colspan="5" style="color:var(--muted)">No usage yet.</td></tr>'}</tbody>
</table>
<h4 style="margin:16px 0 8px;font-size:13px;">Tokens by User (top 5)</h4>
<table style="font-size:12px;">
<thead><tr><th>User</th><th>Tokens 24h</th><th>Tokens 7d</th><th>Calls 7d</th></tr></thead>
<tbody>${userRows || '<tr><td colspan="4" style="color:var(--muted)">No usage yet.</td></tr>'}</tbody>
</table>
<h4 style="margin:16px 0 8px;font-size:13px;">Tokens by Model</h4>
<table style="font-size:12px;">
<thead><tr><th>Model</th><th>Tokens 7d</th><th>Calls 7d</th></tr></thead>
<tbody>${(data.models || []).map(m => `
<tr>
<td><span style="font-size:11px;font-family:monospace;">${esc(m.model || 'unknown')}</span></td>
<td>${(m.tok_7d || 0).toLocaleString()} <div style="background:#3498db; height:4px; width:${Math.min(100, ((m.tok_7d || 0) / 500) * 100)}%; border-radius:2px; margin-top:4px;"></div></td>
<td>${(m.calls_7d || 0).toLocaleString()}</td>
</tr>
`).join('') || '<tr><td colspan="3" style="color:var(--muted)">No model usage yet.</td></tr>'}</tbody>
</table>
`;
if (!window._llmUsageTimer) {
window._llmUsageTimer = setInterval(refreshLlmUsage, 30000);
}
}
async function testAllTools(btn) {
const container = document.getElementById('tools-test-results');
if (!container) return;
btn.disabled = true;
btn.textContent = 'Testing...';
container.innerHTML = '<span style="color:var(--muted);font-size:12px;">Running tests...</span>';
try {
const res = await api('/api/tools-test-all', 'POST');
const data = res || {};
const results = data.results || [];
const s = data.summary || {total: results.length, passed: 0, failed: 0, skipped: 0};
const badge = (status) => {
if (status === 'PASS') return '<span style="color:#2ecc71;font-weight:600;">PASS</span>';
if (status === 'FAIL') return '<span style="color:#e74c3c;font-weight:600;">FAIL</span>';
return '<span style="color:var(--muted);">SKIP</span>';
};
container.innerHTML = `
<div style="font-size:12px;margin-bottom:8px;">
<strong>${s.total}</strong> tools · <span style="color:#2ecc71;">${s.passed} passed</span> · <span style="color:#e74c3c;">${s.failed} failed</span> · <span style="color:var(--muted);">${s.skipped} skipped</span>
</div>
<table style="font-size:12px;width:100%;">
<thead><tr><th>Tool</th><th>Status</th><th>Latency</th><th>Output / Error</th></tr></thead>
<tbody>
${results.map(r => `
<tr>
<td><strong>${esc(r.name)}</strong></td>
<td>${badge(r.status)}</td>
<td style="color:var(--muted);">${r.latency_ms}ms</td>
<td style="font-size:11px;color:${r.status === 'FAIL' ? '#e74c3c' : 'var(--muted)'};max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(r.error || r.output)}">${esc((r.error || r.output || '—').slice(0, 120))}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (e) {
container.innerHTML = '<span style="color:#e74c3c;">Test failed: ' + esc(e.message) + '</span>';
} finally {
btn.disabled = false;
btn.textContent = 'Test All Tools';
}
}
async function refreshLlmHealth() {
const container = document.getElementById('llm-health-container');
if (!container) {
if (window._llmHealthTimer) { clearInterval(window._llmHealthTimer); window._llmHealthTimer = null; }
return;
}
const data = await api('/api/llm-dashboard/health').catch(() => ({ providers: [] }));
const rows = data.providers || [];
if (rows.length === 0) {
container.innerHTML = '<p style="color:var(--muted)">No data yet.</p>';
return;
}
if (!window._latencyHistory) window._latencyHistory = {};
rows.forEach(r => {
if (!window._latencyHistory[r.provider]) window._latencyHistory[r.provider] = [];
if (r.avg_latency_1h_ms) {
window._latencyHistory[r.provider].push(r.avg_latency_1h_ms);
if (window._latencyHistory[r.provider].length > 10) window._latencyHistory[r.provider].shift();
}
});
const sparkline = (prov) => {
const hist = window._latencyHistory[prov] || [];
if (hist.length < 2) return '<span style="color:var(--muted);">—</span>';
const max = Math.max(...hist, 1);
return '<span class="llm-sparkline">' + hist.map(v => {
const h = Math.max(2, Math.round((v / max) * 18));
return '<span class="llm-sparkline-bar" style="height:' + h + 'px;" title="' + v + 'ms"></span>';
}).join('') + '</span>';
};
const badge = (s) => {
if (s === 'active') return '<span style="color:#2ecc71;font-weight:600;">Active</span>';
if (s === 'cooling') return '<span style="color:#f39c12;font-weight:600;">Cooling</span>';
return '<span style="color:var(--muted);">Unknown</span>';
};
const pctColor = (p) => {
if (p === null) return 'var(--muted)';
if (p >= 90) return '#2ecc71';
if (p >= 70) return '#f39c12';
return '#e74c3c';
};
const pctCell = (p, n) => {
if (p === null) return '<span style="color:var(--muted);">—</span>';
return '<span style="color:' + pctColor(p) + ';font-weight:600;">' + p + '%</span> <span style="color:var(--muted);font-size:11px;">(' + n + ')</span>';
};
// Part 671: Update alert banner
const disabled = rows.filter(r => r.auto_disabled);
const banner = document.getElementById('alert-banner');
if (banner) {
if (disabled.length > 0) {
banner.style.display = 'block';
document.getElementById('alert-providers').innerText = disabled.map(r => r.provider).join(', ');
} else {
banner.style.display = 'none';
}
}
container.innerHTML = `
<table style="font-size:12px;">
<thead>
<tr>
<th>Provider</th>
<th>Live</th>
<th>Cost</th>
<th>Cooldown</th>
<th>Success 1h</th>
<th>Avg 1h</th><th>Trend</th>
<th>P95 1h</th>
<th>Success 24h</th>
<th>Last Error</th>
</tr>
</thead>
<tbody>
${rows.map(r => {
const isNowDisabled = (r.total_1h >= 3 && r.success_pct_1h !== null && r.success_pct_1h < 25);
const wasDisabled = prevAutoDisabled[r.provider] || false;
if (wasDisabled !== isNowDisabled) {
if (isNowDisabled) {
toast(`⚠️ ${r.provider} was auto-disabled (low success rate)`, 'warning');
} else {
toast(`✅ ${r.provider} recovered!`, 'success');
}
prevAutoDisabled[r.provider] = isNowDisabled;
}
return `
<tr>
<td><strong>${esc(r.provider)}</strong></td>
<td>${badge(r.live_status)}${isNowDisabled ? ' <span style="color:#e74c3c;font-size:10px;font-weight:600;margin-left:6px;">(auto-disabled)</span>' : ''}</td>
<td>${r.cost_tier === 'Free' ? '<span style="color:#2ecc71;font-weight:600;">Free</span>' : '<span style="color:#e67e22;font-weight:600;">' + (r.cost_tier || 'Paid') + '</span>'}</td>
<td>${r.live_status === 'cooling' ? '<span style="color:#f39c12;">' + r.cooldown_remaining_s + 's</span>' : '-'}</td>
<td>${pctCell(r.success_pct_1h, r.total_1h)}</td>
<td>${r.avg_latency_1h_ms ? r.avg_latency_1h_ms + 'ms' : '—'}</td><td>${sparkline(r.provider)}</td>
<td>${r.p95_latency_1h_ms ? r.p95_latency_1h_ms + 'ms' : '—'}</td>
<td>${pctCell(r.success_pct_24h, r.total_24h)}</td>
<td style="font-size:11px;color:#e74c3c;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(r.last_error)}">${esc(r.last_error) || '—'}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
if (!window._llmHealthTimer && !window._llmHealthPaused) {
window._llmHealthTimer = setInterval(refreshLlmHealth, 15000);
}
}
function toggleHealthPause() {
window._llmHealthPaused = !window._llmHealthPaused;
const btn = document.getElementById('healthPauseBtn');
if (!btn) return;
if (window._llmHealthPaused) {
if (window._llmHealthTimer) { clearInterval(window._llmHealthTimer); window._llmHealthTimer = null; }
btn.textContent = 'Resume';
btn.classList.add('paused');
} else {
btn.textContent = 'Pause';
btn.classList.remove('paused');
refreshLlmHealth();
}
}
async function refreshLlmLogs() {
const container = document.getElementById('llm-logs-container');
if (!container) { if (_llmRefreshTimer) { clearInterval(_llmRefreshTimer); _llmRefreshTimer = null; } return; }
const data = await api('/api/llm-dashboard/logs?limit=50').catch(() => ({ logs: [] }));
const logs = data.logs || [];
const fmtAgo = (ts) => {
const s = Math.floor(Date.now()/1000 - ts);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s/60) + 'm';
return Math.floor(s/3600) + 'h';
};
container.innerHTML = logs.length === 0
? '<p style="color:var(--muted)">No LLM calls yet. Try chatting to generate logs.</p>'
: `<table style="font-size:12px;">
<thead><tr><th>Time</th><th>Provider</th><th>Model</th><th>Tokens</th><th>Latency</th><th>User</th><th>Status</th></tr></thead>
<tbody>
${logs.map(l => `
<tr>
<td style="white-space:nowrap;color:var(--muted);">${fmtAgo(l.ts)} ago</td>
<td><strong>${esc(l.provider)}</strong></td>
<td>${esc(l.model || '-')}</td>
<td>${l.tokens}</td>
<td>${Math.round(l.latency_ms)}ms</td>
<td style="font-size:10px;">${esc(l.user || '-')}</td>
<td style="color:${l.ok ? '#2ecc71' : 'var(--danger)'}">${l.ok ? 'ok' : 'err: ' + esc((l.error||'').slice(0,30))}</td>
</tr>
`).join('')}
</tbody>
</table>`;
}
async function setRoutingStrategy(strategy) {
try {
await api('/api/llm-dashboard/orchestrator', 'POST', { routing_strategy: strategy });
toast('Routing strategy set to: ' + strategy, 'success');
if (typeof refreshLlmHealth === 'function') refreshLlmHealth();
} catch (e) { toast('Failed: ' + e.message, 'error'); }
}
async function saveApiKey(provider, value) {
if (!value) return;
try {
const res = await api('/api/llm-dashboard/providers/' + encodeURIComponent(provider) + '/key', 'POST', { api_key: value });
if (res.ok) {
toast('API key updated for ' + provider, 'success');
} else {
toast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
} catch (e) { toast('Failed: ' + e.message, 'error'); }
}
async function saveProviderConfig(provider, field, value) {
// Handle boolean toggles (e.g. 'enabled')
if (typeof value === 'boolean') {
try {
await api('/api/llm-dashboard/configs/' + encodeURIComponent(provider), 'POST', { [field]: value });
toast('Updated ' + provider + ' ' + field + ' to ' + value, 'success');
} catch (e) { toast('Failed: ' + e.message, 'error'); }
return;
}
// Handle numeric values (limits)
const num = parseInt(value, 10);
if (isNaN(num) || num < 0) return;
try {
await api('/api/llm-dashboard/configs/' + encodeURIComponent(provider), 'POST', { [field]: num });
toast('Updated ' + provider + ' ' + field.replace('limit_per_','') + ' to ' + num);
} catch (e) { toast('Failed: ' + e.message, 'error'); }
}
async function moveProvider(provider, direction) {
const data = await api('/api/llm-dashboard/providers').catch(() => ({ providers: [] }));
const names = (data.providers || []).map(p => p.name);
const idx = names.indexOf(provider);
const newIdx = idx + direction;
if (idx < 0 || newIdx < 0 || newIdx >= names.length) return;
[names[idx], names[newIdx]] = [names[newIdx], names[idx]];
try {
await api('/api/llm-dashboard/priority', 'POST', { order: names });
toast('Priority updated');
renderLlmDashboard();
} catch (e) { toast('Failed: ' + e.message, 'error'); }
}
async function testProvider(provider, btn) {
const orig = btn.innerText;
btn.innerText = "Testing...";
btn.disabled = true;
try {
// Use api() wrapper which automatically adds the X-API-Key
const data = await api('/api/llm-dashboard/test-provider/' + encodeURIComponent(provider), 'POST');
if (data.ok) {
toast(`OK ${provider} (${data.latency_ms}ms): "${data.content}"`, 'success');
} else {
toast(`FAIL ${provider} (${data.latency_ms || 0}ms): ${data.error}`, 'error');
}
} catch (e) {
toast(`ERROR ${provider}: ${e.message}`, 'error');
} finally {
btn.innerText = orig;
btn.disabled = false;
}
}
async function forceRecover(provider) {
if (!confirm(`Force recover ${provider}? This will bypass auto-disable for 1 hour.`)) return;
try {
const res = await api(`/api/llm-dashboard/force-recover/${provider}`, 'POST');
if (res.ok) {
alert(`Success: ${res.message}`);
if (typeof refreshLlmHealth === 'function') refreshLlmHealth();
if (typeof refreshLlmUsage === 'function') refreshLlmUsage();
if (typeof loadProviders === 'function') loadProviders();
} else {
alert(`Failed: ${res.error}`);
}
} catch (e) {
alert(`Error: ${e.message}`);
}
}
async function resetCooldown(provider) {
try {
await api('/api/llm-dashboard/reset-cooldown/' + encodeURIComponent(provider), 'POST', {});
toast('Cooldown reset for ' + provider);
renderLlmDashboard();
} catch (e) { toast('Failed: ' + e.message, 'error'); }
}
async function saveOrch(field, value) {
try {
await api('/api/llm-dashboard/orchestrator', 'POST', { [field]: value });
toast('Saved global setting: ' + field);
if (field === 'disable_all') renderLlmDashboard();
} catch (e) { toast('Failed: ' + e.message, 'error'); }
}
async function clearLlmLogs() {
if (!confirm('Clear all LLM call logs?')) return;
try {
await api('/api/llm-dashboard/logs', 'DELETE');
toast('Logs cleared');
refreshLlmLogs();
} catch (e) { toast('Failed: ' + e.message, 'error'); }
}
// Hook new nav items
(function () {
const hook = () => {
const an = document.querySelector('.nav-item[data-tab="analytics"]');
if (an) an.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
an.classList.add('active');
renderAnalytics();
};
const llm = document.querySelector('.nav-item[data-tab="llm-dashboard"]');
if (llm) llm.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
llm.classList.add('active');
renderLlmDashboard();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
async function downloadInvoicePdf(invoiceId) {
try {
toast('Downloading PDF...');
const res = await fetch('/api/saas/invoices/' + encodeURIComponent(invoiceId) + '/pdf', {
headers: { 'X-API-Key': localStorage.getItem('sunnyware_admin_key') || 'master-admin-key' }
});
if (!res.ok) throw new Error('Failed to generate PDF');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'invoice_' + invoiceId + '.pdf';
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
// ═══════════════════════════════════════════════════════════
// Part 217: Email Dashboard
// ═══════════════════════════════════════════════════════════
async function renderEmail() {
const logsData = await api('/api/email/logs?limit=50').catch(() => ({ logs: [] }));
const logs = logsData.logs || [];
$('content').innerHTML = `
<h2>Email Campaigns</h2>
<p style="color:var(--muted);margin-bottom:16px;">Send promotional emails and view delivery logs.</p>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;color:var(--accent);">Send Email</h3>
<div class="form-row" style="display:grid;grid-template-columns:1fr;gap:12px;">
<div><label>To Email</label><input id="em-to" placeholder="customer@example.com"></div>
<div><label>Subject</label><input id="em-sub" placeholder="Big Discount - 50% off!"></div>
<div><label>HTML Body</label><textarea id="em-body" rows="6" placeholder="<h1>Hello!</h1><p>Check out our latest offer...</p>"></textarea></div>
<button onclick="sendPromoEmail()" style="width:200px;">Send Email</button>
</div>
</div>
<h3>Recent Email Logs (${logs.length})</h3>
${logs.length === 0 ? '<p style="color:var(--muted)">No emails sent yet.</p>' : `
<table>
<thead><tr><th>Time</th><th>To</th><th>Subject</th><th>Status</th><th>Error</th></tr></thead>
<tbody>
${logs.map(l => `
<tr>
<td>${new Date(l.ts*1000).toLocaleString()}</td>
<td>${esc(l.to)}</td>
<td>${esc(l.subject)}</td>
<td><span class="tag ${l.status === 'sent' ? 'ok' : 'bad'}">${esc(l.status)}</span></td>
<td style="font-size:11px;color:var(--danger)">${esc(l.error || '')}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function sendPromoEmail() {
const to = document.getElementById('em-to').value;
const sub = document.getElementById('em-sub').value;
const html = document.getElementById('em-body').value;
if (!to || !sub) return toast('To and Subject are required', 'error');
try {
toast('Sending...');
const res = await api('/api/email/send', 'POST', { to, subject: sub, html });
if (res.ok) {
toast('Email sent successfully!');
document.getElementById('em-to').value = '';
document.getElementById('em-sub').value = '';
document.getElementById('em-body').value = '';
renderEmail();
} else {
toast('Error: ' + (res.error || 'unknown'), 'error');
}
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
// Hook Email nav item
(function () {
const hook = () => {
const em = document.querySelector('.nav-item[data-tab="email"]');
if (em) em.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
em.classList.add('active');
renderEmail();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// ═══════════════════════════════════════════════════════════
// Part 219: Inline SVG chart renderer (no external deps)
// ═══════════════════════════════════════════════════════════
function _renderBarChart(containerId, data) {
const container = document.getElementById(containerId);
if (!container) return;
if (!data || data.length === 0) {
container.innerHTML = '<p style="color:var(--muted);font-size:12px;">No data</p>';
return;
}
const W = 800, H = 250, padX = 40, padY = 30;
const plotW = W - padX * 2;
const plotH = H - padY * 2;
const maxTokens = Math.max(1, ...data.map(d => d.tokens || 0));
const barW = plotW / data.length * 0.6;
const gap = plotW / data.length * 0.4;
let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" style="width:100%;height:auto;display:block;">';
// Bars
data.forEach((d, i) => {
const x = padX + i * (barW + gap) + gap/2;
const barHeight = ((d.tokens || 0) / maxTokens) * plotH;
const y = padY + plotH - barHeight;
// Bar
svg += '<rect x="' + x + '" y="' + y + '" width="' + barW + '" height="' + barHeight + '" fill="#5b8def" rx="4"/>';
// Value on top
svg += '<text x="' + (x + barW/2) + '" y="' + (y - 5) + '" text-anchor="middle" font-size="10" fill="#8892a6">' + (d.tokens || 0) + '</text>';
// User label below
const label = (d.user || '').split('@')[0] || d.user || 'user';
svg += '<text x="' + (x + barW/2) + '" y="' + (H - 8) + '" text-anchor="middle" font-size="9" fill="#8892a6">' + label + '</text>';
});
svg += '</svg>';
container.innerHTML = svg;
}
function _renderSimpleChart(containerId, data) {
const container = document.getElementById(containerId);
if (!container) return;
if (!data || data.length === 0) {
container.innerHTML = '<p style="color:var(--muted);font-size:12px;">No chart data</p>';
return;
}
const W = 800, H = 220, padX = 40, padY = 30;
const plotW = W - padX * 2;
const plotH = H - padY * 2;
const maxTokens = Math.max(1, ...data.map(d => d.tokens || 0));
const maxChats = Math.max(1, ...data.map(d => d.chats || 0));
const stepX = data.length > 1 ? plotW / (data.length - 1) : plotW;
let svg = '<svg viewBox="0 0 ' + W + ' ' + H + '" preserveAspectRatio="none" style="width:100%;height:auto;display:block;">';
// Grid lines
for (let i = 0; i <= 4; i++) {
const y = padY + (plotH * i) / 4;
svg += '<line x1="' + padX + '" y1="' + y + '" x2="' + (W - padX) + '" y2="' + y + '" stroke="#232a38" stroke-dasharray="2,2" stroke-width="1"/>';
const val = Math.round(maxTokens * (1 - i / 4));
svg += '<text x="' + (padX - 6) + '" y="' + (y + 3) + '" text-anchor="end" font-size="9" fill="#8892a6">' + val + '</text>';
}
// X axis labels
data.forEach((d, i) => {
const x = padX + i * stepX;
svg += '<text x="' + x + '" y="' + (H - 8) + '" text-anchor="middle" font-size="9" fill="#8892a6">' + (d.date || '').slice(5) + '</text>';
});
// Tokens line (blue)
const tokenPts = data.map((d, i) => {
const x = padX + i * stepX;
const y = padY + plotH - ((d.tokens || 0) / maxTokens) * plotH;
return x + ',' + y;
}).join(' ');
svg += '<polyline points="' + tokenPts + '" fill="none" stroke="#5b8def" stroke-width="2"/>';
data.forEach((d, i) => {
const x = padX + i * stepX;
const y = padY + plotH - ((d.tokens || 0) / maxTokens) * plotH;
svg += '<circle cx="' + x + '" cy="' + y + '" r="3" fill="#5b8def"><title>' + (d.date || '') + ': ' + (d.tokens || 0) + ' tokens</title></circle>';
});
// Chats line (green, scaled to its own max)
const chatPts = data.map((d, i) => {
const x = padX + i * stepX;
const y = padY + plotH - ((d.chats || 0) / maxChats) * plotH;
return x + ',' + y;
}).join(' ');
svg += '<polyline points="' + chatPts + '" fill="none" stroke="#2ecc71" stroke-width="2" stroke-dasharray="4,2"/>';
data.forEach((d, i) => {
const x = padX + i * stepX;
const y = padY + plotH - ((d.chats || 0) / maxChats) * plotH;
svg += '<circle cx="' + x + '" cy="' + y + '" r="2.5" fill="#2ecc71"><title>' + (d.date || '') + ': ' + (d.chats || 0) + ' chats</title></circle>';
});
// Legend
svg += '<rect x="' + padX + '" y="' + (padY - 22) + '" width="12" height="3" fill="#5b8def"/>';
svg += '<text x="' + (padX + 18) + '" y="' + (padY - 19) + '" font-size="10" fill="#5b8def">Tokens</text>';
svg += '<rect x="' + (padX + 75) + '" y="' + (padY - 22) + '" width="12" height="3" fill="#2ecc71"/>';
svg += '<text x="' + (padX + 93) + '" y="' + (padY - 19) + '" font-size="10" fill="#2ecc71">Chats</text>';
svg += '</svg>';
container.innerHTML = svg;
}
// Part 221a: API KEYS page
async function renderApiKeys() {
const data = await api('/api/api-keys').catch(() => ({ keys: [] }));
const keys = data.keys || [];
$('content').innerHTML = `
<h2>API Keys</h2>
<p style="color:var(--muted);margin-bottom:16px;">Programmatic access keys. Raw key shown only once at creation.</p>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;color:var(--accent);">Create API Key</h3>
<div class="form-row">
<div><label>Name</label><input id="ak-name" placeholder="Production bot"></div>
<div><label>Org ID (optional)</label><input id="ak-org" placeholder="org_..."></div>
<div><label>User Email (optional)</label><input id="ak-user" placeholder="user@example.com"></div>
<div><label>Rate Limit / min</label><input id="ak-rate" type="number" value="60" min="1"></div>
<div><label>Scopes (comma)</label><input id="ak-scopes" value="read" placeholder="read,chat,admin"></div>
<button onclick="createApiKey()">Create Key</button>
</div>
<div id="ak-new-key" style="margin-top:16px;"></div>
</div>
<h3>Active Keys (${keys.length})</h3>
${keys.length === 0 ? '<p style="color:var(--muted)">No API keys yet.</p>' : `
<table>
<thead><tr><th>Name</th><th>Prefix</th><th>Org</th><th>User</th><th>Scopes</th><th>Rate</th><th>Calls</th><th>Tokens</th><th>Last Used</th><th>Status</th><th></th></tr></thead>
<tbody>
${keys.map(k => `
<tr>
<td><strong>${esc(k.name)}</strong></td>
<td>
<code style="font-size:11px">${esc(k.prefix)}…</code>
<button class="ghost small" style="margin-left:6px;padding:2px 6px;" onclick="regenerateApiKey('${esc(k.id)}')">Regenerate</button>
</td>
<td><code style="font-size:10px">${esc(k.org_id || '-')}</code></td>
<td style="font-size:11px;">${esc(k.user_email || '-')}</td>
<td style="font-size:11px;">${(k.scopes || []).join(', ')}</td>
<td>
<input type="number" value="${k.rate_limit_per_min || 60}" min="1" style="width:55px;padding:2px;font-size:11px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px;" onblur="updateKeyRate('${esc(k.id)}', this.value)"> /m
</td>
<td>${k.calls || 0}</td>
<td>${k.tokens_used || 0}</td>
<td style="font-size:11px;">${k.last_used ? new Date(k.last_used*1000).toLocaleString() : '—'}</td>
<td><span class="tag ${k.revoked ? 'bad' : 'ok'}">${k.revoked ? 'Revoked' : 'Active'}</span></td>
<td>
${k.revoked ? '' : `<button class="ghost small" onclick="revokeApiKey('${esc(k.id)}')">Revoke</button>`}
<button class="danger small" onclick="deleteApiKey('${esc(k.id)}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function createApiKey() {
const name = document.getElementById('ak-name').value.trim();
const org = document.getElementById('ak-org').value.trim();
const user = document.getElementById('ak-user').value.trim();
const rate = parseInt(document.getElementById('ak-rate').value) || 60;
const scopesRaw = document.getElementById('ak-scopes').value.trim();
const scopes = scopesRaw ? scopesRaw.split(',').map(s => s.trim()).filter(Boolean) : ['read'];
if (!name) return toast('Name required', 'error');
try {
const res = await api('/api/api-keys', 'POST', {
name, org_id: org, user_email: user, rate_limit_per_min: rate, scopes
});
if (res.ok && res.key) {
document.getElementById('ak-new-key').innerHTML = `
<div class="card" style="border-color:var(--success);background:#2ecc7111;">
<h4 style="margin-top:0;color:var(--success);">API Key Created — Copy Now (won't show again)</h4>
<div style="background:var(--bg);padding:12px;border-radius:6px;font-family:monospace;font-size:13px;word-break:break-all;user-select:all;">${esc(res.key.raw_key)}</div>
<p style="margin:8px 0 0;font-size:12px;color:var(--muted);">Use this as the <code>X-API-Key</code> header.</p>
</div>
`;
setTimeout(() => renderApiKeys(), 2000);
} else {
toast('Error: ' + (res.error || 'unknown'), 'error');
}
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function updateKeyRate(keyId, value) {
const rpm = parseInt(value) || 60;
try {
await api('/api/api-keys/' + encodeURIComponent(keyId) + '/rpm', 'PATCH', { rpm_limit: rpm });
toast('Rate limit updated');
} catch(e) {
toast('Error: ' + e.message, 'error');
}
}
async function revokeApiKey(keyId) {
if (!confirm('Revoke this key? Existing integrations will stop working.')) return;
try {
await api('/api/api-keys/' + encodeURIComponent(keyId) + '/revoke', 'POST', {});
toast('Key revoked');
renderApiKeys();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function deleteApiKey(keyId) {
if (!confirm('Permanently delete this key?')) return;
try {
await api('/api/api-keys/' + encodeURIComponent(keyId), 'DELETE');
toast('Key deleted');
renderApiKeys();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
// Part 221a: WEBHOOKS page
async function renderWebhooks() {
const [epsData, eventsData, delivData] = await Promise.all([
api('/api/webhooks/endpoints').catch(() => ({ endpoints: [] })),
api('/api/webhooks/events').catch(() => ({ events: [] })),
api('/api/webhooks/deliveries?limit=30').catch(() => ({ deliveries: [] }))
]);
const endpoints = epsData.endpoints || [];
const events = eventsData.events || [];
const deliveries = delivData.deliveries || [];
const retriesData = await api('/api/webhooks/retries').catch(() => ({ retries: [] }));
const retries = retriesData.retries || [];
$('content').innerHTML = `
<h2>Webhooks</h2>
<p style="color:var(--muted);margin-bottom:16px;">Send HTTP POST payloads to Slack, Discord, or any URL when events happen.</p>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;color:var(--accent);">Add Endpoint</h3>
<div class="form-row" style="display:grid;grid-template-columns:1fr;gap:12px;">
<div><label>Webhook URL</label><input id="wh-url" placeholder="https://hooks.slack.com/services/..."></div>
<div><label>Name</label><input id="wh-name" placeholder="Slack #alerts"></div>
<div><label>Events (Ctrl/Cmd to multi-select)</label>
<select id="wh-events" multiple style="min-height:100px;">
${events.map(ev => `<option value="${esc(ev)}">${esc(ev)}</option>`).join('')}
</select>
</div>
<div><label>Secret (optional)</label><input id="wh-secret" placeholder="my-secret-token"></div>
<button onclick="addWebhook()" style="width:200px;">Add Endpoint</button>
</div>
</div>
<h3>Endpoints (${endpoints.length})</h3>
${endpoints.length === 0 ? '<p style="color:var(--muted)">No webhook endpoints yet.</p>' : `
<table>
<thead><tr><th>Name</th><th>URL</th><th>Events</th><th>Delivered</th><th>Failed</th><th>Last Fired</th><th>Status</th><th></th></tr></thead>
<tbody>
${endpoints.map(e => `
<tr>
<td><strong>${esc(e.name)}</strong></td>
<td style="font-size:11px;max-width:200px;overflow:hidden;text-overflow:ellipsis;">${esc(e.url)}</td>
<td style="font-size:11px;">${(e.events || []).join(', ')}</td>
<td><span style="color:var(--success)">${e.success_count || 0}</span></td>
<td><span style="color:var(--danger)">${e.fail_count || 0}</span></td>
<td style="font-size:11px;">${e.last_fired ? new Date(e.last_fired*1000).toLocaleString() : '—'}</td>
<td><span class="tag ${e.enabled ? 'ok' : 'bad'}">${e.enabled ? 'On' : 'Off'}</span></td>
<td>
<button class="ghost small" onclick="testWebhook('${esc(e.id)}')">Test</button>
<button class="ghost small" onclick="toggleWebhook('${esc(e.id)}')">${e.enabled ? 'Disable' : 'Enable'}</button>
<button class="danger small" onclick="deleteWebhook('${esc(e.id)}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:32px;">
<h3 style="margin:0;">Pending Retries (${retries.length})</h3>
${retries.length === 0 ? '<p style="color:var(--muted)">No pending retries.</p>' : `
<table style="margin-bottom:20px;">
<thead><tr><th>Event</th><th>Endpoint</th><th>Attempt</th><th>Next Retry</th></tr></thead>
<tbody>
${retries.map(r => `
<tr>
<td><span class="tag">${esc(r.event)}</span></td>
<td>${esc(r.endpoint.name)}</td>
<td>${r.attempt + 1} / 4</td>
<td>${new Date(r.next_retry * 1000).toLocaleTimeString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
<h3 style="margin:0;margin-top:24px;">Recent Deliveries (${deliveries.length})</h3>
<button class="ghost small" onclick="clearWebhookLogs()">Clear Log</button>
</div>
${deliveries.length === 0 ? '<p style="color:var(--muted)">No deliveries yet.</p>' : `
<table style="font-size:12px;">
<thead><tr><th>Time</th><th>Endpoint</th><th>Event</th><th>Status</th><th>HTTP</th><th>Response</th></tr></thead>
<tbody>
${deliveries.map(d => `
<tr>
<td>${new Date(d.ts*1000).toLocaleString()}</td>
<td><code style="font-size:10px">${esc(d.endpoint_id)}</code></td>
<td>${esc(d.event)}</td>
<td><span class="tag ${d.status === 'delivered' ? 'ok' : 'bad'}">${esc(d.status)}</span></td>
<td>${d.http_status || 0}</td>
<td style="font-size:10px;max-width:200px;overflow:hidden;text-overflow:ellipsis;">${esc((d.body||'').slice(0,80))}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function addWebhook() {
const url = document.getElementById('wh-url').value.trim();
const name = document.getElementById('wh-name').value.trim();
const secret = document.getElementById('wh-secret').value.trim();
const select = document.getElementById('wh-events');
const events = Array.from(select.selectedOptions).map(o => o.value);
if (!url) return toast('URL required', 'error');
if (events.length === 0) return toast('Select at least one event', 'error');
try {
const res = await api('/api/webhooks/endpoints', 'POST', { url, name, events, secret });
if (res.ok) { toast('Endpoint added'); renderWebhooks(); }
else { toast('Error: ' + (res.error || 'unknown'), 'error'); }
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function testWebhook(eid) {
try {
const res = await api('/api/webhooks/endpoints/' + encodeURIComponent(eid) + '/test', 'POST', {});
if (res.ok) { toast('Test fired — check deliveries in a few seconds'); setTimeout(() => renderWebhooks(), 2500); }
else { toast('Error: ' + (res.error || 'unknown'), 'error'); }
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function toggleWebhook(eid) {
try {
await api('/api/webhooks/endpoints/' + encodeURIComponent(eid) + '/toggle', 'POST', {});
renderWebhooks();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function deleteWebhook(eid) {
if (!confirm('Delete this endpoint?')) return;
try {
await api('/api/webhooks/endpoints/' + encodeURIComponent(eid), 'DELETE');
toast('Deleted');
renderWebhooks();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function clearWebhookLogs() {
if (!confirm('Clear all delivery logs?')) return;
try {
await api('/api/webhooks/deliveries', 'DELETE');
renderWebhooks();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
(function () {
const hook = () => {
const ak = document.querySelector('.nav-item[data-tab="api-keys"]');
if (ak) ak.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
ak.classList.add('active');
renderApiKeys();
};
const wh = document.querySelector('.nav-item[data-tab="webhooks"]');
if (wh) wh.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
wh.classList.add('active');
renderWebhooks();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 221b: CHAT AUDIT page
async function renderChatAudit() {
const [statsRes, msgsRes] = await Promise.all([
api('/api/chat-audit/stats').catch(() => ({})),
api('/api/chat-audit/messages?limit=100').catch(() => ({ messages: [] }))
]);
const stats = statsRes || {};
const messages = msgsRes.messages || [];
$('content').innerHTML = `
<h2>Chat Audit</h2>
<p style="color:var(--muted);margin-bottom:16px;">Browse, search, and flag conversations for compliance review.</p>
<div class="stat-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px;">
<div class="card"><div style="font-size:11px;color:var(--muted);">Total Messages</div><div style="font-size:22px;font-weight:700;">${stats.total_messages || 0}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Unique Users</div><div style="font-size:22px;font-weight:700;">${stats.unique_users || 0}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Flagged</div><div style="font-size:22px;font-weight:700;color:${(stats.flagged_count||0)>0?'var(--danger)':'inherit'}">${stats.flagged_count || 0}</div></div>
</div>
<div class="card" style="margin-bottom:16px;">
<h3 style="margin-top:0;">Search & Filter</h3>
<div class="form-row">
<div><label>User</label><input id="ca-user" placeholder="user@example.com"></div>
<div><label>Search content</label><input id="ca-search" placeholder="keyword"></div>
<div><label>Limit</label><input id="ca-limit" type="number" value="100" min="10" max="1000"></div>
<button onclick="renderChatAuditLoad()">Search</button>
</div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;">
<button class="ghost small" onclick="loadFlaggedMessages()">Show Flagged Only</button>
<button class="ghost small" onclick="exportChatAudit(false)">Export CSV</button>
<button class="ghost small" onclick="exportChatAudit(true)">Export CSV (Redacted)</button>
</div>
</div>
<div id="ca-table-container">
${renderChatAuditTable(messages)}
</div>
`;
}
function renderChatAuditTable(messages) {
if (messages.length === 0) {
return '<p style="color:var(--muted)">No messages to display.</p>';
}
return `
<table>
<thead><tr><th>Time</th><th>User</th><th>Role</th><th>Content</th><th></th></tr></thead>
<tbody>
${messages.map(m => `
<tr id="msg-${esc(m.id)}">
<td style="white-space:nowrap;font-size:11px;">${new Date(m.ts*1000).toLocaleString()}</td>
<td style="font-size:11px;">${esc(m.user)}</td>
<td><span class="tag ${m.role === 'user' ? 'ok' : ''}">${esc(m.role)}</span></td>
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;" title="${esc(m.content || '')}">${esc((m.content||'').slice(0,200))}</td>
<td><button class="danger small" onclick="flagChatMessage('${esc(m.id)}')">Flag</button></td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function renderChatAuditLoad() {
const user = document.getElementById('ca-user').value.trim();
const search = document.getElementById('ca-search').value.trim();
const limit = parseInt(document.getElementById('ca-limit').value) || 100;
const qs = new URLSearchParams();
if (user) qs.set('user', user);
if (search) qs.set('search', search);
qs.set('limit', limit);
const data = await api('/api/chat-audit/messages?' + qs.toString()).catch(() => ({ messages: [] }));
document.getElementById('ca-table-container').innerHTML = renderChatAuditTable(data.messages || []);
}
async function loadFlaggedMessages() {
const data = await api('/api/chat-audit/flagged?limit=100').catch(() => ({ messages: [] }));
document.getElementById('ca-table-container').innerHTML = renderChatAuditTable(data.messages || []);
}
async function flagChatMessage(mid) {
const reason = prompt('Reason for flagging (optional):');
if (reason === null) return;
try {
await api('/api/chat-audit/messages/' + encodeURIComponent(mid) + '/flag', 'POST', { flagged: true, reason: reason });
toast('Message flagged');
const row = document.getElementById('msg-' + mid);
if (row) row.style.background = '#e74c3c11';
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
function exportChatAudit(redact) {
const user = document.getElementById('ca-user').value.trim();
const qs = new URLSearchParams();
if (user) qs.set('user', user);
if (redact) qs.set('redact', 'true');
const apiKey = localStorage.getItem('sunnyware_admin_key') || 'master-admin-key';
fetch('/api/chat-audit/export?' + qs.toString(), {
headers: { 'X-API-Key': apiKey }
})
.then(res => {
if (!res.ok) throw new Error('Export failed');
return res.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'chat_audit.csv';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast('CSV downloaded');
})
.catch(e => toast('Error: ' + e.message, 'error'));
}
// Part 221b: SCHEDULED TASKS page
async function renderScheduledTasks() {
const [tasksRes, runsRes] = await Promise.all([
api('/api/scheduled-tasks').catch(() => ({ tasks: [] })),
api('/api/scheduled-tasks/runs?limit=30').catch(() => ({ runs: [] }))
]);
const tasks = tasksRes.tasks || [];
const runs = runsRes.runs || [];
$('content').innerHTML = `
<h2>Scheduled Tasks</h2>
<p style="color:var(--muted);margin-bottom:16px;">Prompts that run on a schedule (hourly, daily, weekly, monthly).</p>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;color:var(--accent);">Create Scheduled Task</h3>
<div class="form-row" style="display:grid;grid-template-columns:1fr;gap:12px;">
<div><label>Task Name</label><input id="st-name" placeholder="Daily report"></div>
<div><label>Prompt</label><textarea id="st-prompt" rows="4" placeholder="Generate a summary of yesterday's activity..."></textarea></div>
<div><label>Schedule</label>
<select id="st-schedule">
<option value="hourly">Hourly</option>
<option value="daily" selected>Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="once">Once (1 min from now)</option>
</select>
</div>
<button onclick="createScheduledTask()" style="width:200px;">Create Task</button>
</div>
</div>
<h3>Tasks (${tasks.length})</h3>
${tasks.length === 0 ? '<p style="color:var(--muted)">No tasks yet.</p>' : `
<table>
<thead><tr><th>Name</th><th>Schedule</th><th>Prompt</th><th>Runs</th><th>Last Run</th><th>Next Run</th><th>Status</th><th></th></tr></thead>
<tbody>
${tasks.map(t => `
<tr>
<td><strong>${esc(t.name)}</strong></td>
<td><span class="tag">${esc(t.schedule)}</span></td>
<td style="max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;">${esc((t.prompt||'').slice(0,100))}</td>
<td>${t.run_count || 0}</td>
<td style="font-size:11px;">${t.last_run ? new Date(t.last_run*1000).toLocaleString() : '—'}</td>
<td style="font-size:11px;">${t.next_run ? new Date(t.next_run*1000).toLocaleString() : '—'}</td>
<td><span class="tag ${t.enabled ? 'ok' : 'bad'}">${t.enabled ? 'On' : 'Paused'}</span></td>
<td>
<button class="ghost small" onclick="testScheduledTask('${esc(t.id)}')">Test</button>
<button class="ghost small" onclick="toggleScheduledTask('${esc(t.id)}')">${t.enabled ? 'Pause' : 'Resume'}</button>
<button class="danger small" onclick="deleteScheduledTask('${esc(t.id)}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:32px;">
<h3 style="margin:0;">Recent Runs (${runs.length})</h3>
<button class="ghost small" onclick="clearScheduledRuns()">Clear Log</button>
</div>
${runs.length === 0 ? '<p style="color:var(--muted)">No runs yet.</p>' : `
<table style="font-size:12px;">
<thead><tr><th>Time</th><th>Task</th><th>Status</th><th>Output / Error</th></tr></thead>
<tbody>
${runs.map(r => `
<tr>
<td>${new Date(r.ts*1000).toLocaleString()}</td>
<td><code style="font-size:10px">${esc(r.task_id)}</code></td>
<td><span class="tag ${r.ok ? 'ok' : 'bad'}">${r.ok ? 'ok' : 'fail'}</span></td>
<td style="font-size:11px;max-width:400px;overflow:hidden;text-overflow:ellipsis;">${esc((r.output || r.error || '').slice(0,100))}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function createScheduledTask() {
const name = document.getElementById('st-name').value.trim();
const prompt = document.getElementById('st-prompt').value.trim();
const schedule = document.getElementById('st-schedule').value;
if (!name) return toast('Name required', 'error');
if (!prompt) return toast('Prompt required', 'error');
try {
const res = await api('/api/scheduled-tasks', 'POST', { name, prompt, schedule });
if (res.ok) { toast('Task created'); renderScheduledTasks(); }
else { toast('Error: ' + (res.error || 'unknown'), 'error'); }
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function testScheduledTask(tid) {
try {
const res = await api('/api/scheduled-tasks/' + encodeURIComponent(tid) + '/test-run', 'POST', {});
if (res.ok) { toast('Test run recorded'); setTimeout(() => renderScheduledTasks(), 1500); }
else { toast('Error: ' + (res.error || 'unknown'), 'error'); }
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function toggleScheduledTask(tid) {
try {
await api('/api/scheduled-tasks/' + encodeURIComponent(tid) + '/toggle', 'POST', {});
renderScheduledTasks();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function deleteScheduledTask(tid) {
if (!confirm('Delete this task?')) return;
try {
await api('/api/scheduled-tasks/' + encodeURIComponent(tid), 'DELETE');
toast('Deleted');
renderScheduledTasks();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function clearScheduledRuns() {
if (!confirm('Clear all run logs?')) return;
try {
await api('/api/scheduled-tasks/runs/clear', 'DELETE');
renderScheduledTasks();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
(function () {
const hook = () => {
const ca = document.querySelector('.nav-item[data-tab="chat-audit"]');
if (ca) ca.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
ca.classList.add('active');
renderChatAudit();
};
const st = document.querySelector('.nav-item[data-tab="scheduled-tasks"]');
if (st) st.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
st.classList.add('active');
renderScheduledTasks();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 221c: RAG MANAGEMENT page
async function renderRagAdmin() {
const [statsRes, docsRes] = await Promise.all([
api('/api/rag-admin/stats').catch(() => ({})),
api('/api/rag-admin/documents?limit=200').catch(() => ({ documents: [] }))
]);
const stats = statsRes || {};
const docs = docsRes.documents || [];
const fmtBytes = (b) => {
if (!b) return '0 B';
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
return (b/1048576).toFixed(2) + ' MB';
};
$('content').innerHTML = `
<h2>RAG Management</h2>
<p style="color:var(--muted);margin-bottom:16px;">Upload documents, inspect chunks, and manage the retrieval corpus.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:20px;">
<div class="card"><div style="font-size:11px;color:var(--muted);">Total Documents</div><div style="font-size:22px;font-weight:700;">${stats.total_documents || 0}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Total Chunks</div><div style="font-size:22px;font-weight:700;">${stats.total_chunks || 0}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Total Size</div><div style="font-size:22px;font-weight:700;">${fmtBytes(stats.total_bytes || 0)}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Unique Orgs</div><div style="font-size:22px;font-weight:700;">${stats.unique_orgs || 0}</div></div>
</div>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;color:var(--accent);">Upload Document</h3>
<div class="form-row" style="display:grid;grid-template-columns:1fr;gap:12px;">
<div><label>File name</label><input id="rag-name" placeholder="company-handbook.md"></div>
<div><label>Org ID (optional)</label><input id="rag-org" placeholder="org_..."></div>
<div><label>Content</label><textarea id="rag-content" rows="8" placeholder="Paste text content, or click 'Upload File' below..."></textarea></div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button onclick="uploadRagDoc()" style="width:200px;">Add Document</button>
<input type="file" id="rag-file-input" accept=".txt,.md,.csv,.json,.html" style="display:none;" onchange="uploadRagFile(event)">
<button class="ghost" onclick="document.getElementById('rag-file-input').click()">Upload File (.txt/.md/.csv)</button>
</div>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>Documents (${docs.length})</h3>
<button class="ghost small" onclick="renderRagAdmin()">Refresh</button>
</div>
<div id="rag-docs-container">
${renderRagDocsTable(docs)}
</div>
<div id="rag-chunks-panel" style="margin-top:24px;"></div>
`;
}
function renderRagDocsTable(docs) {
if (docs.length === 0) {
return '<p style="color:var(--muted)">No documents yet. Upload one above.</p>';
}
return `
<table>
<thead><tr><th>Name</th><th>Org</th><th>Size</th><th>Chunks</th><th>Status</th><th>Uploaded</th><th></th></tr></thead>
<tbody>
${docs.map(d => `
<tr>
<td><strong>${esc(d.name)}</strong><br><span style="font-size:10px;color:var(--muted);">${esc(d.mime)}</span></td>
<td><code style="font-size:10px">${esc(d.org_id || '-')}</code></td>
<td>${(d.size_bytes || 0).toLocaleString()} B</td>
<td><a href="#" onclick="showRagChunks('${esc(d.id)}', '${esc(d.name).replace(/\x27/g, '&#39;')}'); return false;">${d.chunk_count || 0} chunks</a></td>
<td><span class="tag ${d.status === 'chunked' ? 'ok' : 'bad'}">${esc(d.status)}</span></td>
<td style="font-size:11px;">${new Date(d.ts*1000).toLocaleString()}</td>
<td>
<button class="danger small" onclick="deleteRagDoc('${esc(d.id)}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
async function uploadRagDoc() {
const name = document.getElementById('rag-name').value.trim();
const org = document.getElementById('rag-org').value.trim();
const content = document.getElementById('rag-content').value;
if (!name) return toast('File name required', 'error');
if (!content || content.length < 10) return toast('Content too short (min 10 chars)', 'error');
try {
const res = await api('/api/rag-admin/documents', 'POST', {
name, org_id: org, content, mime: 'text/plain'
});
if (res.ok) {
toast('Document uploaded + chunked');
document.getElementById('rag-name').value = '';
document.getElementById('rag-content').value = '';
renderRagAdmin();
} else {
toast('Error: ' + (res.error || 'unknown'), 'error');
}
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
function uploadRagFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
document.getElementById('rag-name').value = file.name;
document.getElementById('rag-content').value = e.target.result;
toast('File loaded — click "Add Document"');
};
reader.readAsText(file);
}
async function showRagChunks(docId, docName) {
const panel = document.getElementById('rag-chunks-panel');
if (!panel) return;
panel.innerHTML = '<p style="color:var(--muted)">Loading chunks...</p>';
const data = await api('/api/rag-admin/documents/' + encodeURIComponent(docId) + '/chunks?limit=500').catch(() => ({ chunks: [] }));
const chunks = data.chunks || [];
panel.innerHTML = `
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="margin:0;color:var(--accent);">Chunks — ${esc(docName)}</h3>
<button class="ghost small" onclick="document.getElementById('rag-chunks-panel').innerHTML=''">Close</button>
</div>
<p style="color:var(--muted);font-size:12px;margin-bottom:12px;">${chunks.length} chunks (split by paragraph, max 2000 chars each).</p>
${chunks.length === 0 ? '<p style="color:var(--muted)">No chunks found.</p>' : chunks.map((c, i) => `
<div style="padding:10px;background:var(--bg);border-radius:6px;margin-bottom:8px;border-left:3px solid var(--accent);">
<div style="font-size:10px;color:var(--muted);margin-bottom:4px;">Chunk #${c.index + 1} (${(c.text||'').length} chars)</div>
<div style="font-size:12px;white-space:pre-wrap;">${esc((c.text||'').slice(0, 500))}${(c.text||'').length > 500 ? '…' : ''}</div>
</div>
`).join('')}
</div>
`;
}
async function deleteRagDoc(docId) {
if (!confirm('Delete this document and all its chunks?')) return;
try {
await api('/api/rag-admin/documents/' + encodeURIComponent(docId), 'DELETE');
toast('Document deleted');
renderRagAdmin();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
(function () {
const hook = () => {
const rag = document.querySelector('.nav-item[data-tab="rag-admin"]');
if (rag) rag.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
rag.classList.add('active');
renderRagAdmin();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 222: SECURITY page (2FA management)
async function renderSecurity() {
const status = await api('/api/2fa/status').catch(() => ({ enabled: false, user: 'admin' }));
const devicesData = await api('/api/2fa/devices').catch(() => ({ devices: [] }));
const devices = devicesData.devices || [];
$('content').innerHTML = `
<h2>Security</h2>
<p style="color:var(--muted);margin-bottom:16px;">Two-factor authentication and device management.</p>
<div class="card" style="margin-bottom:24px;">
<h3 style="margin-top:0;color:${status.enabled ? 'var(--success)' : 'var(--accent)'};">
Two-Factor Authentication ${status.enabled ? '— Enabled' : '— Disabled'}
</h3>
<p style="color:var(--muted);font-size:12px;margin-bottom:16px;">
User: <code>${esc(status.user || 'admin')}</code>
${status.enabled ? `<br>Backup codes remaining: <strong>${status.backup_codes_remaining || 0}</strong>` : ''}
</p>
${status.enabled ? `
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="ghost small" onclick="regenBackupCodes()">Regenerate Backup Codes</button>
<button class="danger small" onclick="showDisable2FA()">Disable 2FA</button>
</div>
<div id="2fa-setup-panel" style="margin-top:16px;"></div>
` : `
<button onclick="startSetup2FA()">Enable Two-Factor Authentication</button>
<div id="2fa-setup-panel" style="margin-top:16px;"></div>
`}
</div>
<h3>Trusted Devices (${devices.length})</h3>
<p style="color:var(--muted);font-size:12px;margin-bottom:12px;">Devices that skip 2FA for 30 days after verification.</p>
${devices.length === 0 ? '<p style="color:var(--muted)">No trusted devices yet.</p>' : `
<table>
<thead><tr><th>Name</th><th>Created</th><th>Expires</th><th></th></tr></thead>
<tbody>
${devices.map(d => `
<tr>
<td>${esc(d.name)}</td>
<td style="font-size:11px;">${new Date(d.created*1000).toLocaleString()}</td>
<td style="font-size:11px;">${new Date(d.expires*1000).toLocaleString()}</td>
<td><button class="danger small" onclick="removeTrustedDevice('${esc(d.id)}')">Remove</button></td>
</tr>
`).join('')}
</tbody>
</table>
<div style="margin-top:12px;">
<button class="ghost small" onclick="removeAllTrustedDevices()">Remove All</button>
</div>
`}
`;
}
async function startSetup2FA() {
const panel = document.getElementById('2fa-setup-panel');
if (!panel) return;
panel.innerHTML = '<p style="color:var(--muted)">Generating secret...</p>';
const res = await api('/api/2fa/setup', 'POST', {}).catch(() => null);
if (!res || !res.ok) {
panel.innerHTML = '<p style="color:var(--danger)">Error: ' + esc((res && res.error) || 'unknown') + '</p>';
return;
}
panel.innerHTML = `
<div class="card" style="border-color:var(--accent);background:var(--bg);padding:20px;">
<h4 style="margin-top:0;color:var(--accent);">Step 1: Scan this QR code</h4>
<p style="color:var(--muted);font-size:12px;">Open Google Authenticator, Authy, or 1Password and scan:</p>
<div style="text-align:center;margin:16px 0;background:#fff;padding:12px;border-radius:8px;display:inline-block;">
<img src="${res.qr_data_url}" alt="QR" style="width:200px;height:200px;">
</div>
<p style="color:var(--muted);font-size:11px;">Or enter manually: <code>${esc(res.secret)}</code></p>
<h4 style="margin-top:24px;color:var(--accent);">Step 2: Verify a code</h4>
<p style="color:var(--muted);font-size:12px;">Enter the 6-digit code from your authenticator to confirm setup:</p>
<input id="2fa-setup-code" placeholder="000000" maxlength="6" style="width:200px;text-align:center;font-size:20px;letter-spacing:4px;padding:8px;">
<button onclick="confirmSetup2FA()" style="margin-left:8px;">Confirm</button>
<div id="2fa-setup-result" style="margin-top:16px;"></div>
</div>
`;
}
async function confirmSetup2FA() {
const code = document.getElementById('2fa-setup-code')?.value?.trim() || '';
if (!code) return toast('Enter the 6-digit code', 'error');
const res = await api('/api/2fa/verify-setup', 'POST', { code }).catch(() => null);
if (!res || !res.ok) {
toast('Error: ' + ((res && res.error) || 'invalid code'), 'error');
return;
}
const result = document.getElementById('2fa-setup-result');
if (result) {
result.innerHTML = `
<div class="card" style="border-color:var(--success);background:#2ecc7111;">
<h4 style="margin-top:0;color:var(--success);">2FA is now enabled</h4>
<p style="color:var(--muted);font-size:12px;">Save these backup codes — they won't show again. Each code can be used once.</p>
<pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:13px;letter-spacing:1px;line-height:1.8;">${res.backup_codes.join('\n')}</pre>
<button class="ghost small" onclick="navigator.clipboard.writeText(document.querySelector('#2fa-setup-result pre').textContent); toast('Copied')">Copy Codes</button>
<button class="small" style="margin-left:8px;" onclick="renderSecurity()">Done</button>
</div>
`;
}
toast('2FA enabled');
}
async function showDisable2FA() {
const code = prompt('Enter your 6-digit TOTP code (or a backup code) to confirm disabling 2FA:');
if (!code) return;
const res = await api('/api/2fa/disable', 'POST', { code }).catch(() => null);
if (res && res.ok) {
toast('2FA disabled');
renderSecurity();
} else {
toast('Error: ' + ((res && res.error) || 'invalid code'), 'error');
}
}
async function regenBackupCodes() {
if (!confirm('Regenerate backup codes? Old codes will stop working.')) return;
const res = await api('/api/2fa/backup-codes/regenerate', 'POST', {}).catch(() => null);
if (!res || !res.ok) { toast('Error', 'error'); return; }
const panel = document.getElementById('2fa-setup-panel');
if (panel) {
panel.innerHTML = `
<div class="card" style="border-color:var(--success);background:#2ecc7111;">
<h4 style="margin-top:0;color:var(--success);">New Backup Codes</h4>
<pre style="background:var(--bg);padding:12px;border-radius:6px;font-size:13px;letter-spacing:1px;line-height:1.8;">${res.backup_codes.join('\n')}</pre>
<button class="ghost small" onclick="navigator.clipboard.writeText(document.querySelector('#2fa-setup-panel pre').textContent); toast('Copied')">Copy</button>
</div>
`;
}
}
async function removeTrustedDevice(deviceId) {
if (!confirm('Remove this trusted device?')) return;
await api('/api/2fa/devices/' + encodeURIComponent(deviceId), 'DELETE').catch(() => null);
toast('Device removed');
renderSecurity();
}
async function removeAllTrustedDevices() {
if (!confirm('Remove ALL trusted devices? Everyone will need to 2FA again.')) return;
await api('/api/2fa/devices', 'DELETE').catch(() => null);
toast('All devices removed');
renderSecurity();
}
// 2FA challenge modal
let _pending2FAUser = '';
function show2FAChallenge(user) {
_pending2FAUser = user;
const loginSection = document.getElementById('login-section');
if (loginSection) loginSection.style.display = 'none';
const modal = document.getElementById('2fa-challenge-modal');
const err = document.getElementById('2fa-error');
const inp = document.getElementById('2fa-code-input');
const trust = document.getElementById('2fa-trust-device');
if (err) err.style.display = 'none';
if (inp) inp.value = '';
if (trust) trust.checked = false;
if (modal) modal.style.display = 'flex';
if (inp) inp.focus();
}
function cancel2FAChallenge() {
document.getElementById('2fa-challenge-modal').style.display = 'none';
API_KEY = '';
localStorage.removeItem('sunnyware_admin_key');
logout();
}
async function submit2FAChallenge() {
const code = document.getElementById('2fa-code-input')?.value?.trim() || '';
const trust = document.getElementById('2fa-trust-device')?.checked || false;
const err = document.getElementById('2fa-error');
if (!code) {
if (err) { err.textContent = 'Enter a code'; err.style.display = 'block'; }
return;
}
try {
const apiKeyForChallenge = localStorage.getItem('sunnyware_admin_key') || API_KEY || '';
const res = await fetch('/api/2fa/verify-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKeyForChallenge,
},
body: JSON.stringify({
user: _pending2FAUser || 'admin',
code,
trust_device: trust,
device_name: navigator.userAgent.slice(0, 80),
}),
});
const data = await res.json();
if (data.ok && data.session_token) {
localStorage.setItem('sunnyware_2fa_session', data.session_token);
if (data.trusted_token) {
localStorage.setItem('sunnyware_trusted_device', data.trusted_token);
}
document.getElementById('2fa-challenge-modal').style.display = 'none';
toast('2FA verified');
init();
} else {
if (err) { err.textContent = data.error || 'Invalid code'; err.style.display = 'block'; }
}
} catch (e) {
if (err) { err.textContent = 'Network error: ' + e.message; err.style.display = 'block'; }
}
}
// 2FA-aware api wrapper
const _origApi_2fa = api;
async function api_2fa(path, method = 'GET', body = null) {
const opts = {
method,
headers: {
'X-API-Key': API_KEY,
'Content-Type': 'application/json',
}
};
const sess = localStorage.getItem('sunnyware_2fa_session');
if (sess) opts.headers['X-2FA-Session'] = sess;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
if (res.status === 401) { logout(); throw new Error('Unauthorized'); }
if (res.status === 428) {
show2FAChallenge(API_KEY === 'master-admin-key' ? 'admin' : (localStorage.getItem('sunnyware_admin_email') || 'admin'));
throw new Error('2FA required');
}
if (res.status === 403) { toast('Forbidden', 'error'); throw new Error('Forbidden'); }
if (!res.ok) { const txt = await res.text(); throw new Error(txt || res.statusText); }
return res.json();
}
window.api = api_2fa;
try { api = api_2fa; } catch(e) {}
// Hook Security nav
(function () {
const hook = () => {
const sec = document.querySelector('.nav-item[data-tab="security"]');
if (sec) sec.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
sec.classList.add('active');
renderSecurity();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 223: BACKUP & RESTORE page
async function renderBackup() {
const listData = await api('/api/backup/list').catch(() => ({ files: [] }));
const files = listData.files || [];
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
const fmtBytes = (b) => b >= 1048576 ? (b/1048576).toFixed(2)+' MB' : (b/1024).toFixed(1)+' KB';
$('content').innerHTML = `
<h2>Backup &amp; Restore</h2>
<p style="color:var(--muted);margin-bottom:16px;">Full snapshot of all data — download as ZIP, restore from a previous backup.</p>
<div class="card" style="margin-bottom:20px;border-color:var(--accent);">
<h3 style="margin-top:0;color:var(--accent);">Download Backup</h3>
<p style="color:var(--muted);font-size:13px;margin-bottom:12px;">
${files.length} JSON files · ${fmtBytes(totalBytes)} total. Bundled into one ZIP.
</p>
<button onclick="downloadBackup()">Download Backup ZIP</button>
</div>
<div class="card" style="margin-bottom:20px;">
<h3 style="margin-top:0;">Restore from Backup</h3>
<p style="color:var(--muted);font-size:13px;">Upload a ZIP created by this tool. Only <code>.json</code> files at the root are restored.</p>
<p style="color:var(--warn);font-size:12px;background:#f39c1211;padding:8px;border-radius:6px;margin:12px 0;">
Warning: A pre-restore snapshot is created automatically in <code>/data</code>. If restore goes wrong, download it via FTP or use HF Space terminal.
</p>
<input type="file" id="backup-file" accept=".zip" style="margin-bottom:12px;">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="ghost" onclick="validateBackup()">Validate ZIP (no changes)</button>
<button class="danger" onclick="showRestoreConfirm()">Restore (requires typing RESTORE)</button>
</div>
<div id="backup-validate-result" style="margin-top:16px;"></div>
<div id="backup-restore-confirm" style="display:none;margin-top:16px;padding:12px;background:var(--bg);border-radius:6px;">
<label style="font-size:11px;color:var(--danger);">Type RESTORE to confirm:</label>
<input id="backup-confirm-input" placeholder="RESTORE" style="width:200px;margin-top:4px;">
<button class="danger" style="margin-left:8px;" onclick="doRestore()">Confirm Restore</button>
<button class="ghost" style="margin-left:8px;" onclick="document.getElementById('backup-restore-confirm').style.display='none'">Cancel</button>
</div>
</div>
<h3>Current Data Files (${files.length})</h3>
${files.length === 0 ? '<p style="color:var(--muted)">No data files yet.</p>' : `
<table>
<thead><tr><th>File</th><th class="n">Size</th><th>Last Modified</th></tr></thead>
<tbody>
${files.map(f => `
<tr>
<td><code>${esc(f.name)}</code></td>
<td class="n">${fmtBytes(f.size || 0)}</td>
<td style="font-size:11px;">${new Date(f.modified * 1000).toLocaleString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
function _backupHeaders() {
const h = { 'X-API-Key': localStorage.getItem('sunnyware_admin_key') || 'master-admin-key' };
const sess = localStorage.getItem('sunnyware_2fa_session');
if (sess) h['X-2FA-Session'] = sess;
return h;
}
function downloadBackup() {
fetch('/api/backup/export', { headers: _backupHeaders() })
.then(res => {
if (!res.ok) throw new Error('Export failed: HTTP ' + res.status);
return res.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sunnyware_backup_' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.zip';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast('Backup downloaded');
})
.catch(e => toast('Error: ' + e.message, 'error'));
}
async function validateBackup() {
const file = document.getElementById('backup-file').files[0];
if (!file) return toast('Choose a ZIP file first', 'error');
const result = document.getElementById('backup-validate-result');
result.innerHTML = '<p style="color:var(--muted);">Parsing ZIP...</p>';
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/backup/validate', {
method: 'POST',
headers: _backupHeaders(),
body: form,
});
const data = await res.json();
if (data.ok) {
const jsonCount = data.files.filter(f => f.is_json && f.is_safe).length;
result.innerHTML = `
<div class="card" style="border-color:var(--success);background:#2ecc7111;">
<strong style="color:var(--success);">Valid ZIP — ${data.count} files, ${(data.total_bytes/1024).toFixed(1)} KB</strong>
<p style="color:var(--muted);font-size:12px;margin:8px 0;">${jsonCount} safe .json files will be restored.</p>
<details style="margin-top:8px;">
<summary style="cursor:pointer;color:var(--accent);">Show all files</summary>
<ul style="margin:8px 0 0;padding-left:20px;font-size:12px;">
${data.files.map(f => `
<li>
<code>${esc(f.name)}</code> (${(f.size/1024).toFixed(1)} KB)
${f.is_json && f.is_safe ? '<span class="tag ok" style="margin-left:6px;">will restore</span>' : '<span class="tag bad" style="margin-left:6px;">skipped</span>'}
</li>
`).join('')}
</ul>
</details>
</div>
`;
} else {
result.innerHTML = `<p style="color:var(--danger);">Error: ${esc(data.error || 'unknown')}</p>`;
}
} catch (e) {
result.innerHTML = `<p style="color:var(--danger);">Error: ${esc(e.message)}</p>`;
}
}
function showRestoreConfirm() {
const file = document.getElementById('backup-file').files[0];
if (!file) return toast('Choose a ZIP file first', 'error');
document.getElementById('backup-restore-confirm').style.display = 'block';
document.getElementById('backup-confirm-input').focus();
}
async function doRestore() {
const file = document.getElementById('backup-file').files[0];
const confirmText = document.getElementById('backup-confirm-input').value.trim();
if (!file) return toast('Choose a ZIP file first', 'error');
if (confirmText !== 'RESTORE') return toast('Type RESTORE to confirm', 'error');
const form = new FormData();
form.append('file', file);
form.append('confirm', confirmText);
toast('Restoring...');
try {
const res = await fetch('/api/backup/restore', {
method: 'POST',
headers: _backupHeaders(),
body: form,
});
const data = await res.json();
if (data.ok) {
toast(`Restored ${data.count} files. Snapshot: ${data.snapshot}`);
document.getElementById('backup-restore-confirm').style.display = 'none';
setTimeout(() => renderBackup(), 1000);
} else {
toast('Error: ' + (data.error || 'unknown'), 'error');
}
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
// Hook Backup nav item
(function () {
const hook = () => {
const el = document.querySelector('.nav-item[data-tab="backup"]');
if (el) el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
renderBackup();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 224a: IMPERSONATION page
async function renderImpersonate() {
$('content').innerHTML = `
<h2>Impersonate User</h2>
<p style="color:var(--muted);margin-bottom:16px;">Log in as another user to debug their experience. A red banner will appear on the chat page; click "Stop Impersonating" to return to admin.</p>
<div class="card" style="max-width:600px;">
<div class="form-row">
<div><label>User Email</label><input id="imp-target" placeholder="user@example.com"></div>
<div><label>Reason (optional)</label><input id="imp-reason" placeholder="Support ticket #123"></div>
<button onclick="startImpersonation()">Start Impersonating</button>
</div>
</div>
`;
}
async function startImpersonation() {
const target = document.getElementById('imp-target').value.trim().toLowerCase();
const reason = document.getElementById('imp-reason').value.trim();
if (!target) return toast('Enter a user email', 'error');
try {
const res = await api('/api/impersonate/start', 'POST', { target, reason });
if (res.ok) {
localStorage.setItem('sunnyware_impersonation_token', res.impersonation_token);
localStorage.setItem('sunnyware_impersonation_target', res.target);
localStorage.setItem('sunnyware_impersonation_jwt', res.user_jwt);
toast('Impersonation started — redirecting to chat');
setTimeout(() => window.location.href = '/ui', 800);
} else {
toast('Error: ' + (res.error || 'unknown'), 'error');
}
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
// Hook Impersonate nav
(function () {
const hook = () => {
const el = document.querySelector('.nav-item[data-tab="impersonate"]');
if (el) el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
renderImpersonate();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 224b: FEATURE FLAGS admin page
async function renderFeatureFlags() {
const data = await api('/api/feature-flags').catch(() => ({ flags: [] }));
const flags = data.flags || [];
$('content').innerHTML = `
<h2>Feature Flags</h2>
<p style="color:var(--muted);margin-bottom:16px;">Control feature rollout by plan, org, percentage, or explicit override.</p>
<div class="card" style="margin-bottom:20px;">
<h3 style="margin-top:0;color:var(--accent);">Create New Flag</h3>
<div class="form-row">
<div><label>Flag ID</label><input id="ff-id" placeholder="new_feature_x"></div>
<div><label>Name</label><input id="ff-name" placeholder="New Feature X"></div>
<div><label>Description</label><input id="ff-desc" placeholder="What it does..."></div>
<button onclick="createFeatureFlag()">Create Flag</button>
</div>
</div>
<h3>Flags (${flags.length})</h3>
${flags.length === 0 ? '<p style="color:var(--muted)">No flags yet.</p>' : `
<table>
<thead><tr><th>Flag</th><th>Description</th><th>Global %</th><th>Plans</th><th>Orgs</th><th></th></tr></thead>
<tbody>
${flags.map(f => `
<tr>
<td><strong>${esc(f.id)}</strong><br><span style="font-size:10px;color:var(--muted);">${esc(f.name)}</span></td>
<td style="font-size:11px;max-width:250px;">${esc(f.description || '—')}</td>
<td>
<input type="number" value="${f.global_pct || 0}" min="0" max="100" style="width:60px;padding:4px;font-size:12px;" onblur="updateFlagField('${esc(f.id)}', 'global_pct', parseInt(this.value)||0)">
</td>
<td style="font-size:11px;">${(f.enabled_plans || []).join(', ') || '—'}</td>
<td style="font-size:11px;">${(f.enabled_orgs || []).length} orgs</td>
<td>
<button class="ghost small" onclick="editFlagPlans('${esc(f.id)}', '${esc(f.enabled_plans || [])}')">Plans</button>
<button class="danger small" onclick="deleteFeatureFlag('${esc(f.id)}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div class="card" style="margin-top:20px;">
<h3 style="margin-top:0;color:var(--accent);">Check Flag for User</h3>
<p style="color:var(--muted);font-size:12px;">Simulate evaluation for a specific user.</p>
<div class="form-row">
<div><label>User Email</label><input id="ff-check-user" placeholder="user@example.com"></div>
<button onclick="evaluateFlagsForUser()">Evaluate All Flags</button>
</div>
<div id="ff-eval-result" style="margin-top:16px;"></div>
</div>
`;
}
async function createFeatureFlag() {
const id = document.getElementById('ff-id').value.trim();
const name = document.getElementById('ff-name').value.trim();
const desc = document.getElementById('ff-desc').value.trim();
if (!id) return toast('Flag ID required', 'error');
try {
const res = await api('/api/feature-flags', 'POST', { id, name, description: desc });
if (res.ok) {
toast('Flag created');
renderFeatureFlags();
} else {
toast('Error: ' + (res.error || 'unknown'), 'error');
}
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function updateFlagField(flagId, field, value) {
try {
await api('/api/feature-flags/' + encodeURIComponent(flagId), 'PATCH', { [field]: value });
toast('Flag updated');
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function editFlagPlans(flagId, current) {
const str = prompt('Enter plans (comma-separated, e.g. pro,enterprise):', current.replace(/,/g, ','));
if (str === null) return;
const plans = str.split(',').map(s => s.trim()).filter(Boolean);
try {
await api('/api/feature-flags/' + encodeURIComponent(flagId), 'PATCH', { enabled_plans: plans });
toast('Plans updated');
renderFeatureFlags();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function deleteFeatureFlag(flagId) {
if (!confirm('Delete flag "' + flagId + '"?')) return;
try {
await api('/api/feature-flags/' + encodeURIComponent(flagId), 'DELETE');
toast('Flag deleted');
renderFeatureFlags();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function evaluateFlagsForUser() {
const user = document.getElementById('ff-check-user').value.trim().toLowerCase();
const result = document.getElementById('ff-eval-result');
if (!user) return toast('Enter a user email', 'error');
// Fetch directly from endpoint — needs API key
result.innerHTML = '<p style="color:var(--muted)">Loading...</p>';
try {
const res = await api('/api/feature-flags/evaluate');
const flags = res.flags || {};
result.innerHTML = `
<table style="font-size:12px;">
<thead><tr><th>Flag</th><th>Enabled</th></tr></thead>
<tbody>
${Object.entries(flags).map(([k, v]) => `
<tr><td>${esc(k)}</td><td>${v ? '<span class="tag ok">ON</span>' : '<span class="tag bad">OFF</span>'}</td></tr>
`).join('')}
</tbody>
</table>
<p style="color:var(--muted);font-size:11px;margin-top:8px;">Note: evaluates for the current logged-in user, not the typed email. Admin user has all flags off by default.</p>
`;
} catch(e) { result.innerHTML = '<p style="color:var(--danger)">Error: ' + esc(e.message) + '</p>'; }
}
// Hook Feature Flags nav
(function () {
const hook = () => {
const el = document.querySelector('.nav-item[data-tab="feature-flags"]');
if (el) el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
renderFeatureFlags();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 224c: OVERAGE BILLING page
async function renderOverage() {
const [configData, overviewData, chargesData] = await Promise.all([
api('/api/overage/config').catch(() => ({ config: {}, caps: {} })),
api('/api/overage/overview').catch(() => ({ orgs: [] })),
api('/api/overage/charges?limit=50').catch(() => ({ charges: [] }))
]);
const config = configData.config || {};
const caps = configData.caps || {};
const orgs = overviewData.orgs || [];
const charges = chargesData.charges || [];
const fmtNum = (n) => n >= 1000000 ? (n/1000000).toFixed(2)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n || 0);
const fmtMoney = (n) => '$' + (Number(n) || 0).toFixed(2);
const totalOverages = orgs.filter(o => o.overage_cost > 0).length;
const orgsWithHardCap = orgs.filter(o => o.hard_cap && !o.can_continue).length;
$('content').innerHTML = `
<h2>Overage Billing</h2>
<p style="color:var(--muted);margin-bottom:16px;">Charge customers automatically when they exceed plan token limits.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px;">
<div class="card"><div style="font-size:11px;color:var(--muted);">Orgs Over Limit</div><div style="font-size:22px;font-weight:700;color:${totalOverages > 0 ? 'var(--warn)' : 'inherit'};">${totalOverages}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Total Overage Tokens</div><div style="font-size:22px;font-weight:700;">${fmtNum(overviewData.total_overage_tokens || 0)}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Total Overage Cost</div><div style="font-size:22px;font-weight:700;color:var(--accent);">${fmtMoney(overviewData.total_overage_cost || 0)}</div></div>
<div class="card"><div style="font-size:11px;color:var(--muted);">Hard-Cap Orgs (Blocked)</div><div style="font-size:22px;font-weight:700;color:${orgsWithHardCap > 0 ? 'var(--danger)' : 'inherit'};">${orgsWithHardCap}</div></div>
</div>
<div class="card" style="margin-bottom:20px;">
<h3 style="margin-top:0;color:var(--accent);">Configuration</h3>
<div class="form-row">
<div><label>Rate per 1M tokens ($)</label><input id="ovg-rate" type="number" step="0.01" min="0" value="${config.rate_per_million || 0.50}" onblur="updateOverageConfig()"></div>
<div><label>Enabled</label>
<select id="ovg-enabled" onchange="updateOverageConfig()">
<option value="true" ${config.enabled !== false ? 'selected' : ''}>Yes — enforce overage</option>
<option value="false" ${config.enabled === false ? 'selected' : ''}>No — ignore overage</option>
</select>
</div>
</div>
<div style="font-size:12px;color:var(--muted);margin-top:8px;">
<strong>Plan caps:</strong> Free: hard cap (blocked) · Pro: soft cap 50M overage · Enterprise: unlimited
</div>
</div>
<h3>Organizations (${orgs.length})</h3>
${orgs.length === 0 ? '<p style="color:var(--muted)">No organizations yet.</p>' : `
<table>
<thead><tr><th>Org</th><th>Plan</th><th class="n">Tokens Used</th><th class="n">Limit</th><th class="n">Overage</th><th class="n">Cost</th><th>Status</th></tr></thead>
<tbody>
${orgs.map(o => `
<tr>
<td><strong>${esc(o.org_name || o.org_id)}</strong><br><span style="font-size:10px;color:var(--muted);">${esc(o.owner_email || '')}</span></td>
<td><span class="tag">${esc(o.plan || 'free')}</span></td>
<td class="n">${fmtNum(o.tokens_used)}</td>
<td class="n">${o.token_limit > 0 ? fmtNum(o.token_limit) : (o.token_limit < 0 ? '∞' : '—')}</td>
<td class="n" style="color:${o.overage_tokens > 0 ? 'var(--warn)' : 'inherit'};">${fmtNum(o.overage_tokens)}</td>
<td class="n" style="color:${o.overage_cost > 0 ? 'var(--accent)' : 'inherit'};">${fmtMoney(o.overage_cost)}</td>
<td>${o.can_continue ? '<span class="tag ok">Active</span>' : '<span class="tag bad">Blocked</span>'}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:32px;">
<h3 style="margin:0;">Recent Overage Charges (${charges.length})</h3>
<button class="ghost small" onclick="clearOverageCharges()">Clear Log</button>
</div>
${charges.length === 0 ? '<p style="color:var(--muted)">No charges recorded yet.</p>' : `
<table>
<thead><tr><th>Time</th><th>Org</th><th>Plan</th><th class="n">Tokens</th><th class="n">Amount</th><th>Note</th></tr></thead>
<tbody>
${charges.map(c => `
<tr>
<td style="font-size:11px;">${new Date(c.ts*1000).toLocaleString()}</td>
<td><code style="font-size:10px">${esc(c.org_id)}</code></td>
<td><span class="tag">${esc(c.plan || '—')}</span></td>
<td class="n">${fmtNum(c.tokens)}</td>
<td class="n" style="color:var(--accent);font-weight:600;">${fmtMoney(c.amount)}</td>
<td style="font-size:11px;color:var(--muted);">${esc(c.note || '')}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function updateOverageConfig() {
const rate = parseFloat(document.getElementById('ovg-rate').value) || 0;
const enabled = document.getElementById('ovg-enabled').value === 'true';
try {
await api('/api/overage/config', 'PATCH', { rate_per_million: rate, enabled });
toast('Config updated');
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function clearOverageCharges() {
if (!confirm('Clear all overage charge records? This is for logging only, does not affect billing.')) return;
try {
await api('/api/overage/charges', 'DELETE');
toast('Charges cleared');
renderOverage();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
// Hook Overage nav
(function () {
const hook = () => {
const el = document.querySelector('.nav-item[data-tab="overage"]');
if (el) el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
renderOverage();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 224d: SLACK BOT admin page
async function renderSlackBot() {
const config = await api('/api/slack/config').catch(() => ({}));
const activityData = await api('/api/slack/activity?limit=30').catch(() => ({ activity: [] }));
const activity = activityData.activity || [];
const statusColor = config.enabled ? 'var(--success)' : 'var(--muted)';
const statusText = config.enabled ? 'Enabled' : 'Disabled';
$('content').innerHTML = `
<h2>Slack Bot</h2>
<p style="color:var(--muted);margin-bottom:16px;">Let your team query Sunnyware from Slack using <code>/sunnyware &lt;question&gt;</code>.</p>
<div class="card" style="margin-bottom:20px;border-color:${config.enabled ? 'var(--success)' : 'var(--border)'};">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<h3 style="margin:0;color:${statusColor};">Status: ${statusText}</h3>
<p style="color:var(--muted);font-size:12px;margin:4px 0 0;">${config.has_signing_secret ? 'OK' : 'Missing'} Signing secret · ${config.has_bot_token ? 'OK' : 'Missing'} Bot token</p>
</div>
<label class="toggle-switch">
<input type="checkbox" ${config.enabled ? 'checked' : ''} onchange="toggleSlackBot(this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<h3 style="margin-top:0;color:var(--accent);">Configuration</h3>
<div class="form-row" style="display:grid;grid-template-columns:1fr;gap:12px;">
<div><label>Signing Secret (from Slack Basic Information)</label>
<input id="slack-secret" value="${esc(config.signing_secret || '')}" placeholder="Enter signing secret">
</div>
<div><label>Bot User OAuth Token (starts with xoxb-)</label>
<input id="slack-token" value="${esc(config.bot_token || '')}" placeholder="xoxb-...">
</div>
<div><label>Bot Name</label>
<input id="slack-bot-name" value="${esc(config.bot_name || 'Sunnyware')}" placeholder="Sunnyware">
</div>
<div style="display:flex;align-items:center;gap:8px;">
<input type="checkbox" id="slack-verify" ${config.verify_signature ? 'checked' : ''} style="width:auto;">
<label for="slack-verify" style="margin:0;text-transform:none;letter-spacing:0;font-size:13px;color:var(--text);">
Verify Slack signature (recommended for production)
</label>
</div>
<button onclick="saveSlackConfig()" style="width:200px;">Save Configuration</button>
</div>
</div>
<div class="card" style="margin-bottom:20px;">
<h3 style="margin-top:0;color:var(--accent);">Setup Instructions</h3>
<ol style="margin:8px 0 0;padding-left:24px;font-size:13px;line-height:1.9;color:var(--text);">
<li>Go to <a href="https://api.slack.com/apps" target="_blank" style="color:var(--accent);">api.slack.com/apps</a> → Create New App → From scratch</li>
<li>Copy the <strong>Signing Secret</strong> from Basic Information → paste it above</li>
<li>Go to Slash Commands → Create New Command:
<ul style="margin:4px 0;padding-left:20px;">
<li>Command: <code>/sunnyware</code></li>
<li>Request URL: <code>https://shamiur-sunnyware.hf.space/api/slack/commands</code></li>
<li>Short description: Ask Sunnyware AI</li>
</ul>
</li>
<li>Go to OAuth &amp; Permissions → Install App to Workspace</li>
<li>Copy the <strong>Bot User OAuth Token</strong> (starts with xoxb-) → paste above</li>
<li>Toggle the bot <strong>Enabled</strong> at the top of this page</li>
<li>In Slack, type <code>/sunnyware What is 2+2?</code> and see the response</li>
</ol>
<p style="color:var(--muted);font-size:12px;margin-top:12px;">
<strong>Note:</strong> If you toggle off "Verify Slack signature", any HTTP client can hit the endpoint — only use this during initial testing.
</p>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:32px;">
<h3 style="margin:0;">Recent Activity (${activity.length})</h3>
<button class="ghost small" onclick="clearSlackActivity()">Clear Log</button>
</div>
${activity.length === 0 ? '<p style="color:var(--muted)">No Slack activity yet.</p>' : `
<table>
<thead><tr><th>Time</th><th>User</th><th>Channel</th><th>Prompt</th><th>Response</th><th class="n">Latency</th><th>Status</th></tr></thead>
<tbody>
${activity.map(a => `
<tr>
<td style="font-size:11px;">${new Date(a.ts*1000).toLocaleString()}</td>
<td style="font-size:11px;"><strong>@${esc(a.user)}</strong></td>
<td style="font-size:11px;">${esc(a.channel)}</td>
<td style="font-size:11px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(a.prompt)}">${esc(a.prompt)}</td>
<td style="font-size:11px;max-width:250px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(a.response)}">${esc(a.response)}</td>
<td class="n" style="font-size:11px;">${a.latency_ms}ms</td>
<td>${a.ok ? '<span class="tag ok">ok</span>' : '<span class="tag bad">fail</span>'}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function toggleSlackBot(enabled) {
try {
await api('/api/slack/config', 'PATCH', { enabled });
toast(enabled ? 'Slack bot enabled' : 'Slack bot disabled');
renderSlackBot();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function saveSlackConfig() {
const signing_secret = document.getElementById('slack-secret').value.trim();
const bot_token = document.getElementById('slack-token').value.trim();
const bot_name = document.getElementById('slack-bot-name').value.trim();
const verify_signature = document.getElementById('slack-verify').checked;
try {
await api('/api/slack/config', 'PATCH', { signing_secret, bot_token, bot_name, verify_signature });
toast('Configuration saved');
renderSlackBot();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
async function clearSlackActivity() {
if (!confirm('Clear all Slack activity records?')) return;
try {
await api('/api/slack/activity', 'DELETE');
toast('Activity cleared');
renderSlackBot();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
(function () {
const hook = () => {
const el = document.querySelector('.nav-item[data-tab="slack-bot"]');
if (el) el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
renderSlackBot();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
// Part 225: USER FEEDBACK page
async function renderFeedback() {
const data = await api('/api/admin/feedback?limit=200').catch(() => ({ entries: [], stats: {} }));
const entries = data.entries || [];
const stats = data.stats || { count: 0, avg_rating: 0, ratings: {} };
const fmtStars = (n) => '★'.repeat(n) + '☆'.repeat(5 - n);
const fmtTime = (ts) => new Date(ts * 1000).toLocaleString();
const bar = (count) => {
const max = Math.max(...Object.values(stats.ratings || {}), 1);
return Math.round((count / max) * 100);
};
$('content').innerHTML = `
<h2>User Feedback</h2>
<p style="color:var(--muted);margin-bottom:16px;">Collect and review user ratings and comments. Public endpoint: <code>POST /api/feedback</code></p>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:20px;">
<div class="card">
<div style="font-size:11px;color:var(--muted);">Total Feedback</div>
<div style="font-size:28px;font-weight:700;">${stats.count || 0}</div>
</div>
<div class="card">
<div style="font-size:11px;color:var(--muted);">Average Rating</div>
<div style="font-size:28px;font-weight:700;color:${(stats.avg_rating || 0) >= 4 ? 'var(--success)' : (stats.avg_rating || 0) >= 3 ? 'var(--warn)' : 'var(--danger)'};">
${(stats.avg_rating || 0).toFixed(1)} <span style="font-size:14px;">★</span>
</div>
</div>
<div class="card">
<div style="font-size:11px;color:var(--muted);margin-bottom:8px;">Rating Distribution</div>
${[5,4,3,2,1].map(r => `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;">
<span style="width:40px;font-size:10px;">${r}★</span>
<div style="flex:1;background:var(--bg);border-radius:3px;height:6px;">
<div style="width:${bar((stats.ratings || {})[r] || 0)}%;background:var(--accent);height:100%;border-radius:3px;"></div>
</div>
<span style="width:25px;text-align:right;font-size:10px;color:var(--muted);">${(stats.ratings || {})[r] || 0}</span>
</div>
`).join('')}
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="margin:0;">Recent Feedback (${entries.length})</h3>
<button class="danger small" onclick="clearFeedback()">Clear All</button>
</div>
${entries.length === 0 ? `
<div class="card" style="text-align:center;padding:40px;">
<p style="color:var(--muted);">No feedback yet.</p>
<p style="color:var(--muted);font-size:12px;margin-top:8px;">
Users can submit feedback via <code>POST /api/feedback</code> with
<code>{"rating": 5, "comment": "Love it!"}</code>
</p>
</div>
` : `
<table>
<thead><tr><th>Time</th><th>User</th><th>Rating</th><th>Comment</th><th>Page</th></tr></thead>
<tbody>
${entries.map(e => `
<tr>
<td style="font-size:11px;color:var(--muted);">${fmtTime(e.ts)}</td>
<td style="font-size:11px;">${e.user ? esc(e.user) : '<span style="color:var(--muted);">anonymous</span>'}</td>
<td style="font-size:14px;color:${e.rating >= 4 ? 'var(--success)' : e.rating >= 3 ? 'var(--warn)' : 'var(--danger)'};">
${fmtStars(e.rating)}
</td>
<td style="font-size:12px;max-width:400px;">${esc(e.comment) || '<span style="color:var(--muted);">—</span>'}</td>
<td style="font-size:11px;color:var(--muted);">${esc(e.page) || '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
`}
`;
}
async function clearFeedback() {
if (!confirm('Clear ALL user feedback? This cannot be undone.')) return;
try {
await api('/api/admin/feedback', 'DELETE');
toast('Feedback cleared');
renderFeedback();
} catch(e) { toast('Error: ' + e.message, 'error'); }
}
// Hook Feedback nav
(function () {
const hook = () => {
const el = document.querySelector('.nav-item[data-tab="feedback"]');
if (el) el.onclick = () => {
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
el.classList.add('active');
renderFeedback();
};
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', hook);
else hook();
})();
async function regenerateApiKey(keyId) {
if (!confirm('Are you sure you want to regenerate this key? The old key will stop working immediately.')) return;
try {
const res = await api('/api/api-keys/' + encodeURIComponent(keyId) + '/regenerate', 'POST');
if (!res.ok || !res.key || !res.key.raw_key) {
return toast('Failed to regenerate key', 'error');
}
const rawKey = res.key.raw_key;
const keyName = res.key.name;
// Show the new key in a modal
const modal = document.createElement('div');
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.8);display:flex;align-items:center;justify-content:center;z-index:10000;';
modal.innerHTML = `
<div class="card" style="max-width:500px;width:90%;position:relative;">
<button style="position:absolute;top:10px;right:10px;background:none;border:none;color:var(--muted);font-size:20px;cursor:pointer;" onclick="this.closest('div').parentElement.remove()">&times;</button>
<h3 style="margin-top:0;color:var(--accent);">New API Key: ${esc(keyName)}</h3>
<p style="color:var(--warn);font-size:12px;margin-bottom:12px;">Copy this key now. It will not be shown again.</p>
<div style="background:var(--bg);padding:12px;border-radius:6px;font-family:monospace;font-size:13px;word-break:break-all;user-select:all;margin-bottom:16px;">
${esc(rawKey)}
</div>
<div style="display:flex;gap:8px;">
<button onclick="navigator.clipboard.writeText('${esc(rawKey)}').then(() => toast('Copied to clipboard!'))" style="flex:1;">Copy to Clipboard</button>
<button class="ghost" onclick="this.closest('div').parentElement.parentElement.remove()" style="flex:1;">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Refresh the table to show the new prefix
renderApiKeys();
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
async function applyRolePermissions() {
try {
const me = await api('/api/auth/me');
if (!me || me.role === 'Owner') return;
const allowedTabs = {
'Finance': ['dashboard', 'plans', 'invoices', 'accounting', 'reports', 'customers', 'settings'],
'Support': ['dashboard', 'customers', 'access', 'health', 'chat-audit'],
'Viewer': ['dashboard', 'customers', 'plans', 'health', 'alerts'],
};
const allowed = allowedTabs[me.role] || allowedTabs['Viewer'];
document.querySelectorAll('.nav-item').forEach(el => {
const tab = el.getAttribute('data-tab');
if (tab && !allowed.includes(tab) && tab !== 'dashboard') {
el.style.display = 'none';
}
});
} catch(e) { console.error('Failed to apply permissions', e); }
}
const _origRender = window.render;
if (_origRender) {
window.render = async function() {
await _origRender();
await applyRolePermissions();
};
}
async function loginWithEmail() {
const email = document.getElementById('admin-email').value.trim();
const errEl = document.getElementById('login-error');
if (!email || !email.includes('@')) {
errEl.textContent = 'Please enter a valid email';
return;
}
errEl.textContent = '';
try {
const res = await fetch('/api/auth/admin-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await res.json();
if (data.ok) {
localStorage.setItem('sunnyware_admin_key', data.token);
localStorage.setItem('sunnyware_admin_email', email);
API_KEY = data.token;
document.getElementById('login-modal').style.display = 'none';
nav('dashboard');
toast('Logged in as ' + email);
applyRolePermissions();
} else {
errEl.textContent = data.error || 'Login failed';
}
} catch(e) {
errEl.textContent = 'Error: ' + e.message;
}
}
function loginWithGoogle() {
window.location.href = '/api/auth/sso?redirect=/admin';
}
function showAdminLoginKey() {
const sec = document.getElementById('login-section');
sec.style.display = 'block';
sec.innerHTML = `
<h1>Sunnyware Admin</h1>
<div class="card" style="max-width: 400px;">
<div class="title">Admin API Key</div>
<input type="password" id="api-key" placeholder="master-admin-key">
<button onclick="saveKey()" style="margin-top: 12px;">Authenticate</button>
<div style="margin-top:16px;text-align:center;">
<button class="ghost small" onclick="showEmailLogin()" style="font-size:11px;opacity:0.7;">Back to Email Login</button>
</div>
</div>
`;
}
function showEmailLogin() {
window.location.reload();
}
</script>
<div id="2fa-challenge-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;align-items:center;justify-content:center;">
<div style="background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:400px;width:90%;">
<h3 style="margin-top:0;color:var(--accent);">Two-Factor Authentication</h3>
<p style="color:var(--muted);font-size:13px;margin-bottom:16px;">Enter the 6-digit code from your authenticator app.</p>
<input id="2fa-code-input" placeholder="000000" maxlength="10" autocomplete="one-time-code" style="font-size:24px;text-align:center;letter-spacing:6px;width:100%;padding:12px;">
<label style="display:flex;align-items:center;gap:8px;margin:12px 0;font-size:12px;color:var(--muted);">
<input type="checkbox" id="2fa-trust-device" style="width:auto;">
Trust this device for 30 days
</label>
<div style="display:flex;gap:8px;margin-top:12px;">
<button id="2fa-challenge-submit" onclick="submit2FAChallenge()" style="flex:1;">Verify</button>
<button class="ghost" onclick="cancel2FAChallenge()">Cancel</button>
</div>
<p id="2fa-error" style="color:var(--danger);font-size:12px;margin-top:12px;display:none;"></p>
</div>
</div>
</body>
</html>