Srushti-Kamble commited on
Commit
5e9fce0
·
1 Parent(s): 0370c76

Implemented the admin metrics feature end to end

Browse files
backend/app/main.py CHANGED
@@ -92,11 +92,13 @@ from app.routes.auth import router as auth_router
92
  from app.routes.documents import router as documents_router
93
  from app.routes.chat import router as chat_router
94
  from app.routes.github import router as github_router
 
95
 
96
  app.include_router(auth_router, prefix="/api/v1")
97
  app.include_router(documents_router, prefix="/api/v1")
98
  app.include_router(chat_router, prefix="/api/v1")
99
  app.include_router(github_router, prefix="/api/v1")
 
100
 
101
 
102
  # ── Health Check ─────────────────────────────────────
 
92
  from app.routes.documents import router as documents_router
93
  from app.routes.chat import router as chat_router
94
  from app.routes.github import router as github_router
95
+ from app.routes.admin import router as admin_router
96
 
97
  app.include_router(auth_router, prefix="/api/v1")
98
  app.include_router(documents_router, prefix="/api/v1")
99
  app.include_router(chat_router, prefix="/api/v1")
100
  app.include_router(github_router, prefix="/api/v1")
101
+ app.include_router(admin_router, prefix="/api/v1")
102
 
103
 
104
  # ── Health Check ─────────────────────────────────────
backend/app/metrics.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Runtime metrics helpers for lightweight operational statistics.
3
+ """
4
+ from threading import Lock
5
+
6
+
7
+ _metrics_lock = Lock()
8
+ _query_count = 0
9
+ _query_response_time_total_ms = 0.0
10
+
11
+
12
+ def record_query_response_time(duration_seconds: float) -> None:
13
+ """Record one completed query response duration."""
14
+ global _query_count, _query_response_time_total_ms
15
+
16
+ duration_ms = max(duration_seconds, 0) * 1000
17
+ with _metrics_lock:
18
+ _query_count += 1
19
+ _query_response_time_total_ms += duration_ms
20
+
21
+
22
+ def get_query_metrics() -> dict[str, float | int]:
23
+ """Return aggregate query metrics for the current process lifetime."""
24
+ with _metrics_lock:
25
+ average_ms = (
26
+ _query_response_time_total_ms / _query_count
27
+ if _query_count
28
+ else 0.0
29
+ )
30
+ return {
31
+ "query_count": _query_count,
32
+ "average_query_response_time_ms": round(average_ms, 2),
33
+ }
backend/app/rag/vectorstore.py CHANGED
@@ -4,11 +4,7 @@ Per-user collections for data isolation.
4
  """
5
  import logging
6
  from typing import List, Dict, Any, Optional
7
- import chromadb
8
- from chromadb.config import Settings as ChromaSettings
9
  from app.config import get_settings
10
- from app.rag.embeddings import get_embedding_model
11
- from app.rag.vision import generate_captions_for_chunks
12
 
13
  logger = logging.getLogger(__name__)
14
  settings = get_settings()
@@ -17,12 +13,15 @@ settings = get_settings()
17
  _chroma_client = None
18
 
19
 
20
- def get_chroma_client() -> chromadb.ClientAPI:
21
  """Get or create persistent ChromaDB client."""
22
  global _chroma_client
23
 
24
  if _chroma_client is None:
25
  import os
 
 
 
26
  os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
27
 
