LibBee / src /api /admin.py
nikeshn's picture
Upload admin.py
04328b2 verified
Raw
History Blame Contribute Delete
39.6 kB
"""
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)
"""
# ── Imports ────────────────────────────────────────────────────────────────────
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.",
}
# ── 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, ...]
_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:
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)
# ── Config Store ───────────────────────────────────────────────────────────────
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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
# ── Pydantic Models ────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
password: str
class ConfigUpdate(BaseModel):
key: str
value: str
class ConfigBulkUpdate(BaseModel):
settings: Dict[str, Any]
# ── Auth Endpoints ─────────────────────────────────────────────────────────────
@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()
# ── Lockout check ─────────────────────────────────────────────────────────
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."
)
# ── 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:
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)
# ── Success — clear attempts and issue session ─────────────────────────────
_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
# ── Login Page ─────────────────────────────────────────────────────────────────
@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>""")
# ── Admin Dashboard ────────────────────────────────────────────────────────────
@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"
# Worker credentials injected into this page only. The dashboard tries the
# server-side proxy first and falls back to a direct browser -> Worker call,
# because some hosting environments (HF Spaces) cannot reach workers.dev
# while the admin's own browser can. This page is served exclusively to an
# authenticated admin session, so the token is not publicly exposed.
_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}} &nbsp;|&nbsp; <strong>Chunks:</strong> ${{d.chunk_count}} &nbsp;|&nbsp; <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>""")
# ── API Endpoints ──────────────────────────────────────────────────────────────
@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())}
# ── RAG Management Endpoints ───────────────────────────────────────────────────
@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 = "" # force fresh build
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)}
# ── Metrics & Status Endpoints ─────────────────────────────────────────────────
@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,
}
# ── 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.
@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}")
# ── Analytics proxies ────────────────────────────────────────────────────────
# The dashboard fetches Worker analytics through these session-protected
# proxies so the Worker URL and ANALYTICS_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 = (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
# 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"),
)
# Optional write probe: ?test_write=1 posts one marker row so a POST can be
# compared against the GETs above. It inserts a row tagged 'healthcheck'.
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
# 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 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