mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 15:11:21 +02:00
Merge pull request #383 from Keybored02/feature/storage_meter
Add storage usage metering and breakdown
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1330,6 +1330,14 @@ export default {
|
||||
archiveSettings: 'アーカイブ設定',
|
||||
costTracking: 'コスト追跡',
|
||||
dataManagement: 'データ管理',
|
||||
storageUsage: 'ストレージ使用量',
|
||||
storageUsageDescription: 'カテゴリ別のデータ使用量の内訳',
|
||||
storageUsageTotal: '合計',
|
||||
storageUsageErrors: 'エラー',
|
||||
storageUsageOtherBreakdown: 'その他(静的アセット、スクリプト、設定ファイルを含む)',
|
||||
storageUsageSystem: 'システム',
|
||||
storageUsageData: 'データ',
|
||||
storageUsageUnavailable: 'ストレージ使用量情報は利用できません',
|
||||
enableMqtt: 'MQTTを有効化',
|
||||
useTls: 'TLSを使用',
|
||||
enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user