diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index e52d639..eacf9a7 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -4,13 +4,50 @@ from datetime import datetime from pathlib import Path from urllib.parse import urlparse -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings +from app.core.database import get_session router = APIRouter() +def _dir_stats(path: Path) -> dict: + files = [f for f in path.rglob("*") if f.is_file() and not f.name.startswith(".")] + return {"count": len(files), "size_bytes": sum(f.stat().st_size for f in files)} + + +@router.get("/stats") +async def get_stats(session: AsyncSession = Depends(get_session)): + # Taille de la BDD + db_size = (await session.execute(text("SELECT pg_database_size(current_database())"))).scalar() + + # Compteurs + notes_count = (await session.execute(text("SELECT COUNT(*) FROM notes.items"))).scalar() + todos_count = (await session.execute(text("SELECT COUNT(*) FROM todos.items"))).scalar() + lists_count = (await session.execute(text("SELECT COUNT(*) FROM shopping.lists"))).scalar() + + # Médias sur le disque + uploads = settings.upload_path + photos = _dir_stats(uploads / "images" / "originals") if (uploads / "images" / "originals").exists() else {"count": 0, "size_bytes": 0} + audio = _dir_stats(uploads / "audio") if (uploads / "audio").exists() else {"count": 0, "size_bytes": 0} + + return { + "db_size_bytes": db_size, + "media": { + "photos": photos, + "audio": audio, + }, + "counts": { + "notes": notes_count, + "todos": todos_count, + "shopping_lists": lists_count, + }, + } + + def _pg_env() -> dict: url = urlparse(settings.database_url.replace("+asyncpg", "")) env = os.environ.copy() diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 232edd1..3dd8d4a 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -1,3 +1,22 @@ +export interface AppStats { + db_size_bytes: number + media: { + photos: { count: number; size_bytes: number } + audio: { count: number; size_bytes: number } + } + counts: { + notes: number + todos: number + shopping_lists: number + } +} + +export async function fetchStats(): Promise { + const res = await fetch('/api/admin/stats') + if (!res.ok) throw new Error('Erreur chargement stats') + return res.json() as Promise +} + export interface BackupFile { filename: string size: number diff --git a/frontend/src/pages/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx index 00129ab..f407e75 100644 --- a/frontend/src/pages/ConfigPage.tsx +++ b/frontend/src/pages/ConfigPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useTheme, type ThemeMode } from '../contexts/ThemeContext' -import { fetchBackups, createBackup, restoreBackup, type BackupFile } from '../api/admin' +import { fetchBackups, createBackup, restoreBackup, fetchStats, type BackupFile, type AppStats } from '../api/admin' const sectionStyle: React.CSSProperties = { background: 'var(--bg-3)', @@ -46,12 +46,14 @@ function formatDate(iso: string): string { export default function ConfigPage() { const navigate = useNavigate() const { theme, setTheme, fontScale, setFontScale } = useTheme() + const [stats, setStats] = useState(null) const [backups, setBackups] = useState([]) const [backupLoading, setBackupLoading] = useState(false) const [restoring, setRestoring] = useState(null) const [backupError, setBackupError] = useState(null) useEffect(() => { + fetchStats().then(setStats).catch(() => null) fetchBackups().then(setBackups).catch(() => setBackups([])) }, []) @@ -132,6 +134,58 @@ export default function ConfigPage() { + {/* Statistiques */} +
+
Statistiques
+ {stats ? ( +
+ {/* Compteurs entités */} +
+ {[ + { icon: 'list-check', label: 'Todos', value: stats.counts.todos }, + { icon: 'note-sticky', label: 'Notes', value: stats.counts.notes }, + { icon: 'cart-shopping', label: 'Listes', value: stats.counts.shopping_lists }, + ].map(item => ( +
+ +
{item.value}
+
{item.label}
+
+ ))} +
+ + {/* Médias */} +
+ {[ + { icon: 'image', label: 'Photos', data: stats.media.photos }, + { icon: 'microphone', label: 'Audio', data: stats.media.audio }, + ].map(item => ( +
+ +
+
+ {item.data.count} {item.label.toLowerCase()} +
+
+ {formatSize(item.data.size_bytes)} +
+
+
+ ))} +
+ + {/* BDD */} +
+ + Base de données + {formatSize(stats.db_size_bytes)} +
+
+ ) : ( +
Chargement…
+ )} +
+ {/* Sauvegarde & Restauration */}
Base de données