Paramjit Singh commited on
Commit
bd035a4
·
unverified ·
2 Parent(s): ba887a789cfd9a

Merge pull request #196 from Srushti-Kamble14/admin-metrics

Browse files
.github/workflows/ci.yml CHANGED
@@ -48,16 +48,18 @@ jobs:
48
  env:
49
  SECRET_KEY: ci-dummy-secret
50
  DATABASE_URL: sqlite:///./ci_test.db
 
51
  HF_TOKEN: ci-dummy-token
52
  UPLOAD_DIR: /tmp/uploads
53
  CHROMA_PERSIST_DIR: /tmp/chroma
54
  run: |
55
- python -c "import sys; sys.path.insert(0, 'backend'); from app.config import settings; print('Config imports OK')" || true
56
 
57
  - name: Run backend pytest suite
58
  env:
59
  SECRET_KEY: ci-dummy-secret
60
  DATABASE_URL: sqlite:///./ci_test.db
 
61
  HF_TOKEN: ci-dummy-token
62
  UPLOAD_DIR: /tmp/uploads
63
  CHROMA_PERSIST_DIR: /tmp/chroma
 
48
  env:
49
  SECRET_KEY: ci-dummy-secret
50
  DATABASE_URL: sqlite:///./ci_test.db
51
+ DEBUG: "false"
52
  HF_TOKEN: ci-dummy-token
53
  UPLOAD_DIR: /tmp/uploads
54
  CHROMA_PERSIST_DIR: /tmp/chroma
55
  run: |
56
+ python -c "import sys; sys.path.insert(0, 'backend'); from app.config import get_settings; get_settings(); print('Config imports OK')"
57
 
58
  - name: Run backend pytest suite
59
  env:
60
  SECRET_KEY: ci-dummy-secret
61
  DATABASE_URL: sqlite:///./ci_test.db
62
+ DEBUG: "false"
63
  HF_TOKEN: ci-dummy-token
64
  UPLOAD_DIR: /tmp/uploads
65
  CHROMA_PERSIST_DIR: /tmp/chroma
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,9 +19,9 @@ 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
24
  from app.rate_limit import limiter
25
 
26
  logger = logging.getLogger(__name__)
@@ -28,6 +29,28 @@ logger = logging.getLogger(__name__)
28
  router = APIRouter(prefix="/chat", tags=["Chat"])
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  @router.post("/ask", response_model=ChatResponse)
32
  @limiter.limit("10/minute")
