Spaces:
Running
Running
File size: 36,112 Bytes
4b2991e 1916d8e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 | """
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,'&').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) |