Spaces:
Running
Running
Merge pull request #196 from Srushti-Kamble14/admin-metrics
Browse files- .github/workflows/ci.yml +3 -1
- backend/app/main.py +2 -0
- backend/app/metrics.py +33 -0
- backend/app/rag/vectorstore.py +8 -5
- backend/app/routes/admin.py +73 -0
- backend/app/routes/chat.py +85 -54
- backend/app/schemas.py +18 -0
- backend/tests/conftest.py +6 -5
- backend/tests/test_admin.py +65 -0
- frontend/src/app/admin/page.tsx +277 -0
- frontend/src/components/layout/Header.tsx +8 -2
.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
|
| 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()
|
| 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 |
-
|
| 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 |
@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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
finally:
|
| 191 |
-
|
| 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
|
| 20 |
-
os.environ
|
| 21 |
-
os.environ
|
| 22 |
-
os.environ
|
| 23 |
-
os.environ
|
|
|
|
| 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 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")}
|