researchbee / admin.py
nikeshn's picture
Upload 50 files
4b2991e verified
Raw History Blame Contribute Delete
36.1 kB
"""
admin.py β€” ResearchBee admin dashboard
Ported from LibBee v3.8's admin module so the two dashboards behave
identically: same cookie-session login, same 5-attempt IP lockout, same
server-rendered single page, same visual language. Someone who
administers LibBee should not have to learn a second interface.
What is deliberately different
------------------------------
LibBee's dashboard shows recent questions. This one cannot, because
ResearchBee analytics contain counts, not question text β€” see usage.py. The equivalent
panels here answer "how much of each tool is being used" and "how often
could we not answer", both from counters.
Mount with:
from admin import router as admin_router
app.include_router(admin_router, prefix="/admin")
Environment:
RESEARCHBEE_ADMIN_PASSWORD required β€” the dashboard is unreachable without it
CLOUDFLARE_WORKER_URL the LibBee Worker (shared)
CLOUDFLARE_WORKER_TOKEN must equal the Worker's ANALYTICS_TOKEN
"""
from __future__ import annotations
import json
import hashlib
import hmac
import logging
import math
import stat
import tempfile
from pathlib import Path
import os
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, Field
from runtime_security import get_client_ip
router = APIRouter()
logger = logging.getLogger(__name__)
_NO_STORE = {"Cache-Control": "no-store", "Pragma": "no-cache"}
_START_TIME = time.time()
ADMIN_PASSWORD = (os.environ.get("RESEARCHBEE_ADMIN_PASSWORD") or "").strip()
WORKER_URL = (os.environ.get("CLOUDFLARE_WORKER_URL") or "").strip().strip('"').strip("'").rstrip("/")
WORKER_TOKEN = (os.environ.get("CLOUDFLARE_WORKER_TOKEN") or "").strip()
_SESSIONS_FILE = os.getenv("RB_ADMIN_SESSIONS_FILE", "/tmp/researchbee_admin_sessions.json")
_MAX_SESSIONS = 256
_SESSION_TTL = 86400
admin_sessions: Dict[str, float] = {}
# Failed logins per IP. Five misses inside the window locks that IP out
# for fifteen minutes β€” enough to make guessing a shared password
# impractical without locking out a librarian who fat-fingers it twice.
_login_attempts: Dict[str, list] = {}
_locked_until: Dict[str, float] = {}
_MAX_LOGIN_IDENTITIES = 4096
_MAX_LOGIN_ATTEMPTS = 5
_LOCKOUT_WINDOW = 300
_LOCKOUT_DURATION = 900
# ── Sessions ──────────────────────────────────────────────────────────
def _session_signature(sessions: dict) -> str:
payload = json.dumps(sessions, sort_keys=True, separators=(",", ":")).encode()
return hmac.new(ADMIN_PASSWORD.encode(), payload, hashlib.sha256).hexdigest()
def _load_sessions() -> None:
"""Read the password-bound v2 envelope; old/rotated sessions fail closed."""
admin_sessions.clear()
if not ADMIN_PASSWORD:
return
try:
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
fd = os.open(_SESSIONS_FILE, flags)
with os.fdopen(fd, "r", encoding="utf-8") as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > 131072:
return
data = json.load(handle)
if not isinstance(data, dict) or data.get("version") != 2:
return
sessions = data.get("sessions")
signature = data.get("signature")
if not isinstance(sessions, dict) or len(sessions) > _MAX_SESSIONS:
return
if not isinstance(signature, str) or not hmac.compare_digest(
signature.encode(), _session_signature(sessions).encode()
):
return
now = time.time()
admin_sessions.update({
token: expiry for token, expiry in sessions.items()
if isinstance(token, str) and len(token) == 64
and all(c in "0123456789abcdef" for c in token)
and isinstance(expiry, (int, float)) and not isinstance(expiry, bool)
and math.isfinite(expiry) and now < expiry <= now + _SESSION_TTL
})
except (OSError, ValueError, TypeError):
# Missing, malformed or inaccessible session state never grants access.
return
def _save_sessions() -> bool:
"""Atomic replacement in the same directory; bearer tokens are mode 0600."""
temp_path = None
try:
now = time.time()
sessions = {token: expiry for token, expiry in admin_sessions.items() if expiry > now}
envelope = {"version": 2, "sessions": sessions,
"signature": _session_signature(sessions)}
target = Path(_SESSIONS_FILE)
fd, temp_path = tempfile.mkstemp(prefix=target.name + ".", dir=str(target.parent))
with os.fdopen(fd, "w", encoding="utf-8") as handle:
os.fchmod(handle.fileno(), 0o600)
json.dump(envelope, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, target)
return True
except (OSError, TypeError, ValueError) as exc:
logger.warning("Admin session persistence failed (%s)", type(exc).__name__)
return False
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
def _cleanup_sessions() -> None:
# Reload authoritative state so logout is not undone by a stale copy.
# This envelope verifies with the CURRENT configured password, so a Space
# restart following password rotation invalidates all previous sessions.
_load_sessions()
def _cleanup_login_attempts(now: float) -> None:
for ip, deadline in list(_locked_until.items()):
if deadline <= now:
_locked_until.pop(ip, None)
_login_attempts.pop(ip, None)
for ip, attempts in list(_login_attempts.items()):
recent = [value for value in attempts if now - value < _LOCKOUT_WINDOW]
if recent:
_login_attempts[ip] = recent
elif ip not in _locked_until:
_login_attempts.pop(ip, None)
def _has_session(request: Request) -> bool:
_cleanup_sessions()
token = request.cookies.get("rb_admin_session")
return bool(token and token in admin_sessions)
def _require_session(request: Request) -> None:
if not _has_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
class LoginRequest(BaseModel):
password: str = Field(..., min_length=1, max_length=1024)
# ── Auth ──────────────────────────────────────────────────────────────
@router.post("/auth")
async def admin_auth(req: LoginRequest, request: Request):
client_ip = get_client_ip(request)
now = time.time()
_cleanup_login_attempts(now)
deadline = _locked_until.get(client_ip, 0)
if deadline > now:
wait = max(1, math.ceil(deadline - now))
raise HTTPException(429, "Too many failed attempts. Please try again later.",
headers={"Retry-After": str(wait), **_NO_STORE})
if client_ip not in _login_attempts and len(_login_attempts) >= _MAX_LOGIN_IDENTITIES:
raise HTTPException(429, "Login is busy. Please try again shortly.",
headers={"Retry-After": "60", **_NO_STORE})
attempts = _login_attempts.get(client_ip, [])
if not ADMIN_PASSWORD or not secrets.compare_digest(
req.password.encode(), ADMIN_PASSWORD.encode()
):
attempts.append(now)
_login_attempts[client_ip] = attempts
if len(attempts) >= _MAX_LOGIN_ATTEMPTS:
_locked_until[client_ip] = now + _LOCKOUT_DURATION
raise HTTPException(429, "Too many failed attempts. Try again in 15 minutes.",
headers={"Retry-After": str(_LOCKOUT_DURATION), **_NO_STORE})
raise HTTPException(401, "Sign-in failed. Check your password or contact the administrator.",
headers=_NO_STORE)
_login_attempts.pop(client_ip, None)
_locked_until.pop(client_ip, None)
_cleanup_sessions()
if len(admin_sessions) >= _MAX_SESSIONS:
admin_sessions.pop(min(admin_sessions, key=admin_sessions.get), None)
token = secrets.token_hex(32)
admin_sessions[token] = now + _SESSION_TTL
if not _save_sessions():
admin_sessions.pop(token, None)
raise HTTPException(503, "Sign-in is temporarily unavailable. Please try again later.",
headers=_NO_STORE)
resp = Response(content='{"status":"ok"}', media_type="application/json", headers=_NO_STORE)
resp.set_cookie("rb_admin_session", token, httponly=True,
max_age=_SESSION_TTL, samesite="lax", secure=True)
return resp
@router.get("/logout")
async def admin_logout(request: Request):
_cleanup_sessions()
token = request.cookies.get("rb_admin_session")
if token:
admin_sessions.pop(token, None)
if not _save_sessions():
raise HTTPException(503, "Sign-out could not be completed. Please try again.", headers=_NO_STORE)
resp = RedirectResponse(url="/admin/login", headers=_NO_STORE)
resp.delete_cookie("rb_admin_session")
return resp
# ── Worker proxies ────────────────────────────────────────────────────
# The Worker URL and token stay server-side. The browser only ever talks
# to this Space, so neither secret reaches the page.
async def _worker_get(path: str, params: Optional[dict] = None):
if not WORKER_URL:
raise HTTPException(503, "CLOUDFLARE_WORKER_URL is not set on this Space")
import httpx
headers = {"Authorization": f"Bearer {WORKER_TOKEN}"} if WORKER_TOKEN else {}
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get(f"{WORKER_URL}{path}", params=params or {}, headers=headers)
if not r.is_success:
raise HTTPException(502, "Analytics service is temporarily unavailable.")
data = r.json()
if not isinstance(data, dict):
raise HTTPException(502, "Analytics service returned an invalid response.")
return data
except HTTPException:
raise
except Exception as exc:
logger.warning("Analytics proxy failed (%s)", type(exc).__name__)
raise HTTPException(502, "Analytics service is temporarily unavailable. Counts remain buffered.")
@router.get("/usage")
async def admin_usage(request: Request, days: int = 30):
_require_session(request)
return await _worker_get("/rb/usage", {"days": max(0, min(days, 3650))})
@router.get("/canary-status")
async def admin_canary_status(request: Request):
_require_session(request)
return await _worker_get("/rb/canary/status")
@router.get("/local-usage")
async def admin_local_usage(request: Request):
"""Counts held in this container since it last started. Useful when
the Worker is unreachable, and as a cross-check that counting is
actually happening before blaming the Worker."""
_require_session(request)
from usage import usage
return usage.snapshot()
# ── Pull endpoints (called by the Cloudflare Worker, not by a browser) ─
# Authenticated with the shared token rather than an admin session,
# because the caller is a machine. This is the direction that works:
# Cloudflare can reach this Space even though this Space cannot reach
# Cloudflare.
def _require_worker_token(request: Request) -> None:
if not WORKER_TOKEN:
raise HTTPException(503, "CLOUDFLARE_WORKER_TOKEN is not set on this Space")
supplied = request.headers.get("authorization", "")
if not secrets.compare_digest(supplied.encode(), f"Bearer {WORKER_TOKEN}".encode()):
raise HTTPException(401, "Unauthorized")
def _worker_or_session(request: Request) -> None:
"""Accept either the Worker's token or a signed-in admin, so the same
endpoints back both the cron pull and the dashboard's manual send."""
if _has_session(request):
return
_require_worker_token(request)
@router.get("/usage-export")
async def usage_export(request: Request):
"""Hand over accumulated counts. Counts are held, not deleted, until
the caller confirms with /usage-ack β€” so a caller that reads them and
then fails to store them cannot destroy them."""
_worker_or_session(request)
from usage import usage
return usage.export_snapshot()
@router.post("/usage-ack")
async def usage_ack(request: Request, snapshot_id: str):
"""Confirm a snapshot reached storage. Only now is it discarded."""
_worker_or_session(request)
from usage import usage
return {"acknowledged": usage.ack_snapshot(snapshot_id)}
@router.get("/worker-probe")
async def admin_worker_probe(request: Request):
"""Is this Space's route to the Worker broken, or just slow?
HF Spaces lose connectivity to workers.dev for stretches at a time β€”
LibBee hit the same thing and moved its analytics writes into the
browser because of it. Three attempts with timings tell you which
case you are in: three identical failures is a blocked route, a mix
is the intermittent case, which the flush buffer already absorbs.
"""
_require_session(request)
import httpx, time as _t
if not WORKER_URL:
return {"error": "CLOUDFLARE_WORKER_URL is not set on this Space"}
attempts = []
for i in range(3):
t0 = _t.time()
try:
async with httpx.AsyncClient(timeout=20) as client:
r = await client.get(f"{WORKER_URL}/version")
attempts.append({"attempt": i + 1, "ok": r.status_code == 200,
"status": r.status_code,
"ms": int((_t.time() - t0) * 1000)})
except Exception as exc:
attempts.append({"attempt": i + 1, "ok": False,
"error": type(exc).__name__,
"ms": int((_t.time() - t0) * 1000)})
ok = sum(1 for a in attempts if a.get("ok"))
return {
"worker_url": WORKER_URL,
"attempts": attempts,
"verdict": ("reachable" if ok == 3 else
"intermittent" if ok else "unreachable from this Space"),
"note": ("Counts are buffered and retried every 60 seconds, so patchy "
"egress delays the dashboard. Counts remain in this container until "
"a server-side send or scheduled Worker pull succeeds; a container "
"restart can lose buffered counts."),
}
@router.get("/status")
async def admin_status(request: Request):
_require_session(request)
import app as rb_app
out: Dict[str, Any] = {
"uptime_seconds": int(time.time() - _START_TIME),
"worker_url_configured": bool(WORKER_URL),
"worker_token_configured": bool(WORKER_TOKEN),
"admin_password_set": bool(ADMIN_PASSWORD),
"openai_key_set": bool(os.environ.get("OPENAI_API_KEY")),
}
try:
out["health"] = await rb_app.health()
except Exception as exc:
out["health"] = {"error": "Service status is temporarily unavailable."}
return out
@router.post("/reload-khazna")
async def admin_reload_khazna(request: Request):
"""Session-protected wrapper around the existing reload endpoint, so
the button on this page works without exposing an unauthenticated
admin path."""
_require_session(request)
import asyncio
from app import khazna_index
ok = await asyncio.to_thread(khazna_index.reload)
return {"reloaded": ok, "khazna": khazna_index.status}
@router.post("/usage-flush")
async def admin_flush_usage(request: Request):
"""Send via the backend; never deliver a machine credential to a browser."""
_require_session(request)
if not WORKER_URL or not WORKER_TOKEN:
raise HTTPException(503, "Analytics service is not configured.")
from usage import usage
ok = await usage.flush()
if not ok:
raise HTTPException(502, "Counts remain buffered. The server cannot reach analytics yet; a scheduled Worker pull can collect them.")
return {"sent": True, "message": "Pending counts sent, or no counts were waiting."}
# ── Login page ────────────────────────────────────────────────────────
@router.get("/login", response_class=HTMLResponse)
async def admin_login_page(request: Request):
if _has_session(request):
return RedirectResponse(url="/admin", headers=_NO_STORE)
return HTMLResponse("""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ResearchBee β€” Admin</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}
.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}
.sub{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}
.err{color:#dc2626;font-size:.82rem;margin-bottom:10px;display:none}
</style></head><body>
<div class="box">
<h1>🐝 ResearchBee Admin</h1>
<div class="sub">KU Library publishing assistant</div>
<div class="err" id="err"></div>
<form onsubmit="return go(event)">
<input type="password" id="pw" placeholder="Admin password" autofocus>
<button type="submit" class="btn">Sign in</button>
</form>
</div>
<script>
async function go(e){
e.preventDefault();
const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({password:document.getElementById('pw').value})});
if(r.ok){window.location.href='/admin';return false;}
const d=await r.json().catch(()=>({}));
const el=document.getElementById('err');
el.textContent=d.detail||'Incorrect password.';el.style.display='block';
document.getElementById('pw').value='';document.getElementById('pw').focus();
return false;
}
</script></body></html>""", headers=_NO_STORE)
# ── Dashboard ─────────────────────────────────────────────────────────
SURFACE_LABELS = {
"journal_subject": "Find a journal β€” browse by subject",
"journal_manuscript": "Find a journal β€” analyse manuscript",
"licence_deposit": "Share your article (deposit rights)",
"data_repository": "Deposit your data (repository finder)",
"cover_letter": "Cover letter generator",
"chat": "Chat assistant",
}
GAP_LABELS = {
"scimago_miss": "Subject not found in the journal index",
"subject_unrecognised": "Subject did not map to any SCImago category",
# Model named a category that does not exist. High counts here mean the
# vocabulary is missing terms researchers actually use (e.g. criminology).
"category_invented": "Subject has no category in Scopus/SCImago",
# Answered, but with the wrong kind of answer: no category matched, so the
# list is the whole subject AREA ranked by h-index. A high count here means
# the vocabulary is missing terms researchers actually use.
"area_fallback": "Showed a broad subject area, not a specific category",
"oaworks_404": "No permissions record for that article",
"oaworks_unconfirmed": "Permissions record found but policy not confirmed",
"doi_unparseable": "Could not read a DOI from what was entered",
"khazna_no_doi": "No DOI supplied, so Khazna could not be checked",
"khazna_unavailable": "Khazna index not loaded",
"no_apc_agreement": "No KU APC agreement for that publisher",
"no_repository_match": "No repository matched the dataset",
}
@router.get("", response_class=HTMLResponse)
async def admin_dashboard(request: Request):
if not _has_session(request):
return RedirectResponse(url="/admin/login", headers=_NO_STORE)
page = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>ResearchBee β€” Admin</title>
<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:6px}
.card .hint{font-size:.78rem;color:#6b7280;margin-bottom:14px;line-height:1.5}
.btn{padding:9px 20px;background:#003366;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer;font-size:.88rem}
.btn:hover{background:#004488}
.btn-ghost{background:#fff;color:#003366;border:1.5px solid #d1d5db}
.btn-ghost:hover{background:#f8fafc}
.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}
table{width:100%;border-collapse:collapse;font-size:.84rem}
th{text-align:left;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;border-bottom:1px solid #e5e7eb;padding:7px 8px}
td{padding:7px 8px;border-bottom:1px solid #f1f5f9}
tr:last-child td{border-bottom:none}
.bar{height:8px;background:#e5e7eb;border-radius:4px;overflow:hidden;min-width:60px}
.bar span{display:block;height:100%;background:#003366}
.pill{display:inline-block;padding:2px 9px;border-radius:20px;font-size:.72rem;font-weight:700}
.pill-ok{background:#dcfce7;color:#15803d}
.pill-bad{background:#fee2e2;color:#b91c1c}
.pill-warn{background:#fef3c7;color:#92400e}
.range{display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap}
.range button{padding:4px 12px;border:1.5px solid #d1d5db;background:#fff;border-radius:20px;font-size:.78rem;cursor:pointer;color:#6b7280}
.range button.on{border-color:#003366;background:#003366;color:#fff;font-weight:700}
.msg{font-size:.82rem;color:#6b7280;padding:8px 0}
.err{font-size:.82rem;color:#b91c1c;padding:8px 0}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.76rem}
</style></head><body>
<div class="topbar">
<h1>🐝 ResearchBee β€” Admin</h1>
<a href="/admin/logout">Sign out</a>
</div>
<div class="main">
<div class="card">
<h2>Tool usage</h2>
<div class="hint">How many times each tool was used. Counts only β€” this analytics dashboard does not collect question text.
Research inputs are processed by the services described in the tool’s privacy notice.
Keep-alive traffic is excluded.</div>
<div class="range">
<button data-days="7">7 days</button>
<button data-days="30" class="on">30 days</button>
<button data-days="90">90 days</button>
<button data-days="0">All time</button>
</div>
<div id="usage"><div class="msg">Loading…</div></div>
</div>
<div class="card">
<h2>Questions we could not answer</h2>
<div class="hint">Requests that completed but found nothing to answer with β€” a subject
missing from the journal index, an article with no permissions record. Each one is a
candidate for improving a data source.</div>
<div id="gaps"><div class="msg">Loading…</div></div>
</div>
<div class="card">
<h2>Health checks</h2>
<div class="hint">Every six hours the Worker asks ResearchBee a real question, rotating
through the four tools. This keeps the Space awake and proves a different pipeline
still works each time.</div>
<div id="canary"><div class="msg">Loading…</div></div>
</div>
<div class="card">
<h2>Service status</h2>
<div class="hint">Data sources this Space is currently using, and its connection to the
analytics Worker.</div>
<div id="status"><div class="msg">Loading…</div></div>
<div style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" id="reload-khazna">Reload the Khazna index</button>
<button class="btn btn-ghost" id="show-local">Show counts held in this container</button>
<button class="btn btn-ghost" id="probe-worker">Test the connection to Cloudflare</button>
<button class="btn btn-ghost" id="send-now">Send counts to Cloudflare now</button>
</div>
<div id="action-out"></div>
</div>
</div>
<script>
// Analytics credentials remain server-side.
const SURFACE = """ + json.dumps(SURFACE_LABELS) + """;
const GAP = """ + json.dumps(GAP_LABELS) + """;
const esc = s => String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const num = n => Number(n||0).toLocaleString();
async function get(path){
const r = await fetch(path, {cache: 'no-store'});
if(r.status===401){window.location.href='/admin/login';throw new Error('Sign-in required');}
const d = await r.json().catch(()=>({}));
if(!r.ok) throw new Error(typeof d.detail === 'string' ? d.detail : ('HTTP '+r.status));
return d;
}
// ── Usage ──────────────────────────────────────────────────────
let days = 30;
async function loadUsage(){
const el = document.getElementById('usage');
const gapEl = document.getElementById('gaps');
el.innerHTML = '<div class="msg">Loading…</div>';
try {
const d = await get('/admin/usage?days='+days);
const t = d.totals || {};
const rows = (d.by_surface || []);
const max = Math.max(1, ...rows.map(r => r.total || 0));
el.innerHTML =
`<div class="stat-grid" style="margin-bottom:16px">
<div class="stat"><div class="val">${num(t.total)}</div><div class="lbl">Times used</div></div>
<div class="stat"><div class="val">${num(t.today)}</div><div class="lbl">Today</div></div>
<div class="stat"><div class="val">${num(t.miss)}</div><div class="lbl">Unanswered</div></div>
<div class="stat"><div class="val">${num(t.errors)}</div><div class="lbl">Errors</div></div>
</div>` +
(rows.length
? `<table><thead><tr><th>Tool</th><th>Times used</th><th style="width:35%"></th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +
rows.map(r => `<tr>
<td>${esc(SURFACE[r.surface] || r.surface)}</td>
<td><strong>${num(r.total)}</strong></td>
<td><div class="bar"><span style="width:${Math.round((r.total/max)*100)}%"></span></div></td>
<td>${num(r.miss)}</td>
<td>${num(r.errors)}</td>
</tr>`).join('') +
`</tbody></table>`
: `<div class="msg">Nothing recorded in this period yet. Use one of the tools, wait a minute for the next flush, then reload.</div>`);
const gaps = d.gaps || [];
gapEl.innerHTML = gaps.length
? `<table><thead><tr><th>What was missing</th><th>Tool</th><th>Times</th></tr></thead><tbody>` +
gaps.map(g => `<tr>
<td>${esc(GAP[g.gap_code] || g.gap_code)}</td>
<td>${esc(SURFACE[g.surface] || g.surface)}</td>
<td><strong>${num(g.c)}</strong></td>
</tr>`).join('') + `</tbody></table>`
: `<div class="msg">Nothing flagged in this period.</div>`;
} catch(e) {
el.innerHTML = '<div class="err">Could not load usage: '+esc(e.message)+'</div>';
gapEl.innerHTML = '';
}
}
document.querySelectorAll('.range button').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.range button').forEach(x => x.classList.remove('on'));
b.classList.add('on');
days = parseInt(b.dataset.days, 10);
loadUsage();
});
});
// ── Canary ─────────────────────────────────────────────────────
async function loadCanary(){
const el = document.getElementById('canary');
try {
const d = await get('/admin/canary-status');
const runs = d.runs || [];
el.innerHTML =
`<div class="msg" style="padding-top:0">${esc(d.summary||'')}</div>` +
(runs.length
? `<table><thead><tr><th>When (UTC)</th><th>Tool checked</th><th>Result</th><th>Took</th><th>Detail</th></tr></thead><tbody>` +
runs.map(r => `<tr>
<td class="mono">${esc(r.run_at)}</td>
<td>${esc(r.slot||'')}</td>
<td><span class="pill ${r.ok?'pill-ok':'pill-bad'}">${r.ok?'Passed':'Failed'}</span></td>
<td>${r.latency_ms!=null?(r.latency_ms/1000).toFixed(1)+'s':''}</td>
<td class="mono">${esc(r.error || r.detail || '')}</td>
</tr>`).join('') + `</tbody></table>`
: '');
} catch(e) {
el.innerHTML = '<div class="err">Could not load health checks: '+esc(e.message)+'</div>';
}
}
// ── Status ─────────────────────────────────────────────────────
function yesNo(v){ return `<span class="pill ${v?'pill-ok':'pill-warn'}">${v?'Yes':'No'}</span>`; }
async function loadStatus(){
const el = document.getElementById('status');
try {
const d = await get('/admin/status');
const h = d.health || {};
const src = h.sources || {};
const k = src.khazna || {};
const hrs = Math.floor((d.uptime_seconds||0)/3600);
const mins = Math.floor(((d.uptime_seconds||0)%3600)/60);
el.innerHTML = `<table><tbody>
<tr><td>Version</td><td>${esc(h.version||'unknown')}</td></tr>
<tr><td>Running for</td><td>${hrs}h ${mins}m</td></tr>
<tr><td>Khazna index loaded</td><td>${yesNo(k.available)}${k.records?' &middot; '+num(k.records)+' records':''}</td></tr>
<tr><td>KU institution ID set for permissions</td><td>${yesNo(src.oaworks && src.oaworks.ku_ror_set)}</td></tr>
<tr><td>Repository registry</td><td>${num((src.repository_registry||{}).entries)} entries</td></tr>
<tr><td>Analytics Worker configured</td><td>${yesNo(d.worker_url_configured)}</td></tr>
<tr><td>Worker token configured</td><td>${yesNo(d.worker_token_configured)}</td></tr>
<tr><td>Language model key set</td><td>${yesNo(d.openai_key_set)}</td></tr>
</tbody></table>`;
} catch(e) {
el.innerHTML = '<div class="err">Could not load status: '+esc(e.message)+'</div>';
}
}
// ── Actions ────────────────────────────────────────────────────
document.getElementById('reload-khazna').addEventListener('click', async (ev) => {
const out = document.getElementById('action-out');
ev.target.disabled = true;
out.innerHTML = '<div class="msg">Reloading…</div>';
try {
const r = await fetch('/admin/reload-khazna', {method:'POST'});
const d = await r.json();
out.innerHTML = d.reloaded
? `<div class="msg">Reloaded. ${num((d.khazna||{}).records)} records now in the index.</div>`
: `<div class="err">Reload failed. Check the Space logs for the line beginning "Khazna index download failed".</div>`;
loadStatus();
} catch(e) {
out.innerHTML = '<div class="err">Reload failed: '+esc(e.message)+'</div>';
}
ev.target.disabled = false;
});
document.getElementById('show-local').addEventListener('click', async () => {
const out = document.getElementById('action-out');
try {
const d = await get('/admin/local-usage');
const rows = Object.entries(d.by_surface||{});
out.innerHTML =
`<div class="msg">${esc(d.note)} Counting since ${esc(d.since)}. `+
`${d.pending_deltas} delta(s) waiting to be sent.</div>` +
(rows.length
? `<table><thead><tr><th>Tool</th><th>Used</th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +
rows.map(([k,v]) => `<tr><td>${esc(SURFACE[k]||k)}</td><td>${num(v.total)}</td><td>${num(v.miss)}</td><td>${num(v.error)}</td></tr>`).join('') +
`</tbody></table>`
: '<div class="msg">Nothing counted since this container started.</div>') +
((d.recent_flushes||[]).length
? `<div class="hint" style="margin-top:10px">Last send to the Worker: <span class="mono">${esc(d.recent_flushes[0].outcome)} β€” ${esc(d.recent_flushes[0].detail)}</span></div>`
: '');
} catch(e) {
out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';
}
});
// The manual send uses the same server-side route as automatic flushing.
document.getElementById('send-now').addEventListener('click', async (ev) => {
const out = document.getElementById('action-out');
ev.target.disabled = true;
out.textContent = 'Sending pending counts…';
try {
const r = await fetch('/admin/usage-flush', {method: 'POST', cache: 'no-store'});
const d = await r.json();
if (!r.ok) throw new Error(typeof d.detail === 'string' ? d.detail : 'Send failed.');
out.textContent = d.message || 'Pending counts sent.';
loadUsage();
} catch(e) {
out.textContent = e.message;
} finally {
ev.target.disabled = false;
}
});
document.getElementById('probe-worker').addEventListener('click', async (ev) => {
const out = document.getElementById('action-out');
ev.target.disabled = true;
out.innerHTML = '<div class="msg">Testing three times…</div>';
try {
const d = await get('/admin/worker-probe');
const rows = (d.attempts||[]).map(a =>
`<tr><td>Attempt ${a.attempt}</td>
<td><span class="pill ${a.ok?'pill-ok':'pill-bad'}">${a.ok?'Reached':'Failed'}</span></td>
<td>${a.ms} ms</td><td class="mono">${esc(a.error||'')}</td></tr>`).join('');
out.innerHTML =
`<div class="msg">Verdict: <strong>${esc(d.verdict)}</strong></div>` +
`<table><tbody>${rows}</tbody></table>` +
`<div class="hint" style="margin-top:8px">${esc(d.note||'')}</div>`;
} catch(e) {
out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';
}
ev.target.disabled = false;
});
loadUsage(); loadCanary(); loadStatus();
</script></body></html>"""
return HTMLResponse(page, headers=_NO_STORE)