Browse Source

Added system info page

maziggy 5 months ago
parent
commit
77a66ea867

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

@@ -0,0 +1,200 @@
+"""System information API routes."""
+
+import os
+import platform
+import psutil
+from datetime import datetime
+from pathlib import Path
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings, APP_VERSION
+from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
+from backend.app.models.printer import Printer
+from backend.app.models.filament import Filament
+from backend.app.models.project import Project
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.printer_manager import printer_manager
+
+router = APIRouter(prefix="/system", tags=["system"])
+
+
+def get_directory_size(path: Path) -> int:
+    """Calculate total size of a directory in bytes."""
+    total = 0
+    try:
+        for entry in path.rglob('*'):
+            if entry.is_file():
+                total += entry.stat().st_size
+    except (PermissionError, OSError):
+        pass
+    return total
+
+
+def format_bytes(bytes_value: int) -> str:
+    """Format bytes to human-readable string."""
+    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
+        if bytes_value < 1024:
+            return f"{bytes_value:.1f} {unit}"
+        bytes_value /= 1024
+    return f"{bytes_value:.1f} PB"
+
+
+def format_uptime(seconds: float) -> str:
+    """Format uptime in seconds to human-readable string."""
+    days = int(seconds // 86400)
+    hours = int((seconds % 86400) // 3600)
+    minutes = int((seconds % 3600) // 60)
+
+    parts = []
+    if days > 0:
+        parts.append(f"{days}d")
+    if hours > 0:
+        parts.append(f"{hours}h")
+    if minutes > 0:
+        parts.append(f"{minutes}m")
+
+    return " ".join(parts) if parts else "< 1m"
+
+
+@router.get("/info")
+async def get_system_info(db: AsyncSession = Depends(get_db)):
+    """Get comprehensive system information."""
+
+    # Database stats
+    archive_count = await db.scalar(select(func.count(PrintArchive.id)))
+    printer_count = await db.scalar(select(func.count(Printer.id)))
+    filament_count = await db.scalar(select(func.count(Filament.id)))
+    project_count = await db.scalar(select(func.count(Project.id)))
+    smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
+
+    # Archive stats by status
+    completed_count = await db.scalar(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")
+    )
+    failed_count = await db.scalar(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")
+    )
+    printing_count = await db.scalar(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing")
+    )
+
+    # Total print time
+    total_print_time = await db.scalar(
+        select(func.sum(PrintArchive.print_time_seconds)).where(
+            PrintArchive.print_time_seconds.isnot(None)
+        )
+    ) or 0
+
+    # Total filament used
+    total_filament = await db.scalar(
+        select(func.sum(PrintArchive.filament_used_grams)).where(
+            PrintArchive.filament_used_grams.isnot(None)
+        )
+    ) or 0
+
+    # Connected printers
+    connected_printers = []
+    for printer_id, client in printer_manager._clients.items():
+        state = client.state
+        if state and state.connected:
+            # Get printer name and model from database
+            result = await db.execute(
+                select(Printer.name, Printer.model).where(Printer.id == printer_id)
+            )
+            row = result.first()
+            name = row[0] if row else f"Printer {printer_id}"
+            model = row[1] if row else "unknown"
+            connected_printers.append({
+                "id": printer_id,
+                "name": name,
+                "state": state.state,
+                "model": model,
+            })
+
+    # Storage info
+    archive_dir = settings.archive_dir
+    archive_size = get_directory_size(archive_dir) if archive_dir.exists() else 0
+
+    # Database file size
+    db_path = settings.base_dir / "bambuddy.db"
+    db_size = db_path.stat().st_size if db_path.exists() else 0
+
+    # Disk usage
+    disk = psutil.disk_usage(str(settings.base_dir))
+
+    # System info
+    memory = psutil.virtual_memory()
+    boot_time = datetime.fromtimestamp(psutil.boot_time())
+    uptime_seconds = (datetime.now() - boot_time).total_seconds()
+
+    # Python and system info
+    import sys
+
+    return {
+        "app": {
+            "version": APP_VERSION,
+            "base_dir": str(settings.base_dir),
+            "archive_dir": str(archive_dir),
+        },
+        "database": {
+            "archives": archive_count,
+            "archives_completed": completed_count,
+            "archives_failed": failed_count,
+            "archives_printing": printing_count,
+            "printers": printer_count,
+            "filaments": filament_count,
+            "projects": project_count,
+            "smart_plugs": smart_plug_count,
+            "total_print_time_seconds": total_print_time,
+            "total_print_time_formatted": format_uptime(total_print_time),
+            "total_filament_grams": round(total_filament, 1),
+            "total_filament_kg": round(total_filament / 1000, 2),
+        },
+        "printers": {
+            "total": printer_count,
+            "connected": len(connected_printers),
+            "connected_list": connected_printers,
+        },
+        "storage": {
+            "archive_size_bytes": archive_size,
+            "archive_size_formatted": format_bytes(archive_size),
+            "database_size_bytes": db_size,
+            "database_size_formatted": format_bytes(db_size),
+            "disk_total_bytes": disk.total,
+            "disk_total_formatted": format_bytes(disk.total),
+            "disk_used_bytes": disk.used,
+            "disk_used_formatted": format_bytes(disk.used),
+            "disk_free_bytes": disk.free,
+            "disk_free_formatted": format_bytes(disk.free),
+            "disk_percent_used": disk.percent,
+        },
+        "system": {
+            "platform": platform.system(),
+            "platform_release": platform.release(),
+            "platform_version": platform.version(),
+            "architecture": platform.machine(),
+            "hostname": platform.node(),
+            "python_version": sys.version.split()[0],
+            "uptime_seconds": uptime_seconds,
+            "uptime_formatted": format_uptime(uptime_seconds),
+            "boot_time": boot_time.isoformat(),
+        },
+        "memory": {
+            "total_bytes": memory.total,
+            "total_formatted": format_bytes(memory.total),
+            "available_bytes": memory.available,
+            "available_formatted": format_bytes(memory.available),
+            "used_bytes": memory.used,
+            "used_formatted": format_bytes(memory.used),
+            "percent_used": memory.percent,
+        },
+        "cpu": {
+            "count": psutil.cpu_count(),
+            "count_logical": psutil.cpu_count(logical=True),
+            "percent": psutil.cpu_percent(interval=0.1),
+        },
+    }

+ 2 - 1
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_, delete
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history, system
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -1225,6 +1225,7 @@ app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
+app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 

+ 2 - 0
frontend/src/App.tsx

@@ -11,6 +11,7 @@ import { MaintenancePage } from './pages/MaintenancePage';
 import { ProjectsPage } from './pages/ProjectsPage';
 import { CameraPage } from './pages/CameraPage';
 import { ExternalLinkPage } from './pages/ExternalLinkPage';
+import { SystemInfoPage } from './pages/SystemInfoPage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -49,6 +50,7 @@ function App() {
                   <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="projects" element={<ProjectsPage />} />
                   <Route path="settings" element={<SettingsPage />} />
+                  <Route path="system" element={<SystemInfoPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                 </Route>
               </Routes>

+ 74 - 0
frontend/src/api/client.ts

@@ -1907,6 +1907,9 @@ export const api = {
   // AMS History
   getAMSHistory: (printerId: number, amsId: number, hours = 24) =>
     request<AMSHistoryResponse>(`/ams-history/${printerId}/${amsId}?hours=${hours}`),
+
+  // System Info
+  getSystemInfo: () => request<SystemInfo>('/system/info'),
 };
 
 // AMS History types
@@ -1928,3 +1931,74 @@ export interface AMSHistoryResponse {
   max_temperature: number | null;
   avg_temperature: number | null;
 }
+
+// System Info types
+export interface SystemInfo {
+  app: {
+    version: string;
+    base_dir: string;
+    archive_dir: string;
+  };
+  database: {
+    archives: number;
+    archives_completed: number;
+    archives_failed: number;
+    archives_printing: number;
+    printers: number;
+    filaments: number;
+    projects: number;
+    smart_plugs: number;
+    total_print_time_seconds: number;
+    total_print_time_formatted: string;
+    total_filament_grams: number;
+    total_filament_kg: number;
+  };
+  printers: {
+    total: number;
+    connected: number;
+    connected_list: Array<{
+      id: number;
+      name: string;
+      state: string;
+      model: string;
+    }>;
+  };
+  storage: {
+    archive_size_bytes: number;
+    archive_size_formatted: string;
+    database_size_bytes: number;
+    database_size_formatted: string;
+    disk_total_bytes: number;
+    disk_total_formatted: string;
+    disk_used_bytes: number;
+    disk_used_formatted: string;
+    disk_free_bytes: number;
+    disk_free_formatted: string;
+    disk_percent_used: number;
+  };
+  system: {
+    platform: string;
+    platform_release: string;
+    platform_version: string;
+    architecture: string;
+    hostname: string;
+    python_version: string;
+    uptime_seconds: number;
+    uptime_formatted: string;
+    boot_time: string;
+  };
+  memory: {
+    total_bytes: number;
+    total_formatted: string;
+    available_bytes: number;
+    available_formatted: string;
+    used_bytes: number;
+    used_formatted: string;
+    percent_used: number;
+  };
+  cpu: {
+    count: number;
+    count_logical: number;
+    percent: number;
+  };
+}

+ 23 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, X, Menu, Info, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -457,6 +457,17 @@ export function Layout() {
                 )}
               </div>
               <div className="flex items-center gap-1">
+                <NavLink
+                  to="/system"
+                  className={({ isActive }) =>
+                    `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                      isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                    }`
+                  }
+                  title={t('nav.system')}
+                >
+                  <Info className="w-5 h-5" />
+                </NavLink>
                 <a
                   href="https://github.com/maziggy/bambuddy"
                   target="_blank"
@@ -493,6 +504,17 @@ export function Layout() {
                   <ArrowUpCircle className="w-5 h-5" />
                 </button>
               )}
+              <NavLink
+                to="/system"
+                className={({ isActive }) =>
+                  `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
+                    isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
+                  }`
+                }
+                title={t('nav.system')}
+              >
+                <Info className="w-5 h-5" />
+              </NavLink>
               <a
                 href="https://github.com/maziggy/bambuddy"
                 target="_blank"

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Wartung',
     projects: 'Projekte',
     settings: 'Einstellungen',
+    system: 'System',
     collapseSidebar: 'Seitenleiste einklappen',
     expandSidebar: 'Seitenleiste ausklappen',
     update: 'Update',

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

@@ -9,6 +9,7 @@ export default {
     maintenance: 'Maintenance',
     projects: 'Projects',
     settings: 'Settings',
+    system: 'System',
     collapseSidebar: 'Collapse sidebar',
     expandSidebar: 'Expand sidebar',
     update: 'Update',

+ 369 - 0
frontend/src/pages/SystemInfoPage.tsx

@@ -0,0 +1,369 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  Server,
+  Database,
+  HardDrive,
+  Cpu,
+  MemoryStick,
+  Printer,
+  Archive,
+  Clock,
+  CheckCircle2,
+  XCircle,
+  Loader2,
+  RefreshCw,
+  Plug,
+  FolderKanban,
+  Palette,
+} from 'lucide-react';
+import { api } from '../api/client';
+import { Card } from '../components/Card';
+
+function StatCard({
+  icon: Icon,
+  label,
+  value,
+  subValue,
+  color = 'text-bambu-green',
+}: {
+  icon: React.ElementType;
+  label: string;
+  value: string | number;
+  subValue?: string;
+  color?: string;
+}) {
+  return (
+    <div className="flex items-start gap-3 p-4 bg-bambu-dark rounded-lg">
+      <div className={`p-2 rounded-lg bg-bambu-dark-tertiary ${color}`}>
+        <Icon className="w-5 h-5" />
+      </div>
+      <div className="flex-1 min-w-0">
+        <p className="text-sm text-bambu-gray">{label}</p>
+        <p className="text-lg font-semibold text-white truncate">{value}</p>
+        {subValue && <p className="text-xs text-bambu-gray mt-0.5">{subValue}</p>}
+      </div>
+    </div>
+  );
+}
+
+function ProgressBar({ percent, color = 'bg-bambu-green' }: { percent: number; color?: string }) {
+  return (
+    <div className="w-full h-2 bg-bambu-dark rounded-full overflow-hidden">
+      <div
+        className={`h-full ${color} transition-all duration-300`}
+        style={{ width: `${Math.min(100, percent)}%` }}
+      />
+    </div>
+  );
+}
+
+function Section({
+  title,
+  icon: Icon,
+  children,
+}: {
+  title: string;
+  icon: React.ElementType;
+  children: React.ReactNode;
+}) {
+  return (
+    <Card className="p-6">
+      <div className="flex items-center gap-2 mb-4">
+        <Icon className="w-5 h-5 text-bambu-green" />
+        <h2 className="text-lg font-semibold text-white">{title}</h2>
+      </div>
+      {children}
+    </Card>
+  );
+}
+
+export function SystemInfoPage() {
+  const { t } = useTranslation();
+
+  const { data: systemInfo, isLoading, refetch, isFetching } = useQuery({
+    queryKey: ['systemInfo'],
+    queryFn: api.getSystemInfo,
+    refetchInterval: 30000, // Auto-refresh every 30 seconds
+  });
+
+  if (isLoading) {
+    return (
+      <div className="flex items-center justify-center h-64">
+        <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
+      </div>
+    );
+  }
+
+  if (!systemInfo) {
+    return (
+      <div className="p-6 text-center text-bambu-gray">
+        {t('system.failedToLoad', 'Failed to load system information')}
+      </div>
+    );
+  }
+
+  const diskColor =
+    systemInfo.storage.disk_percent_used > 90
+      ? 'bg-red-500'
+      : systemInfo.storage.disk_percent_used > 75
+      ? 'bg-yellow-500'
+      : 'bg-bambu-green';
+
+  const memoryColor =
+    systemInfo.memory.percent_used > 90
+      ? 'bg-red-500'
+      : systemInfo.memory.percent_used > 75
+      ? 'bg-yellow-500'
+      : 'bg-bambu-green';
+
+  return (
+    <div className="p-6 space-y-6">
+      {/* Header */}
+      <div className="flex items-center justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-white">{t('system.title', 'System Information')}</h1>
+          <p className="text-bambu-gray mt-1">
+            {t('system.subtitle', 'Monitor system resources and database statistics')}
+          </p>
+        </div>
+        <button
+          onClick={() => refetch()}
+          disabled={isFetching}
+          className="flex items-center gap-2 px-4 py-2 bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
+        >
+          <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
+          {t('common.refresh', 'Refresh')}
+        </button>
+      </div>
+
+      {/* Application Info */}
+      <Section title={t('system.application', 'Application')} icon={Server}>
+        <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+          <StatCard
+            icon={Server}
+            label={t('system.version', 'Version')}
+            value={`v${systemInfo.app.version}`}
+          />
+          <StatCard
+            icon={Clock}
+            label={t('system.uptime', 'System Uptime')}
+            value={systemInfo.system.uptime_formatted}
+          />
+          <StatCard
+            icon={Server}
+            label={t('system.hostname', 'Hostname')}
+            value={systemInfo.system.hostname}
+          />
+        </div>
+      </Section>
+
+      {/* Database Stats */}
+      <Section title={t('system.database', 'Database')} icon={Database}>
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
+          <StatCard
+            icon={Archive}
+            label={t('system.totalArchives', 'Total Archives')}
+            value={systemInfo.database.archives}
+          />
+          <StatCard
+            icon={CheckCircle2}
+            label={t('system.completed', 'Completed')}
+            value={systemInfo.database.archives_completed}
+            color="text-green-500"
+          />
+          <StatCard
+            icon={XCircle}
+            label={t('system.failed', 'Failed')}
+            value={systemInfo.database.archives_failed}
+            color="text-red-500"
+          />
+          <StatCard
+            icon={Loader2}
+            label={t('system.printing', 'Printing')}
+            value={systemInfo.database.archives_printing}
+            color="text-yellow-500"
+          />
+        </div>
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+          <StatCard
+            icon={Printer}
+            label={t('system.printers', 'Printers')}
+            value={systemInfo.database.printers}
+          />
+          <StatCard
+            icon={Palette}
+            label={t('system.filaments', 'Filaments')}
+            value={systemInfo.database.filaments}
+          />
+          <StatCard
+            icon={FolderKanban}
+            label={t('system.projects', 'Projects')}
+            value={systemInfo.database.projects}
+          />
+          <StatCard
+            icon={Plug}
+            label={t('system.smartPlugs', 'Smart Plugs')}
+            value={systemInfo.database.smart_plugs}
+          />
+        </div>
+        <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
+          <StatCard
+            icon={Clock}
+            label={t('system.totalPrintTime', 'Total Print Time')}
+            value={systemInfo.database.total_print_time_formatted}
+          />
+          <StatCard
+            icon={Archive}
+            label={t('system.totalFilament', 'Total Filament Used')}
+            value={`${systemInfo.database.total_filament_kg} kg`}
+            subValue={`${systemInfo.database.total_filament_grams.toLocaleString()} g`}
+          />
+        </div>
+      </Section>
+
+      {/* Connected Printers */}
+      <Section title={t('system.connectedPrinters', 'Connected Printers')} icon={Printer}>
+        <div className="flex items-center gap-4 mb-4">
+          <div className="text-3xl font-bold text-bambu-green">
+            {systemInfo.printers.connected}
+          </div>
+          <div className="text-bambu-gray">
+            {t('system.ofTotal', 'of {{total}} printers connected', {
+              total: systemInfo.printers.total,
+            })}
+          </div>
+        </div>
+        {systemInfo.printers.connected_list.length > 0 ? (
+          <div className="space-y-2">
+            {systemInfo.printers.connected_list.map((printer) => (
+              <div
+                key={printer.id}
+                className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg"
+              >
+                <div className="flex items-center gap-3">
+                  <div className="w-2 h-2 rounded-full bg-bambu-green" />
+                  <span className="font-medium text-white">{printer.name}</span>
+                </div>
+                <div className="flex items-center gap-4 text-sm text-bambu-gray">
+                  <span>{printer.model}</span>
+                  <span
+                    className={`px-2 py-0.5 rounded ${
+                      printer.state === 'RUNNING'
+                        ? 'bg-bambu-green/20 text-bambu-green'
+                        : printer.state === 'IDLE'
+                        ? 'bg-blue-500/20 text-blue-400'
+                        : 'bg-bambu-dark-tertiary'
+                    }`}
+                  >
+                    {printer.state}
+                  </span>
+                </div>
+              </div>
+            ))}
+          </div>
+        ) : (
+          <p className="text-bambu-gray">{t('system.noPrintersConnected', 'No printers connected')}</p>
+        )}
+      </Section>
+
+      {/* Storage */}
+      <Section title={t('system.storage', 'Storage')} icon={HardDrive}>
+        <div className="space-y-4">
+          <div>
+            <div className="flex justify-between text-sm mb-1">
+              <span className="text-bambu-gray">{t('system.diskUsage', 'Disk Usage')}</span>
+              <span className="text-white">
+                {systemInfo.storage.disk_used_formatted} / {systemInfo.storage.disk_total_formatted}
+              </span>
+            </div>
+            <ProgressBar percent={systemInfo.storage.disk_percent_used} color={diskColor} />
+            <p className="text-xs text-bambu-gray mt-1">
+              {systemInfo.storage.disk_free_formatted} {t('system.free', 'free')} (
+              {(100 - systemInfo.storage.disk_percent_used).toFixed(1)}%)
+            </p>
+          </div>
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <StatCard
+              icon={Archive}
+              label={t('system.archiveStorage', 'Archive Storage')}
+              value={systemInfo.storage.archive_size_formatted}
+            />
+            <StatCard
+              icon={Database}
+              label={t('system.databaseSize', 'Database Size')}
+              value={systemInfo.storage.database_size_formatted}
+            />
+          </div>
+        </div>
+      </Section>
+
+      {/* System Resources */}
+      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+        {/* Memory */}
+        <Section title={t('system.memory', 'Memory')} icon={MemoryStick}>
+          <div className="space-y-4">
+            <div>
+              <div className="flex justify-between text-sm mb-1">
+                <span className="text-bambu-gray">{t('system.memoryUsage', 'Memory Usage')}</span>
+                <span className="text-white">
+                  {systemInfo.memory.used_formatted} / {systemInfo.memory.total_formatted}
+                </span>
+              </div>
+              <ProgressBar percent={systemInfo.memory.percent_used} color={memoryColor} />
+              <p className="text-xs text-bambu-gray mt-1">
+                {systemInfo.memory.available_formatted} {t('system.available', 'available')}
+              </p>
+            </div>
+          </div>
+        </Section>
+
+        {/* CPU */}
+        <Section title={t('system.cpu', 'CPU')} icon={Cpu}>
+          <div className="space-y-4">
+            <div className="grid grid-cols-2 gap-4">
+              <StatCard
+                icon={Cpu}
+                label={t('system.cores', 'Cores')}
+                value={systemInfo.cpu.count}
+                subValue={`${systemInfo.cpu.count_logical} logical`}
+              />
+              <StatCard
+                icon={Cpu}
+                label={t('system.usage', 'Usage')}
+                value={`${systemInfo.cpu.percent}%`}
+              />
+            </div>
+          </div>
+        </Section>
+      </div>
+
+      {/* System Details */}
+      <Section title={t('system.systemDetails', 'System Details')} icon={Server}>
+        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+          <StatCard
+            icon={Server}
+            label={t('system.os', 'Operating System')}
+            value={systemInfo.system.platform}
+            subValue={systemInfo.system.platform_release}
+          />
+          <StatCard
+            icon={Cpu}
+            label={t('system.architecture', 'Architecture')}
+            value={systemInfo.system.architecture}
+          />
+          <StatCard
+            icon={Server}
+            label={t('system.python', 'Python')}
+            value={systemInfo.system.python_version}
+          />
+          <StatCard
+            icon={Clock}
+            label={t('system.bootTime', 'Boot Time')}
+            value={new Date(systemInfo.system.boot_time).toLocaleString()}
+          />
+        </div>
+      </Section>
+    </div>
+  );
+}

+ 3 - 0
requirements.txt

@@ -27,6 +27,9 @@ pywebpush>=2.0.0
 python-multipart>=0.0.6
 aiofiles>=23.0.0
 
+# System monitoring
+psutil>=6.0.0
+
 # Development
 pytest>=8.0.0
 pytest-asyncio>=0.23.0

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-93kTpHtK.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-B1Oj5EC9.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BEiBb2xd.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BxbiDdlQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-93kTpHtK.css">
+    <script type="module" crossorigin src="/assets/index-BEiBb2xd.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B1Oj5EC9.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff