Merge pull request #383 from Keybored02/feature/storage_meter

Add storage usage metering and breakdown
This commit is contained in:
MartinNYHC
2026-02-15 14:49:37 +01:00
committed by GitHub
8 changed files with 583 additions and 1 deletions
+340
View File
@@ -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)
+41
View File
@@ -3825,6 +3825,14 @@ export const api = {
// System Info
getSystemInfo: () => request<SystemInfo>('/system/info'),
getStorageUsage: (options?: { refresh?: boolean }) => {
const params = new URLSearchParams();
if (options?.refresh) {
params.set('refresh', 'true');
}
const query = params.toString();
return request<StorageUsageResponse>(`/system/storage-usage${query ? `?${query}` : ''}`);
},
// Library (File Manager)
getLibraryFolders: () => request<LibraryFolderTree[]>('/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;
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -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',
+8
View File
@@ -1330,6 +1330,14 @@ export default {
archiveSettings: 'アーカイブ設定',
costTracking: 'コスト追跡',
dataManagement: 'データ管理',
storageUsage: 'ストレージ使用量',
storageUsageDescription: 'カテゴリ別のデータ使用量の内訳',
storageUsageTotal: '合計',
storageUsageErrors: 'エラー',
storageUsageOtherBreakdown: 'その他(静的アセット、スクリプト、設定ファイルを含む)',
storageUsageSystem: 'システム',
storageUsageData: 'データ',
storageUsageUnavailable: 'ストレージ使用量情報は利用できません',
enableMqtt: 'MQTTを有効化',
useTls: 'TLSを使用',
enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
+162 -1
View File
@@ -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<string, string> = {
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<StorageUsageResponse>({
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
</Button>
</div>
<div className="pt-4 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between">
<div>
<p className="text-white">{t('settings.storageUsage', 'Storage Usage')}</p>
<p className="text-sm text-bambu-gray">
{t('settings.storageUsageDescription', 'Breakdown of data usage by category')}
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleStorageUsageRefresh}
disabled={storageUsageFetching || storageUsageRefreshing}
>
<RefreshCw
className={`w-4 h-4 ${storageUsageFetching || storageUsageRefreshing ? 'animate-spin' : ''}`}
/>
{t('common.refresh', 'Refresh')}
</Button>
</div>
<div className="mt-3">
{storageUsageLoading ? (
<div className="flex items-center gap-2 text-sm text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
{t('common.loading', 'Loading')}
</div>
) : storageUsage ? (
<>
<div className="w-full h-3 bg-bambu-dark rounded-full overflow-hidden flex">
{storageUsage.categories
.filter((category) => category.bytes > 0)
.map((category, index) => (
<div
key={category.key}
className={`${getStorageColor(category.key, index)} h-full`}
style={{ width: `${category.percent_of_total}%` }}
title={`${category.label}: ${category.formatted}`}
/>
))}
</div>
<div className="mt-3 flex flex-wrap gap-3">
{storageUsage.categories
.filter((category) => category.bytes > 0)
.map((category, index) => (
<div key={category.key} className="flex items-center gap-2 text-xs">
<span
className={`w-3 h-3 rounded-full ${getStorageColor(category.key, index)}`}
/>
<span className="text-bambu-gray">{category.label}</span>
<span className="text-white">{category.formatted}</span>
<span className="text-bambu-gray">({category.percent_of_total.toFixed(1)}%)</span>
</div>
))}
</div>
<div className="mt-2 text-xs text-bambu-gray">
{t('settings.storageUsageTotal', 'Total')}: <span className="text-white">{storageUsage.total_formatted}</span>
{storageUsage.scan_errors > 0 && (
<span className="ml-2 text-amber-400">
{t('settings.storageUsageErrors', 'Scan errors')}: {storageUsage.scan_errors}
</span>
)}
</div>
{storageUsage.other_breakdown?.length > 0 && (
<div className="mt-4">
<p className="text-xs text-bambu-gray mb-2">
{t('settings.storageUsageOtherBreakdown', 'Other breakdown')}
</p>
<div className="space-y-2">
{storageUsage.other_breakdown.map((item) => (
<div key={`${item.bucket}-${item.kind}`} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-white">{item.label}</span>
<span
className={`px-2 py-0.5 rounded-full border ${
item.kind === 'system'
? 'border-slate-500 text-slate-300'
: 'border-bambu-green text-bambu-green'
}`}
>
{item.kind === 'system'
? t('settings.storageUsageSystem', 'System')
: t('settings.storageUsageData', 'Data')}
</span>
</div>
<div className="flex items-center gap-2 text-bambu-gray">
<span className="text-white">{item.formatted}</span>
<span>({item.percent_of_total.toFixed(1)}%)</span>
</div>
</div>
))}
</div>
</div>
)}
</>
) : (
<p className="text-sm text-bambu-gray">
{t('settings.storageUsageUnavailable', 'Storage usage data is unavailable')}
</p>
)}
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-bambu-dark-tertiary">
<div>
<p className="text-white">Backup & Restore</p>