28
  _chroma_client = chromadb.PersistentClient(
@@ -58,11 +57,15 @@ def store_chunks(
58
 
59
  # Generate captions for any extracted images before embedding
60
  try:
 
 
61
  generate_captions_for_chunks(chunks)
62
  except Exception as e:
63
  logger.warning(f"Could not generate image captions: {e}")
64
 
65
  client = get_chroma_client()
 
 
66
  embedding_model = get_embedding_model()
67
 
68
  collection_name = get_collection_name(user_id)
 
4
  """
5
  import logging
6
  from typing import List, Dict, Any, Optional
 
 
7
  from app.config import get_settings
 
 
8
 
9
  logger = logging.getLogger(__name__)
10
  settings = get_settings()
 
13
  _chroma_client = None
14
 
15
 
16
+ def get_chroma_client():
17
  """Get or create persistent ChromaDB client."""
18
  global _chroma_client
19
 
20
  if _chroma_client is None:
21
  import os
22
+ import chromadb
23
+ from chromadb.config import Settings as ChromaSettings
24
+
25
  os.makedirs(settings.CHROMA_PERSIST_DIR, exist_ok=True)
26
 
27
  _chroma_client = chromadb.PersistentClient(
 
57
 
58
  # Generate captions for any extracted images before embedding
59
  try:
60
+ from app.rag.vision import generate_captions_for_chunks
61
+
62
  generate_captions_for_chunks(chunks)
63
  except Exception as e:
64
  logger.warning(f"Could not generate image captions: {e}")
65
 
66
  client = get_chroma_client()
67
+ from app.rag.embeddings import get_embedding_model
68
+
69
  embedding_model = get_embedding_model()
70
 
71
  collection_name = get_collection_name(user_id)
backend/app/routes/admin.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin-only operational statistics routes.
3
+ """
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter, Depends
8
+ from sqlalchemy import func
9
+ from sqlalchemy.orm import Session
10
+
11
+ from app.auth import get_admin_user
12
+ from app.config import get_settings
13
+ from app.database import get_db
14
+ from app.metrics import get_query_metrics
15
+ from app.models import Document, User
16
+ from app.schemas import AdminStatsResponse, DiskUsageResponse
17
+
18
+ router = APIRouter(prefix="/admin", tags=["Admin"])
19
+ settings = get_settings()
20
+
21
+
22
+ def _directory_size(path: Path) -> int:
23
+ if not path.exists():
24
+ return 0
25
+
26
+ total = 0
27
+ for item in path.rglob("*"):
28
+ if item.is_file():
29
+ try:
30
+ total += item.stat().st_size
31
+ except OSError:
32
+ continue
33
+ return total
34
+
35
+
36
+ @router.get("/stats", response_model=AdminStatsResponse)
37
+ def get_admin_stats(
38
+ _admin: User = Depends(get_admin_user),
39
+ db: Session = Depends(get_db),
40
+ ):
41
+ """Return aggregate system statistics for administrators."""
42
+ upload_dir = Path(settings.UPLOAD_DIR).resolve()
43
+ upload_dir.mkdir(parents=True, exist_ok=True)
44
+
45
+ disk_usage = shutil.disk_usage(upload_dir)
46
+ used_percent = (
47
+ round((disk_usage.used / disk_usage.total) * 100, 2)
48
+ if disk_usage.total
49
+ else 0.0
50
+ )
51
+ query_metrics = get_query_metrics()
52
+
53
+ total_pdfs_uploaded = (
54
+ db.query(Document)
55
+ .filter(func.lower(Document.original_name).like("%.pdf"))
56
+ .count()
57
+ )
58
+
59
+ return AdminStatsResponse(
60
+ total_users=db.query(User).count(),
61
+ total_pdfs_uploaded=total_pdfs_uploaded,
62
+ average_query_response_time_ms=float(
63
+ query_metrics["average_query_response_time_ms"]
64
+ ),
65
+ query_count=int(query_metrics["query_count"]),
66
+ disk_space_usage=DiskUsageResponse(
67
+ total_bytes=disk_usage.total,
68
+ used_bytes=disk_usage.used,
69
+ free_bytes=disk_usage.free,
70
+ usage_percent=used_percent,
71
+ upload_dir_bytes=_directory_size(upload_dir),
72
+ ),
73
+ )
backend/app/routes/chat.py CHANGED
@@ -3,6 +3,7 @@ Chat routes — ask questions with RAG, stream responses via SSE, manage history
3
  """
4
  import html
5
  import json
 
6
  from datetime import datetime
7
  from io import BytesIO
8
  import logging
@@ -18,6 +19,7 @@ from sqlalchemy.orm import Session
18
 
19
  from app.database import get_db
20
  from app.models import User, ChatMessage, Document
 
21
  from app.schemas import ChatRequest, ChatResponse, ChatMessageResponse, ChatHistoryResponse, SourceChunk
22
  from app.auth import get_current_user
23
  from app.rag.agent import generate_answer, generate_answer_stream
@@ -63,38 +65,42 @@ def ask_question(
63
  HTTPException: 400 if the document exists but its status is not
64
  "ready" (e.g., still processing or failed).
65
  """
66
- # Validate document exists if specified
67
- if payload.document_id:
68
- doc = db.query(Document).filter(
69
- Document.id == payload.document_id,
70
- Document.user_id == user.id,
71
- ).first()
72
-
73
- if not doc:
74
- raise HTTPException(status_code=404, detail="Document not found")
75
-
76
- if doc.status != "ready":
77
- raise HTTPException(
78
- status_code=400,
79
- detail=f"Document is still {doc.status}. Please wait for processing to complete.",
80
- )
81
-
82
- # Generate answer
83
- result = generate_answer(
84
- question=payload.question,
85
- user_id=user.id,
86
- document_id=payload.document_id,
87
- )
 
 
88
 
89
- # Save to chat history
90
- _save_message(db, user.id, payload.document_id, "user", payload.question)
91
- _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
92
 
93
- return ChatResponse(
94
- answer=result["answer"],
95
- sources=[SourceChunk(**s) for s in result["sources"]],
96
- document_id=payload.document_id,
97
- )
 
 
98
 
99
 
100
  @router.post("/ask/stream")
@@ -156,6 +162,8 @@ def ask_question_stream(
156
  detail=f"Document is still {doc.status}. Please wait for processing to complete.",
157
  )
158
 
 
 
159
  # Save user message immediately
160
  _save_message(db, user.id, payload.document_id, "user", payload.question)
161
 
@@ -164,31 +172,34 @@ def ask_question_stream(
164
  full_answer = ""
165
  sources = []
166
 
167
- for chunk in generate_answer_stream(
168
- question=payload.question,
169
- user_id=user.id,
170
- document_id=payload.document_id,
171
- ):
172
- yield chunk
173
-
174
- # Parse to accumulate full answer for history
175
- try:
176
- if chunk.startswith("data: "):
177
- data = json.loads(chunk[6:].strip())
178
- if data.get("type") == "token":
179
- full_answer += data.get("data", "")
180
- elif data.get("type") == "sources":
181
- sources = data.get("data", [])
182
- except Exception:
183
- pass
184
-
185
- # Save assistant response to history
186
- from app.database import SessionLocal
187
- save_db = SessionLocal()
188
  try:
189
- _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  finally:
191
- save_db.close()
192
 
193
  return StreamingResponse(
194
  event_stream(),
 
3
  """
4
  import html
5
  import json
6
+ import time
7
  from datetime import datetime
8
  from io import BytesIO
9
  import logging
 
19
 
20
  from app.database import get_db
21
  from app.models import User, ChatMessage, Document
22
+ from app.metrics import record_query_response_time
23
  from app.schemas import ChatRequest, ChatResponse, ChatMessageResponse, ChatHistoryResponse, SourceChunk
24
  from app.auth import get_current_user
25
  from app.rag.agent import generate_answer, generate_answer_stream
 
65
  HTTPException: 400 if the document exists but its status is not
66
  "ready" (e.g., still processing or failed).
67
  """
68
+ started_at = time.perf_counter()
69
+ try:
70
+ # Validate document exists if specified
71
+ if payload.document_id:
72
+ doc = db.query(Document).filter(
73
+ Document.id == payload.document_id,
74
+ Document.user_id == user.id,
75
+ ).first()
76
+
77
+ if not doc:
78
+ raise HTTPException(status_code=404, detail="Document not found")
79
+
80
+ if doc.status != "ready":
81
+ raise HTTPException(
82
+ status_code=400,
83
+ detail=f"Document is still {doc.status}. Please wait for processing to complete.",
84
+ )
85
+
86
+ # Generate answer
87
+ result = generate_answer(
88
+ question=payload.question,
89
+ user_id=user.id,
90
+ document_id=payload.document_id,
91
+ )
92
 
93
+ # Save to chat history
94
+ _save_message(db, user.id, payload.document_id, "user", payload.question)
95
+ _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
96
 
97
+ return ChatResponse(
98
+ answer=result["answer"],
99
+ sources=[SourceChunk(**s) for s in result["sources"]],
100
+ document_id=payload.document_id,
101
+ )
102
+ finally:
103
+ record_query_response_time(time.perf_counter() - started_at)
104
 
105
 
106
  @router.post("/ask/stream")
 
162
  detail=f"Document is still {doc.status}. Please wait for processing to complete.",
163
  )
164
 
165
+ started_at = time.perf_counter()
166
+
167
  # Save user message immediately
168
  _save_message(db, user.id, payload.document_id, "user", payload.question)
169
 
 
172
  full_answer = ""
173
  sources = []
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  try:
176
+ for chunk in generate_answer_stream(
177
+ question=payload.question,
178
+ user_id=user.id,
179
+ document_id=payload.document_id,
180
+ ):
181
+ yield chunk
182
+
183
+ # Parse to accumulate full answer for history
184
+ try:
185
+ if chunk.startswith("data: "):
186
+ data = json.loads(chunk[6:].strip())
187
+ if data.get("type") == "token":
188
+ full_answer += data.get("data", "")
189
+ elif data.get("type") == "sources":
190
+ sources = data.get("data", [])
191
+ except Exception:
192
+ pass
193
+
194
+ # Save assistant response to history
195
+ from app.database import SessionLocal
196
+ save_db = SessionLocal()
197
+ try:
198
+ _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
199
+ finally:
200
+ save_db.close()
201
  finally:
202
+ record_query_response_time(time.perf_counter() - started_at)
203
 
204
  return StreamingResponse(
205
  event_stream(),
backend/app/schemas.py CHANGED
@@ -99,6 +99,24 @@ class DocumentListResponse(BaseModel):
99
  pages: int
100
 
101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  # ── Chat ─────────────────────────────────────────────
103
 
104
  class ChatRequest(BaseModel):
 
99
  pages: int
100
 
101
 
102
+ # Admin
103
+
104
+ class DiskUsageResponse(BaseModel):
105
+ total_bytes: int
106
+ used_bytes: int
107
+ free_bytes: int
108
+ usage_percent: float
109
+ upload_dir_bytes: int
110
+
111
+
112
+ class AdminStatsResponse(BaseModel):
113
+ total_users: int
114
+ total_pdfs_uploaded: int
115
+ average_query_response_time_ms: float
116
+ query_count: int
117
+ disk_space_usage: DiskUsageResponse
118
+
119
+
120
  # ── Chat ─────────────────────────────────────────────
121
 
122
  class ChatRequest(BaseModel):
backend/tests/test_admin.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.auth import create_access_token, hash_password
2
+ from app.metrics import record_query_response_time
3
+ from app.models import Document, User
4
+
5
+
6
+ def test_admin_stats_requires_admin(client, auth_headers):
7
+ response = client.get("/api/v1/admin/stats", headers=auth_headers)
8
+
9
+ assert response.status_code == 403
10
+ assert response.json()["detail"] == "Admin access required"
11
+
12
+
13
+ def test_admin_stats_returns_aggregate_metrics(client, db_session):
14
+ admin = User(
15
+ username="admin",
16
+ email="admin@example.com",
17
+ hashed_password=hash_password("password123"),
18
+ is_admin=True,
19
+ )
20
+ regular = User(
21
+ username="regular",
22
+ email="regular@example.com",
23
+ hashed_password=hash_password("password123"),
24
+ )
25
+ db_session.add_all([admin, regular])
26
+ db_session.commit()
27
+ db_session.refresh(admin)
28
+ db_session.refresh(regular)
29
+
30
+ db_session.add_all(
31
+ [
32
+ Document(
33
+ user_id=regular.id,
34
+ filename="first.pdf",
35
+ original_name="first.pdf",
36
+ file_size=100,
37
+ status="ready",
38
+ ),
39
+ Document(
40
+ user_id=regular.id,
41
+ filename="notes.txt",
42
+ original_name="notes.txt",
43
+ file_size=50,
44
+ status="ready",
45
+ ),
46
+ ]
47
+ )
48
+ db_session.commit()
49
+
50
+ record_query_response_time(0.25)
51
+
52
+ token = create_access_token(admin.id)
53
+ response = client.get(
54
+ "/api/v1/admin/stats",
55
+ headers={"Authorization": f"Bearer {token}"},
56
+ )
57
+
58
+ assert response.status_code == 200
59
+ payload = response.json()
60
+ assert payload["total_users"] == 2
61
+ assert payload["total_pdfs_uploaded"] == 1
62
+ assert payload["average_query_response_time_ms"] > 0
63
+ assert payload["query_count"] >= 1
64
+ assert payload["disk_space_usage"]["total_bytes"] > 0
65
+ assert payload["disk_space_usage"]["usage_percent"] >= 0
frontend/src/app/admin/page.tsx ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import {
6
+ ArrowLeft,
7
+ Clock3,
8
+ Database,
9
+ FileText,
10
+ HardDrive,
11
+ RefreshCw,
12
+ Users,
13
+ } from "lucide-react";
14
+
15
+ import { api, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
16
+ import { useAuth } from "@/lib/auth";
17
+ import { Button } from "@/components/ui/button";
18
+ import {
19
+ Card,
20
+ CardContent,
21
+ CardDescription,
22
+ CardHeader,
23
+ CardTitle,
24
+ } from "@/components/ui/card";
25
+ import { Progress } from "@/components/ui/progress";
26
+ import { Skeleton } from "@/components/ui/skeleton";
27
+
28
+ interface AdminStats {
29
+ total_users: number;
30
+ total_pdfs_uploaded: number;
31
+ average_query_response_time_ms: number;
32
+ query_count: number;
33
+ disk_space_usage: {
34
+ total_bytes: number;
35
+ used_bytes: number;
36
+ free_bytes: number;
37
+ usage_percent: number;
38
+ upload_dir_bytes: number;
39
+ };
40
+ }
41
+
42
+ const formatBytes = (bytes: number) => {
43
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
44
+
45
+ const units = ["B", "KB", "MB", "GB", "TB"];
46
+ const index = Math.min(
47
+ Math.floor(Math.log(bytes) / Math.log(1024)),
48
+ units.length - 1
49
+ );
50
+
51
+ return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
52
+ };
53
+
54
+ const formatTime = (milliseconds: number) => {
55
+ if (milliseconds >= 1000) return `${(milliseconds / 1000).toFixed(2)} s`;
56
+ return `${Math.round(milliseconds)} ms`;
57
+ };
58
+
59
+ function MetricCard({
60
+ icon: Icon,
61
+ label,
62
+ value,
63
+ detail,
64
+ }: {
65
+ icon: typeof Users;
66
+ label: string;
67
+ value: string;
68
+ detail: string;
69
+ }) {
70
+ return (
71
+ <Card className="min-h-36">
72
+ <CardHeader className="grid-cols-[1fr_auto]">
73
+ <div>
74
+ <CardDescription>{label}</CardDescription>
75
+ <CardTitle className="mt-2 text-3xl tabular-nums">{value}</CardTitle>
76
+ </div>
77
+ <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/15 text-primary">
78
+ <Icon className="h-4 w-4" />
79
+ </div>
80
+ </CardHeader>
81
+ <CardContent>
82
+ <p className="text-sm text-muted-foreground">{detail}</p>
83
+ </CardContent>
84
+ </Card>
85
+ );
86
+ }
87
+
88
+ function AdminSkeleton() {
89
+ return (
90
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
91
+ {[1, 2, 3, 4].map((item) => (
92
+ <Card key={item} className="min-h-36">
93
+ <CardHeader>
94
+ <Skeleton className="h-4 w-28" />
95
+ <Skeleton className="h-8 w-20" />
96
+ </CardHeader>
97
+ <CardContent>
98
+ <Skeleton className="h-4 w-36" />
99
+ </CardContent>
100
+ </Card>
101
+ ))}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export default function AdminPage() {
107
+ const { user, loading } = useAuth();
108
+ const router = useRouter();
109
+ const [stats, setStats] = useState<AdminStats | null>(null);
110
+ const [statsLoading, setStatsLoading] = useState(true);
111
+ const [error, setError] = useState("");
112
+ const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
113
+
114
+ useEffect(() => {
115
+ if (loading) return;
116
+ if (!user) router.replace("/login");
117
+ else if (!user.is_admin) router.replace("/dashboard");
118
+ }, [loading, router, user]);
119
+
120
+ const loadStats = useCallback(async () => {
121
+ try {
122
+ setStatsLoading(true);
123
+ const data = await api.get<AdminStats>("/api/v1/admin/stats");
124
+ setStats(data);
125
+ setLastUpdated(new Date());
126
+ setError("");
127
+ } catch (err) {
128
+ const message =
129
+ err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
130
+ setError(message);
131
+ } finally {
132
+ setStatsLoading(false);
133
+ }
134
+ }, []);
135
+
136
+ useEffect(() => {
137
+ if (!user?.is_admin) return;
138
+
139
+ const initialLoad = window.setTimeout(() => void loadStats(), 0);
140
+ const interval = window.setInterval(() => void loadStats(), 10000);
141
+
142
+ return () => {
143
+ window.clearTimeout(initialLoad);
144
+ window.clearInterval(interval);
145
+ };
146
+ }, [loadStats, user?.is_admin]);
147
+
148
+ const diskDetail = useMemo(() => {
149
+ if (!stats) return "";
150
+ const disk = stats.disk_space_usage;
151
+ return `${formatBytes(disk.used_bytes)} used of ${formatBytes(disk.total_bytes)}`;
152
+ }, [stats]);
153
+
154
+ if (loading || !user || !user.is_admin) {
155
+ return (
156
+ <div className="min-h-screen flex items-center justify-center">
157
+ <div className="animate-pulse-glow w-12 h-12 rounded-full bg-primary/20" />
158
+ </div>
159
+ );
160
+ }
161
+
162
+ return (
163
+ <div className="min-h-screen bg-background">
164
+ <header className="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-border/50 bg-card/50 px-4 backdrop-blur-md">
165
+ <div className="flex items-center gap-3">
166
+ <Button
167
+ variant="ghost"
168
+ size="icon"
169
+ onClick={() => router.push("/dashboard")}
170
+ title="Back to dashboard"
171
+ >
172
+ <ArrowLeft className="h-4 w-4" />
173
+ </Button>
174
+ <div className="flex items-center gap-2">
175
+ <div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/15">
176
+ <Database className="h-4 w-4 text-primary" />
177
+ </div>
178
+ <span className="text-sm font-semibold">Admin Metrics</span>
179
+ </div>
180
+ </div>
181
+
182
+ <Button
183
+ variant="outline"
184
+ size="sm"
185
+ onClick={() => void loadStats()}
186
+ disabled={statsLoading}
187
+ >
188
+ <RefreshCw className={statsLoading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
189
+ Refresh
190
+ </Button>
191
+ </header>
192
+
193
+ <main className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 py-6">
194
+ <div className="flex flex-col gap-1">
195
+ <h1 className="text-2xl font-semibold tracking-normal">System overview</h1>
196
+ <p className="text-sm text-muted-foreground">
197
+ {lastUpdated
198
+ ? `Last updated ${lastUpdated.toLocaleTimeString()}`
199
+ : "Waiting for live metrics"}
200
+ </p>
201
+ </div>
202
+
203
+ {error && (
204
+ <div
205
+ role="alert"
206
+ className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
207
+ >
208
+ {error}
209
+ </div>
210
+ )}
211
+
212
+ {statsLoading && !stats ? (
213
+ <AdminSkeleton />
214
+ ) : stats ? (
215
+ <>
216
+ <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
217
+ <MetricCard
218
+ icon={Users}
219
+ label="Total users"
220
+ value={stats.total_users.toLocaleString()}
221
+ detail="Registered accounts"
222
+ />
223
+ <MetricCard
224
+ icon={FileText}
225
+ label="PDFs uploaded"
226
+ value={stats.total_pdfs_uploaded.toLocaleString()}
227
+ detail="All uploaded PDF records"
228
+ />
229
+ <MetricCard
230
+ icon={Clock3}
231
+ label="Avg response time"
232
+ value={formatTime(stats.average_query_response_time_ms)}
233
+ detail={`${stats.query_count.toLocaleString()} measured queries`}
234
+ />
235
+ <MetricCard
236
+ icon={HardDrive}
237
+ label="Upload storage"
238
+ value={formatBytes(stats.disk_space_usage.upload_dir_bytes)}
239
+ detail="Files in the upload directory"
240
+ />
241
+ </div>
242
+
243
+ <Card>
244
+ <CardHeader>
245
+ <CardTitle>Disk space usage</CardTitle>
246
+ <CardDescription>{diskDetail}</CardDescription>
247
+ </CardHeader>
248
+ <CardContent className="space-y-4">
249
+ <Progress value={stats.disk_space_usage.usage_percent} />
250
+ <div className="grid gap-3 text-sm sm:grid-cols-3">
251
+ <div>
252
+ <p className="text-muted-foreground">Used</p>
253
+ <p className="font-medium tabular-nums">
254
+ {formatBytes(stats.disk_space_usage.used_bytes)}
255
+ </p>
256
+ </div>
257
+ <div>
258
+ <p className="text-muted-foreground">Free</p>
259
+ <p className="font-medium tabular-nums">
260
+ {formatBytes(stats.disk_space_usage.free_bytes)}
261
+ </p>
262
+ </div>
263
+ <div>
264
+ <p className="text-muted-foreground">Usage</p>
265
+ <p className="font-medium tabular-nums">
266
+ {stats.disk_space_usage.usage_percent.toFixed(2)}%
267
+ </p>
268
+ </div>
269
+ </div>
270
+ </CardContent>
271
+ </Card>
272
+ </>
273
+ ) : null}
274
+ </main>
275
+ </div>
276
+ );
277
+ }
frontend/src/components/layout/Header.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
  PanelRightOpen,
20
  LogOut,
21
  Moon,
 
22
  Sun,
23
  } from "lucide-react";
24
  import { useState } from "react";
@@ -93,6 +94,13 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
93
  <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
94
  </div>
95
  <DropdownMenuSeparator />
 
 
 
 
 
 
 
96
  <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
97
  <LogOut className="w-4 h-4 mr-2" />
98
  Sign out
 
19
  PanelRightOpen,
20
  LogOut,
21
  Moon,
22
+ Shield,
23
  Sun,
24
  } from "lucide-react";
25
  import { useState } from "react";
 
94
  <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
95
  </div>
96
  <DropdownMenuSeparator />
97
+ {user?.is_admin && (
98
+ <DropdownMenuItem className="cursor-pointer" onClick={() => router.push("/admin")}>
99
+ <Shield className="w-4 h-4 mr-2" />
100
+ Admin metrics
101
+ </DropdownMenuItem>
102
+ )}
103
+ {user?.is_admin && <DropdownMenuSeparator />}
104
  <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
105
  <LogOut className="w-4 h-4 mr-2" />
106
  Sign out