File size: 39,580 Bytes
abc7ed3 1cdd7a4 43287b2 abc7ed3 1cdd7a4 abc7ed3 43287b2 a9fcd2e 2167c4f a9fcd2e ef378ac 2bb3831 abc7ed3 2bb3831 2167c4f 2bb3831 a9fcd2e 3c8bee9 43287b2 a9fcd2e 43287b2 3a437f7 20217cc 1cdd7a4 20217cc 3a437f7 7c8de95 43287b2 7c8de95 43287b2 7c8de95 3a437f7 7c8de95 3a437f7 43287b2 3a437f7 7c8de95 3a437f7 3c8bee9 3a437f7 43287b2 3a437f7 2bb3831 a9fcd2e abc7ed3 43287b2 abc7ed3 2bb3831 abc7ed3 2bb3831 abc7ed3 43287b2 abc7ed3 43287b2 a9fcd2e 20217cc a9fcd2e 20217cc 2167c4f 20217cc a9fcd2e 2bb3831 3a437f7 2bb3831 0a18afc 20217cc a9fcd2e 2bb3831 abc7ed3 2bb3831 3a437f7 2bb3831 abc7ed3 1b56c45 abc7ed3 43287b2 abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc 20217cc abc7ed3 43287b2 abc7ed3 e6b00c1 0a18afc abc7ed3 f8c042e abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 60f3584 325bca9 3c8bee9 325bca9 60f3584 0a18afc abc7ed3 0a18afc abc7ed3 f8c042e 4e997a2 f8c042e 4e997a2 f8c042e 4e997a2 f8c042e 325bca9 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc 325bca9 abc7ed3 3c8bee9 325bca9 f8c042e 325bca9 3c8bee9 325bca9 3c8bee9 325bca9 f8c042e 325bca9 3c8bee9 325bca9 3c8bee9 325bca9 3c8bee9 325bca9 3c8bee9 325bca9 abc7ed3 0a18afc 3c8bee9 abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc abc7ed3 0a18afc 325bca9 0a18afc abc7ed3 3c8bee9 abc7ed3 43287b2 2bb3831 3a437f7 3c8bee9 53bb431 3a437f7 2bb3831 abc7ed3 2bb3831 abc7ed3 0a18afc 2bb3831 abc7ed3 2bb3831 abc7ed3 2bb3831 abc7ed3 0a18afc abc7ed3 43287b2 2bb3831 abc7ed3 0a18afc abc7ed3 2bb3831 43287b2 2bb3831 abc7ed3 2167c4f abc7ed3 2167c4f ef378ac 2167c4f ef378ac 01a35b0 ef378ac 4e997a2 ef378ac b033a52 996d527 b033a52 996d527 b033a52 01a35b0 b033a52 996d527 b033a52 996d527 b033a52 996d527 b033a52 996d527 b033a52 996d527 b033a52 01a35b0 996d527 4e997a2 01a35b0 996d527 01a35b0 996d527 01a35b0 996d527 01a35b0 4e997a2 996d527 01a35b0 996d527 b033a52 996d527 b033a52 996d527 b033a52 996d527 b033a52 996d527 b033a52 996d527 04328b2 | 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 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 | """
admin.py β LibBee v3.8
Admin dashboard: session management, config store, RAG rebuild, metrics, system status.
Changes over v3.2:
- Login lockout: 5 failed attempts β 15 minute lockout per IP
- Remaining attempts shown in error message
- secure=True on session cookie
- Login page JS shows server error message (lockout, remaining attempts)
"""
# ββ Imports ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
import json
import os
import secrets
import secrets
import time
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
from src.config import get_settings, LIBBEE_VERSION
from src.services.runtime_store import JsonRuntimeStore
router = APIRouter()
_START_TIME = time.time()
_CONFIG_DEFAULTS: Dict[str, Any] = {
"max_results": 5,
"maintenance_mode": False,
"welcome_message": "Hi! I'm LibBee, the Khalifa University Library AI Assistant.",
"custom_instructions": "",
"announcement": "",
"maintenance_message": "LibBee is currently under maintenance. Please try again shortly.",
}
# ββ Session Persistence ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
admin_sessions: Dict[str, float] = {}
# ββ Admin login lockout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# Tracks failed login attempts per IP.
# After _MAX_LOGIN_ATTEMPTS failures within _LOCKOUT_WINDOW seconds,
# that IP is locked out for _LOCKOUT_DURATION seconds.
_login_attempts: Dict[str, list] = {} # ip β [timestamp, ...]
_MAX_LOGIN_ATTEMPTS = 5
_LOCKOUT_WINDOW = 300 # 5 minutes β window in which attempts are counted
_LOCKOUT_DURATION = 900 # 15 minutes β lockout duration after max attempts
def _sessions_file() -> str:
cfg_path = os.environ.get("CONFIG_STORE_PATH", "")
if cfg_path and "/data" in cfg_path:
return os.path.join(os.path.dirname(cfg_path), "admin_sessions.json")
return "/tmp/libbee_admin_sessions.json"
def _load_sessions() -> None:
global admin_sessions
try:
with open(_sessions_file(), "r", encoding="utf-8") as f:
data = json.load(f)
now = time.time()
valid = {t: exp for t, exp in data.items()
if isinstance(exp, (int, float)) and exp > now}
admin_sessions.update(valid)
except Exception:
pass
def _save_sessions() -> None:
try:
now = time.time()
valid = {t: exp for t, exp in admin_sessions.items() if exp > now}
with open(_sessions_file(), "w", encoding="utf-8") as f:
json.dump(valid, f)
except Exception:
pass
def _cleanup_sessions() -> None:
now = time.time()
expired = [t for t, exp in admin_sessions.items() if exp <= now]
for t in expired:
admin_sessions.pop(t, None)
try:
_load_sessions()
except Exception:
pass
def _require_session(request: Request) -> None:
_cleanup_sessions()
token = request.cookies.get("admin_session")
if not token or token not in admin_sessions:
raise HTTPException(status_code=401, detail="Not authenticated")
def _has_session(request: Request) -> bool:
_cleanup_sessions()
token = request.cookies.get("admin_session")
return bool(token and token in admin_sessions)
# ββ Config Store βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _config_store() -> JsonRuntimeStore:
settings = get_settings()
return JsonRuntimeStore(settings.config_path, default=_CONFIG_DEFAULTS)
def _load_config() -> Dict[str, Any]:
data = _config_store().load()
for k, v in _CONFIG_DEFAULTS.items():
if k not in data:
data[k] = v
return data
def _esc(s: Any) -> str:
return (
str(s or "")
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
)
# ββ Pydantic Models ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class LoginRequest(BaseModel):
password: str
class ConfigUpdate(BaseModel):
key: str
value: str
class ConfigBulkUpdate(BaseModel):
settings: Dict[str, Any]
# ββ Auth Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post("/auth")
async def admin_auth(req: LoginRequest, request: Request):
settings = get_settings()
client_ip = request.client.host if request.client else "unknown"
now = time.time()
# ββ Lockout check βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
attempts = _login_attempts.get(client_ip, [])
attempts = [t for t in attempts if now - t < _LOCKOUT_WINDOW]
if len(attempts) >= _MAX_LOGIN_ATTEMPTS:
wait = int(_LOCKOUT_DURATION - (now - attempts[0]))
raise HTTPException(
status_code=429,
detail=f"Too many failed login attempts. Try again in {wait} seconds."
)
# ββ Password check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if not settings.admin_password or not secrets.compare_digest(
req.password.encode(), settings.admin_password.encode()
):
attempts.append(now)
_login_attempts[client_ip] = attempts
remaining = _MAX_LOGIN_ATTEMPTS - len(attempts)
if remaining <= 0:
detail = f"Too many failed login attempts. Try again in {int(_LOCKOUT_DURATION / 60)} minutes."
else:
detail = f"Invalid password. {remaining} attempt{'s' if remaining != 1 else ''} remaining before lockout."
raise HTTPException(status_code=401, detail=detail)
# ββ Success β clear attempts and issue session βββββββββββββββββββββββββββββ
_login_attempts.pop(client_ip, None)
token = secrets.token_hex(32)
admin_sessions[token] = time.time() + 86400
_save_sessions()
resp = Response(content='{"status":"ok"}', media_type="application/json")
resp.set_cookie(key="admin_session", value=token, httponly=True,
max_age=86400, samesite="lax", secure=True)
return resp
@router.post("/logout")
async def admin_logout_post(request: Request):
token = request.cookies.get("admin_session")
if token:
admin_sessions.pop(token, None)
_save_sessions()
resp = Response(content='{"status":"ok"}', media_type="application/json")
resp.delete_cookie("admin_session")
return resp
@router.get("/logout")
async def admin_logout_get(request: Request):
token = request.cookies.get("admin_session")
if token:
admin_sessions.pop(token, None)
resp = RedirectResponse(url="/admin/login")
resp.delete_cookie("admin_session")
return resp
# ββ Login Page βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/login", response_class=HTMLResponse)
async def admin_login_page(request: Request):
if _has_session(request):
return RedirectResponse(url="/admin")
return HTMLResponse("""<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>KU Library AI β Admin Login</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;display:flex;justify-content:center;align-items:center;min-height:100vh}
.login-box{background:#fff;border-radius:16px;padding:40px;width:360px;box-shadow:0 4px 24px rgba(0,0,0,.1);text-align:center}
h1{color:#003366;font-size:1.3rem;margin-bottom:6px}
.subtitle{color:#6b7280;font-size:.84rem;margin-bottom:24px}
input{width:100%;padding:12px 14px;border:1.5px solid #d1d5db;border-radius:10px;font-size:.92rem;margin-bottom:14px}
input:focus{outline:none;border-color:#003366}
.btn{width:100%;padding:12px;background:#003366;color:#fff;border:none;border-radius:10px;font-weight:700;font-size:.92rem;cursor:pointer}
.btn:hover{background:#004488}
.error{color:#dc2626;font-size:.82rem;margin-bottom:10px;display:none}
</style></head><body>
<div class="login-box">
<h1>π Admin Login</h1>
<div class="subtitle">KU Library AI Dashboard</div>
<div class="error" id="err">Incorrect password. Try again.</div>
<form onsubmit="return doLogin(event)">
<input type="password" id="pw" placeholder="Enter admin password" autofocus>
<button type="submit" class="btn">Login</button>
</form>
</div>
<script>
async function doLogin(e){
e.preventDefault();
const pw=document.getElementById('pw').value;
const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});
if(r.ok){window.location.href='/admin';}
else{const data=await r.json().catch(()=>({}));const msg=data.detail||'Incorrect password. Try again.';const e=document.getElementById('err');e.textContent=msg;e.style.display='block';document.getElementById('pw').value='';document.getElementById('pw').focus();}
return false;
}
</script></body></html>""")
# ββ Admin Dashboard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("", response_class=HTMLResponse)
async def admin_dashboard(request: Request):
if not _has_session(request):
return RedirectResponse(url="/admin/login")
try:
cfg = _load_config()
except Exception:
cfg = dict(_CONFIG_DEFAULTS)
mm_on = "selected" if cfg.get("maintenance_mode") else ""
mm_off = "" if cfg.get("maintenance_mode") else "selected"
# Worker credentials injected into this page only. The dashboard tries the
# server-side proxy first and falls back to a direct browser -> Worker call,
# because some hosting environments (HF Spaces) cannot reach workers.dev
# while the admin's own browser can. This page is served exclusively to an
# authenticated admin session, so the token is not publicly exposed.
_s = get_settings()
cf_url = (_s.cloudflare_worker_url or "").strip().strip('"').strip("'").rstrip("/")
cf_token = (_s.cloudflare_worker_token or "").strip()
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>KU Library AI β Admin</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:'Segoe UI',system-ui,sans-serif;background:#f0f4f8;color:#1a1a2e}}
.topbar{{background:#003366;color:#fff;padding:12px 20px;display:flex;align-items:center;justify-content:space-between}}
.topbar h1{{font-size:1.1rem;font-weight:700}}
.topbar a{{color:#C8A951;font-size:.82rem;text-decoration:none;padding:5px 12px;border:1px solid #C8A951;border-radius:8px}}
.main{{max-width:900px;margin:24px auto;padding:0 16px;display:flex;flex-direction:column;gap:20px}}
.card{{background:#fff;border-radius:14px;padding:20px;box-shadow:0 2px 12px rgba(0,0,0,.07)}}
.card h2{{font-size:1rem;font-weight:700;color:#003366;margin-bottom:14px}}
label{{display:block;font-size:.82rem;font-weight:600;color:#374151;margin-bottom:4px;margin-top:10px}}
input[type=text],textarea,select{{width:100%;padding:9px 12px;border:1.5px solid #d1d5db;border-radius:8px;font-size:.88rem;font-family:inherit}}
textarea{{min-height:80px;resize:vertical}}
.btn{{padding:9px 20px;background:#003366;color:#fff;border:none;border-radius:8px;font-weight:700;cursor:pointer;font-size:.88rem;margin-top:12px}}
.btn:hover{{background:#004488}}
.btn-danger{{background:#dc2626}}.btn-danger:hover{{background:#b91c1c}}
.btn-green{{background:#16a34a}}.btn-green:hover{{background:#15803d}}
.stat-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}}
.stat{{background:#f8fafc;border-radius:10px;padding:14px;text-align:center;border:1px solid #e5e7eb}}
.stat .val{{font-size:1.6rem;font-weight:800;color:#003366}}
.stat .lbl{{font-size:.74rem;color:#6b7280;margin-top:2px}}
.status-ok{{color:#16a34a;font-weight:700}}.status-warn{{color:#d97706;font-weight:700}}
#saveMsg{{font-size:.82rem;color:#16a34a;margin-top:8px;display:none}}
table{{width:100%;border-collapse:collapse;font-size:.82rem}}
th{{background:#f1f5f9;padding:8px 10px;text-align:left;font-weight:700;color:#374151}}
td{{padding:7px 10px;border-bottom:1px solid #f1f5f9;word-break:break-word}}
tr:last-child td{{border-bottom:none}}
</style></head>
<body>
<div class="topbar"><h1>π KU Library AI β Admin Dashboard</h1><a href="/admin/logout">Logout</a></div>
<div class="main">
<div class="card">
<h2>π Analytics</h2>
<div class="stat-grid" id="statsGrid"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div>
</div>
<div class="card">
<h2>βοΈ Bot Configuration</h2>
<label>Welcome Message</label>
<input type="text" id="cfg_welcome_message" value="{_esc(cfg.get('welcome_message', ''))}">
<label>Custom Instructions (appended to system prompt)</label>
<textarea id="cfg_custom_instructions">{_esc(cfg.get('custom_instructions', ''))}</textarea>
<label>Announcement Banner (leave blank to hide)</label>
<input type="text" id="cfg_announcement" value="{_esc(cfg.get('announcement', ''))}">
<label>Max Search Results (per query)</label>
<input type="text" id="cfg_max_results" value="{_esc(cfg.get('max_results', 5))}" style="width:80px">
<label>Maintenance Mode</label>
<select id="cfg_maintenance_mode">
<option value="false" {mm_off}>Off</option>
<option value="true" {mm_on}>On</option>
</select>
<label>Maintenance Message</label>
<input type="text" id="cfg_maintenance_message" value="{_esc(cfg.get('maintenance_message', ''))}">
<button class="btn" onclick="saveConfig()">πΎ Save Configuration</button>
<div id="saveMsg">β
Saved!</div>
</div>
<div class="card">
<h2>π Index Management</h2>
<p style="font-size:.84rem;color:#6b7280;margin-bottom:12px">Rebuild the FAISS + BM25 hybrid index from knowledge files in the KB directory.</p>
<div id="ragInfo" style="font-size:.82rem;color:#374151;margin-bottom:12px">Loading RAG statusβ¦</div>
<button class="btn btn-green" onclick="rebuildIndex()">π Rebuild Index</button>
<div id="rebuildMsg" style="margin-top:10px;font-size:.84rem"></div>
</div>
<div class="card">
<h2>π Request Metrics</h2>
<div id="metricsTable" style="overflow-x:auto"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div>
</div>
<div class="card">
<h2>π Cloudflare Analytics (Persistent)</h2>
<div class="stat-grid" id="cfStatsGrid"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div>
<div style="margin-top:14px" id="cfToolsTable"></div>
</div>
<div class="card">
<h2>π Recent Queries (Cloudflare D1)</h2>
<div id="recentTable" style="overflow-x:auto"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div>
</div>
<div class="card">
<h2>π System Status</h2>
<div id="sysStatus"><div style="color:#6b7280;font-size:.84rem">Loadingβ¦</div></div>
</div>
</div>
<script>
const CF_URL = "{cf_url}";
const CF_TOKEN = "{cf_token}";
// Two-path analytics fetch. The backend proxy keeps credentials server-side and
// is tried first; if the backend cannot reach the Worker (a known limitation on
// some hosts) we retry directly from this browser, which can.
// Browser-FIRST: this browser reaches the Worker reliably and fast, whereas the
// host's own egress to workers.dev is unreliable. The server-side proxy is kept
// as a fallback for environments where the browser is the restricted one.
async function cfFetch(path){{
if(CF_URL){{
try{{
const h = CF_TOKEN ? {{'Authorization':'Bearer '+CF_TOKEN}} : {{}};
const r = await fetch(CF_URL+path, {{headers:h}});
const d = await r.json();
if(r.ok && !d.error) return {{data:d, via:'direct'}};
if(d.error) throw new Error(d.error.message||'Worker error');
}}catch(e){{ /* fall through to the backend proxy */ }}
}}
const r2 = await fetch('/admin'+path);
const d2 = await r2.json();
if(!r2.ok||d2.detail) throw new Error('HTTP '+r2.status+' '+(d2.detail||''));
if(d2.error) throw new Error(d2.error.message||JSON.stringify(d2.error));
return {{data:d2, via:'backend proxy'}};
}}
async function loadStats(){{
try{{
const r=await fetch('/admin/metrics');
const d=await r.json();
document.getElementById('statsGrid').innerHTML=`
<div class="stat"><div class="val">${{d.agent_requests||0}}</div><div class="lbl">Requests (session)</div></div>
<div class="stat"><div class="val">${{d.search_requests||0}}</div><div class="lbl">Searches (session)</div></div>
<div class="stat"><div class="val">${{d.follow_up_hits||0}}</div><div class="lbl">Follow-ups (session)</div></div>
<div class="stat"><div class="val">${{d.feedback_total||0}}</div><div class="lbl">Feedback (session)</div></div>
`;
}}catch(e){{document.getElementById('statsGrid').innerHTML='<span style="color:#dc2626">Unavailable</span>';}}
}}
async function loadCFAnalytics(){{
try{{
const res=await cfFetch('/analytics');
const d=res.data;
document.getElementById('cfStatsGrid').innerHTML=`
<div class="stat"><div class="val">${{d.total||0}}</div><div class="lbl">Total Queries</div></div>
<div class="stat"><div class="val">${{d.today||0}}</div><div class="lbl">Today</div></div>
<div class="stat"><div class="val">${{d.week||0}}</div><div class="lbl">This Week</div></div>
<div class="stat"><div class="val">${{(d.avg_time||0).toFixed(2)}}s</div><div class="lbl">Avg Response</div></div>
<div class="stat"><div class="val">${{d.errors||0}}</div><div class="lbl">Errors</div></div>
`;
const tools=(d.tools||[]).slice(0,15);
let rows=tools.map(t=>`<tr><td>${{t.tool_used||"unknown"}}</td><td>${{t.c}}</td></tr>`).join('');
document.getElementById('cfToolsTable').innerHTML=rows
?`<table><thead><tr><th>Intent:Tool</th><th>Count</th></tr></thead><tbody>${{rows}}</tbody></table>`:'';
}}catch(e){{
document.getElementById('cfStatsGrid').innerHTML='<span style="color:#dc2626">CF D1 unavailable: '+e.message+'</span>';
}}
}}
async function loadRecentQueries(){{
try{{
const res=await cfFetch('/analytics/recent');
const d=res.data;
if(d.error) throw new Error(d.error);
const rows=(d.results||[]).map(q=>`<tr>
<td style="white-space:nowrap;font-size:.74rem">${{q.timestamp||""}}</td>
<td style="max-width:260px;word-break:break-word">${{(q.question||"").substring(0,100)}}</td>
<td>${{q.tool_used||""}}</td>
<td>${{q.model||""}}</td>
<td>${{(q.response_time||0).toFixed(2)}}s</td>
<td>${{q.result_count||0}}</td>
</tr>`).join('');
document.getElementById('recentTable').innerHTML=rows
?`<table><thead><tr><th>Time</th><th>Question</th><th>Tool</th><th>Model</th><th>Time</th><th>#</th></tr></thead><tbody>${{rows}}</tbody></table>`
:'<span style="color:#6b7280;font-size:.84rem">No queries yet.</span>';
}}catch(e){{
document.getElementById('recentTable').innerHTML='<span style="color:#dc2626">'+e.message+'</span>';
}}
}}
async function loadMetrics(){{
try{{
const r=await fetch('/admin/metrics');
const d=await r.json();
const intents=d.intents||{{}};
const errors=d.errors||{{}};
let rows=Object.entries(intents).sort((a,b)=>b[1]-a[1])
.map(([k,v])=>`<tr><td>${{k}}</td><td>${{v}}</td><td style="color:#6b7280">intent</td></tr>`).join('');
rows+=Object.entries(errors).sort((a,b)=>b[1]-a[1])
.map(([k,v])=>`<tr><td style="color:#dc2626">${{k}}</td><td>${{v}}</td><td style="color:#6b7280">error</td></tr>`).join('');
document.getElementById('metricsTable').innerHTML=rows
?`<table><thead><tr><th>Key</th><th>Count</th><th>Type</th></tr></thead><tbody>${{rows}}</tbody></table>`
:'<span style="color:#6b7280;font-size:.84rem">No session data yet.</span>';
}}catch(e){{document.getElementById('metricsTable').innerHTML='<span style="color:#dc2626">Could not load</span>';}}
}}
async function loadRAGInfo(){{
try{{
const r=await fetch('/admin/rag-status');
const d=await r.json();
document.getElementById('ragInfo').innerHTML=
`<strong>Ready:</strong> ${{d.ready}} | <strong>Chunks:</strong> ${{d.chunk_count}} | <strong>KB Dir:</strong> ${{d.knowledge_dir}}`;
}}catch(e){{document.getElementById('ragInfo').textContent='Could not load RAG info.';}}
}}
async function saveConfig(){{
const keys=['welcome_message','custom_instructions','announcement','max_results','maintenance_mode','maintenance_message'];
const settings={{}};
keys.forEach(k=>{{const el=document.getElementById('cfg_'+k);if(el)settings[k]=el.value;}});
const r=await fetch('/admin/config-bulk',{{method:'POST',headers:{{'Content-Type':'application/json'}},body:JSON.stringify({{settings}})}});
if(r.ok){{const m=document.getElementById('saveMsg');m.style.display='block';setTimeout(()=>m.style.display='none',3000);}}
}}
async function rebuildIndex(){{
const el=document.getElementById('rebuildMsg');
el.innerHTML='<span style="color:#d97706">Rebuildingβ¦ this may take 30β60 seconds</span>';
try{{
const r=await fetch('/admin/rebuild',{{method:'POST'}});
const d=await r.json();
if(d.status==='ok') el.innerHTML=`<span style="color:#16a34a">β
Done β ${{d.chunks}} chunks rebuilt.</span>`;
else el.innerHTML=`<span style="color:#dc2626">Error: ${{d.error}}</span>`;
loadRAGInfo();
}}catch(e){{el.innerHTML=`<span style="color:#dc2626">Error: ${{e.message}}</span>`;}}
}}
async function loadStatus(){{
const el=document.getElementById('sysStatus');
try{{
const r=await fetch('/admin/status');
const d=await r.json();
el.innerHTML=`
<div class="stat-grid">
<div class="stat"><div class="val ${{d.rag_ready?'status-ok':'status-warn'}}">${{d.rag_ready?'β
':'β οΈ'}}</div><div class="lbl">RAG (${{d.rag_chunks||0}} chunks)</div></div>
<div class="stat"><div class="val ${{d.openai?'status-ok':'status-warn'}}">${{d.openai?'β
':'β'}}</div><div class="lbl">OpenAI API</div></div>
<div class="stat"><div class="val ${{d.anthropic?'status-ok':'status-warn'}}">${{d.anthropic?'β
':'β'}}</div><div class="lbl">Anthropic API</div></div>
<div class="stat"><div class="val ${{d.primo?'status-ok':'status-warn'}}">${{d.primo?'β
':'β'}}</div><div class="lbl">PRIMO API</div></div>
<div class="stat"><div class="val">${{d.kb_files||0}}</div><div class="lbl">KB Files</div></div>
<div class="stat"><div class="val">${{Math.floor((d.uptime_secs||0)/60)}}m</div><div class="lbl">Uptime</div></div>
<div class="stat"><div class="val">${{d.mem_used_mb||0}}MB</div><div class="lbl">Memory (${{d.mem_pct||0}}%)</div></div>
<div class="stat"><div class="val ${{d.maintenance?'status-warn':'status-ok'}}">${{d.maintenance?'ON':'OFF'}}</div><div class="lbl">Maintenance</div></div>
</div>
<div style="margin-top:8px;font-size:.74rem;color:#6b7280">Last checked: ${{new Date().toLocaleTimeString()}}</div>`;
}}catch(e){{
el.innerHTML='<span style="color:#dc2626">β Cannot reach server: '+e.message+'</span>';
}}
}}
loadStats();
loadCFAnalytics();
loadRecentQueries();
loadMetrics();
loadRAGInfo();
loadStatus();
setInterval(loadStatus,30000);
setInterval(loadCFAnalytics,60000);
setInterval(loadRecentQueries,60000);
</script>
</body></html>""")
# ββ API Endpoints ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/public-config")
async def public_config():
"""No auth β returns public fields for the chat frontend."""
try:
cfg = _load_config()
except Exception:
cfg = {}
return {
"welcome_message": cfg.get("welcome_message", ""),
"announcement": cfg.get("announcement", ""),
"maintenance_mode": cfg.get("maintenance_mode", False),
"maintenance_message": cfg.get("maintenance_message", ""),
}
@router.get("/config")
async def get_config(request: Request):
_require_session(request)
return _load_config()
@router.post("/config")
async def update_config(req: ConfigUpdate, request: Request):
_require_session(request)
allowed = {"max_results", "maintenance_mode", "welcome_message",
"custom_instructions", "announcement", "maintenance_message"}
if req.key not in allowed:
raise HTTPException(status_code=400,
detail=f"Unsupported key. Allowed: {sorted(allowed)}")
data = _config_store().load()
value: Any = req.value
if req.key == "max_results":
value = int(req.value)
elif req.key == "maintenance_mode":
value = str(req.value).lower() in {"1", "true", "yes", "on"}
data[req.key] = value
_config_store().save(data)
return {"status": "ok", "updated": req.key, "value": value}
@router.post("/config-bulk")
async def update_config_bulk(req: ConfigBulkUpdate, request: Request):
_require_session(request)
data = _config_store().load()
for key, value in req.settings.items():
if key == "max_results":
try:
data[key] = int(value)
except (ValueError, TypeError):
pass
elif key == "maintenance_mode":
data[key] = str(value).lower() in {"1", "true", "yes", "on"}
else:
data[key] = str(value)
_config_store().save(data)
return {"status": "ok", "updated": list(req.settings.keys())}
# ββ RAG Management Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/rag-status")
async def rag_status(request: Request):
_require_session(request)
try:
from app import get_rag_service
return get_rag_service().get_stats()
except Exception as e:
return {"error": str(e), "ready": False}
@router.post("/rebuild")
async def rebuild_rag(request: Request):
_require_session(request)
try:
from app import get_rag_service
svc = get_rag_service()
settings = get_settings()
if not settings.openai_api_key:
return {"status": "error", "error": "OPENAI_API_KEY not set"}
svc._kb_hash = "" # force fresh build
await svc.initialize(openai_api_key=settings.openai_api_key)
return {"status": "ok", "chunks": len(svc.bm25_corpus), "ready": svc.is_ready()}
except Exception as e:
return {"status": "error", "error": str(e)}
# ββ Metrics & Status Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββ
@router.get("/metrics")
async def metrics(request: Request):
_require_session(request)
try:
from app import get_metrics_service
return get_metrics_service().snapshot()
except Exception as e:
return {"error": str(e)}
@router.get("/status")
async def system_status(request: Request):
_require_session(request)
settings = get_settings()
mem_used_mb = mem_pct = 0
try:
with open("/sys/fs/cgroup/memory.current") as f:
used = int(f.read().strip())
with open("/sys/fs/cgroup/memory.max") as f:
val = f.read().strip()
total = int(val) if val != "max" else 16 * 1024 ** 3
mem_used_mb = round(used / 1024 / 1024)
mem_pct = round(used / total * 100)
except Exception:
try:
with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f:
used = int(f.read().strip())
with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f:
total = int(f.read().strip())
mem_used_mb = round(used / 1024 / 1024)
mem_pct = round(used / total * 100)
except Exception:
pass
rag_ready = False
rag_chunks = 0
kb_files = 0
try:
from app import get_rag_service
svc = get_rag_service()
rag_ready = svc.is_ready()
rag_chunks = len(svc.bm25_corpus)
kb_dir = settings.kb_dir
kb_files = len(list(kb_dir.glob("*.txt"))) if kb_dir.exists() else 0
except Exception:
pass
cfg = _load_config()
return {
"status": "ok",
"rag_ready": rag_ready,
"rag_chunks": rag_chunks,
"kb_files": kb_files,
"openai": bool(settings.openai_api_key),
"anthropic": bool(settings.anthropic_api_key),
"primo": bool(settings.primo_api_key),
"mem_used_mb": mem_used_mb,
"mem_pct": mem_pct,
"uptime_secs": round(time.time() - _START_TIME),
"maintenance": bool(cfg.get("maintenance_mode")),
"version": LIBBEE_VERSION,
}
# ββ Daily KB-gap / relevance reports (generated by the Cloudflare Worker) βββββ
# The Worker's daily cron writes one report row per day; these endpoints proxy
# them into the admin dashboard. Session-protected like every admin route, and
# the Worker itself additionally requires the shared bearer token.
@router.get("/reports")
async def admin_reports(request: Request, days: int = 14):
_require_session(request)
return await _worker_get("/reports", {"days": max(1, min(days, 60))})
@router.post("/reports/{report_id}/reviewed")
async def admin_report_reviewed(report_id: int, request: Request):
_require_session(request)
import httpx as _hx
from src.config import get_settings as _gs
settings = _gs()
worker = (settings.cloudflare_worker_url or "").rstrip("/")
if not worker:
raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured")
headers = {}
if settings.cloudflare_worker_token:
headers["Authorization"] = f"Bearer {settings.cloudflare_worker_token}"
try:
async with _hx.AsyncClient(timeout=10) as client:
r = await client.post(f"{worker}/reports/{int(report_id)}/reviewed", headers=headers)
return r.json()
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Worker unreachable: {exc}")
# ββ Analytics proxies ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# The dashboard fetches Worker analytics through these session-protected
# proxies so the Worker URL and ANALYTICS_TOKEN never reach the browser.
async def _worker_get(path: str, params: Optional[dict] = None):
from src.services.http_client import make_client as _mk
from src.config import get_settings as _gs
settings = _gs()
worker = (settings.cloudflare_worker_url or "").rstrip("/")
if not worker:
raise HTTPException(status_code=503, detail="CLOUDFLARE_WORKER_URL not configured")
headers = {}
if settings.cloudflare_worker_token:
headers["Authorization"] = f"Bearer {settings.cloudflare_worker_token}"
try:
async with _mk(timeout=8) as client:
r = await client.get(f"{worker}{path}", params=params or {}, headers=headers)
return r.json()
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Worker unreachable: {exc}")
@router.get("/analytics")
async def admin_analytics(request: Request):
_require_session(request)
return await _worker_get("/analytics")
@router.get("/analytics/recent")
async def admin_analytics_recent(request: Request):
_require_session(request)
return await _worker_get("/analytics/recent")
@router.get("/worker-health")
async def admin_worker_health(request: Request):
"""Diagnose the backend -> Worker -> D1 chain.
Every probe runs independently and records its own outcome, including the
exception type, so one failure never hides the rest. Also reports the exact
URL being used (whitespace or quotes in the secret are a common cause).
"""
_require_session(request)
import asyncio as _aio
from src.services.http_client import make_client as _mk
from src.config import get_settings as _gs, LIBBEE_VERSION as _ver
settings = _gs()
raw = settings.cloudflare_worker_url or ""
worker = raw.strip().strip('"').strip("'").rstrip("/")
token = (settings.cloudflare_worker_token or "").strip()
out = {
"backend_version": _ver,
"worker_url_configured": bool(worker),
"worker_url_used": worker,
"worker_url_had_whitespace_or_quotes": raw != worker + ("/" if raw.endswith("/") else ""),
"worker_token_configured": bool(token),
"worker_token_length": len(token),
"probes": {},
"verdict": "",
}
if not worker:
out["verdict"] = "CLOUDFLARE_WORKER_URL is not set in the Space secrets."
return out
headers = {"Authorization": f"Bearer {token}"} if token else {}
async def probe(name, path, use_auth=True):
"""One probe, its own short timeout, never raises."""
entry = {"path": path}
try:
async with _mk(timeout=8) as client:
r = await client.get(f"{worker}{path}",
headers=headers if use_auth else {})
entry["status"] = r.status_code
entry["body"] = r.text[:300]
except Exception as exc:
entry["error_type"] = type(exc).__name__
entry["error"] = repr(exc)[:200] or type(exc).__name__
out["probes"][name] = entry
return entry
# Run all probes concurrently so the endpoint returns in ~6s, not ~48s.
ver, _hours, ana, sch = await _aio.gather(
probe("version", "/version", use_auth=False),
probe("hours", "/hours", use_auth=False),
probe("analytics", "/analytics"),
probe("schema", "/analytics/schema"),
)
# Optional write probe: ?test_write=1 posts one marker row so a POST can be
# compared against the GETs above. It inserts a row tagged 'healthcheck'.
if request.query_params.get("test_write") == "1":
entry = {"path": "/log", "method": "POST"}
try:
async with _mk(timeout=10) as client:
r = await client.post(
f"{worker}/log", headers=headers,
json={"question": "healthcheck", "tool": "healthcheck:probe",
"model": "n/a", "response_time": 0, "result_count": 0},
)
entry["status"] = r.status_code
entry["body"] = r.text[:200]
except Exception as exc:
entry["error_type"] = type(exc).__name__
entry["error"] = repr(exc)[:200] or type(exc).__name__
out["probes"]["log_write"] = entry
# Interpretation
if "error_type" in ver and "error_type" in out["probes"]["hours"]:
et = ver.get("error_type")
if et in ("ConnectTimeout", "ConnectError", "ReadTimeout"):
out["verdict"] = (
f"{et}: the Space cannot open a connection to workers.dev, even "
f"on unauthenticated routes. Other outbound calls (OpenAI, "
f"OpenAlex) work, so this is host-specific - typically an "
f"unroutable IPv6 path or Cloudflare throttling shared "
f"workers.dev addresses from datacentre IPs. IPv4 is now forced; "
f"if this persists, put the Worker behind a custom domain "
f"(Worker > Settings > Domains & Routes > Add custom domain) and "
f"point CLOUDFLARE_WORKER_URL at that instead."
)
else:
out["verdict"] = (
f"No HTTP response from the Worker ({et}). Check that "
f"worker_url_used above is exactly your workers.dev address."
)
elif ver.get("status") == 404:
out["verdict"] = (
"Worker responds but has no /version route: the deployed script is "
"still the pre-3.8 version. Deploy cloudflare-worker-v3.8.1.js."
)
elif ana.get("status") == 401:
out["verdict"] = (
"Analytics returns 401. CLOUDFLARE_WORKER_TOKEN does not match the "
"Worker's ANALYTICS_TOKEN (check for trailing spaces in either)."
)
elif ana.get("status") == 200:
migrated = None
if sch.get("status") == 200:
try:
import json as _json
migrated = (_json.loads(sch["body"]) or {}).get("migration_applied")
except Exception:
migrated = None
if migrated is False:
out["verdict"] = ("Connected, but the D1 migration is not applied "
"(answer_excerpt/source_ids missing).")
else:
out["verdict"] = "OK - backend, Worker and D1 analytics are connected."
else:
out["verdict"] = (f"Unexpected analytics status {ana.get('status')}. "
f"See probes for details.")
return out
@router.get("/log-status")
async def admin_log_status(request: Request):
"""Outcome of recent analytics writes, without needing container logs.
'recent_attempts' is newest-first. Read it like this:
ok -> the write reached the Worker (HTTP 2xx)
throttled -> HTTP 403/429: the host is being rate-limited by Cloudflare
unreachable -> no HTTP response; exception type is in 'detail'
rejected_401 -> CLOUDFLARE_WORKER_TOKEN does not match ANALYTICS_TOKEN
Buffered rows are retried automatically on later queries.
"""
_require_session(request)
from src.agentcore.orchestrator import analytics_log_status
from src.config import get_settings as _gs
st = _gs()
data = analytics_log_status()
data["worker_url_configured"] = bool(st.cloudflare_worker_url)
data["worker_token_configured"] = bool(st.cloudflare_worker_token)
if not data["recent_attempts"]:
data["hint"] = ("No write attempts recorded since the last restart. "
"Send a query through LibBee, then reload this page.")
return data
|