From 6ce3f5246cd9ed2fdd22decd282276d44840d74d Mon Sep 17 00:00:00 2001 From: KRITIKA BENJWAL Date: Wed, 8 Jul 2026 14:29:53 +0530 Subject: [PATCH] feat: serve interactive admin dashboard from root directory --- dashboard/templates/index.html | 200 ++++++++++++++++++++++++++++----- humane_proxy/api/admin.py | 30 ++++- tests/test_admin_api.py | 12 ++ 3 files changed, 214 insertions(+), 28 deletions(-) diff --git a/dashboard/templates/index.html b/dashboard/templates/index.html index 9f2d2fa..3982248 100644 --- a/dashboard/templates/index.html +++ b/dashboard/templates/index.html @@ -141,6 +141,7 @@ +
@@ -231,7 +232,7 @@

Safety operations

-

48,219

+

48,219

vs. previous 30 days

Safety operations
-

23

+

23

operator review queue

Safety operations
-

86

+

86

currently monitored

Recent Escalations Category Risk Score Triggers + Actions - + 2026-06-26 14:32 sess-a91f2 @@ -406,8 +408,8 @@

Recent Escalations

class="rounded-full bg-rose-500/10 px-2.5 py-1 text-xs font-bold text-rose-500">self_harm 0.92 - urgent phrasing, intent signal, repeat spike - + urgent phrasing, intent signal, repeat spike + Sample 2026-06-26 13:58 @@ -417,6 +419,7 @@

Recent Escalations

0.78 threat language, named target + Sample 2026-06-26 13:21 @@ -426,6 +429,7 @@

Recent Escalations

0.66 harassment marker, toxicity score + Sample 2026-06-26 12:40 @@ -435,6 +439,7 @@

Recent Escalations

0.51 unsafe advice request + Sample 2026-06-26 11:55 @@ -444,6 +449,7 @@

Recent Escalations

0.28 sentiment dip + Sample @@ -512,21 +518,28 @@

Recent Escalations

ctx.stroke(); } + let donutItems = [9, 6, 5, 3]; + let donutTotalVal = '23'; + let barOpened = [9, 13, 15, 14, 16, 18]; + let barResolved = [4, 5, 7, 6, 5, 6]; + let riskValues = [0.38, 0.42, 0.49, 0.58, 0.55, 0.66, 0.73]; + let baseValues = [0.22, 0.25, 0.29, 0.32, 0.35, 0.38, 0.40]; + let riskLabels = ['Jun 20', 'Jun 21', 'Jun 22', 'Jun 23', 'Jun 24', 'Jun 25', 'Jun 26']; + function drawRisk(progress) { const canvas = document.getElementById('risk-chart'); const { ctx, width, height } = prep(canvas); const c = colors(); const pad = 34; - const risk = [0.38, 0.42, 0.49, 0.58, 0.55, 0.66, 0.73]; - const base = [0.22, 0.25, 0.29, 0.32, 0.35, 0.38, 0.40]; - const labels = ['Jun 20', 'Jun 21', 'Jun 22', 'Jun 23', 'Jun 24', 'Jun 25', 'Jun 26']; + const risk = riskValues; + const base = baseValues; + const labels = riskLabels; ctx.clearRect(0, 0, width, height); ctx.font = '12px Inter'; ctx.strokeStyle = c.grid; ctx.fillStyle = c.text; - ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; const yLabels = ['1.0', '0.75', '0.50', '0.25', '0.0']; @@ -541,7 +554,6 @@

Recent Escalations

ctx.fillText(yLabels[i], pad - 8, y); } - ctx.beginPath(); ctx.moveTo(pad, pad); ctx.lineTo(pad, height - pad); @@ -553,7 +565,7 @@

Recent Escalations

function points(data) { return data.map((v, i) => ({ - x: pad + i * ((width - pad * 2) / (data.length - 1)), + x: pad + i * ((width - pad * 2) / (data.length - 1 || 1)), y: height - pad - (v * progress) * (height - pad * 2) })); } @@ -566,8 +578,10 @@

Recent Escalations

ctx.beginPath(); riskPts.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); - ctx.lineTo(width - pad, height - pad); - ctx.lineTo(pad, height - pad); + if (riskPts.length > 0) { + ctx.lineTo(riskPts[riskPts.length - 1].x, height - pad); + ctx.lineTo(riskPts[0].x, height - pad); + } ctx.closePath(); ctx.fillStyle = gradient; ctx.fill(); @@ -586,7 +600,9 @@

Recent Escalations

