Spaces:
Running
Running
Download admin.py from nikeshn/researchbee: direct link, hf CLI and curl.
- Browser
- Download file 36.1 kB
-
https://huggingface.co/spaces/nikeshn/researchbee/resolve/main/admin.py
- Command line
-
hf download hf://spaces/nikeshn/researchbee/admin.py
-
curl -L -o admin.py https://huggingface.co/spaces/nikeshn/researchbee/resolve/main/admin.py
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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| 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.") | |
| async def admin_usage(request: Request, days: int = 30): | |
| _require_session(request) | |
| return await _worker_get("/rb/usage", {"days": max(0, min(days, 3650))}) | |
| async def admin_canary_status(request: Request): | |
| _require_session(request) | |
| return await _worker_get("/rb/canary/status") | |
| 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) | |
| 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() | |
| 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)} | |
| 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."), | |
| } | |
| 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 | |
| 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} | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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", | |
| } | |
| 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,'&').replace(/</g,'<').replace(/>/g,'>'); | |
| 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?' · '+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) |