feat(config): section statistiques — BDD, médias, entités

- GET /api/admin/stats : taille BDD (pg_database_size), nb+poids photos/audio
  (scan filesystem), nb notes/todos/listes (requêtes SQL directes)
- ConfigPage : grille 3 colonnes todos/notes/listes + 2 tuiles médias + ligne BDD

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:04:08 +02:00
parent de9a1e3c73
commit 454dbadb2f
3 changed files with 112 additions and 2 deletions
+38 -1
View File
@@ -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()
+19
View File
@@ -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<AppStats> {
const res = await fetch('/api/admin/stats')
if (!res.ok) throw new Error('Erreur chargement stats')
return res.json() as Promise<AppStats>
}
export interface BackupFile {
filename: string
size: number
+55 -1
View File
@@ -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<AppStats | null>(null)
const [backups, setBackups] = useState<BackupFile[]>([])
const [backupLoading, setBackupLoading] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [backupError, setBackupError] = useState<string | null>(null)
useEffect(() => {
fetchStats().then(setStats).catch(() => null)
fetchBackups().then(setBackups).catch(() => setBackups([]))
}, [])
@@ -132,6 +134,58 @@ export default function ConfigPage() {
</div>
</div>
{/* Statistiques */}
<div style={sectionStyle}>
<div style={labelStyle}>Statistiques</div>
{stats ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Compteurs entités */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 8 }}>
{[
{ 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 => (
<div key={item.label} style={{ background: 'var(--bg-4)', borderRadius: 8, padding: '10px 8px', textAlign: 'center' }}>
<i className={`fa-solid fa-${item.icon}`} style={{ color: 'var(--accent)', fontSize: 16, display: 'block', marginBottom: 4 }} />
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 18, fontWeight: 700, color: 'var(--ink-1)' }}>{item.value}</div>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 10, color: 'var(--ink-4)', textTransform: 'uppercase', letterSpacing: 0.5 }}>{item.label}</div>
</div>
))}
</div>
{/* Médias */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[
{ icon: 'image', label: 'Photos', data: stats.media.photos },
{ icon: 'microphone', label: 'Audio', data: stats.media.audio },
].map(item => (
<div key={item.label} style={{ background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 8 }}>
<i className={`fa-solid fa-${item.icon}`} style={{ color: 'var(--info)', fontSize: 14, flexShrink: 0 }} />
<div>
<div style={{ fontFamily: 'var(--font-ui)', fontSize: 12, color: 'var(--ink-2)' }}>
{item.data.count} {item.label.toLowerCase()}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--ink-4)' }}>
{formatSize(item.data.size_bytes)}
</div>
</div>
</div>
))}
</div>
{/* BDD */}
<div style={{ background: 'var(--bg-4)', borderRadius: 8, padding: '8px 10px', display: 'flex', alignItems: 'center', gap: 8 }}>
<i className="fa-solid fa-database" style={{ color: 'var(--ok)', fontSize: 14, flexShrink: 0 }} />
<span style={{ fontFamily: 'var(--font-ui)', fontSize: 12, color: 'var(--ink-2)', flex: 1 }}>Base de données</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--ink-3)' }}>{formatSize(stats.db_size_bytes)}</span>
</div>
</div>
) : (
<div style={{ color: 'var(--ink-4)', fontFamily: 'var(--font-ui)', fontSize: 12 }}>Chargement…</div>
)}
</div>
{/* Sauvegarde & Restauration */}
<div style={sectionStyle}>
<div style={labelStyle}>Base de données</div>