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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');

const num = n => Number(n||0).toLocaleString();



async function get(path){

  const r = await fetch(path, {cache: 'no-store'});

  if(r.status===401){window.location.href='/admin/login';throw new Error('Sign-in required');}

  const d = await r.json().catch(()=>({}));

  if(!r.ok) throw new Error(typeof d.detail === 'string' ? d.detail : ('HTTP '+r.status));

  return d;

}



// ── Usage ──────────────────────────────────────────────────────

let days = 30;



async function loadUsage(){

  const el = document.getElementById('usage');

  const gapEl = document.getElementById('gaps');

  el.innerHTML = '<div class="msg">Loading…</div>';

  try {

    const d = await get('/admin/usage?days='+days);

    const t = d.totals || {};

    const rows = (d.by_surface || []);

    const max = Math.max(1, ...rows.map(r => r.total || 0));



    el.innerHTML =

      `<div class="stat-grid" style="margin-bottom:16px">

         <div class="stat"><div class="val">${num(t.total)}</div><div class="lbl">Times used</div></div>

         <div class="stat"><div class="val">${num(t.today)}</div><div class="lbl">Today</div></div>

         <div class="stat"><div class="val">${num(t.miss)}</div><div class="lbl">Unanswered</div></div>

         <div class="stat"><div class="val">${num(t.errors)}</div><div class="lbl">Errors</div></div>

       </div>` +

      (rows.length

        ? `<table><thead><tr><th>Tool</th><th>Times used</th><th style="width:35%"></th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +

          rows.map(r => `<tr>

              <td>${esc(SURFACE[r.surface] || r.surface)}</td>

              <td><strong>${num(r.total)}</strong></td>

              <td><div class="bar"><span style="width:${Math.round((r.total/max)*100)}%"></span></div></td>

              <td>${num(r.miss)}</td>

              <td>${num(r.errors)}</td>

            </tr>`).join('') +

          `</tbody></table>`

        : `<div class="msg">Nothing recorded in this period yet. Use one of the tools, wait a minute for the next flush, then reload.</div>`);



    const gaps = d.gaps || [];

    gapEl.innerHTML = gaps.length

      ? `<table><thead><tr><th>What was missing</th><th>Tool</th><th>Times</th></tr></thead><tbody>` +

        gaps.map(g => `<tr>

            <td>${esc(GAP[g.gap_code] || g.gap_code)}</td>

            <td>${esc(SURFACE[g.surface] || g.surface)}</td>

            <td><strong>${num(g.c)}</strong></td>

          </tr>`).join('') + `</tbody></table>`

      : `<div class="msg">Nothing flagged in this period.</div>`;



  } catch(e) {

    el.innerHTML = '<div class="err">Could not load usage: '+esc(e.message)+'</div>';

    gapEl.innerHTML = '';

  }

}



document.querySelectorAll('.range button').forEach(b => {

  b.addEventListener('click', () => {

    document.querySelectorAll('.range button').forEach(x => x.classList.remove('on'));

    b.classList.add('on');

    days = parseInt(b.dataset.days, 10);

    loadUsage();

  });

});



// ── Canary ─────────────────────────────────────────────────────

async function loadCanary(){

  const el = document.getElementById('canary');

  try {

    const d = await get('/admin/canary-status');

    const runs = d.runs || [];

    el.innerHTML =

      `<div class="msg" style="padding-top:0">${esc(d.summary||'')}</div>` +

      (runs.length

        ? `<table><thead><tr><th>When (UTC)</th><th>Tool checked</th><th>Result</th><th>Took</th><th>Detail</th></tr></thead><tbody>` +

          runs.map(r => `<tr>

              <td class="mono">${esc(r.run_at)}</td>

              <td>${esc(r.slot||'')}</td>

              <td><span class="pill ${r.ok?'pill-ok':'pill-bad'}">${r.ok?'Passed':'Failed'}</span></td>

              <td>${r.latency_ms!=null?(r.latency_ms/1000).toFixed(1)+'s':''}</td>

              <td class="mono">${esc(r.error || r.detail || '')}</td>

            </tr>`).join('') + `</tbody></table>`

        : '');

  } catch(e) {

    el.innerHTML = '<div class="err">Could not load health checks: '+esc(e.message)+'</div>';

  }

}



// ── Status ─────────────────────────────────────────────────────

function yesNo(v){ return `<span class="pill ${v?'pill-ok':'pill-warn'}">${v?'Yes':'No'}</span>`; }



async function loadStatus(){

  const el = document.getElementById('status');

  try {

    const d = await get('/admin/status');

    const h = d.health || {};

    const src = h.sources || {};

    const k = src.khazna || {};

    const hrs = Math.floor((d.uptime_seconds||0)/3600);

    const mins = Math.floor(((d.uptime_seconds||0)%3600)/60);

    el.innerHTML = `<table><tbody>

      <tr><td>Version</td><td>${esc(h.version||'unknown')}</td></tr>

      <tr><td>Running for</td><td>${hrs}h ${mins}m</td></tr>

      <tr><td>Khazna index loaded</td><td>${yesNo(k.available)}${k.records?' &middot; '+num(k.records)+' records':''}</td></tr>

      <tr><td>KU institution ID set for permissions</td><td>${yesNo(src.oaworks && src.oaworks.ku_ror_set)}</td></tr>

      <tr><td>Repository registry</td><td>${num((src.repository_registry||{}).entries)} entries</td></tr>

      <tr><td>Analytics Worker configured</td><td>${yesNo(d.worker_url_configured)}</td></tr>

      <tr><td>Worker token configured</td><td>${yesNo(d.worker_token_configured)}</td></tr>

      <tr><td>Language model key set</td><td>${yesNo(d.openai_key_set)}</td></tr>

    </tbody></table>`;

  } catch(e) {

    el.innerHTML = '<div class="err">Could not load status: '+esc(e.message)+'</div>';

  }

}



// ── Actions ────────────────────────────────────────────────────

document.getElementById('reload-khazna').addEventListener('click', async (ev) => {

  const out = document.getElementById('action-out');

  ev.target.disabled = true;

  out.innerHTML = '<div class="msg">Reloading…</div>';

  try {

    const r = await fetch('/admin/reload-khazna', {method:'POST'});

    const d = await r.json();

    out.innerHTML = d.reloaded

      ? `<div class="msg">Reloaded. ${num((d.khazna||{}).records)} records now in the index.</div>`

      : `<div class="err">Reload failed. Check the Space logs for the line beginning "Khazna index download failed".</div>`;

    loadStatus();

  } catch(e) {

    out.innerHTML = '<div class="err">Reload failed: '+esc(e.message)+'</div>';

  }

  ev.target.disabled = false;

});



document.getElementById('show-local').addEventListener('click', async () => {

  const out = document.getElementById('action-out');

  try {

    const d = await get('/admin/local-usage');

    const rows = Object.entries(d.by_surface||{});

    out.innerHTML =

      `<div class="msg">${esc(d.note)} Counting since ${esc(d.since)}. `+

      `${d.pending_deltas} delta(s) waiting to be sent.</div>` +

      (rows.length

        ? `<table><thead><tr><th>Tool</th><th>Used</th><th>Unanswered</th><th>Errors</th></tr></thead><tbody>` +

          rows.map(([k,v]) => `<tr><td>${esc(SURFACE[k]||k)}</td><td>${num(v.total)}</td><td>${num(v.miss)}</td><td>${num(v.error)}</td></tr>`).join('') +

          `</tbody></table>`

        : '<div class="msg">Nothing counted since this container started.</div>') +

      ((d.recent_flushes||[]).length

        ? `<div class="hint" style="margin-top:10px">Last send to the Worker: <span class="mono">${esc(d.recent_flushes[0].outcome)} β€” ${esc(d.recent_flushes[0].detail)}</span></div>`

        : '');

  } catch(e) {

    out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';

  }

});



// The manual send uses the same server-side route as automatic flushing.

document.getElementById('send-now').addEventListener('click', async (ev) => {

  const out = document.getElementById('action-out');

  ev.target.disabled = true;

  out.textContent = 'Sending pending counts…';

  try {

    const r = await fetch('/admin/usage-flush', {method: 'POST', cache: 'no-store'});

    const d = await r.json();

    if (!r.ok) throw new Error(typeof d.detail === 'string' ? d.detail : 'Send failed.');

    out.textContent = d.message || 'Pending counts sent.';

    loadUsage();

  } catch(e) {

    out.textContent = e.message;

  } finally {

    ev.target.disabled = false;

  }

});



document.getElementById('probe-worker').addEventListener('click', async (ev) => {

  const out = document.getElementById('action-out');

  ev.target.disabled = true;

  out.innerHTML = '<div class="msg">Testing three times…</div>';

  try {

    const d = await get('/admin/worker-probe');

    const rows = (d.attempts||[]).map(a =>

      `<tr><td>Attempt ${a.attempt}</td>

           <td><span class="pill ${a.ok?'pill-ok':'pill-bad'}">${a.ok?'Reached':'Failed'}</span></td>

           <td>${a.ms} ms</td><td class="mono">${esc(a.error||'')}</td></tr>`).join('');

    out.innerHTML =

      `<div class="msg">Verdict: <strong>${esc(d.verdict)}</strong></div>` +

      `<table><tbody>${rows}</tbody></table>` +

      `<div class="hint" style="margin-top:8px">${esc(d.note||'')}</div>`;

  } catch(e) {

    out.innerHTML = '<div class="err">'+esc(e.message)+'</div>';

  }

  ev.target.disabled = false;

});



loadUsage(); loadCanary(); loadStatus();

</script></body></html>"""
    return HTMLResponse(page, headers=_NO_STORE)