ctx.lineJoin = 'round'; ctx.stroke(); - labels.forEach((label, i) => ctx.fillText(label, riskPts[i].x - 18, height - 8)); + labels.forEach((label, i) => { + if (riskPts[i]) ctx.fillText(label, riskPts[i].x, height - 8); + }); } function drawBars(progress) { @@ -594,9 +610,9 @@

Recent Escalations

const { ctx, width, height } = prep(canvas); const c = colors(); const labels = ['09:00', '10:00', '11:00', '12:00', '13:00', '14:00']; - const opened = [9, 13, 15, 14, 16, 18]; - const resolved = [4, 5, 7, 6, 5, 6]; - const max = 20; + const opened = barOpened; + const resolved = barResolved; + const max = Math.max(...opened, ...resolved, 20); const pad = 36; const gap = (width - pad * 2) / labels.length; @@ -631,9 +647,9 @@

Recent Escalations

function drawDonut(progress) { const canvas = document.getElementById('donut-chart'); const { ctx, width, height } = prep(canvas); - const items = [9, 6, 5, 3]; - const palette = ['#f43f5e', '#f59e0b', '#1d7cff', '#10b981']; - const total = items.reduce((a, b) => a + b, 0); + const items = donutItems; + const palette = ['#f43f5e', '#f59e0b', '#1d7cff', '#10b981', '#a855f7', '#6366f1']; + const total = items.reduce((a, b) => a + b, 0) || 1; const cx = width / 2; const cy = height / 2; const radius = Math.min(width, height) * .38; @@ -649,9 +665,9 @@

Recent Escalations

items.forEach((value, i) => { const angle = (value / total) * Math.PI * 2 * progress; - ctx.strokeStyle = palette[i]; + ctx.strokeStyle = palette[i % palette.length]; ctx.beginPath(); - ctx.arc(cx, cy, radius, start, start + angle - .04); + ctx.arc(cx, cy, radius, start, start + angle - (angle > 0.05 ? 0.04 : 0)); ctx.stroke(); start += (value / total) * Math.PI * 2; }); @@ -659,7 +675,7 @@

Recent Escalations

ctx.fillStyle = root.classList.contains('dark') ? '#f4f4f5' : '#18181b'; ctx.font = '700 28px Inter'; ctx.textAlign = 'center'; - ctx.fillText('23', cx, cy + 2); + ctx.fillText(donutTotalVal, cx, cy + 2); ctx.font = '12px Inter'; ctx.fillStyle = '#71717a'; ctx.fillText('Escalations', cx, cy + 24); @@ -697,7 +713,6 @@

Recent Escalations

}); } - let resizeTimer; const ro = new ResizeObserver(() => { clearTimeout(resizeTimer); @@ -706,8 +721,139 @@

Recent Escalations

const mainEl = document.querySelector('main'); if (mainEl) ro.observe(mainEl); - animateCharts(); + async function apiFetch(endpoint, options = {}) { + const key = localStorage.getItem('hp-admin-key') || ''; + const headers = options.headers || {}; + if (key) { + headers['Authorization'] = `Bearer ${key}`; + } + const res = await fetch(endpoint, { + ...options, + headers + }); + if (res.status === 401 || res.status === 403) { + const newKey = prompt("HumaneProxy Admin Key is required or invalid. Please enter the key:"); + if (newKey) { + localStorage.setItem('hp-admin-key', newKey.trim()); + location.reload(); + } + } + if (res.status === 204) { + return null; + } + if (!res.ok) { + throw new Error(`API error: ${res.status}`); + } + return res.json(); + } + + function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + async function deleteSession(sessionId) { + if (!confirm(`Are you sure you want to delete all data for session "${sessionId}"?`)) { + return; + } + try { + await apiFetch(`/admin/sessions/${sessionId}`, { + method: 'DELETE' + }); + alert('Session deleted successfully.'); + loadData(); + } catch (err) { + alert(`Error deleting session: ${err.message}`); + } + } + + async function loadData() { + try { + const health = await apiFetch('/admin/health'); + const stats = await apiFetch('/admin/stats'); + + if (stats.by_category) { + const keys = Object.keys(stats.by_category); + if (keys.length > 0) { + donutItems = keys.map(k => stats.by_category[k]); + donutTotalVal = String(stats.total_escalations || 0); + } + document.getElementById('stat-escalations-today').textContent = String(stats.total_escalations || 0); + } + + const escData = await apiFetch('/admin/escalations?limit=100'); + const items = escData.items || []; + + document.getElementById('stat-total-messages').textContent = String(stats.total_escalations || items.length); + + const uniqueSessions = new Set(items.map(i => i.session_id)); + document.getElementById('stat-active-sessions').textContent = String(uniqueSessions.size); + + const tbody = document.getElementById('escalations-tbody'); + if (items.length > 0) { + tbody.innerHTML = ''; + items.forEach(item => { + const tr = document.createElement('tr'); + tr.className = 'transition hover:bg-zinc-50 dark:hover:bg-white/[0.035]'; + + const d = new Date(item.timestamp * 1000); + const dateStr = d.toISOString().replace('T', ' ').substring(0, 16); + + let badgeColor = 'bg-zinc-500/10 text-zinc-500'; + if (item.category === 'self_harm') badgeColor = 'bg-rose-500/10 text-rose-500'; + else if (item.category === 'criminal_intent') badgeColor = 'bg-amber-500/10 text-amber-500'; + else if (item.category === 'violence') badgeColor = 'bg-rose-500/10 text-rose-500'; + + const triggersStr = (item.triggers || []).join(', ') || 'none'; + + tr.innerHTML = ` + ${dateStr} + ${escapeHtml(item.session_id)} + ${escapeHtml(item.category)} + ${item.risk_score.toFixed(2)} + ${escapeHtml(triggersStr)} + + + + `; + tbody.appendChild(tr); + }); + } + + animateCharts(); + } catch (err) { + console.error("Failed to load dashboard data:", err); + animateCharts(); + } + } + + const tbodyEl = document.getElementById('escalations-tbody'); + if (tbodyEl) { + tbodyEl.addEventListener('click', (event) => { + const btn = event.target.closest('.delete-btn'); + if (btn) { + const sessionId = btn.getAttribute('data-session-id'); + if (sessionId) { + deleteSession(sessionId); + } + } + }); + } + + const authBtn = document.getElementById('auth-btn'); + if (authBtn) { + authBtn.addEventListener('click', () => { + const newKey = prompt("Enter HUMANE_PROXY_ADMIN_KEY:", localStorage.getItem('hp-admin-key') || ''); + if (newKey !== null) { + localStorage.setItem('hp-admin-key', newKey.trim()); + location.reload(); + } + }); + } + + // Initialise + loadData(); - \ No newline at end of file + diff --git a/humane_proxy/api/admin.py b/humane_proxy/api/admin.py index 5fd316a..f651bba 100644 --- a/humane_proxy/api/admin.py +++ b/humane_proxy/api/admin.py @@ -27,8 +27,11 @@ from datetime import datetime, timezone from typing import Any +import sys +from pathlib import Path + from fastapi import APIRouter, Depends, HTTPException, Query, Response -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, HTMLResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from humane_proxy._json import dumps as _json_dumps @@ -42,6 +45,31 @@ _start_time = time.monotonic() +# Try to locate the dashboard index.html across multiple layout conventions +_HTML_PATH = None +_CANDIDATE_PATHS = [ + # 1. Dev/Git clone layout: dashboard/ is at the repo root (parent of package dir) + Path(__file__).resolve().parent.parent.parent / "dashboard" / "templates" / "index.html", + # 2. Packaged layout: dashboard/ is nested inside the package (old convention) + Path(__file__).resolve().parent.parent / "dashboard" / "templates" / "index.html", + # 3. System prefix layout (for standard non-editable installations using data-files) + Path(sys.prefix) / "dashboard" / "templates" / "index.html", +] + +for _path in _CANDIDATE_PATHS: + if _path.exists(): + _HTML_PATH = _path + break + + +@router.get("", response_class=HTMLResponse, include_in_schema=False) +@router.get("/", response_class=HTMLResponse, include_in_schema=False) +def serve_dashboard(): + """Serve the admin dashboard HTML page.""" + if not _HTML_PATH or not _HTML_PATH.exists(): + raise HTTPException(status_code=404, detail="Dashboard template not found.") + return HTMLResponse(content=_HTML_PATH.read_text(encoding="utf-8")) + # --------------------------------------------------------------------------- # Auth dependency diff --git a/tests/test_admin_api.py b/tests/test_admin_api.py index 0177059..d3418b6 100644 --- a/tests/test_admin_api.py +++ b/tests/test_admin_api.py @@ -189,3 +189,15 @@ def test_empty_category_param_returns_200(self, _seeded_db): resp = client.get("/admin/escalations?category=&session_id=", headers=self.HEADERS) assert resp.status_code == 200 assert resp.json()["total"] == 3 + + +class TestDashboard: + def test_serve_dashboard(self): + resp = client.get("/admin") + assert resp.status_code == 200 + assert "