diff --git a/backend/app/api/routes/system.py b/backend/app/api/routes/system.py index 82acf445d..051e59b37 100644 --- a/backend/app/api/routes/system.py +++ b/backend/app/api/routes/system.py @@ -1,6 +1,10 @@ """System information API routes.""" +import asyncio +import os import platform +import time +from collections.abc import Callable from datetime import datetime from pathlib import Path @@ -23,6 +27,11 @@ from backend.app.services.printer_manager import printer_manager router = APIRouter(prefix="/system", tags=["system"]) +STORAGE_USAGE_CACHE_SECONDS = 300 +_storage_usage_cache: dict | None = None +_storage_usage_cache_ts: float | None = None +_storage_usage_lock = asyncio.Lock() + def get_directory_size(path: Path) -> int: """Calculate total size of a directory in bytes.""" @@ -62,6 +71,326 @@ def format_uptime(seconds: float) -> str: return " ".join(parts) if parts else "< 1m" +def _is_under(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except ValueError: + return False + + +def _get_database_paths() -> list[Path]: + candidates = [settings.base_dir / "bambuddy.db", settings.base_dir / "bambutrack.db"] + return [path for path in candidates if path.exists()] + + +def _get_database_items() -> list[dict]: + items: list[dict] = [] + for path in _get_database_paths(): + try: + size = path.stat().st_size + except OSError: + continue + items.append( + { + "name": path.name, + "path": str(path), + "bytes": size, + "formatted": format_bytes(size), + } + ) + items.sort(key=lambda item: item["bytes"], reverse=True) + return items + + +def _get_app_dir() -> Path: + return settings.static_dir.parent + + +def _get_data_dirs() -> list[Path]: + return [ + settings.archive_dir, + settings.log_dir, + settings.plate_calibration_dir, + settings.base_dir / "virtual_printer", + settings.base_dir / "firmware", + ] + + +def _is_system_path(path: Path) -> bool: + app_dir = _get_app_dir() + if not _is_under(path, app_dir): + return False + return all(not _is_under(path, data_dir) for data_dir in _get_data_dirs()) + + +def _get_storage_rules() -> list[tuple[str, str, Callable]]: + base_dir = settings.base_dir + archive_dir = settings.archive_dir + library_dir = archive_dir / "library" + virtual_printer_dir = base_dir / "virtual_printer" + upload_dir = virtual_printer_dir / "uploads" + + db_paths = set(_get_database_paths()) + + return [ + ( + "database", + "Database", + lambda path: path in db_paths, + ), + ( + "library_thumbnails", + "Library Thumbnails", + lambda path: _is_under(path, library_dir / "thumbnails"), + ), + ( + "library_files", + "Library Files", + lambda path: _is_under(path, library_dir / "files"), + ), + ( + "library_other", + "Library Other", + lambda path: _is_under(path, library_dir), + ), + ( + "archive_timelapses", + "Timelapses", + lambda path: _is_under(path, archive_dir) and "timelapse" in path.name.lower(), + ), + ( + "archive_thumbnails", + "Thumbnails", + lambda path: _is_under(path, archive_dir) and path.name.lower().startswith("thumbnail"), + ), + ( + "archive_files", + "Archives", + lambda path: _is_under(path, archive_dir), + ), + ( + "virtual_printer_upload_cache", + "Virtual Printer Upload Cache", + lambda path: _is_under(path, upload_dir / "cache"), + ), + ( + "virtual_printer_uploads", + "Virtual Printer Uploads", + lambda path: _is_under(path, upload_dir), + ), + ( + "virtual_printer_certs", + "Virtual Printer Certs", + lambda path: _is_under(path, virtual_printer_dir / "certs"), + ), + ( + "virtual_printer_other", + "Virtual Printer Other", + lambda path: _is_under(path, virtual_printer_dir), + ), + ( + "downloads", + "Downloads", + lambda path: _is_under(path, base_dir / "firmware"), + ), + ( + "plate_calibration", + "Plate Calibration", + lambda path: _is_under(path, settings.plate_calibration_dir), + ), + ( + "logs", + "Logs", + lambda path: _is_under(path, settings.log_dir), + ), + ] + + +def _classify_file(path: Path, rules: list[tuple[str, str, Callable]]) -> tuple[str, str]: + for key, label, matcher in rules: + try: + if matcher(path): + return key, label + except OSError: + continue + return "other_data", "Other" + + +def _format_percentage(part: int, total: int) -> float: + if total <= 0: + return 0.0 + return round((part / total) * 100, 2) + + +def _get_other_bucket(path: Path, base_dir: Path) -> str: + try: + relative = path.resolve().relative_to(base_dir.resolve()) + except ValueError: + return path.parent.name or path.name + + parts = relative.parts + return parts[0] if parts else path.name + + +def _walk_files(roots: list[Path]) -> list[Path]: + files: list[Path] = [] + stack = [root for root in roots if root.exists()] + while stack: + current = stack.pop() + try: + with os.scandir(current) as entries: + for entry in entries: + try: + if entry.is_symlink(): + continue + if entry.is_dir(follow_symlinks=False): + stack.append(Path(entry.path)) + elif entry.is_file(follow_symlinks=False): + files.append(Path(entry.path)) + except OSError: + continue + except OSError: + continue + return files + + +def _scan_storage_usage() -> dict: + base_dir = settings.base_dir + rules = _get_storage_rules() + + roots = _get_data_dirs() + + seen_roots = set() + unique_roots = [] + for root in roots: + resolved = root.resolve() + if resolved not in seen_roots: + seen_roots.add(resolved) + unique_roots.append(root) + + total_bytes = 0 + error_count = 0 + category_sizes: dict[str, dict] = {} + other_breakdown: dict[tuple[str, str], int] = {} + database_items = _get_database_items() + + files = _walk_files(unique_roots) + for file_path in files: + try: + size = file_path.stat().st_size + except OSError: + error_count += 1 + continue + + total_bytes += size + + key, label = _classify_file(file_path, rules) + if key not in category_sizes: + category_sizes[key] = {"key": key, "label": label, "bytes": 0} + category_sizes[key]["bytes"] += size + + if key == "other_data": + bucket = _get_other_bucket(file_path, base_dir) + kind = "system" if _is_system_path(file_path) else "data" + other_breakdown[(bucket, kind)] = other_breakdown.get((bucket, kind), 0) + size + + for item in database_items: + total_bytes += item["bytes"] + key = "database" + label = "Database" + if key not in category_sizes: + category_sizes[key] = {"key": key, "label": label, "bytes": 0} + category_sizes[key]["bytes"] += item["bytes"] + + categories = [] + for item in category_sizes.values(): + bytes_value = item["bytes"] + categories.append( + { + "key": item["key"], + "label": item["label"], + "bytes": bytes_value, + "formatted": format_bytes(bytes_value), + "percent_of_total": _format_percentage(bytes_value, total_bytes), + } + ) + + categories.sort(key=lambda entry: entry["bytes"], reverse=True) + + other_items = [] + for (bucket, kind), size in other_breakdown.items(): + other_items.append( + { + "bucket": bucket, + "label": bucket, + "kind": kind, + "deletable": kind != "system", + "bytes": size, + "formatted": format_bytes(size), + "percent_of_total": _format_percentage(size, total_bytes), + } + ) + + other_items.sort(key=lambda entry: entry["bytes"], reverse=True) + + return { + "roots": [str(root) for root in unique_roots], + "total_bytes": total_bytes, + "total_formatted": format_bytes(total_bytes), + "categories": categories, + "other_breakdown": other_items, + "scan_errors": error_count, + } + + +async def _get_storage_usage_cached(refresh: bool, max_age_seconds: int) -> dict: + global _storage_usage_cache + global _storage_usage_cache_ts + + now = time.time() + if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None: + age = now - _storage_usage_cache_ts + if age < max_age_seconds: + return { + **_storage_usage_cache, + "cache": { + "hit": True, + "age_seconds": round(age, 2), + "max_age_seconds": max_age_seconds, + }, + } + + async with _storage_usage_lock: + now = time.time() + if not refresh and _storage_usage_cache and _storage_usage_cache_ts is not None: + age = now - _storage_usage_cache_ts + if age < max_age_seconds: + return { + **_storage_usage_cache, + "cache": { + "hit": True, + "age_seconds": round(age, 2), + "max_age_seconds": max_age_seconds, + }, + } + + snapshot = await asyncio.to_thread(_scan_storage_usage) + _storage_usage_cache = { + **snapshot, + "generated_at": datetime.now().isoformat(), + } + _storage_usage_cache_ts = time.time() + return { + **_storage_usage_cache, + "cache": { + "hit": False, + "age_seconds": 0, + "max_age_seconds": max_age_seconds, + }, + } + + @router.get("/info") async def get_system_info( db: AsyncSession = Depends(get_db), @@ -199,3 +528,14 @@ async def get_system_info( "percent": psutil.cpu_percent(interval=0.1), }, } + + +@router.get("/storage-usage") +async def get_storage_usage( + refresh: bool = False, + max_age_seconds: int = STORAGE_USAGE_CACHE_SECONDS, + _: User | None = RequirePermissionIfAuthEnabled(Permission.SYSTEM_READ), +): + """Get storage usage breakdown for Bambuddy data directories.""" + max_age_seconds = max(0, min(max_age_seconds, 3600)) + return await _get_storage_usage_cached(refresh=refresh, max_age_seconds=max_age_seconds) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 65f054366..8b189d5c0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -3825,6 +3825,14 @@ export const api = { // System Info getSystemInfo: () => request('/system/info'), + getStorageUsage: (options?: { refresh?: boolean }) => { + const params = new URLSearchParams(); + if (options?.refresh) { + params.set('refresh', 'true'); + } + const query = params.toString(); + return request(`/system/storage-usage${query ? `?${query}` : ''}`); + }, // Library (File Manager) getLibraryFolders: () => request('/library/folders'), @@ -4167,6 +4175,39 @@ export interface SystemInfo { }; } +export interface StorageUsageCategory { + key: string; + label: string; + bytes: number; + formatted: string; + percent_of_total: number; +} + +export interface StorageUsageOtherItem { + bucket: string; + label: string; + kind: 'system' | 'data'; + deletable: boolean; + bytes: number; + formatted: string; + percent_of_total: number; +} + +export interface StorageUsageResponse { + roots: string[]; + total_bytes: number; + total_formatted: string; + categories: StorageUsageCategory[]; + other_breakdown: StorageUsageOtherItem[]; + scan_errors: number; + generated_at: string; + cache: { + hit: boolean; + age_seconds: number; + max_age_seconds: number; + }; +} + // Library (File Manager) types export interface LibraryFolderTree { id: number; diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index d8dd06b1c..82fc392db 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -1406,6 +1406,14 @@ export default { printsOnly: 'Nur Drucke', totalConsumption: 'Gesamtverbrauch', dataManagement: 'Datenverwaltung', + storageUsage: 'Speichernutzung', + storageUsageDescription: 'Aufschlüsselung der Datennutzung nach Kategorie', + storageUsageTotal: 'Gesamt', + storageUsageErrors: 'Fehler', + storageUsageOtherBreakdown: 'Sonstiges (enthält statische Assets, Skripte und Konfigurationsdateien)', + storageUsageSystem: 'System', + storageUsageData: 'Daten', + storageUsageUnavailable: 'Speichernutzungsinformationen nicht verfügbar', clearNotificationLogsDescription: 'Benachrichtigungsprotokolle älter als 30 Tage löschen', resetUiPreferencesDescription: 'Seitenleisten-Reihenfolge, Theme, Ansichtsmodi und Layout-Einstellungen zurücksetzen. Drucker, Archive und Einstellungen werden nicht beeinflusst.', enableHomeAssistant: 'Home Assistant aktivieren', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4cb043511..c394f9df0 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1406,6 +1406,14 @@ export default { printsOnly: 'Prints Only', totalConsumption: 'Total Consumption', dataManagement: 'Data Management', + storageUsage: 'Storage Usage', + storageUsageDescription: 'Breakdown of data usage by category', + storageUsageTotal: 'Total', + storageUsageErrors: 'Errors', + storageUsageOtherBreakdown: 'Other (includes static assets, scripts, and configuration files)', + storageUsageSystem: 'System', + storageUsageData: 'Data', + storageUsageUnavailable: 'Storage usage information unavailable', clearNotificationLogsDescription: 'Delete notification logs older than 30 days', resetUiPreferencesDescription: 'Reset sidebar order, theme, view modes, and layout preferences. Printers, archives, and settings are not affected.', enableHomeAssistant: 'Enable Home Assistant', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index aef55a3e0..4102de98c 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -1402,6 +1402,14 @@ export default { printsOnly: 'Impressions uniquement', totalConsumption: 'Consommation totale', dataManagement: 'Gestion des données', + storageUsage: 'Utilisation du stockage', + storageUsageDescription: 'Répartition de l\'utilisation des données par catégorie', + storageUsageTotal: 'Total', + storageUsageErrors: 'Erreurs', + storageUsageOtherBreakdown: 'Autre (inclut ressources statiques, scripts et fichiers de configuration)', + storageUsageSystem: 'Système', + storageUsageData: 'Données', + storageUsageUnavailable: 'Informations d\'utilisation du stockage non disponibles', clearNotificationLogsDescription: 'Supprimer logs de plus de 30 jours', resetUiPreferencesDescription: 'Réinitialise thèmes et affichage sans toucher aux données.', enableHomeAssistant: 'Activer Home Assistant', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 22f3ed522..557e0a42f 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -1312,6 +1312,14 @@ export default { printsOnly: 'Solo stampe', totalConsumption: 'Consumo totale', dataManagement: 'Gestione dati', + storageUsage: 'Memoria utilizzata', + storageUsageDescription: 'Ripartizione della memoria per categoria', + storageUsageTotal: 'Totale', + storageUsageErrors: 'Errori', + storageUsageOtherBreakdown: 'Altro (include risorse statiche, script e file di configurazione)', + storageUsageSystem: 'Sistema', + storageUsageData: 'Dati', + storageUsageUnavailable: 'Informazioni sull\'utilizzo della memoria non disponibili', clearNotificationLogsDescription: 'Elimina log notifiche più vecchi di 30 giorni', resetUiPreferencesDescription: 'Reimposta ordine barra laterale, tema, modalità vista e preferenze layout. Stampanti, archivi e impostazioni non vengono modificati.', enableHomeAssistant: 'Abilita Home Assistant', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index d0677f6aa..8fd4537e1 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -1330,6 +1330,14 @@ export default { archiveSettings: 'アーカイブ設定', costTracking: 'コスト追跡', dataManagement: 'データ管理', + storageUsage: 'ストレージ使用量', + storageUsageDescription: 'カテゴリ別のデータ使用量の内訳', + storageUsageTotal: '合計', + storageUsageErrors: 'エラー', + storageUsageOtherBreakdown: 'その他(静的アセット、スクリプト、設定ファイルを含む)', + storageUsageSystem: 'システム', + storageUsageData: 'データ', + storageUsageUnavailable: 'ストレージ使用量情報は利用できません', enableMqtt: 'MQTTを有効化', useTls: 'TLSを使用', enableMetricsEndpoint: 'メトリクスエンドポイントを有効化', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 2ddde7bf4..b13788bc5 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -6,7 +6,7 @@ import { api } from '../api/client'; import { useAuth } from '../contexts/AuthContext'; import { formatDateOnly } from '../utils/date'; import { getCurrencySymbol, SUPPORTED_CURRENCIES } from '../utils/currency'; -import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, Group, GroupCreate, GroupUpdate, Permission, PermissionCategory } from '../api/client'; +import type { AppSettings, AppSettingsUpdate, SmartPlug, SmartPlugStatus, NotificationProvider, NotificationTemplate, UpdateStatus, GitHubBackupStatus, CloudAuthStatus, UserCreate, UserUpdate, UserResponse, Group, GroupCreate, GroupUpdate, Permission, PermissionCategory, StorageUsageResponse } from '../api/client'; import { Card, CardContent, CardHeader } from '../components/Card'; import { Button } from '../components/Button'; import { SmartPlugCard } from '../components/SmartPlugCard'; @@ -37,6 +37,38 @@ const validTabs = ['general', 'network', 'plugs', 'notifications', 'filament', ' type TabType = typeof validTabs[number]; type UsersSubTab = 'users' | 'email'; +const STORAGE_CATEGORY_COLORS: Record = { + database: 'bg-blue-600', + library_files: 'bg-green-500', + library_thumbnails: 'bg-teal-500', + library_other: 'bg-emerald-700', + archive_timelapses: 'bg-red-500', + archive_thumbnails: 'bg-amber-500', + archive_files: 'bg-sky-500', + virtual_printer_uploads: 'bg-purple-500', + virtual_printer_upload_cache: 'bg-fuchsia-500', + virtual_printer_certs: 'bg-violet-500', + virtual_printer_other: 'bg-purple-700', + downloads: 'bg-cyan-500', + plate_calibration: 'bg-lime-500', + logs: 'bg-orange-500', + other_data: 'bg-yellow-500', +}; + +const STORAGE_FALLBACK_COLORS = [ + 'bg-blue-500', + 'bg-green-500', + 'bg-yellow-500', + 'bg-red-500', + 'bg-orange-500', + 'bg-teal-500', + 'bg-cyan-500', + 'bg-purple-500', +]; + +const getStorageColor = (key: string, index: number) => + STORAGE_CATEGORY_COLORS[key] || STORAGE_FALLBACK_COLORS[index % STORAGE_FALLBACK_COLORS.length]; + export function SettingsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -100,6 +132,7 @@ export function SettingsPage() { const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }); const [changePasswordLoading, setChangePasswordLoading] = useState(false); + const [storageUsageRefreshing, setStorageUsageRefreshing] = useState(false); // User management state const [showCreateUserModal, setShowCreateUserModal] = useState(false); @@ -163,6 +196,33 @@ export function SettingsPage() { queryFn: api.getSettings, }); + const { + data: storageUsage, + isLoading: storageUsageLoading, + isFetching: storageUsageFetching, + } = useQuery({ + queryKey: ['storage-usage'], + queryFn: () => api.getStorageUsage(), + enabled: activeTab === 'general', + staleTime: Infinity, + refetchInterval: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const handleStorageUsageRefresh = async () => { + setStorageUsageRefreshing(true); + try { + const data = await api.getStorageUsage({ refresh: true }); + queryClient.setQueryData(['storage-usage'], data); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to refresh storage usage'; + showToast(message, 'error'); + } finally { + setStorageUsageRefreshing(false); + } + }; + const { data: smartPlugs, isLoading: plugsLoading } = useQuery({ queryKey: ['smart-plugs'], queryFn: api.getSmartPlugs, @@ -1878,6 +1938,107 @@ export function SettingsPage() { Reset +
+
+
+

{t('settings.storageUsage', 'Storage Usage')}

+

+ {t('settings.storageUsageDescription', 'Breakdown of data usage by category')} +

+
+ +
+
+ {storageUsageLoading ? ( +
+ + {t('common.loading', 'Loading')} +
+ ) : storageUsage ? ( + <> +
+ {storageUsage.categories + .filter((category) => category.bytes > 0) + .map((category, index) => ( +
+ ))} +
+
+ {storageUsage.categories + .filter((category) => category.bytes > 0) + .map((category, index) => ( +
+ + {category.label} + {category.formatted} + ({category.percent_of_total.toFixed(1)}%) +
+ ))} +
+
+ {t('settings.storageUsageTotal', 'Total')}: {storageUsage.total_formatted} + {storageUsage.scan_errors > 0 && ( + + {t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors} + + )} +
+ {storageUsage.other_breakdown?.length > 0 && ( +
+

+ {t('settings.storageUsageOtherBreakdown', 'Other breakdown')} +

+
+ {storageUsage.other_breakdown.map((item) => ( +
+
+ {item.label} + + {item.kind === 'system' + ? t('settings.storageUsageSystem', 'System') + : t('settings.storageUsageData', 'Data')} + +
+
+ {item.formatted} + ({item.percent_of_total.toFixed(1)}%) +
+
+ ))} +
+
+ )} + + ) : ( +

+ {t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')} +

+ )} +
+

Backup & Restore