33
  def ask_question(
@@ -63,38 +86,41 @@ 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 +182,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 +192,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.rate_limit import limiter
26
 
27
  logger = logging.getLogger(__name__)
 
29
  router = APIRouter(prefix="/chat", tags=["Chat"])
30
 
31
 
32
+ def generate_answer(question: str, user_id: str, document_id: Optional[str] = None):
33
+ """Import the RAG agent lazily so route tests can patch this boundary."""
34
+ from app.rag.agent import generate_answer as _generate_answer
35
+
36
+ return _generate_answer(
37
+ question=question,
38
+ user_id=user_id,
39
+ document_id=document_id,
40
+ )
41
+
42
+
43
+ def generate_answer_stream(question: str, user_id: str, document_id: Optional[str] = None):
44
+ """Import the streaming RAG agent lazily so route tests can patch this boundary."""
45
+ from app.rag.agent import generate_answer_stream as _generate_answer_stream
46
+
47
+ return _generate_answer_stream(
48
+ question=question,
49
+ user_id=user_id,
50
+ document_id=document_id,
51
+ )
52
+
53
+
54
  @router.post("/ask", response_model=ChatResponse)
55
  @limiter.limit("10/minute")
56
  def ask_question(
 
86
  HTTPException: 400 if the document exists but its status is not
87
  "ready" (e.g., still processing or failed).
88
  """
89
+ started_at = time.perf_counter()
90
+ try:
91
+ # Validate document exists if specified
92
+ if payload.document_id:
93
+ doc = db.query(Document).filter(
94
+ Document.id == payload.document_id,
95
+ Document.user_id == user.id,
96
+ ).first()
97
+
98
+ if not doc:
99
+ raise HTTPException(status_code=404, detail="Document not found")
100
+
101
+ if doc.status != "ready":
102
+ raise HTTPException(
103
+ status_code=400,
104
+ detail=f"Document is still {doc.status}. Please wait for processing to complete.",
105
+ )
106
+
107
+ result = generate_answer(
108
+ question=payload.question,
109
+ user_id=user.id,
110
+ document_id=payload.document_id,
111
+ )
112
 
113
+ # Save to chat history
114
+ _save_message(db, user.id, payload.document_id, "user", payload.question)
115
+ _save_message(db, user.id, payload.document_id, "assistant", result["answer"], result["sources"])
116
 
117
+ return ChatResponse(
118
+ answer=result["answer"],
119
+ sources=[SourceChunk(**s) for s in result["sources"]],
120
+ document_id=payload.document_id,
121
+ )
122
+ finally:
123
+ record_query_response_time(time.perf_counter() - started_at)
124
 
125
 
126
  @router.post("/ask/stream")
 
182
  detail=f"Document is still {doc.status}. Please wait for processing to complete.",
183
  )
184
 
185
+ started_at = time.perf_counter()
186
+
187
  # Save user message immediately
188
  _save_message(db, user.id, payload.document_id, "user", payload.question)
189
 
 
192
  full_answer = ""
193
  sources = []
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  try:
196
+ for chunk in generate_answer_stream(
197
+ question=payload.question,
198
+ user_id=user.id,
199
+ document_id=payload.document_id,
200
+ ):
201
+ yield chunk
202
+
203
+ # Parse to accumulate full answer for history
204
+ try:
205
+ if chunk.startswith("data: "):
206
+ data = json.loads(chunk[6:].strip())
207
+ if data.get("type") == "token":
208
+ full_answer += data.get("data", "")
209
+ elif data.get("type") == "sources":
210
+ sources = data.get("data", [])
211
+ except Exception:
212
+ pass
213
+
214
+ # Save assistant response to history
215
+ from app.database import SessionLocal
216
+ save_db = SessionLocal()
217
+ try:
218
+ _save_message(save_db, user.id, payload.document_id, "assistant", full_answer, sources)
219
+ finally:
220
+ save_db.close()
221
  finally:
222
+ record_query_response_time(time.perf_counter() - started_at)
223
 
224
  return StreamingResponse(
225
  event_stream(),
backend/app/schemas.py CHANGED
@@ -105,6 +105,24 @@ class DocumentListResponse(BaseModel):
105
  pages: int
106
 
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  # ── Chat ─────────────────────────────────────────────
109
 
110
  class ChatRequest(BaseModel):
 
105
  pages: int
106
 
107
 
108
+ # Admin
109
+
110
+ class DiskUsageResponse(BaseModel):
111
+ total_bytes: int
112
+ used_bytes: int
113
+ free_bytes: int
114
+ usage_percent: float
115
+ upload_dir_bytes: int
116
+
117
+
118
+ class AdminStatsResponse(BaseModel):
119
+ total_users: int
120
+ total_pdfs_uploaded: int
121
+ average_query_response_time_ms: float
122
+ query_count: int
123
+ disk_space_usage: DiskUsageResponse
124
+
125
+
126
  # ── Chat ─────────────────────────────────────────────
127
 
128
  class ChatRequest(BaseModel):
backend/tests/conftest.py CHANGED
@@ -16,11 +16,12 @@ BACKEND_DIR = ROOT / "backend"
16
  if str(BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(BACKEND_DIR))
18
 
19
- os.environ.setdefault("SECRET_KEY", "test-secret-key")
20
- os.environ.setdefault("DATABASE_URL", "sqlite:///./test_bootstrap.db")
21
- os.environ.setdefault("HF_TOKEN", "test-hf-token")
22
- os.environ.setdefault("UPLOAD_DIR", str(ROOT / "backend" / "test_uploads"))
23
- os.environ.setdefault("CHROMA_PERSIST_DIR", str(ROOT / "backend" / "test_chroma"))
 
24
 
25
 
26
  fake_embeddings = types.ModuleType("app.rag.embeddings")
 
16
  if str(BACKEND_DIR) not in sys.path:
17
  sys.path.insert(0, str(BACKEND_DIR))
18
 
19
+ os.environ["SECRET_KEY"] = "test-secret-key"
20
+ os.environ["DATABASE_URL"] = "sqlite:///./test_bootstrap.db"
21
+ os.environ["DEBUG"] = "false"
22
+ os.environ["HF_TOKEN"] = "test-hf-token"
23
+ os.environ["UPLOAD_DIR"] = str(ROOT / "backend" / "test_uploads")
24
+ os.environ["CHROMA_PERSIST_DIR"] = str(ROOT / "backend" / "test_chroma")
25
 
26
 
27
  fake_embeddings = types.ModuleType("app.rag.embeddings")
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
@@ -20,6 +20,7 @@ import {
20
  PanelRightOpen,
21
  LogOut,
22
  Moon,
 
23
  Sun,
24
  } from "lucide-react";
25
  import { useSyncExternalStore } from "react";
@@ -126,8 +127,13 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
126
  <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
127
  </div>
128
  <DropdownMenuSeparator />
129
- <ApiKeyManager />
130
- <DropdownMenuSeparator />
 
 
 
 
 
131
  <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
132
  <LogOut className="w-4 h-4 mr-2" />
133
  {t("header.signOut")}
 
20
  PanelRightOpen,
21
  LogOut,
22
  Moon,
23
+ Shield,
24
  Sun,
25
  } from "lucide-react";
26
  import { useSyncExternalStore } from "react";
 
127
  <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
128
  </div>
129
  <DropdownMenuSeparator />
130
+ {user?.is_admin && (
131
+ <DropdownMenuItem className="cursor-pointer" onClick={() => router.push("/admin")}>
132
+ <Shield className="w-4 h-4 mr-2" />
133
+ Admin metrics
134
+ </DropdownMenuItem>
135
+ )}
136
+ {user?.is_admin && <DropdownMenuSeparator />}
137
  <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
138
  <LogOut className="w-4 h-4 mr-2" />
139
  {t("header.signOut")}