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 pathlib import Path
|
||||||
from urllib.parse import urlparse
|
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.config import settings
|
||||||
|
from app.core.database import get_session
|
||||||
|
|
||||||
router = APIRouter()
|
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:
|
def _pg_env() -> dict:
|
||||||
url = urlparse(settings.database_url.replace("+asyncpg", ""))
|
url = urlparse(settings.database_url.replace("+asyncpg", ""))
|
||||||
env = os.environ.copy()
|
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 {
|
export interface BackupFile {
|
||||||
filename: string
|
filename: string
|
||||||
size: number
|
size: number
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useTheme, type ThemeMode } from '../contexts/ThemeContext'
|
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 = {
|
const sectionStyle: React.CSSProperties = {
|
||||||
background: 'var(--bg-3)',
|
background: 'var(--bg-3)',
|
||||||
@@ -46,12 +46,14 @@ function formatDate(iso: string): string {
|
|||||||
export default function ConfigPage() {
|
export default function ConfigPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { theme, setTheme, fontScale, setFontScale } = useTheme()
|
const { theme, setTheme, fontScale, setFontScale } = useTheme()
|
||||||
|
const [stats, setStats] = useState<AppStats | null>(null)
|
||||||
const [backups, setBackups] = useState<BackupFile[]>([])
|
const [backups, setBackups] = useState<BackupFile[]>([])
|
||||||
const [backupLoading, setBackupLoading] = useState(false)
|
const [backupLoading, setBackupLoading] = useState(false)
|
||||||
const [restoring, setRestoring] = useState<string | null>(null)
|
const [restoring, setRestoring] = useState<string | null>(null)
|
||||||
const [backupError, setBackupError] = useState<string | null>(null)
|
const [backupError, setBackupError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
fetchStats().then(setStats).catch(() => null)
|
||||||
fetchBackups().then(setBackups).catch(() => setBackups([]))
|
fetchBackups().then(setBackups).catch(() => setBackups([]))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -132,6 +134,58 @@ export default function ConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Sauvegarde & Restauration */}
|
||||||
<div style={sectionStyle}>
|
<div style={sectionStyle}>
|
||||||
<div style={labelStyle}>Base de données</div>
|
<div style={labelStyle}>Base de données</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user