Spaces:
Running on Zero
Running on Zero
Fetching metadata from the HF Docker repository...
Download static/admin-v2.html from shamiur/sunnyware: direct link, hf CLI and curl.
- Browser
- Download file 251 kB
-
https://huggingface.co/spaces/shamiur/sunnyware/resolve/main/static/admin-v2.html
- Command line
-
hf download hf://spaces/shamiur/sunnyware/static/admin-v2.html
-
curl -L -o admin-v2.html https://huggingface.co/spaces/shamiur/sunnyware/resolve/main/static/admin-v2.html
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 & 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 · ${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 · ${new Date().toLocaleDateString()} · 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 (<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()">×</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('''); | |
| 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 & 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 & 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, ''')}'); 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 & 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 <question></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 & 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()">×</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> | |