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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user