Download src/api/admin.py from nikeshn/LibBee: direct link, hf CLI and curl.
- Browser
- Download file 64.8 kB
-
https://huggingface.co/spaces/nikeshn/LibBee/resolve/main/src/api/admin.py
- Command line
-
hf download hf://spaces/nikeshn/LibBee/src/api/admin.py
-
curl -L -o admin.py https://huggingface.co/spaces/nikeshn/LibBee/resolve/main/src/api/admin.py
64.8 kB
| """ | |
| admin.py β LibBee v4.1 | |
| Admin dashboard: session management, config store, RAG rebuild, metrics, system status. | |
| Security features: | |
| - Login lockout: 5 failed attempts β 15 minute lockout per IP | |
| - Remaining attempts shown in error message | |
| - secure=True on session cookie | |
| - Login page JS shows server error message (lockout, remaining attempts) | |
| - Short-lived read-only browser tokens for Worker analytics | |
| """ | |
| # ββ Imports ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import json | |
| import logging | |
| import secrets | |
| import time | |
| from typing import Any, Dict, Optional | |
| from urllib.parse import urlsplit | |
| from fastapi import APIRouter, HTTPException, Request, Response | |
| from fastapi.responses import HTMLResponse, RedirectResponse | |
| from pydantic import BaseModel, ConfigDict, Field | |
| from src.config import get_settings, LIBBEE_VERSION | |
| from src.services.runtime_store import JsonRuntimeStore | |
| router = APIRouter() | |
| logger = logging.getLogger(__name__) | |
| _START_TIME = time.time() | |
| _CONFIG_DEFAULTS: Dict[str, Any] = { | |
| "max_results": 5, | |
| "maintenance_mode": False, | |
| "welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.", | |
| "custom_instructions": "", | |
| "announcement": "", | |
| "maintenance_message": "LibBee is currently under maintenance. Please try again shortly.", | |
| } | |
| # ββ Session Persistence ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| admin_sessions: Dict[str, float] = {} | |
| # ββ Admin login lockout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Tracks failed login attempts per IP. | |
| # After _MAX_LOGIN_ATTEMPTS failures within _LOCKOUT_WINDOW seconds, | |
| # that IP is locked out for _LOCKOUT_DURATION seconds. | |
| _login_attempts: Dict[str, list] = {} # ip β [timestamp, ...] | |
| _login_lockouts: Dict[str, float] = {} # ip β absolute expiry (separate from attempt window) | |
| _MAX_LOGIN_ATTEMPTS = 5 | |
| _LOCKOUT_WINDOW = 300 # 5 minutes β window in which attempts are counted | |
| _LOCKOUT_DURATION = 900 # 15 minutes β lockout duration after max attempts | |
| def _sessions_file() -> str: | |
| return str(get_settings().runtime_path / "admin_sessions.json") | |
| def _load_sessions() -> None: | |
| global admin_sessions | |
| try: | |
| from pathlib import Path | |
| data = JsonRuntimeStore(Path(_sessions_file())).load() | |
| now = time.time() | |
| valid = {t: exp for t, exp in data.items() | |
| if isinstance(exp, (int, float)) and exp > now} | |
| admin_sessions.clear() | |
| admin_sessions.update(valid) | |
| except Exception: | |
| pass | |
| def _save_sessions() -> None: | |
| try: | |
| from pathlib import Path | |
| now = time.time() | |
| valid = {t: exp for t, exp in admin_sessions.items() if exp > now} | |
| JsonRuntimeStore(Path(_sessions_file())).save(valid) | |
| except Exception as exc: | |
| logger.error("Could not persist admin sessions: %s", type(exc).__name__) | |
| raise HTTPException(status_code=503, detail="Session storage unavailable") from exc | |
| def _cleanup_sessions() -> None: | |
| now = time.time() | |
| expired = [t for t, exp in admin_sessions.items() if exp <= now] | |
| for t in expired: | |
| admin_sessions.pop(t, None) | |
| try: | |
| _load_sessions() | |
| except Exception: | |
| pass | |
| def _require_session(request: Request) -> None: | |
| _cleanup_sessions() | |
| token = request.cookies.get("admin_session") | |
| if not token or token not in admin_sessions: | |
| raise HTTPException(status_code=401, detail="Not authenticated") | |
| def _has_session(request: Request) -> bool: | |
| _cleanup_sessions() | |
| token = request.cookies.get("admin_session") | |
| return bool(token and token in admin_sessions) | |
| # ββ Config Store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _config_store() -> JsonRuntimeStore: | |
| settings = get_settings() | |
| return JsonRuntimeStore(settings.config_path, default=settings.runtime_defaults) | |
| def _load_config() -> Dict[str, Any]: | |
| data = _config_store().load() | |
| for k, v in _CONFIG_DEFAULTS.items(): | |
| if k not in data: | |
| data[k] = v | |
| return data | |
| def _esc(s: Any) -> str: | |
| return ( | |
| str(s or "") | |
| .replace("&", "&") | |
| .replace("<", "<") | |
| .replace(">", ">") | |
| .replace('"', """) | |
| ) | |
| def _escape_untrusted_json(value: Any) -> Any: | |
| """Escape Worker/D1 strings before dashboard code inserts them as HTML.""" | |
| if isinstance(value, str): | |
| return _esc(value) | |
| if isinstance(value, list): | |
| return [_escape_untrusted_json(item) for item in value] | |
| if isinstance(value, dict): | |
| return {key: _escape_untrusted_json(item) for key, item in value.items()} | |
| return value | |
| def _clean_worker_url(raw: str) -> str: | |
| """Return a browser-safe HTTPS Worker base URL or an empty string.""" | |
| value = str(raw or "").strip().strip('"').strip("'").rstrip("/") | |
| try: | |
| parsed = urlsplit(value) | |
| except ValueError: | |
| return "" | |
| if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password: | |
| return "" | |
| if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: | |
| return "" | |
| return value | |
| def _make_worker_browser_token(secret: str, now: Optional[int] = None) -> tuple[str, int]: | |
| """Create a 90-second read-only token accepted by the v4.1 Worker.""" | |
| issued_at = int(time.time() if now is None else now) | |
| expires_at = issued_at + 90 | |
| payload = { | |
| "aud": "libbee-worker-admin", | |
| "scope": "analytics:read", | |
| "iat": issued_at, | |
| "exp": expires_at, | |
| "nonce": secrets.token_urlsafe(12), | |
| } | |
| raw_payload = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") | |
| encoded_payload = base64.urlsafe_b64encode(raw_payload).decode("ascii").rstrip("=") | |
| signing_input = f"lb1.{encoded_payload}" | |
| signature = hmac.new(secret.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest() | |
| encoded_signature = base64.urlsafe_b64encode(signature).decode("ascii").rstrip("=") | |
| return f"{signing_input}.{encoded_signature}", expires_at | |
| # ββ Pydantic Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class LoginRequest(BaseModel): | |
| model_config = ConfigDict(extra="forbid") | |
| password: str = Field(min_length=1, max_length=256) | |
| class ConfigUpdate(BaseModel): | |
| model_config = ConfigDict(extra="forbid") | |
| key: str = Field(min_length=1, max_length=40) | |
| value: str = Field(max_length=5000) | |
| class ConfigBulkUpdate(BaseModel): | |
| model_config = ConfigDict(extra="forbid") | |
| settings: Dict[str, Any] = Field(max_length=6) | |
| def _validated_config_values(values: Dict[str, Any]) -> Dict[str, Any]: | |
| limits = {"welcome_message": 500, "custom_instructions": 5000, | |
| "announcement": 500, "maintenance_message": 300} | |
| cleaned = {} | |
| for key, value in values.items(): | |
| if key == "max_results": | |
| try: | |
| # Reject fractional numbers instead of silently rounding. | |
| number = int(str(value)) | |
| except (ValueError, TypeError): | |
| raise HTTPException(status_code=422, detail="max_results must be an integer from 1 to 20") | |
| if not 1 <= number <= 20: | |
| raise HTTPException(status_code=422, detail="max_results must be from 1 to 20") | |
| cleaned[key] = number | |
| elif key == "maintenance_mode": | |
| normalized = str(value).lower().strip() | |
| if normalized not in {"1", "true", "yes", "on", "0", "false", "no", "off"}: | |
| raise HTTPException(status_code=422, detail="maintenance_mode must be true or false") | |
| cleaned[key] = normalized in {"1", "true", "yes", "on"} | |
| elif key in limits: | |
| if not isinstance(value, str) or len(value) > limits[key]: | |
| raise HTTPException(status_code=422, detail=f"{key} must be text of at most {limits[key]} characters") | |
| cleaned[key] = value | |
| else: | |
| raise HTTPException(status_code=422, detail=f"Unsupported setting: {key}") | |
| return cleaned | |
| # ββ Auth Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def admin_auth(req: LoginRequest, request: Request): | |
| settings = get_settings() | |
| if not settings.admin_password: | |
| raise HTTPException(status_code=503, detail="Admin login is not configured") | |
| client_ip = request.client.host if request.client else "unknown" | |
| now = time.time() | |
| # ββ Lockout check βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # The lockout survives the shorter rolling attempt window. Previously the | |
| # fifth attempt was forgotten after five minutes, releasing a 15-minute ban. | |
| for ip, expiry in list(_login_lockouts.items()): | |
| if expiry <= now: | |
| _login_lockouts.pop(ip, None) | |
| for ip, previous in list(_login_attempts.items()): | |
| recent = [t for t in previous if now - t < _LOCKOUT_WINDOW] | |
| if recent: | |
| _login_attempts[ip] = recent | |
| else: | |
| _login_attempts.pop(ip, None) | |
| if client_ip in _login_lockouts: | |
| wait = max(1, int(_login_lockouts[client_ip] - now)) | |
| raise HTTPException( | |
| status_code=429, | |
| detail=f"Too many failed login attempts. Try again in {wait} seconds.", | |
| headers={"Retry-After": str(wait)}, | |
| ) | |
| attempts = _login_attempts.get(client_ip, []) | |
| # ββ Password check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if not settings.admin_password or not secrets.compare_digest( | |
| req.password.encode(), settings.admin_password.encode() | |
| ): | |
| attempts.append(now) | |
| _login_attempts[client_ip] = attempts | |
| remaining = _MAX_LOGIN_ATTEMPTS - len(attempts) | |
| if remaining <= 0: | |
| _login_lockouts[client_ip] = now + _LOCKOUT_DURATION | |
| detail = f"Too many failed login attempts. Try again in {int(_LOCKOUT_DURATION / 60)} minutes." | |
| else: | |
| detail = f"Invalid password. {remaining} attempt{'s' if remaining != 1 else ''} remaining before lockout." | |
| raise HTTPException(status_code=429 if remaining <= 0 else 401, detail=detail) | |
| # ββ Success β clear attempts and issue session βββββββββββββββββββββββββββββ | |
| _login_attempts.pop(client_ip, None) | |
| _cleanup_sessions() | |
| token = secrets.token_hex(32) | |
| admin_sessions[token] = time.time() + 86400 | |
| _save_sessions() | |
| resp = Response(content='{"status":"ok"}', media_type="application/json") | |
| resp.set_cookie(key="admin_session", value=token, httponly=True, | |
| max_age=86400, samesite="lax", secure=settings.environment == "production") | |
| return resp | |
| async def admin_logout_post(request: Request): | |
| _cleanup_sessions() | |
| token = request.cookies.get("admin_session") | |
| if token: | |
| admin_sessions.pop(token, None) | |
| _save_sessions() | |
| resp = Response(content='{"status":"ok"}', media_type="application/json") | |
| resp.delete_cookie("admin_session") | |
| return resp | |
| async def admin_logout_get(request: Request): | |
| _cleanup_sessions() | |
| token = request.cookies.get("admin_session") | |
| if token: | |
| admin_sessions.pop(token, None) | |
| _save_sessions() | |
| resp = RedirectResponse(url="/admin/login") | |
| resp.delete_cookie("admin_session") | |
| return resp | |
| # ββ Login Page βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def admin_login_page(request: Request): | |
| if _has_session(request): | |
| return RedirectResponse(url="/admin") | |
| return HTMLResponse("""<!DOCTYPE html> | |
| <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>KU Library AI β Admin Login</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;display:flex;justify-content:center;align-items:center;min-height:100vh} | |
| .login-box{background:#fff;border-radius:16px;padding:40px;width:360px;box-shadow:0 4px 24px rgba(0,0,0,.1);text-align:center} | |
| h1{color:#003366;font-size:1.3rem;margin-bottom:6px} | |
| .subtitle{color:#6b7280;font-size:.84rem;margin-bottom:24px} | |
| input{width:100%;padding:12px 14px;border:1.5px solid #d1d5db;border-radius:10px;font-size:.92rem;margin-bottom:14px} | |
| input:focus{outline:none;border-color:#003366} | |
| .btn{width:100%;padding:12px;background:#003366;color:#fff;border:none;border-radius:10px;font-weight:700;font-size:.92rem;cursor:pointer} | |
| .btn:hover{background:#004488} | |
| .error{color:#dc2626;font-size:.82rem;margin-bottom:10px;display:none} | |
| </style></head><body> | |
| <div class="login-box"> | |
| <h1>π Admin Login</h1> | |
| <div class="subtitle">KU Library AI Dashboard</div> | |
| <div class="error" id="err">Incorrect password. Try again.</div> | |
| <form onsubmit="return doLogin(event)"> | |
| <input type="password" id="pw" aria-label="Admin password" autocomplete="current-password" required maxlength="256" placeholder="Enter admin password" autofocus> | |
| <button type="submit" class="btn">Login</button> | |
| </form> | |
| </div> | |
| <script> | |
| async function doLogin(e){ | |
| e.preventDefault(); | |
| const pw=document.getElementById('pw').value; | |
| let r; | |
| try{r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});} | |
| catch(error){const el=document.getElementById('err');el.textContent='Cannot reach LibBee. Please retry.';el.style.display='block';return false;} | |
| if(r.ok){window.location.href='/admin';} | |
| else{const data=await r.json().catch(()=>({}));const msg=data.detail||'Incorrect password. Try again.';const e=document.getElementById('err');e.textContent=msg;e.style.display='block';document.getElementById('pw').value='';document.getElementById('pw').focus();} | |
| return false; | |
| } | |
| </script></body></html>""") | |
| # ββ Admin Dashboard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def admin_dashboard(request: Request): | |
| if not _has_session(request): | |
| return RedirectResponse(url="/admin/login") | |
| try: | |
| cfg = _load_config() | |
| except Exception: | |
| cfg = dict(_CONFIG_DEFAULTS) | |
| mm_on = "selected" if cfg.get("maintenance_mode") else "" | |
| mm_off = "" if cfg.get("maintenance_mode") else "selected" | |
| return HTMLResponse(f"""<!DOCTYPE html> | |
| <html><head><meta charset="utf-8"><title>KU Library AI β Admin</title> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <style> | |
| *{{box-sizing:border-box;margin:0;padding:0}} | |
| body{{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a1a2e}} | |
| .topbar{{background:#003366;color:#fff;padding:12px 20px;display:flex;align-items:center;justify-content:space-between}} | |
| .topbar h1{{font-size:1.1rem;font-weight:700}} | |
| .topbar a{{color:#C8A951;font-size:.82rem;text-decoration:none;padding:5px 12px;border:1px solid #C8A951;border-radius:8px}} | |
| .main{{max-width:900px;margin:24px auto;padding:0 16px;display:flex;flex-direction:column;gap:20px}} | |
| .card{{background:#fff;border-radius:14px;padding:20px;box-shadow:0 2px 12px rgba(0,0,0,.07)}} | |
| .card h2{{font-size:1rem;font-weight:700;color:#003366;margin-bottom:14px}} | |
| label{{display:block;font-size:.82rem;font-weight:600;color:#374151;margin-bottom:4px;margin-top:10px}} | |
| input[type=text],textarea,select{{width:100%;padding:9px 12px;border:1.5px solid #d1d5db;border-radius:8px;font-size:.88rem;font-family:inherit}} | |
| textarea{{min-height:80px;resize:vertical}} | |
| .btn{{padding:9px 20px;background:#003366;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer;font-size:.88rem;margin-top:12px}} | |
| .btn:hover{{background:#004488}} | |
| .btn-danger{{background:#dc2626}}.btn-danger:hover{{background:#b91c1c}} | |
| .btn-green{{background:#16a34a}}.btn-green:hover{{background:#15803d}} | |
| .stat-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}} | |
| .stat{{background:#f8fafc;border-radius:10px;padding:14px;text-align:center;border:1px solid #e5e7eb}} | |
| .stat .val{{font-size:1.6rem;font-weight:800;color:#003366}} | |
| .stat .lbl{{font-size:.74rem;color:#6b7280;margin-top:2px}} | |
| .status-ok{{color:#16a34a;font-weight:700}}.status-warn{{color:#d97706;font-weight:700}} | |
| #saveMsg{{font-size:.82rem;color:#16a34a;margin-top:8px;display:none}} | |
| table{{width:100%;border-collapse:collapse;font-size:.82rem}} | |
| th{{background:#f1f5f9;padding:8px 10px;text-align:left;font-weight:700;color:#374151}} | |
| td{{padding:7px 10px;border-bottom:1px solid #f1f5f9;word-break:break-word}} | |
| tr:last-child td{{border-bottom:none}} | |
| </style></head> | |
| <body> | |
| <div class="topbar"><h1>π KU Library AI β Admin Dashboard</h1><a href="/admin/logout">Logout</a></div> | |
| <div class="main"> | |
| <div class="card"> | |
| <h2>π Analytics</h2> | |
| <div class="stat-grid" id="statsGrid"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div> | |
| </div> | |
| <div class="card"> | |
| <h2>βοΈ Bot Configuration</h2> | |
| <label>Welcome Message</label> | |
| <input type="text" id="cfg_welcome_message" value="{_esc(cfg.get('welcome_message', ''))}"> | |
| <label>Custom Instructions (appended to system prompt)</label> | |
| <textarea id="cfg_custom_instructions">{_esc(cfg.get('custom_instructions', ''))}</textarea> | |
| <label>Announcement Banner (leave blank to hide)</label> | |
| <input type="text" id="cfg_announcement" value="{_esc(cfg.get('announcement', ''))}"> | |
| <label>Max Search Results (per query)</label> | |
| <input type="text" id="cfg_max_results" value="{_esc(cfg.get('max_results', 5))}" style="width:80px"> | |
| <label>Maintenance Mode</label> | |
| <select id="cfg_maintenance_mode"> | |
| <option value="false" {mm_off}>Off</option> | |
| <option value="true" {mm_on}>On</option> | |
| </select> | |
| <label>Maintenance Message</label> | |
| <input type="text" id="cfg_maintenance_message" value="{_esc(cfg.get('maintenance_message', ''))}"> | |
| <button class="btn" onclick="saveConfig()">πΎ Save Configuration</button> | |
| <div id="saveMsg">β Saved!</div> | |
| </div> | |
| <div class="card"> | |
| <h2>π Index Management</h2> | |
| <p style="font-size:.84rem;color:#6b7280;margin-bottom:12px">Rebuild the FAISS + BM25 hybrid index from knowledge files in the KB directory.</p> | |
| <div id="ragInfo" style="font-size:.82rem;color:#374151;margin-bottom:12px">Loading RAG statusβ¦</div> | |
| <button class="btn btn-green" onclick="rebuildIndex()">π Rebuild Index</button> | |
| <div id="rebuildMsg" style="margin-top:10px;font-size:.84rem"></div> | |
| </div> | |
| <div class="card"> | |
| <h2>π Request Metrics</h2> | |
| <div id="metricsTable" style="overflow-x:auto"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div> | |
| </div> | |
| <div class="card"> | |
| <h2>π Cloudflare Analytics (Persistent)</h2> | |
| <div class="stat-grid" id="cfStatsGrid"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div> | |
| <div style="margin-top:14px" id="cfToolsTable"></div> | |
| </div> | |
| <div class="card"> | |
| <h2>π Recent Queries (Cloudflare D1)</h2> | |
| <div id="recentTable" style="overflow-x:auto"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div> | |
| </div> | |
| <div class="card"> | |
| <h2>π Reports</h2> | |
| <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px"> | |
| <label style="font-size:.82rem">Period:</label> | |
| <select id="repPeriod" style="padding:5px 8px;border:1px solid #d1d5db;border-radius:6px"> | |
| <option value="7">Last 7 days</option> | |
| <option value="30" selected>Last 30 days</option> | |
| <option value="90">Last 90 days</option> | |
| <option value="0">All time</option> | |
| </select> | |
| <label style="font-size:.82rem;margin-left:8px">Report:</label> | |
| <select id="repKind" style="padding:5px 8px;border:1px solid #d1d5db;border-radius:6px"> | |
| <option value="overview">1. Usage overview</option> | |
| <option value="topics">2. What people ask about</option> | |
| <option value="when">3. When people use it</option> | |
| <option value="speed">4. How fast it answers</option> | |
| <option value="gaps">5. Questions we could not answer well</option> | |
| <option value="health">6. Service health</option> | |
| <option value="all">Everything (printable summary)</option> | |
| </select> | |
| <button class="btn" id="repRun" style="padding:6px 12px">Show report</button> | |
| <button class="btn" id="repPrint" style="padding:6px 12px">Print / PDF</button> | |
| <button class="btn" id="repCsv" style="padding:6px 12px">Download CSV</button> | |
| </div> | |
| <div id="reportOut"><div style="color:#6b7280;font-size:.84rem">Choose a report and click "Show report".</div></div> | |
| </div> | |
| <div class="card"> | |
| <h2>π System Status</h2> | |
| <div id="sysStatus"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div> | |
| </div> | |
| </div> | |
| <script> | |
| // The permanent Worker secret stays server-side. A logged-in administrator | |
| // receives only a read-only token that expires after 90 seconds. | |
| let CF_BROWSER_AUTH={{token:'',workerUrl:'',expiresAt:0}}; | |
| function cfEscape(value){{ | |
| return String(value==null?'':value).replace(/[&<>"']/g, character=>({{ | |
| '&':'&','<':'<','>':'>','"':'"',"'":''' | |
| }})[character]); | |
| }} | |
| function cfEscapeData(value){{ | |
| if(typeof value==='string') return cfEscape(value); | |
| if(Array.isArray(value)) return value.map(cfEscapeData); | |
| if(value&&typeof value==='object'){{ | |
| const out={{}}; | |
| Object.keys(value).forEach(key=>{{out[key]=cfEscapeData(value[key]);}}); | |
| return out; | |
| }} | |
| return value; | |
| }} | |
| async function cfBrowserAuth(forceRefresh){{ | |
| const now=Math.floor(Date.now()/1000); | |
| if(!forceRefresh&&CF_BROWSER_AUTH.token&&CF_BROWSER_AUTH.expiresAt>now+10) return CF_BROWSER_AUTH; | |
| const response=await fetch('/admin/worker-browser-token',{{ | |
| credentials:'same-origin',cache:'no-store',headers:{{'Accept':'application/json'}} | |
| }}); | |
| const data=await response.json(); | |
| if(!response.ok||!data.token||!data.worker_url){{ | |
| throw new Error('HTTP '+response.status+' '+cfEscape(data.detail||'Worker authorization unavailable')); | |
| }} | |
| const target=new URL(data.worker_url); | |
| if(target.protocol!=='https:'||target.pathname!=='/') throw new Error('Invalid Worker URL'); | |
| CF_BROWSER_AUTH={{token:data.token,workerUrl:data.worker_url.replace(/\\/$/,''),expiresAt:Number(data.expires_at||0)}}; | |
| return CF_BROWSER_AUTH; | |
| }} | |
| async function cfFetch(path,retried){{ | |
| if(!/^\\/(?:analytics|reports|canary)(?:\\/|\\?|$)/.test(path)) throw new Error('Unsupported Worker path'); | |
| const auth=await cfBrowserAuth(Boolean(retried)); | |
| let response; | |
| try{{ | |
| response=await fetch(auth.workerUrl+path,{{ | |
| method:'GET',credentials:'omit',cache:'no-store', | |
| headers:{{'Accept':'application/json','Authorization':'Bearer '+auth.token}} | |
| }}); | |
| }}catch(error){{ | |
| throw new Error('Browser could not reach the Worker. Check ALLOWED_ORIGINS and CORS.'); | |
| }} | |
| if(response.status===401&&!retried){{ | |
| CF_BROWSER_AUTH={{token:'',workerUrl:'',expiresAt:0}}; | |
| return cfFetch(path,true); | |
| }} | |
| const text=await response.text(); | |
| let data={{}}; | |
| try{{data=JSON.parse(text);}}catch(error){{throw new Error('Worker returned invalid JSON');}} | |
| if(!response.ok||data.detail) throw new Error('HTTP '+response.status+' '+cfEscape(data.detail||data.error?.message||'')); | |
| if(data.error) throw new Error(cfEscape(data.error.message||JSON.stringify(data.error))); | |
| return {{data:cfEscapeData(data),via:'short-lived browser token'}}; | |
| }} | |
| async function loadStats(){{ | |
| try{{ | |
| const r=await fetch('/admin/metrics'); | |
| const d=await r.json(); | |
| document.getElementById('statsGrid').innerHTML=` | |
| <div class="stat"><div class="val">${{d.agent_requests||0}}</div><div class="lbl">Requests (session)</div></div> | |
| <div class="stat"><div class="val">${{d.search_requests||0}}</div><div class="lbl">Searches (session)</div></div> | |
| <div class="stat"><div class="val">${{d.follow_up_hits||0}}</div><div class="lbl">Follow-ups (session)</div></div> | |
| <div class="stat"><div class="val">${{d.feedback_total||0}}</div><div class="lbl">Feedback (session)</div></div> | |
| `; | |
| }}catch(e){{document.getElementById('statsGrid').innerHTML='<span style="color:#dc2626">Unavailable</span>';}} | |
| }} | |
| async function loadCFAnalytics(){{ | |
| try{{ | |
| const res=await cfFetch('/analytics'); | |
| const d=res.data; | |
| document.getElementById('cfStatsGrid').innerHTML=` | |
| <div class="stat"><div class="val">${{d.total||0}}</div><div class="lbl">Total Queries</div></div> | |
| <div class="stat"><div class="val">${{d.today||0}}</div><div class="lbl">Today</div></div> | |
| <div class="stat"><div class="val">${{d.week||0}}</div><div class="lbl">This Week</div></div> | |
| <div class="stat"><div class="val">${{(d.avg_time||0).toFixed(2)}}s</div><div class="lbl">Avg Response</div></div> | |
| <div class="stat"><div class="val">${{d.errors||0}}</div><div class="lbl">Errors</div></div> | |
| `; | |
| const tools=(d.tools||[]).slice(0,15); | |
| let rows=tools.map(t=>`<tr><td>${{t.tool_used||"unknown"}}</td><td>${{t.c}}</td></tr>`).join(''); | |
| document.getElementById('cfToolsTable').innerHTML=rows | |
| ?`<table><thead><tr><th>Intent:Tool</th><th>Count</th></tr></thead><tbody>${{rows}}</tbody></table>`:''; | |
| }}catch(e){{ | |
| document.getElementById('cfStatsGrid').innerHTML='<span style="color:#dc2626">CF D1 unavailable: '+cfEscape(e.message)+'</span>'; | |
| }} | |
| }} | |
| async function loadRecentQueries(){{ | |
| try{{ | |
| const res=await cfFetch('/analytics/recent'); | |
| const d=res.data; | |
| if(d.error) throw new Error(d.error); | |
| const rows=(d.results||[]).map(q=>`<tr> | |
| <td style="white-space:nowrap;font-size:.74rem">${{q.timestamp||""}}</td> | |
| <td style="max-width:260px;word-break:break-word">${{(q.question||"").substring(0,100)}}</td> | |
| <td>${{q.tool_used||""}}</td> | |
| <td>${{q.model||""}}</td> | |
| <td>${{(q.response_time||0).toFixed(2)}}s</td> | |
| <td>${{q.result_count||0}}</td> | |
| </tr>`).join(''); | |
| document.getElementById('recentTable').innerHTML=rows | |
| ?`<table><thead><tr><th>Time</th><th>Question</th><th>Tool</th><th>Model</th><th>Time</th><th>#</th></tr></thead><tbody>${{rows}}</tbody></table>` | |
| :'<span style="color:#6b7280;font-size:.84rem">No queries yet.</span>'; | |
| }}catch(e){{ | |
| document.getElementById('recentTable').innerHTML='<span style="color:#dc2626">'+cfEscape(e.message)+'</span>'; | |
| }} | |
| }} | |
| // ββ Plain-language reporting ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Internal routing codes are meaningless to a reader, so every code is mapped | |
| // to a phrase a librarian or director would recognise. Unknown codes degrade to | |
| // a title-cased version of the code rather than disappearing. | |
| const INTENT_LABELS = {{ | |
| 'library_info:rag_hybrid_search':'Library information (from our guides)', | |
| 'library_info:libcal_live_hours':'Opening hours', | |
| 'library_info:hours_hardcoded':'Opening hours', | |
| 'library_info:libcal_live_events':'Library events & workshops', | |
| 'library_info:fulltext_chain':'How to get the full text of an article', | |
| 'library_info:database_recommendation':'Which database should I use', | |
| 'library_info:staff_name_match':'Contacting a named staff member', | |
| 'library_info:staff_role_match':'Who to contact for help', | |
| 'library_info:theses_guide':'Finding theses & dissertations', | |
| 'library_info:campus_hardcoded':'Campus & location', | |
| 'library_info':'General library information', | |
| 'search_academic:primo_inline':'Finding articles & books', | |
| 'search_academic:primo_browser_link':'Finding articles & books (opened in catalogue)', | |
| 'search_academic:research_snapshot':'Research starting points', | |
| 'search_academic:follow_up_resolution, both_resources':'Refining an earlier search', | |
| 'search_academic':'Finding articles & books', | |
| 'search_medical:primo_inline':'Medical & health literature', | |
| 'search_medical:primo_browser_link':'Medical & health literature (opened in catalogue)', | |
| 'search_medical':'Medical & health literature', | |
| 'general:web_search':'General questions (answered from the web)', | |
| 'general_recent:web_search':'Current events & news', | |
| 'general_topic':'General topic questions','general_recent':'Current events & news', | |
| 'general':'General questions', | |
| 'social_greeting:libbee_casual':'Greetings & small talk', | |
| 'social_greeting:':'Greetings & small talk','social_greeting':'Greetings & small talk', | |
| 'sensitive_blocked:sensitive_intent_guard':'Sensitive topics (safely redirected)', | |
| 'blocked:injection_guard':'Blocked misuse attempts', | |
| 'not_in_kb':'No information available in our guides', | |
| 'unknown':'Could not be categorised','error':'Errors', | |
| 'faq':'Frequently asked questions (older system)', | |
| 'rag':'Library information (older system)', | |
| 'search':'Catalogue search (older system)', | |
| 'database':'Database recommendation (older system)', | |
| 'agent':'Assistant reply (older system)', | |
| 'clarify':'Clarifying question (older system)', | |
| 'search_recovery':'Search retry (older system)', | |
| 'llm_general':'General questions (older system)', | |
| 'llm_general_confirmed':'General questions (older system)', | |
| 'llm_library':'Library questions (older system)' | |
| }}; | |
| const MODEL_LABELS={{gpt:'ChatGPT (GPT-4o-mini)',claude:'Claude','n/a':'Not applicable'}}; | |
| const WEEKDAYS=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; | |
| function labelFor(code){{ | |
| if(INTENT_LABELS[code]) return INTENT_LABELS[code]; | |
| const base=String(code||'').split(':')[0]; | |
| if(INTENT_LABELS[base]) return INTENT_LABELS[base]; | |
| return base?base.replace(/_/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase()):'Other'; | |
| }} | |
| function speedBand(sec){{ | |
| if(!sec||sec<0.5) return 'Instant'; | |
| if(sec<3) return 'Fast'; | |
| if(sec<8) return 'Moderate'; | |
| return 'Slower (searches external databases)'; | |
| }} | |
| function fmtDate(s){{ return (s||'').substring(0,10) || 'β'; }} | |
| function pct(n,total){{ return total?Math.round(n*1000/total)/10:0; }} | |
| function bar(p){{ | |
| return '<div style="background:#e5e7eb;border-radius:4px;height:8px;width:110px;display:inline-block;vertical-align:middle">' | |
| +'<div style="background:#003366;height:8px;border-radius:4px;width:'+Math.max(2,Math.round(p))+'%"></div></div>'; | |
| }} | |
| let REPORT_DATA=null; | |
| async function fetchReport(){{ | |
| const days=document.getElementById('repPeriod').value; | |
| const res=await cfFetch('/analytics/report?days='+days); | |
| REPORT_DATA=res.data; | |
| return REPORT_DATA; | |
| }} | |
| function reportOverview(d){{ | |
| const t=d.totals||{{}}; | |
| const prev=t.previous_period_queries||0; | |
| const change=prev?Math.round(((t.queries-prev)/prev)*100):null; | |
| const perDay=t.active_days?Math.round(t.queries/t.active_days*10)/10:0; | |
| const trend=change===null?'<em>no comparable earlier period</em>' | |
| :(change>=0?'<span style="color:#047857">up '+change+'%</span>':'<span style="color:#b45309">down '+Math.abs(change)+'%</span>') | |
| +' compared with the previous period'; | |
| return `<h3 style="margin:6px 0">1. Usage overview</h3> | |
| <p style="font-size:.9rem;line-height:1.7"> | |
| LibBee answered <strong>${{t.queries||0}} questions</strong> during this period, | |
| across <strong>${{t.active_days||0}} days</strong> when it was used | |
| (about <strong>${{perDay}} questions per day</strong> on those days). Usage was ${{trend}}.<br> | |
| First question in this period: <strong>${{fmtDate(t.first_query)}}</strong> Β· | |
| most recent: <strong>${{fmtDate(t.last_query)}}</strong>. | |
| ${{t.busiest_day?'Busiest single day: <strong>'+t.busiest_day.day+'</strong> with '+t.busiest_day.count+' questions.':''}}<br> | |
| <strong>${{t.blocked||0}}</strong> were misuse attempts the assistant safely refused. | |
| ${{t.unanswered_legacy?'<br>'+t.unanswered_legacy+' questions from the older system (before April 2026) were recorded as having no matching guide content.':''}} | |
| See report 5 for questions the nightly review judged unanswered. | |
| </p>`; | |
| }} | |
| function reportTopics(d){{ | |
| const total=(d.totals||{{}}).queries||0; | |
| const grouped={{}}; | |
| (d.by_intent||[]).forEach(r=>{{ | |
| const l=labelFor(r.k); | |
| if(!grouped[l]) grouped[l]={{c:0,t:0,n:0}}; | |
| grouped[l].c+=r.c; | |
| if(r.avg_time){{ grouped[l].t+=r.avg_time*r.c; grouped[l].n+=r.c; }} | |
| }}); | |
| const rows=Object.entries(grouped).sort((a,b)=>b[1].c-a[1].c).map(([l,v])=>{{ | |
| const p=pct(v.c,total); const avg=v.n?v.t/v.n:0; | |
| return `<tr><td>${{l}}</td><td style="white-space:nowrap">${{v.c}}</td> | |
| <td style="white-space:nowrap">${{bar(p)}} ${{p}}%</td> | |
| <td>${{speedBand(avg)}}</td></tr>`; | |
| }}).join(''); | |
| return `<h3 style="margin:6px 0">2. What people ask about</h3> | |
| <p style="font-size:.86rem;color:#4b5563">Every question is sorted into a category automatically. This is what people came for.</p> | |
| <div style="overflow-x:auto"><table><thead><tr><th>Type of question</th><th>Questions</th><th>Share</th><th>Typical speed</th></tr></thead><tbody>${{rows||'<tr><td colspan="4">No data.</td></tr>'}}</tbody></table></div>`; | |
| }} | |
| function reportWhen(d){{ | |
| const hours=d.hourly||[]; const maxH=Math.max(1,...hours.map(h=>h.c)); | |
| const hourRows=hours.map(h=>{{ | |
| const hr=parseInt(h.h,10); | |
| const uae=(hr+4)%24; | |
| const ampm=(uae%12===0?12:uae%12)+(uae<12?' AM':' PM'); | |
| return `<tr><td style="white-space:nowrap">${{ampm}}</td><td>${{bar(h.c/maxH*100)}} ${{h.c}}</td></tr>`; | |
| }}).join(''); | |
| const wd=d.weekday||[]; const maxW=Math.max(1,...wd.map(w=>w.c)); | |
| const busiestW=wd.slice().sort((a,b)=>b.c-a.c)[0]; | |
| const wdRows=wd.map(w=>`<tr><td>${{WEEKDAYS[parseInt(w.d,10)]||w.d}}</td><td>${{bar(w.c/maxW*100)}} ${{w.c}}</td></tr>`).join(''); | |
| return `<h3 style="margin:6px 0">3. When people use it</h3> | |
| <p style="font-size:.86rem;color:#4b5563">Times shown in UAE local time. Useful for deciding when human help should be available. | |
| ${{busiestW?'Busiest day of the week: <strong>'+(WEEKDAYS[parseInt(busiestW.d,10)]||'')+'</strong>.':''}}</p> | |
| <div style="display:flex;gap:24px;flex-wrap:wrap"> | |
| <div><h4 style="font-size:.85rem;margin:4px 0">By hour of day</h4><table><tbody>${{hourRows||'<tr><td>No data.</td></tr>'}}</tbody></table></div> | |
| <div><h4 style="font-size:.85rem;margin:4px 0">By day of week</h4><table><tbody>${{wdRows||'<tr><td>No data.</td></tr>'}}</tbody></table></div> | |
| </div>`; | |
| }} | |
| function reportSpeed(d){{ | |
| const t=d.totals||{{}}; | |
| const bands={{}}; | |
| (d.by_intent||[]).forEach(r=>{{ | |
| if(!r.avg_time) return; | |
| const b=speedBand(r.avg_time); | |
| bands[b]=(bands[b]||0)+r.c; | |
| }}); | |
| const totalTimed=Object.values(bands).reduce((a,b)=>a+b,0); | |
| const rows=Object.entries(bands).sort((a,b)=>b[1]-a[1]).map(([b,c])=> | |
| `<tr><td>${{b}}</td><td>${{c}}</td><td>${{bar(pct(c,totalTimed))}} ${{pct(c,totalTimed)}}%</td></tr>`).join(''); | |
| const slowest=(d.by_intent||[]).filter(r=>r.avg_time).sort((a,b)=>b.avg_time-a.avg_time).slice(0,5) | |
| .map(r=>`<tr><td>${{labelFor(r.k)}}</td><td>${{r.avg_time.toFixed(1)}} seconds</td></tr>`).join(''); | |
| return `<h3 style="margin:6px 0">4. How fast it answers</h3> | |
| <p style="font-size:.9rem;line-height:1.7">Average answer time across all questions: | |
| <strong>${{(t.avg_response_time||0).toFixed(1)}} seconds</strong>. | |
| Questions answered from our own guides are near-instant; questions that search | |
| outside databases take longer because they wait for those services.</p> | |
| <div style="display:flex;gap:24px;flex-wrap:wrap"> | |
| <div><h4 style="font-size:.85rem;margin:4px 0">Answer speed</h4> | |
| <table><thead><tr><th>Speed</th><th>Questions</th><th>Share</th></tr></thead><tbody>${{rows||'<tr><td colspan="3">No timing data.</td></tr>'}}</tbody></table></div> | |
| <div><h4 style="font-size:.85rem;margin:4px 0">Slowest categories</h4> | |
| <table><thead><tr><th>Type of question</th><th>Average</th></tr></thead><tbody>${{slowest||'<tr><td colspan="2">No data.</td></tr>'}}</tbody></table></div> | |
| </div>`; | |
| }} | |
| function reportGaps(d){{ | |
| const reps=d.daily_reports||[]; | |
| const clusters=[]; const lows=[]; let notAnswered=0; let reviewed=0; let llmErr=null; | |
| reps.forEach(r=>{{ | |
| (r.kb_gap_clusters||[]).forEach(c=>clusters.push(Object.assign({{}},c,{{date:r.report_date}}))); | |
| (r.low_relevance_samples||[]).forEach(x=>lows.push(Object.assign({{}},x,{{date:r.report_date}}))); | |
| const st=r.stats||{{}}; | |
| notAnswered+=(st.not_answered||0); reviewed+=(st.graded||0); | |
| if(st.llm_error) llmErr=st.llm_error; | |
| }}); | |
| const cRows=clusters.sort((a,b)=>(b.count||0)-(a.count||0)).slice(0,15).map(c=> | |
| `<tr><td>${{c.label||'(unlabelled)'}}</td><td>${{c.count||0}}</td> | |
| <td style="color:#6b7280;font-size:.78rem">${{(c.sample_questions||[]).slice(0,2).join(' | ')}}</td></tr>`).join(''); | |
| const lRows=lows.slice(0,15).map(x=> | |
| `<tr><td>${{(x.question||'').substring(0,90)}}</td><td>${{x.score}}</td> | |
| <td style="color:#6b7280;font-size:.78rem">${{x.reason||''}}</td></tr>`).join(''); | |
| let note=''; | |
| if(!reps.length){{ | |
| note='<div style="background:#fffbeb;border:1px solid #fde68a;padding:8px 10px;border-radius:8px;font-size:.82rem">' | |
| +'The nightly review has not produced results yet. It runs at 2am UAE and examines the ' | |
| +'previous day’s questions, so results appear the morning after a day with activity.</div>'; | |
| }} else if(llmErr){{ | |
| note='<div style="background:#fef2f2;border:1px solid #fecaca;padding:8px 10px;border-radius:8px;font-size:.82rem">' | |
| +'The most recent review could not complete: '+llmErr+'</div>'; | |
| }} | |
| return `<h3 style="margin:6px 0">5. Questions we could not answer well</h3> | |
| <p style="font-size:.9rem;line-height:1.7"> | |
| Each night an automated reviewer reads the previous day’s questions and answers and | |
| judges two things: whether the question was actually answered, and how well. | |
| Over the last ${{reps.length}} review(s), <strong>${{reviewed}}</strong> answers were examined and | |
| <strong>${{notAnswered}}</strong> were found not to answer the question — usually because | |
| the library guides contain nothing on that topic. Those become the themes below.</p> | |
| ${{note}} | |
| <h4 style="font-size:.85rem;margin:8px 0 4px">Themes worth adding to the guides</h4> | |
| <div style="overflow-x:auto"><table><thead><tr><th>Theme</th><th>Times asked</th><th>Examples</th></tr></thead><tbody>${{cRows||'<tr><td colspan="3">Nothing flagged.</td></tr>'}}</tbody></table></div> | |
| <h4 style="font-size:.85rem;margin:12px 0 4px">Answers judged weak on review</h4> | |
| <div style="overflow-x:auto"><table><thead><tr><th>Question</th><th>Score (0–1)</th><th>Reason</th></tr></thead><tbody>${{lRows||'<tr><td colspan="3">Nothing flagged.</td></tr>'}}</tbody></table></div>`; | |
| }} | |
| function reportHealth(d){{ | |
| const runs=d.canary||[]; | |
| const ok=runs.filter(r=>r.ok).length; | |
| const rows=runs.map(r=>`<tr><td style="white-space:nowrap">${{(r.run_at||'').substring(0,16)}}</td> | |
| <td>${{r.ok?'<span style="color:#047857;font-weight:700">Working</span>':'<span style="color:#dc2626;font-weight:700">Failed</span>'}}</td> | |
| <td style="max-width:260px">${{(r.question||'').substring(0,80)}}</td> | |
| <td>${{r.latency_ms!=null?(r.latency_ms/1000).toFixed(1)+'s':''}}</td></tr>`).join(''); | |
| return `<h3 style="margin:6px 0">6. Service health</h3> | |
| <p style="font-size:.9rem;line-height:1.7">Each night the system asks itself a real question and checks it gets a | |
| sensible answer. These checks are not counted in the usage figures above.<br> | |
| <strong>${{ok}} of the last ${{runs.length||0}} daily checks passed.</strong></p> | |
| <div style="overflow-x:auto"><table><thead><tr><th>Check</th><th>Result</th><th>Question asked</th><th>Time taken</th></tr></thead><tbody>${{rows||'<tr><td colspan="4">No checks recorded yet.</td></tr>'}}</tbody></table></div>`; | |
| }} | |
| const REPORTS={{overview:reportOverview,topics:reportTopics,when:reportWhen, | |
| speed:reportSpeed,gaps:reportGaps,health:reportHealth}}; | |
| async function runReport(){{ | |
| const out=document.getElementById('reportOut'); | |
| out.innerHTML='<div style="color:#6b7280;font-size:.84rem">Preparing reportβ¦</div>'; | |
| try{{ | |
| const d=await fetchReport(); | |
| const kind=document.getElementById('repKind').value; | |
| const periodTxt=document.getElementById('repPeriod').selectedOptions[0].text; | |
| let body; | |
| if(kind==='all') body=Object.values(REPORTS).map(f=>f(d)).join('<hr style="margin:16px 0;border:none;border-top:1px solid #e5e7eb">'); | |
| else body=REPORTS[kind](d); | |
| out.innerHTML='<div id="reportBody"><div style="font-size:.78rem;color:#6b7280;margin-bottom:8px">' | |
| +'KU Library β LibBee report Β· '+periodTxt+' Β· generated '+new Date().toLocaleString()+'</div>'+body+'</div>'; | |
| }}catch(e){{ | |
| out.innerHTML='<span style="color:#dc2626">Could not load report: '+cfEscape(e.message)+'</span>'; | |
| }} | |
| }} | |
| function printReport(){{ | |
| const el=document.getElementById('reportBody'); | |
| if(!el){{ alert('Show a report first.'); return; }} | |
| const w=window.open('','_blank'); | |
| if(!w){{ alert('Allow popups to print this report.'); return; }} | |
| w.document.write('<html><head><title>LibBee report</title><style>' | |
| +'body{{font-family:Segoe UI,system-ui,sans-serif;padding:24px;color:#111}}' | |
| +'table{{border-collapse:collapse;width:100%;margin:8px 0}}' | |
| +'th,td{{border:1px solid #ddd;padding:6px 8px;text-align:left;font-size:.85rem}}' | |
| +'th{{background:#f3f4f6}}h3{{color:#003366}}</style></head><body>' | |
| +el.innerHTML+'</body></html>'); | |
| w.document.close(); w.focus(); w.print(); | |
| }} | |
| function downloadCsv(){{ | |
| if(!REPORT_DATA){{ alert('Show a report first.'); return; }} | |
| const rows=[['Type of question','Questions','Share %','Average seconds']]; | |
| const total=(REPORT_DATA.totals||{{}}).queries||0; | |
| const grouped={{}}; | |
| (REPORT_DATA.by_intent||[]).forEach(r=>{{ | |
| const l=labelFor(r.k); | |
| if(!grouped[l]) grouped[l]={{c:0,t:0,n:0}}; | |
| grouped[l].c+=r.c; | |
| if(r.avg_time){{ grouped[l].t+=r.avg_time*r.c; grouped[l].n+=r.c; }} | |
| }}); | |
| Object.entries(grouped).sort((a,b)=>b[1].c-a[1].c).forEach(([l,v])=> | |
| rows.push([l,v.c,pct(v.c,total),v.n?(v.t/v.n).toFixed(2):''])); | |
| const csv=rows.map(r=>r.map(x=>'"'+String(x).replace(/"/g,'""')+'"').join(',')).join('\\n'); | |
| const a=document.createElement('a'); | |
| a.href=URL.createObjectURL(new Blob([csv],{{type:'text/csv'}})); | |
| a.download='libbee-report-'+new Date().toISOString().substring(0,10)+'.csv'; | |
| a.click(); | |
| }} | |
| async function loadMetrics(){{ | |
| try{{ | |
| const r=await fetch('/admin/metrics'); | |
| const d=await r.json(); | |
| const intents=d.intents||{{}}; | |
| const errors=d.errors||{{}}; | |
| let rows=Object.entries(intents).sort((a,b)=>b[1]-a[1]) | |
| .map(([k,v])=>`<tr><td>${{k}}</td><td>${{v}}</td><td style="color:#6b7280">intent</td></tr>`).join(''); | |
| rows+=Object.entries(errors).sort((a,b)=>b[1]-a[1]) | |
| .map(([k,v])=>`<tr><td style="color:#dc2626">${{k}}</td><td>${{v}}</td><td style="color:#6b7280">error</td></tr>`).join(''); | |
| document.getElementById('metricsTable').innerHTML=rows | |
| ?`<table><thead><tr><th>Key</th><th>Count</th><th>Type</th></tr></thead><tbody>${{rows}}</tbody></table>` | |
| :'<span style="color:#6b7280;font-size:.84rem">No session data yet.</span>'; | |
| }}catch(e){{document.getElementById('metricsTable').innerHTML='<span style="color:#dc2626">Could not load</span>';}} | |
| }} | |
| async function loadRAGInfo(){{ | |
| try{{ | |
| const r=await fetch('/admin/rag-status'); | |
| const d=await r.json(); | |
| document.getElementById('ragInfo').innerHTML= | |
| `<strong>Ready:</strong> ${{d.ready}} | <strong>Chunks:</strong> ${{d.chunk_count}} | <strong>KB Dir:</strong> ${{cfEscape(d.knowledge_dir)}}`; | |
| }}catch(e){{document.getElementById('ragInfo').textContent='Could not load RAG info.';}} | |
| }} | |
| async function saveConfig(){{ | |
| const keys=['welcome_message','custom_instructions','announcement','max_results','maintenance_mode','maintenance_message']; | |
| const settings={{}}; | |
| keys.forEach(k=>{{const el=document.getElementById('cfg_'+k);if(el)settings[k]=el.value;}}); | |
| const message=document.getElementById('saveMsg'); | |
| message.style.display='block'; | |
| message.textContent='Savingβ¦'; | |
| try{{ | |
| const r=await fetch('/admin/config-bulk',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{settings}})}}); | |
| const data=await r.json(); | |
| if(!r.ok) throw new Error(typeof data.detail==='string'?data.detail:'Please check the configuration values.'); | |
| message.style.color='#166534';message.textContent='Configuration saved.'; | |
| }}catch(error){{message.style.color='#b91c1c';message.textContent='Could not save: '+error.message;}} | |
| }} | |
| async function rebuildIndex(){{ | |
| const el=document.getElementById('rebuildMsg'); | |
| el.innerHTML='<span style="color:#d97706">Rebuildingβ¦ this may take 30β60 seconds</span>'; | |
| try{{ | |
| const r=await fetch('/admin/rebuild',{{method:'POST'}}); | |
| const d=await r.json(); | |
| if(d.status==='ok') el.innerHTML=`<span style="color:#16a34a">β Done β ${{d.chunks}} chunks rebuilt.</span>`; | |
| else el.innerHTML=`<span style="color:#dc2626">Error: ${{cfEscape(d.error||d.detail||'Request failed')}}</span>`; | |
| loadRAGInfo(); | |
| }}catch(e){{el.innerHTML=`<span style="color:#dc2626">Error: ${{cfEscape(e.message)}}</span>`;}} | |
| }} | |
| async function loadStatus(){{ | |
| const el=document.getElementById('sysStatus'); | |
| try{{ | |
| const r=await fetch('/admin/status'); | |
| const d=await r.json(); | |
| el.innerHTML=` | |
| <div class="stat-grid"> | |
| <div class="stat"><div class="val ${{d.rag_ready?'status-ok':'status-warn'}}">${{d.rag_ready?'β ':'β οΈ'}}</div><div class="lbl">RAG (${{d.rag_chunks||0}} chunks)</div></div> | |
| <div class="stat"><div class="val ${{d.openai?'status-ok':'status-warn'}}">${{d.openai?'β ':'β'}}</div><div class="lbl">OpenAI API</div></div> | |
| <div class="stat"><div class="val ${{d.anthropic?'status-ok':'status-warn'}}">${{d.anthropic?'β ':'β'}}</div><div class="lbl">Anthropic API</div></div> | |
| <div class="stat"><div class="val ${{d.primo?'status-ok':'status-warn'}}">${{d.primo?'β ':'β'}}</div><div class="lbl">PRIMO API</div></div> | |
| <div class="stat"><div class="val">${{d.kb_files||0}}</div><div class="lbl">KB Files</div></div> | |
| <div class="stat"><div class="val">${{Math.floor((d.uptime_secs||0)/60)}}m</div><div class="lbl">Uptime</div></div> | |
| <div class="stat"><div class="val">${{d.mem_used_mb||0}}MB</div><div class="lbl">Memory (${{d.mem_pct||0}}%)</div></div> | |
| <div class="stat"><div class="val ${{d.maintenance?'status-warn':'status-ok'}}">${{d.maintenance?'ON':'OFF'}}</div><div class="lbl">Maintenance</div></div> | |
| </div> | |
| <div style="margin-top:8px;font-size:.74rem;color:#6b7280">Last checked: ${{new Date().toLocaleTimeString()}}</div>`; | |
| }}catch(e){{ | |
| el.innerHTML='<span style="color:#dc2626">β Cannot reach server: '+cfEscape(e.message)+'</span>'; | |
| }} | |
| }} | |
| loadStats(); | |
| loadCFAnalytics(); | |
| loadRecentQueries(); | |
| document.getElementById('repRun').onclick=runReport; | |
| document.getElementById('repPrint').onclick=printReport; | |
| document.getElementById('repCsv').onclick=downloadCsv; | |
| runReport(); | |
| loadMetrics(); | |
| loadRAGInfo(); | |
| loadStatus(); | |
| setInterval(loadStatus,30000); | |
| setInterval(loadCFAnalytics,60000); | |
| setInterval(loadRecentQueries,60000); | |
| </script> | |
| </body></html>""") | |
| # ββ API Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def public_config(): | |
| """No auth β returns public fields for the chat frontend.""" | |
| try: | |
| cfg = _load_config() | |
| except Exception: | |
| cfg = get_settings().runtime_defaults | |
| return { | |
| "welcome_message": str(cfg.get("welcome_message", ""))[:500], | |
| "announcement": str(cfg.get("announcement", ""))[:500], | |
| "maintenance_mode": bool(cfg.get("maintenance_mode", False)), | |
| "maintenance_message": str(cfg.get("maintenance_message", ""))[:300], | |
| } | |
| async def worker_browser_token(request: Request): | |
| """Issue a short-lived read-only Worker token to a logged-in administrator.""" | |
| _require_session(request) | |
| settings = get_settings() | |
| worker_url = _clean_worker_url(settings.cloudflare_worker_url) | |
| if not worker_url: | |
| raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL is missing or invalid") | |
| if not settings.cloudflare_admin_token: | |
| raise HTTPException(status_code=503, detail="CLOUDFLARE_ADMIN_TOKEN is not configured") | |
| token, expires_at = _make_worker_browser_token(settings.cloudflare_admin_token) | |
| return { | |
| "worker_url": worker_url, | |
| "token": token, | |
| "expires_at": expires_at, | |
| "scope": "analytics:read", | |
| } | |
| async def get_config(request: Request): | |
| _require_session(request) | |
| return _load_config() | |
| async def update_config(req: ConfigUpdate, request: Request): | |
| _require_session(request) | |
| cleaned = _validated_config_values({req.key: req.value}) | |
| data = _config_store().load() | |
| data.update(cleaned) | |
| _config_store().save(data) | |
| return {"status": "ok", "updated": req.key, "value": cleaned[req.key]} | |
| async def update_config_bulk(req: ConfigBulkUpdate, request: Request): | |
| _require_session(request) | |
| # Validate everything before writing; invalid values used to be skipped | |
| # silently even while the dashboard displayed "Saved". | |
| cleaned = _validated_config_values(req.settings) | |
| data = _config_store().load() | |
| data.update(cleaned) | |
| _config_store().save(data) | |
| return {"status": "ok", "updated": sorted(cleaned)} | |
| # ββ RAG Management Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def rag_status(request: Request): | |
| _require_session(request) | |
| try: | |
| from app import get_rag_service | |
| return get_rag_service().get_stats() | |
| except Exception as e: | |
| return {"error": str(e), "ready": False} | |
| async def rebuild_rag(request: Request): | |
| _require_session(request) | |
| try: | |
| from app import get_rag_service | |
| svc = get_rag_service() | |
| settings = get_settings() | |
| await svc.initialize(openai_api_key=settings.openai_api_key, force_rebuild=True) | |
| if not svc.is_ready(): | |
| return {"status": "error", "error": "No usable knowledge index. Check knowledge files and server logs.", "ready": False} | |
| return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": True, | |
| "retrieval_mode": svc.get_stats().get("retrieval_mode", "unknown")} | |
| except Exception as e: | |
| logger.error("Admin index rebuild failed: %s", type(e).__name__) | |
| return {"status": "error", "error": "Index rebuild failed. Check server logs."} | |
| # ββ Metrics & Status Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def metrics(request: Request): | |
| _require_session(request) | |
| try: | |
| from app import get_metrics_service | |
| return get_metrics_service().snapshot() | |
| except Exception as e: | |
| return {"error": str(e)} | |
| async def system_status(request: Request): | |
| _require_session(request) | |
| settings = get_settings() | |
| mem_used_mb = mem_pct = 0 | |
| try: | |
| with open("/sys/fs/cgroup/memory.current") as f: | |
| used = int(f.read().strip()) | |
| with open("/sys/fs/cgroup/memory.max") as f: | |
| val = f.read().strip() | |
| total = int(val) if val != "max" else 16 * 1024 ** 3 | |
| mem_used_mb = round(used / 1024 / 1024) | |
| mem_pct = round(used / total * 100) | |
| except Exception: | |
| try: | |
| with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f: | |
| used = int(f.read().strip()) | |
| with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f: | |
| total = int(f.read().strip()) | |
| mem_used_mb = round(used / 1024 / 1024) | |
| mem_pct = round(used / total * 100) | |
| except Exception: | |
| pass | |
| rag_ready = False | |
| rag_chunks = 0 | |
| kb_files = 0 | |
| try: | |
| from app import get_rag_service | |
| svc = get_rag_service() | |
| rag_ready = svc.is_ready() | |
| rag_chunks = len(svc.bm25_corpus) | |
| kb_dir = settings.kb_dir | |
| kb_files = len(list(kb_dir.glob("*.txt"))) if kb_dir.exists() else 0 | |
| except Exception: | |
| pass | |
| cfg = _load_config() | |
| return { | |
| "status": "ok", | |
| "rag_ready": rag_ready, | |
| "rag_chunks": rag_chunks, | |
| "kb_files": kb_files, | |
| "openai": bool(settings.openai_api_key), | |
| "anthropic": bool(settings.anthropic_api_key), | |
| "primo": bool(settings.primo_api_key), | |
| "mem_used_mb": mem_used_mb, | |
| "mem_pct": mem_pct, | |
| "uptime_secs": round(time.time() - _START_TIME), | |
| "maintenance": bool(cfg.get("maintenance_mode")), | |
| "version": LIBBEE_VERSION, | |
| } | |
| # ββ Daily KB-gap / relevance reports (generated by the Cloudflare Worker) βββββ | |
| # The Worker's daily cron writes one report row per day; these endpoints proxy | |
| # them into the admin dashboard. Session-protected like every admin route, and | |
| # the Worker itself additionally requires the shared bearer token. | |
| async def admin_reports(request: Request, days: int = 14): | |
| _require_session(request) | |
| return await _worker_get("/reports", {"days": max(1, min(days, 60))}) | |
| async def admin_report_reviewed(report_id: int, request: Request): | |
| _require_session(request) | |
| import httpx as _hx | |
| from src.config import get_settings as _gs | |
| settings = _gs() | |
| worker = _clean_worker_url(settings.cloudflare_worker_url) | |
| if not worker: | |
| raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") | |
| headers = {} | |
| if settings.cloudflare_admin_token: | |
| headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" | |
| try: | |
| async with _hx.AsyncClient(timeout=10) as client: | |
| r = await client.post(f"{worker}/reports/{int(report_id)}/reviewed", headers=headers) | |
| if r.status_code >= 400: | |
| raise HTTPException(status_code=502, detail=f"Worker returned HTTP {r.status_code}") | |
| return r.json() | |
| except Exception as exc: | |
| raise HTTPException(status_code=502, detail="Worker unavailable. Check the configured URL and server logs.") | |
| # ββ Analytics proxies ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # The dashboard fetches Worker analytics through these session-protected | |
| # proxies so the Worker URL and ADMIN_TOKEN never reach the browser. | |
| async def _worker_get(path: str, params: Optional[dict] = None): | |
| from src.services.http_client import make_client as _mk | |
| from src.config import get_settings as _gs | |
| settings = _gs() | |
| worker = _clean_worker_url(settings.cloudflare_worker_url) | |
| if not worker: | |
| raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") | |
| headers = {} | |
| if settings.cloudflare_admin_token: | |
| headers["Authorization"] = f"Bearer {settings.cloudflare_admin_token}" | |
| try: | |
| async with _mk(timeout=8) as client: | |
| r = await client.get(f"{worker}{path}", params=params or {}, headers=headers) | |
| if r.status_code >= 400: | |
| raise HTTPException(status_code=502, detail=f"Worker returned HTTP {r.status_code}") | |
| return _escape_untrusted_json(r.json()) | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| raise HTTPException(status_code=502, detail="Worker unavailable. Check the configured URL and server logs.") | |
| async def admin_analytics(request: Request): | |
| _require_session(request) | |
| return await _worker_get("/analytics") | |
| async def admin_analytics_recent(request: Request): | |
| _require_session(request) | |
| return await _worker_get("/analytics/recent") | |
| async def admin_worker_health(request: Request): | |
| """Diagnose the backend -> Worker -> D1 chain. | |
| Every probe runs independently and records its own outcome, including the | |
| exception type, so one failure never hides the rest. Also reports the exact | |
| URL being used (whitespace or quotes in the secret are a common cause). | |
| """ | |
| _require_session(request) | |
| import asyncio as _aio | |
| from src.services.http_client import make_client as _mk | |
| from src.config import get_settings as _gs, LIBBEE_VERSION as _ver | |
| settings = _gs() | |
| raw = settings.cloudflare_worker_url or "" | |
| worker = _clean_worker_url(raw) | |
| token = (settings.cloudflare_admin_token or "").strip() | |
| out = { | |
| "backend_version": _ver, | |
| "worker_url_configured": bool(worker), | |
| "worker_url_used": worker, | |
| "worker_url_had_whitespace_or_quotes": raw != worker + ("/" if raw.endswith("/") else ""), | |
| "worker_token_configured": bool(token), | |
| "worker_token_length": len(token), | |
| "probes": {}, | |
| "verdict": "", | |
| } | |
| if not worker: | |
| out["verdict"] = "CLOUDFLARE_WORKER_URL is not set in the Space secrets." | |
| return out | |
| headers = {"Authorization": f"Bearer {token}"} if token else {} | |
| async def probe(name, path, use_auth=True): | |
| """One probe, its own short timeout, never raises.""" | |
| entry = {"path": path} | |
| try: | |
| async with _mk(timeout=8) as client: | |
| r = await client.get(f"{worker}{path}", | |
| headers=headers if use_auth else {}) | |
| entry["status"] = r.status_code | |
| entry["body"] = r.text[:300] | |
| except Exception as exc: | |
| entry["error_type"] = type(exc).__name__ | |
| entry["error"] = repr(exc)[:200] or type(exc).__name__ | |
| out["probes"][name] = entry | |
| return entry | |
| # Run all probes concurrently so the endpoint returns in ~6s, not ~48s. | |
| ver, _hours, ana, sch = await _aio.gather( | |
| probe("version", "/version", use_auth=False), | |
| probe("hours", "/hours", use_auth=False), | |
| probe("analytics", "/analytics"), | |
| probe("schema", "/analytics/schema"), | |
| ) | |
| if request.query_params.get("test_write") == "1": | |
| out["canary_note"] = "Use POST /admin/canary-run to run a canary. This diagnostic GET is read-only." | |
| # Interpretation | |
| if "error_type" in ver and "error_type" in out["probes"]["hours"]: | |
| et = ver.get("error_type") | |
| if et in ("ConnectTimeout", "ConnectError", "ReadTimeout"): | |
| out["verdict"] = ( | |
| f"{et}: the HF server cannot open a connection to workers.dev. " | |
| f"In v4.1 this legacy probe may continue to fail: the logged-in " | |
| f"admin browser reads D1 with a 90-second token instead, and HF " | |
| f"calls LibCal directly. Verify the dashboard rather than using " | |
| f"this server-to-server probe as the acceptance test." | |
| ) | |
| else: | |
| out["verdict"] = ( | |
| f"No HTTP response from the Worker ({et}). Check that " | |
| f"worker_url_used above is exactly your workers.dev address." | |
| ) | |
| elif ver.get("status") == 404: | |
| out["verdict"] = ( | |
| "Worker responds but has no /version route. Deploy the v4 secure gateway." | |
| ) | |
| elif ana.get("status") == 401: | |
| out["verdict"] = ( | |
| "Analytics returns 401. CLOUDFLARE_ADMIN_TOKEN does not match the " | |
| "Worker's ADMIN_TOKEN (check for trailing spaces in either)." | |
| ) | |
| elif ana.get("status") == 200: | |
| migrated = None | |
| if sch.get("status") == 200: | |
| try: | |
| import json as _json | |
| migrated = (_json.loads(sch["body"]) or {}).get("migration_applied") | |
| except Exception: | |
| migrated = None | |
| if migrated is False: | |
| out["verdict"] = ("Connected, but the D1 migration is not applied " | |
| "(answer_excerpt/source_ids missing).") | |
| else: | |
| out["verdict"] = "OK - backend, Worker and D1 analytics are connected." | |
| else: | |
| out["verdict"] = (f"Unexpected analytics status {ana.get('status')}. " | |
| f"See probes for details.") | |
| return out | |
| async def admin_canary_run(request: Request): | |
| """Explicit admin action; never start a paid model request from a GET.""" | |
| _require_session(request) | |
| settings = get_settings() | |
| worker = _clean_worker_url(settings.cloudflare_worker_url) | |
| if not worker or not settings.cloudflare_admin_token: | |
| raise HTTPException(status_code=503, detail="Worker admin access is not configured") | |
| from src.services.http_client import make_client | |
| try: | |
| async with make_client(timeout=190, retries=0) as client: | |
| response = await client.post( | |
| f"{worker}/canary/run", | |
| headers={"Authorization": f"Bearer {settings.cloudflare_admin_token}"}, | |
| ) | |
| if response.status_code >= 400: | |
| raise HTTPException(status_code=502, detail=f"Worker returned HTTP {response.status_code}") | |
| return response.json() | |
| except HTTPException: | |
| raise | |
| except Exception as exc: | |
| logger.warning("Worker canary request failed: %s", type(exc).__name__) | |
| raise HTTPException(status_code=502, detail="Worker canary unavailable") from exc | |
| async def admin_log_status(request: Request): | |
| """Outcome of recent analytics writes, without needing container logs. | |
| 'recent_attempts' is newest-first. Read it like this: | |
| ok -> the write reached the Worker (HTTP 2xx) | |
| throttled -> HTTP 403/429: the host is being rate-limited by Cloudflare | |
| unreachable -> no HTTP response; exception type is in 'detail' | |
| rejected_401 -> CLOUDFLARE_ADMIN_TOKEN does not match ADMIN_TOKEN | |
| Buffered rows are retried automatically on later queries. | |
| """ | |
| _require_session(request) | |
| from src.agentcore.orchestrator import analytics_log_status | |
| from src.config import get_settings as _gs | |
| st = _gs() | |
| data = analytics_log_status() | |
| data["worker_url_configured"] = bool(st.cloudflare_worker_url) | |
| data["worker_token_configured"] = bool(st.cloudflare_admin_token) | |
| if not data["recent_attempts"]: | |
| data["hint"] = ("No write attempts recorded since the last restart. " | |
| "Send a query through LibBee, then reload this page.") | |
| return data | |
| async def admin_canary_status(request: Request): | |
| """Last 14 daily canary runs (the Worker's scheduled health probe). | |
| Each run is one real question through the full pipeline, chosen from a | |
| fixed 14-question pool rotating by day-of-year. Canary traffic is excluded | |
| from analytics, so these runs never appear in the D1 usage data. | |
| """ | |
| _require_session(request) | |
| return await _worker_get("/canary/status") | |