Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Nova AI | Admin Command Center</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=Orbitron:wght@400;700&display=swap" rel="stylesheet"> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <style> | |
| :root { | |
| --primary: #6366f1; | |
| --primary-glow: rgba(99, 102, 241, 0.5); | |
| --bg: #0f172a; | |
| --card-bg: rgba(30, 41, 59, 0.7); | |
| --card-border: rgba(255, 255, 255, 0.1); | |
| --text-main: #f8fafc; | |
| --text-muted: #94a3b8; | |
| --accent: #10b981; | |
| --danger: #ef4444; | |
| } | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text-main); | |
| min-height: 100vh; | |
| display: flex; | |
| overflow: hidden; | |
| } | |
| /* --- Background Animation --- */ | |
| .bg-glow { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| z-index: -1; | |
| background: radial-gradient(circle at 20% 30%, rgba(99, 102, 241, 0.15), transparent 40%), | |
| radial-gradient(circle at 80% 70%, rgba(16, 185, 129, 0.1), transparent 40%); | |
| } | |
| /* --- Sidebar --- */ | |
| aside { | |
| width: 280px; | |
| background: rgba(15, 23, 42, 0.8); | |
| backdrop-filter: blur(10px); | |
| border-right: 1px solid var(--card-border); | |
| display: flex; | |
| flex-direction: column; | |
| padding: 2rem; | |
| height: 100vh; | |
| } | |
| .logo { | |
| font-family: 'Orbitron', sans-serif; | |
| font-size: 1.5rem; | |
| font-weight: 700; | |
| margin-bottom: 3rem; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| color: var(--primary); | |
| } | |
| nav { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| } | |
| .nav-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| padding: 0.8rem 1rem; | |
| border-radius: 12px; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| color: var(--text-muted); | |
| text-decoration: none; | |
| } | |
| .nav-item:hover, .nav-item.active { | |
| background: rgba(99, 102, 241, 0.1); | |
| color: var(--text-main); | |
| } | |
| .nav-item.active { | |
| border-left: 4px solid var(--primary); | |
| } | |
| /* --- Main Content --- */ | |
| main { | |
| flex: 1; | |
| padding: 2rem; | |
| overflow-y: auto; | |
| position: relative; | |
| } | |
| .header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 2rem; | |
| } | |
| h1 { font-family: 'Orbitron', sans-serif; font-size: 1.8rem; } | |
| /* --- Grid Layout --- */ | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| gap: 1.5rem; | |
| margin-bottom: 2rem; | |
| } | |
| /* --- Glass Card --- */ | |
| .card { | |
| background: var(--card-bg); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid var(--card-border); | |
| border-radius: 20px; | |
| padding: 1.5rem; | |
| transition: transform 0.3s ease; | |
| } | |
| .card:hover { transform: translateY(-5px); } | |
| .stat-value { font-size: 2.5rem; font-weight: 700; color: var(--primary); margin: 0.5rem 0; } | |
| .stat-label { color: var(--text-muted); font-size: 0.9rem; text-transform: uppercase; } | |
| /* --- Sections --- */ | |
| .section { display: none; } | |
| .section.active { display: block; animation: fadeIn 0.4s ease forwards; } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(10px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* --- Tables & Lists --- */ | |
| table { width: 100%; border-collapse: collapse; margin-top: 1rem; } | |
| th { text-align: left; padding: 1rem; border-bottom: 1px solid var(--card-border); color: var(--text-muted); } | |
| td { padding: 1rem; border-bottom: 1px solid var(--card-border); } | |
| tr:last-child td { border-bottom: none; } | |
| .btn { | |
| background: var(--primary); | |
| color: white; | |
| border: none; | |
| padding: 0.6rem 1.2rem; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: 0.3s; | |
| } | |
| .btn:hover { opacity: 0.9; box-shadow: 0 0 15px var(--primary-glow); } | |
| .btn-danger { background: var(--danger); } | |
| .btn-sm { padding: 0.4rem 0.8rem; font-size: 0.8rem; } | |
| input { | |
| background: rgba(255,255,255,0.05); | |
| border: 1px solid var(--card-border); | |
| padding: 0.8rem; | |
| border-radius: 10px; | |
| color: white; | |
| outline: none; | |
| width: 100%; | |
| margin-bottom: 1rem; | |
| } | |
| .tag { | |
| background: rgba(16, 185, 129, 0.1); | |
| color: var(--accent); | |
| padding: 0.2rem 0.6rem; | |
| border-radius: 10px; | |
| font-size: 0.8rem; | |
| } | |
| .log-item { | |
| padding: 1rem; | |
| border-bottom: 1px solid var(--card-border); | |
| font-family: monospace; | |
| font-size: 0.9rem; | |
| } | |
| .symptom-bar-container { | |
| margin-bottom: 1rem; | |
| } | |
| .symptom-label-row { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 0.4rem; | |
| font-size: 0.9rem; | |
| } | |
| .symptom-bar-bg { | |
| background: rgba(255,255,255,0.05); | |
| height: 8px; | |
| border-radius: 4px; | |
| overflow: hidden; | |
| } | |
| .symptom-bar-fill { | |
| background: var(--primary); | |
| height: 100%; | |
| border-radius: 4px; | |
| transition: width 1s ease-in-out; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="bg-glow"></div> | |
| <aside> | |
| <div class="logo"> | |
| <i data-lucide="zap"></i> | |
| NOVA AI | |
| </div> | |
| <nav> | |
| <div class="nav-item active" onclick="showSection('dashboard', this)"> | |
| <i data-lucide="layout-dashboard"></i> Dashboard | |
| </div> | |
| <div class="nav-item" onclick="showSection('gaps', this)"> | |
| <i data-lucide="brain-circuit"></i> Knowledge Gaps | |
| </div> | |
| <div class="nav-item" onclick="showSection('synonyms', this)"> | |
| <i data-lucide="languages"></i> Synonyms | |
| </div> | |
| <div class="nav-item" onclick="showSection('patches', this)"> | |
| <i data-lucide="shield-check"></i> Patches | |
| </div> | |
| <div class="nav-item" onclick="showSection('logs', this)"> | |
| <i data-lucide="scroll-text"></i> Live Logs | |
| </div> | |
| <div class="nav-item" onclick="showSection('tests', this)"> | |
| <i data-lucide="flask-conical"></i> System Tests | |
| </div> | |
| <div class="nav-item" onclick="showSection('flags', this)"> | |
| <i data-lucide="toggle-left"></i> Feature Flags | |
| </div> | |
| <div class="nav-item" onclick="showSection('audit', this)"> | |
| <i data-lucide="clipboard-list"></i> Audit Log | |
| </div> | |
| </nav> | |
| </aside> | |
| <main> | |
| <div class="header"> | |
| <h1 id="section-title">Dashboard</h1> | |
| <div id="status-bubble" class="tag">System Online</div> | |
| </div> | |
| <!-- Dashboard Section --> | |
| <div id="dashboard" class="section active"> | |
| <div class="stats-grid"> | |
| <div class="card"> | |
| <div class="stat-label">Total Users</div> | |
| <div class="stat-value" id="stat-users">--</div> | |
| <i data-lucide="users" style="color: var(--text-muted)"></i> | |
| </div> | |
| <div class="card"> | |
| <div class="stat-label">Active Sessions</div> | |
| <div class="stat-value" id="stat-sessions">--</div> | |
| <i data-lucide="activity" style="color: var(--text-muted)"></i> | |
| </div> | |
| <div class="card"> | |
| <div class="stat-label">Guest Queries</div> | |
| <div class="stat-value" id="stat-guests">--</div> | |
| <i data-lucide="fingerprint" style="color: var(--text-muted)"></i> | |
| </div> | |
| <div class="card" id="card-gaps" style="border-color: rgba(250,200,50,0.3);"> | |
| <div class="stat-label" style="color: #f59e0b;">Pending Gaps</div> | |
| <div class="stat-value" id="stat-gaps" style="color: #f59e0b;">--</div> | |
| <i data-lucide="alert-triangle" style="color: #f59e0b;"></i> | |
| </div> | |
| <div class="card" id="card-patches" style="border-color: rgba(99,102,241,0.3);"> | |
| <div class="stat-label" style="color: var(--primary);">Active Patches</div> | |
| <div class="stat-value" id="stat-patches">--</div> | |
| <i data-lucide="layers" style="color: var(--primary);"></i> | |
| </div> | |
| </div> | |
| <div style="display: grid; grid-template-columns: 1.5fr 1fr; gap: 1.5rem;"> | |
| <div class="card"> | |
| <h3>Recent Activity</h3> | |
| <div id="activity-list" style="margin-top: 1rem;"> | |
| <!-- Activity items here --> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>Popular Symptoms</h3> | |
| <p style="color: var(--text-muted); font-size: 0.8rem; margin-bottom: 1.5rem;">Most frequent user entries.</p> | |
| <div id="symptom-stats"> | |
| <!-- Symptom bars here --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Gaps Section --> | |
| <div id="gaps" class="section"> | |
| <div class="card"> | |
| <h3>Unmapped Symptom Phrases</h3> | |
| <p style="color: var(--text-muted); margin-bottom: 1rem;">Review symptoms Nova couldn't identify and map them to medical terms.</p> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>User Phrase</th> | |
| <th>Analysis</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody id="gaps-table-body"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Synonyms Section --> | |
| <div id="synonyms" class="section"> | |
| <div class="card" style="margin-bottom: 1.5rem;"> | |
| <h3>Add New Synonym Mapping</h3> | |
| <div style="display: flex; gap: 1rem; margin-top: 1rem;"> | |
| <input type="text" id="synonym-phrase" placeholder="Informal Phrase (e.g. belly ache)"> | |
| <input type="text" id="synonym-expansion" placeholder="Medical Term (e.g. abdominal pain)"> | |
| <button class="btn" style="height: 48px;" onclick="addSynonym()">Add</button> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>Current Synonym Dictionary</h3> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Informal</th> | |
| <th>Medical Mapping</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody id="synonym-table-body"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Patches Section --> | |
| <div id="patches" class="section"> | |
| <div class="card"> | |
| <h3>Learned Patches</h3> | |
| <p style="color: var(--text-muted); margin-bottom: 1rem;">These are custom rules taught by you during gap verification.</p> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Symptom Pattern</th> | |
| <th>Resulting Disease</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody id="patch-table-body"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Logs Section --> | |
| <div id="logs" class="section"> | |
| <div class="card"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> | |
| <h3>Live System Logs</h3> | |
| <button class="btn btn-sm" onclick="fetchLogs()">Refresh</button> | |
| </div> | |
| <div id="log-container" style="max-height: 60vh; overflow-y: auto; background: rgba(0,0,0,0.2); border-radius: 10px;"> | |
| <!-- Logs will appear here --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- System Tests Section --> | |
| <div id="tests" class="section"> | |
| <div class="card" style="margin-bottom: 1.5rem;"> | |
| <div style="display: flex; justify-content: space-between; align-items: center;"> | |
| <div> | |
| <h3>Gold Evaluation Suite</h3> | |
| <p style="color: var(--text-muted); margin-top: 0.4rem; font-size: 0.9rem;">Runs 20 curated clinical test cases to validate disease prediction and risk-level accuracy. Per <em>new_update.md Β§6.4</em>.</p> | |
| </div> | |
| <button class="btn" id="run-tests-btn" onclick="runGoldEval()">▶ Run Tests</button> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>Last Results <span id="test-run-time" style="font-size:0.8rem; color: var(--text-muted); font-weight:400;"></span></h3> | |
| <div id="test-summary" style="margin: 1rem 0; font-size: 1rem;"></div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>ID</th> | |
| <th>Description</th> | |
| <th>Result</th> | |
| <th>Errors</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tests-table-body"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Feature Flags Section --> | |
| <div id="flags" class="section"> | |
| <div class="card"> | |
| <h3>Feature Flags & Rollouts (A/B Testing)</h3> | |
| <p style="color: var(--text-muted); margin-bottom: 1.5rem;">Manage feature gates and gradual A/B rollout percentages. Toggles apply immediately to subsequent requests.</p> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Flag Name</th> | |
| <th>Enabled</th> | |
| <th>Rollout %</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody id="flags-table-body"> | |
| <!-- Flag items will populate here --> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <!-- Audit Log Section --> | |
| <div id="audit" class="section"> | |
| <div class="card"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> | |
| <div> | |
| <h3>Admin Audit Log</h3> | |
| <p style="color: var(--text-muted); margin-top: 0.4rem; font-size: 0.9rem;">Every admin mutation is recorded here. Per <em>new_update.md Β§5.6 & Β§6.2</em>.</p> | |
| </div> | |
| <button class="btn btn-sm" onclick="fetchAuditLog()">Refresh</button> | |
| </div> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Timestamp</th> | |
| <th>Admin</th> | |
| <th>Action</th> | |
| <th>Details</th> | |
| </tr> | |
| </thead> | |
| <tbody id="audit-table-body"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </main> | |
| <script> | |
| // Init Lucide Icons | |
| lucide.createIcons(); | |
| function showSection(sectionId, navEl) { | |
| document.querySelectorAll('.section').forEach(s => s.classList.remove('active')); | |
| document.getElementById(sectionId).classList.add('active'); | |
| document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active')); | |
| navEl.classList.add('active'); | |
| document.getElementById('section-title').innerText = navEl.innerText.trim(); | |
| if(sectionId === 'dashboard') fetchStats(); | |
| if(sectionId === 'gaps') fetchGaps(); | |
| if(sectionId === 'synonyms') fetchSynonyms(); | |
| if(sectionId === 'patches') fetchPatches(); | |
| if(sectionId === 'logs') fetchLogs(); | |
| if(sectionId === 'tests') fetchTestResults(); | |
| if(sectionId === 'flags') fetchFlags(); | |
| if(sectionId === 'audit') fetchAuditLog(); | |
| } | |
| // --- API HELPERS --- | |
| async function api(path, method = 'GET', body = null) { | |
| const options = { method, headers: { 'Content-Type': 'application/json' } }; | |
| if(body) options.body = JSON.stringify(body); | |
| try { | |
| const res = await fetch(path, options); | |
| if(res.status === 401) { | |
| alert("Unauthorized. Please ensure Basic Auth headers are present."); | |
| return null; | |
| } | |
| return await res.json(); | |
| } catch(e) { console.error(e); return null; } | |
| } | |
| async function fetchStats() { | |
| const data = await api('/api/admin/stats'); | |
| if(!data) return; | |
| document.getElementById('stat-users').innerText = data.total_users; | |
| document.getElementById('stat-sessions').innerText = data.total_sessions; | |
| document.getElementById('stat-guests').innerText = data.guest_sessions; | |
| // v17.1: Loop health stats | |
| const gaps = data.pending_gaps ?? '--'; | |
| const patches = data.active_patches ?? '--'; | |
| document.getElementById('stat-gaps').innerText = gaps; | |
| document.getElementById('stat-patches').innerText = patches; | |
| const gapCard = document.getElementById('card-gaps'); | |
| if (typeof gaps === 'number' && gaps > 0) { | |
| gapCard.style.borderColor = 'rgba(245,158,11,0.6)'; | |
| gapCard.style.boxShadow = '0 0 12px rgba(245,158,11,0.25)'; | |
| } | |
| const list = document.getElementById('activity-list'); | |
| list.innerHTML = data.recent_activity.map(a => ` | |
| <div class="log-item"> | |
| <span class="tag" style="margin-right: 1rem">${a.is_guest ? 'Guest' : 'Member'}</span> | |
| <strong>${a.title}</strong> | |
| <span style="color: var(--text-muted); float: right">${new Date(a.updated_at).toLocaleTimeString()}</span> | |
| </div> | |
| `).join(''); | |
| const symptomList = document.getElementById('symptom-stats'); | |
| if (data.top_symptoms && data.top_symptoms.length > 0) { | |
| const maxCount = data.top_symptoms[0].count; | |
| symptomList.innerHTML = data.top_symptoms.map(s => { | |
| const percent = Math.round((s.count / maxCount) * 100); | |
| return ` | |
| <div class="symptom-bar-container"> | |
| <div class="symptom-label-row"> | |
| <span>${s.symptom}</span> | |
| <span style="color: var(--text-muted)">${s.count} entries</span> | |
| </div> | |
| <div class="symptom-bar-bg"> | |
| <div class="symptom-bar-fill" style="width: ${percent}%"></div> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| } else { | |
| symptomList.innerHTML = `<p style="color: var(--text-muted); text-align: center; margin-top: 2rem;">No symptom data yet.</p>`; | |
| } | |
| } | |
| async function fetchGaps() { | |
| const data = await api('/api/admin/gaps'); | |
| if(!data) return; | |
| const tbody = document.getElementById('gaps-table-body'); | |
| // Diagnostics | |
| if (data.exists === false) { | |
| tbody.innerHTML = `<tr><td colspan="3" style="color: #ff4d4d; text-align: center; padding: 2rem;"> | |
| β οΈ CRITICAL: Gaps file not found at: <code style="display:block;margin-top:0.5rem">${data.debug_path}</code> | |
| </td></tr>`; | |
| return; | |
| } | |
| if (!data.gaps || data.gaps.length === 0) { | |
| tbody.innerHTML = `<tr><td colspan="3" style="color: var(--text-muted); text-align: center; padding: 2rem;"> | |
| No knowledge gaps currently logged.<br> | |
| <small style="opacity:0.6">(Checked: ${data.debug_path})</small> | |
| </td></tr>`; | |
| return; | |
| } | |
| tbody.innerHTML = (data.gaps || []).map((g, idx) => ` | |
| <tr> | |
| <td><strong>${g.user_input}</strong></td> | |
| <td>${g.reason || 'Gap detected'}</td> | |
| <td> | |
| <button class="btn btn-sm" onclick="verifyGap(${idx}, '${g.user_input}')">Approve as...</button> | |
| <button class="btn btn-sm btn-danger" onclick="removeGap(${idx})">Dismiss</button> | |
| </td> | |
| </tr> | |
| `).join(''); | |
| } | |
| async function verifyGap(idx, input) { | |
| const disease = prompt(`Map "${input}" to which disease?`); | |
| if(!disease) return; | |
| const res = await api('/api/admin/verify', 'POST', { gap_idx: idx, disease: disease }); | |
| if(res.success) { alert(res.message); fetchGaps(); } | |
| } | |
| async function removeGap(idx) { | |
| if(!confirm("Remove this gap log?")) return; | |
| await api('/api/admin/remove-gap', 'DELETE', { gap_idx: idx }); | |
| fetchGaps(); | |
| } | |
| async function fetchSynonyms() { | |
| const data = await api('/api/admin/synonyms'); | |
| const tbody = document.getElementById('synonym-table-body'); | |
| tbody.innerHTML = Object.entries(data.synonyms || {}).map(([ph, ex]) => ` | |
| <tr> | |
| <td>${ph}</td> | |
| <td><span class="tag">${ex}</span></td> | |
| <td><button class="btn btn-sm btn-danger" onclick="removeSynonym('${ph}')">Delete</button></td> | |
| </tr> | |
| `).join(''); | |
| } | |
| async function addSynonym() { | |
| const phrase = document.getElementById('synonym-phrase').value; | |
| const expansion = document.getElementById('synonym-expansion').value; | |
| if(!phrase || !expansion) return; | |
| const res = await api('/api/admin/synonyms', 'POST', { phrase, expansion }); | |
| if(res.success) { | |
| document.getElementById('synonym-phrase').value = ''; | |
| document.getElementById('synonym-expansion').value = ''; | |
| fetchSynonyms(); | |
| } | |
| } | |
| async function removeSynonym(phrase) { | |
| await api(`/api/admin/synonyms/${encodeURIComponent(phrase)}`, 'DELETE'); | |
| fetchSynonyms(); | |
| } | |
| async function fetchPatches() { | |
| const data = await api('/api/admin/patches'); | |
| const tbody = document.getElementById('patch-table-body'); | |
| tbody.innerHTML = (data.patches || []).map((p, idx) => ` | |
| <tr> | |
| <td>${p.pattern}</td> | |
| <td><span class="tag">${p.disease}</span></td> | |
| <td><button class="btn btn-sm btn-danger" onclick="removePatch(${idx})">Remove Rule</button></td> | |
| </tr> | |
| `).join(''); | |
| } | |
| async function removePatch(idx) { | |
| await api(`/api/admin/patches/${idx}`, 'DELETE'); | |
| fetchPatches(); | |
| } | |
| async function fetchLogs() { | |
| const data = await api('/api/admin/logs'); | |
| const container = document.getElementById('log-container'); | |
| container.innerHTML = (data.logs || []).map(l => ` | |
| <div class="log-item"> | |
| <span style="color: var(--primary)">[${new Date(l.timestamp).toLocaleTimeString()}]</span> | |
| <span style="color: var(--accent); font-weight: bold">${l.role.toUpperCase()}</span> | |
| <span style="color: var(--text-muted)">(${l.user_display})</span>: | |
| ${l.text} | |
| </div> | |
| `).join(''); | |
| } | |
| // v17.1 ββ System Tests ββββββββββββββββββββββββββββββββββββββββββββ | |
| async function runGoldEval() { | |
| const btn = document.getElementById('run-tests-btn'); | |
| btn.disabled = true; | |
| btn.textContent = 'Running...'; | |
| const data = await api('/api/admin/run-tests', 'POST'); | |
| btn.disabled = false; | |
| btn.textContent = '\u25B6 Run Tests'; | |
| if(!data) return; | |
| if(data.last_results) renderTestResults(data.last_results); | |
| else document.getElementById('test-summary').innerHTML = '<em style="color:var(--text-muted)">Test running in background. Refresh in ~60s.</em>'; | |
| } | |
| async function fetchTestResults() { | |
| // Just try to GET last results via run-tests (returns immediately) | |
| const data = await api('/api/admin/run-tests', 'POST'); | |
| if(data && data.last_results) renderTestResults(data.last_results); | |
| } | |
| function renderTestResults(r) { | |
| const summary = document.getElementById('test-summary'); | |
| const passed = r.passed; const total = r.total; const failed = r.failed; | |
| const pct = Math.round((passed/total)*100); | |
| const colour = failed === 0 ? 'var(--accent)' : 'var(--danger)'; | |
| summary.innerHTML = `<span style="font-size:1.6rem;font-weight:700;color:${colour}">${passed}/${total} PASSED</span> <span style="color:var(--text-muted);font-size:0.9rem;">(${pct}%)</span>`; | |
| document.getElementById('test-run-time').textContent = r.run_at ? 'β ' + new Date(r.run_at).toLocaleString() : ''; | |
| const tbody = document.getElementById('tests-table-body'); | |
| tbody.innerHTML = (r.results || []).map(res => ` | |
| <tr> | |
| <td><code>${res.case_id}</code></td> | |
| <td>${res.description}</td> | |
| <td style="color:${res.passed ? 'var(--accent)' : 'var(--danger)'}; font-weight:600">${res.passed ? '\u2705 PASS' : '\u274C FAIL'}</td> | |
| <td style="font-size:0.82rem; color:var(--text-muted)">${(res.errors||[]).join('<br>') || '—'}</td> | |
| </tr> | |
| `).join(''); | |
| } | |
| // v17.1 ββ Audit Log βββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function fetchAuditLog() { | |
| const data = await api('/api/admin/audit-log'); | |
| if(!data) return; | |
| const tbody = document.getElementById('audit-table-body'); | |
| const entries = data.audit_log || []; | |
| if(entries.length === 0) { | |
| tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:2rem;">No audit entries yet.</td></tr>'; | |
| return; | |
| } | |
| tbody.innerHTML = entries.map(e => ` | |
| <tr> | |
| <td style="font-size:0.82rem;color:var(--text-muted)">${new Date(e.timestamp).toLocaleString()}</td> | |
| <td><span class="tag">${e.admin}</span></td> | |
| <td><strong>${e.action}</strong></td> | |
| <td style="font-size:0.82rem;color:var(--text-muted)">${JSON.stringify(e.details)}</td> | |
| </tr> | |
| `).join(''); | |
| } | |
| // v17.1 ββ Feature Flags βββββββββββββββββββββββββββββββββββββββββββ | |
| async function fetchFlags() { | |
| const data = await api('/api/admin/feature-flags'); | |
| if(!data) return; | |
| const tbody = document.getElementById('flags-table-body'); | |
| tbody.innerHTML = Object.entries(data).map(([name, flag]) => ` | |
| <tr> | |
| <td><code>${name}</code></td> | |
| <td> | |
| <span class="tag" style="background: ${flag.enabled ? 'rgba(16, 185, 129, 0.1)' : 'rgba(239, 68, 68, 0.1)'}; color: ${flag.enabled ? 'var(--accent)' : 'var(--danger)'}"> | |
| ${flag.enabled ? 'Active' : 'Disabled'} | |
| </span> | |
| </td> | |
| <td> | |
| <input type="number" min="0" max="100" value="${flag.rollout_pct}" id="pct-${name}" style="width: 80px; padding: 0.3rem 0.5rem; margin-bottom: 0; display: inline-block;"> % | |
| </td> | |
| <td> | |
| <button class="btn btn-sm" onclick="updateFlag('${name}', true)">Enable</button> | |
| <button class="btn btn-sm btn-danger" onclick="updateFlag('${name}', false)">Disable</button> | |
| </td> | |
| </tr> | |
| `).join(''); | |
| } | |
| async function updateFlag(name, enabled) { | |
| const pct = parseInt(document.getElementById(`pct-${name}`).value) || 0; | |
| const res = await api(`/api/admin/feature-flags/${name}`, 'POST', { enabled, rollout_pct: pct }); | |
| if(res && res.updated) { | |
| alert(`Successfully updated flag ${name}`); | |
| fetchFlags(); | |
| } else { | |
| alert("Failed to update feature flag."); | |
| } | |
| } | |
| // Initial Load | |
| fetchStats(); | |
| </script> | |
| </body> | |
| </html> | |