| """ |
| admin.py — LibBee v3.8 |
| Admin dashboard: session management, config store, RAG rebuild, metrics, system status. |
| |
| Changes over v3.2: |
| - 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) |
| """ |
|
|
| |
|
|
| import json |
| import os |
| import secrets |
| import secrets |
| import time |
| from typing import Any, Dict, Optional |
|
|
| from fastapi import APIRouter, HTTPException, Request, Response |
| from fastapi.responses import HTMLResponse, RedirectResponse |
| from pydantic import BaseModel |
|
|
| from src.config import get_settings, LIBBEE_VERSION |
| from src.services.runtime_store import JsonRuntimeStore |
|
|
| router = APIRouter() |
|
|
| _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.", |
| } |
|
|
|
|
| |
|
|
| admin_sessions: Dict[str, float] = {} |
|
|
| |
| |
| |
| |
| _login_attempts: Dict[str, list] = {} |
| _MAX_LOGIN_ATTEMPTS = 5 |
| _LOCKOUT_WINDOW = 300 |
| _LOCKOUT_DURATION = 900 |
|
|
|
|
| def _sessions_file() -> str: |
| cfg_path = os.environ.get("CONFIG_STORE_PATH", "") |
| if cfg_path and "/data" in cfg_path: |
| return os.path.join(os.path.dirname(cfg_path), "admin_sessions.json") |
| return "/tmp/libbee_admin_sessions.json" |
|
|
|
|
| def _load_sessions() -> None: |
| global admin_sessions |
| try: |
| with open(_sessions_file(), "r", encoding="utf-8") as f: |
| data = json.load(f) |
| now = time.time() |
| valid = {t: exp for t, exp in data.items() |
| if isinstance(exp, (int, float)) and exp > now} |
| admin_sessions.update(valid) |
| except Exception: |
| pass |
|
|
|
|
| def _save_sessions() -> None: |
| try: |
| now = time.time() |
| valid = {t: exp for t, exp in admin_sessions.items() if exp > now} |
| with open(_sessions_file(), "w", encoding="utf-8") as f: |
| json.dump(valid, f) |
| except Exception: |
| pass |
|
|
|
|
| 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) |
|
|
|
|
| |
|
|
| def _config_store() -> JsonRuntimeStore: |
| settings = get_settings() |
| return JsonRuntimeStore(settings.config_path, default=_CONFIG_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('"', """) |
| ) |
|
|
|
|
| |
|
|
| class LoginRequest(BaseModel): |
| password: str |
|
|
|
|
| class ConfigUpdate(BaseModel): |
| key: str |
| value: str |
|
|
|
|
| class ConfigBulkUpdate(BaseModel): |
| settings: Dict[str, Any] |
|
|
|
|
| |
|
|
| @router.post("/auth") |
| async def admin_auth(req: LoginRequest, request: Request): |
| settings = get_settings() |
| client_ip = request.client.host if request.client else "unknown" |
| now = time.time() |
|
|
| |
| attempts = _login_attempts.get(client_ip, []) |
| attempts = [t for t in attempts if now - t < _LOCKOUT_WINDOW] |
| if len(attempts) >= _MAX_LOGIN_ATTEMPTS: |
| wait = int(_LOCKOUT_DURATION - (now - attempts[0])) |
| raise HTTPException( |
| status_code=429, |
| detail=f"Too many failed login attempts. Try again in {wait} seconds." |
| ) |
|
|
| |
| 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: |
| 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=401, detail=detail) |
|
|
| |
| _login_attempts.pop(client_ip, None) |
| 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=True) |
| return resp |
|
|
|
|
| @router.post("/logout") |
| async def admin_logout_post(request: Request): |
| 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 |
|
|
|
|
| @router.get("/logout") |
| async def admin_logout_get(request: Request): |
| token = request.cookies.get("admin_session") |
| if token: |
| admin_sessions.pop(token, None) |
| resp = RedirectResponse(url="/admin/login") |
| resp.delete_cookie("admin_session") |
| return resp |
|
|
|
|
| |
|
|
| @router.get("/login", response_class=HTMLResponse) |
| 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" 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; |
| const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})}); |
| 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>""") |
|
|
|
|
| |
|
|
| @router.get("", response_class=HTMLResponse) |
| 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" |
|
|
| |
| |
| |
| |
| |
| _s = get_settings() |
| cf_url = (_s.cloudflare_worker_url or "").strip().strip('"').strip("'").rstrip("/") |
| cf_token = (_s.cloudflare_worker_token or "").strip() |
|
|
| 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>🔌 System Status</h2> |
| <div id="sysStatus"><div style="color:#6b7280;font-size:.84rem">Loading…</div></div> |
| </div> |
| |
| </div> |
| <script> |
| const CF_URL = "{cf_url}"; |
| const CF_TOKEN = "{cf_token}"; |
| |
| // Two-path analytics fetch. The backend proxy keeps credentials server-side and |
| // is tried first; if the backend cannot reach the Worker (a known limitation on |
| // some hosts) we retry directly from this browser, which can. |
| // Browser-FIRST: this browser reaches the Worker reliably and fast, whereas the |
| // host's own egress to workers.dev is unreliable. The server-side proxy is kept |
| // as a fallback for environments where the browser is the restricted one. |
| async function cfFetch(path){{ |
| if(CF_URL){{ |
| try{{ |
| const h = CF_TOKEN ? {{'Authorization':'Bearer '+CF_TOKEN}} : {{}}; |
| const r = await fetch(CF_URL+path, {{headers:h}}); |
| const d = await r.json(); |
| if(r.ok && !d.error) return {{data:d, via:'direct'}}; |
| if(d.error) throw new Error(d.error.message||'Worker error'); |
| }}catch(e){{ /* fall through to the backend proxy */ }} |
| }} |
| const r2 = await fetch('/admin'+path); |
| const d2 = await r2.json(); |
| if(!r2.ok||d2.detail) throw new Error('HTTP '+r2.status+' '+(d2.detail||'')); |
| if(d2.error) throw new Error(d2.error.message||JSON.stringify(d2.error)); |
| return {{data:d2, via:'backend proxy'}}; |
| }} |
| |
| 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: '+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">'+e.message+'</span>'; |
| }} |
| }} |
| |
| 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> ${{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 r=await fetch('/admin/config-bulk',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{settings}})}}); |
| if(r.ok){{const m=document.getElementById('saveMsg');m.style.display='block';setTimeout(()=>m.style.display='none',3000);}} |
| }} |
| |
| 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: ${{d.error}}</span>`; |
| loadRAGInfo(); |
| }}catch(e){{el.innerHTML=`<span style="color:#dc2626">Error: ${{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: '+e.message+'</span>'; |
| }} |
| }} |
| |
| loadStats(); |
| loadCFAnalytics(); |
| loadRecentQueries(); |
| loadMetrics(); |
| loadRAGInfo(); |
| loadStatus(); |
| setInterval(loadStatus,30000); |
| setInterval(loadCFAnalytics,60000); |
| setInterval(loadRecentQueries,60000); |
| </script> |
| </body></html>""") |
|
|
|
|
| |
|
|
| @router.get("/public-config") |
| async def public_config(): |
| """No auth — returns public fields for the chat frontend.""" |
| try: |
| cfg = _load_config() |
| except Exception: |
| cfg = {} |
| return { |
| "welcome_message": cfg.get("welcome_message", ""), |
| "announcement": cfg.get("announcement", ""), |
| "maintenance_mode": cfg.get("maintenance_mode", False), |
| "maintenance_message": cfg.get("maintenance_message", ""), |
| } |
|
|
|
|
| @router.get("/config") |
| async def get_config(request: Request): |
| _require_session(request) |
| return _load_config() |
|
|
|
|
| @router.post("/config") |
| async def update_config(req: ConfigUpdate, request: Request): |
| _require_session(request) |
| allowed = {"max_results", "maintenance_mode", "welcome_message", |
| "custom_instructions", "announcement", "maintenance_message"} |
| if req.key not in allowed: |
| raise HTTPException(status_code=400, |
| detail=f"Unsupported key. Allowed: {sorted(allowed)}") |
| data = _config_store().load() |
| value: Any = req.value |
| if req.key == "max_results": |
| value = int(req.value) |
| elif req.key == "maintenance_mode": |
| value = str(req.value).lower() in {"1", "true", "yes", "on"} |
| data[req.key] = value |
| _config_store().save(data) |
| return {"status": "ok", "updated": req.key, "value": value} |
|
|
|
|
| @router.post("/config-bulk") |
| async def update_config_bulk(req: ConfigBulkUpdate, request: Request): |
| _require_session(request) |
| data = _config_store().load() |
| for key, value in req.settings.items(): |
| if key == "max_results": |
| try: |
| data[key] = int(value) |
| except (ValueError, TypeError): |
| pass |
| elif key == "maintenance_mode": |
| data[key] = str(value).lower() in {"1", "true", "yes", "on"} |
| else: |
| data[key] = str(value) |
| _config_store().save(data) |
| return {"status": "ok", "updated": list(req.settings.keys())} |
|
|
|
|
| |
|
|
| @router.get("/rag-status") |
| 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} |
|
|
|
|
| @router.post("/rebuild") |
| async def rebuild_rag(request: Request): |
| _require_session(request) |
| try: |
| from app import get_rag_service |
| svc = get_rag_service() |
| settings = get_settings() |
| if not settings.openai_api_key: |
| return {"status": "error", "error": "OPENAI_API_KEY not set"} |
| svc._kb_hash = "" |
| await svc.initialize(openai_api_key=settings.openai_api_key) |
| return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": svc.is_ready()} |
| except Exception as e: |
| return {"status": "error", "error": str(e)} |
|
|
|
|
| |
|
|
| @router.get("/metrics") |
| 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)} |
|
|
|
|
| @router.get("/status") |
| 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, |
| } |
|
|
| |
| |
| |
| |
|
|
| @router.get("/reports") |
| async def admin_reports(request: Request, days: int = 14): |
| _require_session(request) |
| return await _worker_get("/reports", {"days": max(1, min(days, 60))}) |
|
|
|
|
| @router.post("/reports/{report_id}/reviewed") |
| 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 = (settings.cloudflare_worker_url or "").rstrip("/") |
| if not worker: |
| raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") |
| headers = {} |
| if settings.cloudflare_worker_token: |
| headers["Authorization"] = f"Bearer {settings.cloudflare_worker_token}" |
| try: |
| async with _hx.AsyncClient(timeout=10) as client: |
| r = await client.post(f"{worker}/reports/{int(report_id)}/reviewed", headers=headers) |
| return r.json() |
| except Exception as exc: |
| raise HTTPException(status_code=502, detail=f"Worker unreachable: {exc}") |
|
|
| |
| |
| |
|
|
| 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 = (settings.cloudflare_worker_url or "").rstrip("/") |
| if not worker: |
| raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured") |
| headers = {} |
| if settings.cloudflare_worker_token: |
| headers["Authorization"] = f"Bearer {settings.cloudflare_worker_token}" |
| try: |
| async with _mk(timeout=8) as client: |
| r = await client.get(f"{worker}{path}", params=params or {}, headers=headers) |
| return r.json() |
| except HTTPException: |
| raise |
| except Exception as exc: |
| raise HTTPException(status_code=502, detail=f"Worker unreachable: {exc}") |
|
|
|
|
| @router.get("/analytics") |
| async def admin_analytics(request: Request): |
| _require_session(request) |
| return await _worker_get("/analytics") |
|
|
|
|
| @router.get("/analytics/recent") |
| async def admin_analytics_recent(request: Request): |
| _require_session(request) |
| return await _worker_get("/analytics/recent") |
|
|
| @router.get("/worker-health") |
| 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 = raw.strip().strip('"').strip("'").rstrip("/") |
| token = (settings.cloudflare_worker_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 |
|
|
| |
| 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": |
| entry = {"path": "/log", "method": "POST"} |
| try: |
| async with _mk(timeout=10) as client: |
| r = await client.post( |
| f"{worker}/log", headers=headers, |
| json={"question": "healthcheck", "tool": "healthcheck:probe", |
| "model": "n/a", "response_time": 0, "result_count": 0}, |
| ) |
| entry["status"] = r.status_code |
| entry["body"] = r.text[:200] |
| except Exception as exc: |
| entry["error_type"] = type(exc).__name__ |
| entry["error"] = repr(exc)[:200] or type(exc).__name__ |
| out["probes"]["log_write"] = entry |
|
|
| |
| 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 Space cannot open a connection to workers.dev, even " |
| f"on unauthenticated routes. Other outbound calls (OpenAI, " |
| f"OpenAlex) work, so this is host-specific - typically an " |
| f"unroutable IPv6 path or Cloudflare throttling shared " |
| f"workers.dev addresses from datacentre IPs. IPv4 is now forced; " |
| f"if this persists, put the Worker behind a custom domain " |
| f"(Worker > Settings > Domains & Routes > Add custom domain) and " |
| f"point CLOUDFLARE_WORKER_URL at that instead." |
| ) |
| 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: the deployed script is " |
| "still the pre-3.8 version. Deploy cloudflare-worker-v3.8.1.js." |
| ) |
| elif ana.get("status") == 401: |
| out["verdict"] = ( |
| "Analytics returns 401. CLOUDFLARE_WORKER_TOKEN does not match the " |
| "Worker's ANALYTICS_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
|
|
|
| @router.get("/log-status")
|
| 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_WORKER_TOKEN does not match ANALYTICS_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_worker_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
|
|
|
|
|