Procházet zdrojové kódy

Add storgae usage metering and breakdown

Matteo Parenti před 3 měsíci
rodič
revize
26557f6038

+ 350 - 0
backend/app/api/routes/system.py

@@ -1,6 +1,9 @@
 """System information API routes."""
 
+import asyncio
+import os
 import platform
+import time
 from datetime import datetime
 from pathlib import Path
 
@@ -23,6 +26,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 +70,337 @@ 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 _get_storage_scan_roots() -> list[Path]:
+    roots = []
+    for path in _get_data_dirs():
+        roots.append(path)
+    return roots
+
+
+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
+    if len(parts) > 1:
+        return parts[0]
+    if parts:
+        return parts[0]
+    return 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_storage_scan_roots()
+
+    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 +538,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 - 0
frontend/src/api/client.ts

@@ -3766,6 +3766,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'),
@@ -4108,6 +4116,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;

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -1373,6 +1373,8 @@ export default {
     printsOnly: 'Nur Drucke',
     totalConsumption: 'Gesamtverbrauch',
     dataManagement: 'Datenverwaltung',
+    storageUsage: 'Speichernutzung',
+    storageUsageDescription: 'Aufschlüsselung der Datennutzung nach Kategorie',
     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',

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -1373,6 +1373,8 @@ export default {
     printsOnly: 'Prints Only',
     totalConsumption: 'Total Consumption',
     dataManagement: 'Data Management',
+    storageUsage: 'Storage Usage',
+    storageUsageDescription: 'Breakdown of data usage by category',
     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',

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -1369,6 +1369,8 @@ 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',
     clearNotificationLogsDescription: 'Supprimer logs de plus de 30 jours',
     resetUiPreferencesDescription: 'Réinitialise thèmes et affichage sans toucher aux données.',
     enableHomeAssistant: 'Activer Home Assistant',

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -1289,6 +1289,8 @@ export default {
     printsOnly: 'Solo stampe',
     totalConsumption: 'Consumo totale',
     dataManagement: 'Gestione dati',
+    storageUsage: 'Memoria utilizzata',
+    storageUsageDescription: 'Ripartizione della memoria per categoria',
     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',

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -1296,6 +1296,8 @@ export default {
     archiveSettings: 'アーカイブ設定',
     costTracking: 'コスト追跡',
     dataManagement: 'データ管理',
+    storageUsage: 'ストレージ使用量',
+    storageUsageDescription: 'カテゴリ別のデータ使用量の内訳',
     enableMqtt: 'MQTTを有効化',
     useTls: 'TLSを使用',
     enableMetricsEndpoint: 'メトリクスエンドポイントを有効化',

+ 162 - 1
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<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,
@@ -1875,6 +1935,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>