Bladeren bron

Add SpoolBuddy System tab with live OS stats from Raspberry Pi

  The daemon now collects CPU temp, core count, load average, memory/disk
  usage, OS info, and system uptime every heartbeat using stdlib-only reads
  from /proc and /sys. Stats are sent as a JSON blob in the heartbeat
  payload, stored in a new system_stats TEXT column, and displayed in a
  new "System" tab in SpoolBuddy Settings with color-coded usage bars.
maziggy 2 maanden geleden
bovenliggende
commit
77cb7158d2

+ 1 - 0
CHANGELOG.md

@@ -19,6 +19,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.
 - **SpoolBuddy Install Script Now Upgrades System Packages** — The install script now runs `apt-get upgrade -y` after installing required packages and the WiFi safeguard. This ensures the Pi is fully up to date before SpoolBuddy is deployed, and the WiFi safeguard protects connectivity during the upgrade.
 - **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.
 - **SpoolBuddy Assign-to-AMS Material Mismatch Warnings** — The SpoolBuddy "Assign to AMS" modal now warns when the spool's material or slicer profile doesn't match the target slot's current filament. Shows a confirmation dialog with five warning levels: exact material mismatch, partial material match, profile-only mismatch, and combined material+profile mismatches. Respects the global `disable_filament_warnings` setting. Previously, assigning a spool to an occupied slot proceeded without any validation, matching the behavior already present in the main Assign Spool modal.
 - **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
 - **Spool Assignment Changes Sync Across Tabs** — Assigning or unassigning a spool now broadcasts a WebSocket event to all connected clients. Other open browser tabs and the SpoolBuddy frontend update automatically without requiring a page reload.
+- **SpoolBuddy System Tab** — Added a "System" tab to SpoolBuddy Settings showing live OS stats from the Raspberry Pi: CPU temperature, core count, load average, memory usage, disk usage, OS distro/kernel/architecture, Python version, and system uptime. Stats are collected by the daemon every heartbeat (10s) using stdlib-only reads from `/proc` and `/sys` — no additional dependencies required. Usage bars turn amber at 70% and red at 90%; CPU temperature is color-coded green/amber/red.
 
 
 ### Fixed
 ### Fixed
 - **Delete Tag Leaves Stale Tag Type** — The "Delete Tag" button in the spool edit modal only cleared `tag_uid` but left `tray_uuid`, `tag_type`, and `data_origin` intact. All tag-related fields are now cleared together.
 - **Delete Tag Leaves Stale Tag Type** — The "Delete Tag" button in the spool edit modal only cleared `tag_uid` but left `tray_uuid`, `tag_type`, and `data_origin` intact. All tag-related fields are now cleared together.

+ 3 - 0
backend/app/api/routes/spoolbuddy.py

@@ -81,6 +81,7 @@ def _device_to_response(device: SpoolBuddyDevice) -> DeviceResponse:
         uptime_s=device.uptime_s,
         uptime_s=device.uptime_s,
         update_status=device.update_status,
         update_status=device.update_status,
         update_message=device.update_message,
         update_message=device.update_message,
+        system_stats=json.loads(device.system_stats) if device.system_stats else None,
         online=_is_online(device),
         online=_is_online(device),
         created_at=device.created_at,
         created_at=device.created_at,
         updated_at=device.updated_at,
         updated_at=device.updated_at,
@@ -216,6 +217,8 @@ async def device_heartbeat(
         device.nfc_connection = req.nfc_connection
         device.nfc_connection = req.nfc_connection
     if req.backend_url:
     if req.backend_url:
         device.backend_url = req.backend_url
         device.backend_url = req.backend_url
+    if req.system_stats is not None:
+        device.system_stats = json.dumps(req.system_stats)
 
 
     # Return and clear pending command
     # Return and clear pending command
     pending = device.pending_command
     pending = device.pending_command

+ 6 - 0
backend/app/core/database.py

@@ -1424,6 +1424,12 @@ async def run_migrations(conn):
     except OperationalError:
     except OperationalError:
         pass  # Already applied
         pass  # Already applied
 
 
+    # Migration: Add system_stats JSON blob column to spoolbuddy_devices
+    try:
+        await conn.execute(text("ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT"))
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
     try:
     try:

+ 1 - 0
backend/app/models/spoolbuddy_device.py

@@ -36,5 +36,6 @@ class SpoolBuddyDevice(Base):
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     nfc_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     scale_ok: Mapped[bool] = mapped_column(Boolean, default=False)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
     uptime_s: Mapped[int] = mapped_column(Integer, default=0)
+    system_stats: Mapped[str | None] = mapped_column(Text, nullable=True)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
     updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 2 - 0
backend/app/schemas/spoolbuddy.py

@@ -44,6 +44,7 @@ class DeviceResponse(BaseModel):
     uptime_s: int
     uptime_s: int
     update_status: str | None = None
     update_status: str | None = None
     update_message: str | None = None
     update_message: str | None = None
+    system_stats: dict | None = None
     online: bool = False
     online: bool = False
     ssh_public_key: str | None = None
     ssh_public_key: str | None = None
     created_at: datetime
     created_at: datetime
@@ -62,6 +63,7 @@ class HeartbeatRequest(BaseModel):
     nfc_reader_type: str | None = None
     nfc_reader_type: str | None = None
     nfc_connection: str | None = None
     nfc_connection: str | None = None
     backend_url: str | None = None
     backend_url: str | None = None
+    system_stats: dict | None = None
 
 
 
 
 class HeartbeatResponse(BaseModel):
 class HeartbeatResponse(BaseModel):

+ 118 - 0
backend/tests/unit/test_spoolbuddy_system_stats.py

@@ -0,0 +1,118 @@
+"""Tests for SpoolBuddy daemon system_stats collector."""
+
+from unittest.mock import patch
+
+from spoolbuddy.daemon.system_stats import (
+    _cpu_count,
+    _cpu_temp,
+    _disk_info,
+    _load_avg,
+    _memory_info,
+    _os_info,
+    _system_uptime,
+    collect,
+)
+
+
+class TestCpuTemp:
+    def test_reads_thermal_zone(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value="52100"):
+            assert _cpu_temp() == 52.1
+
+    def test_returns_none_on_missing_file(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=None):
+            assert _cpu_temp() is None
+
+    def test_returns_none_on_bad_value(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value="not_a_number"):
+            assert _cpu_temp() is None
+
+
+class TestMemoryInfo:
+    SAMPLE_MEMINFO = (
+        "MemTotal:        1024000 kB\n"
+        "MemFree:          200000 kB\n"
+        "MemAvailable:     512000 kB\n"
+        "Buffers:           50000 kB\n"
+    )
+
+    def test_parses_meminfo(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=self.SAMPLE_MEMINFO):
+            result = _memory_info()
+            assert result is not None
+            assert result["total_mb"] == 1000
+            assert result["available_mb"] == 500
+            assert result["used_mb"] == 500
+            assert result["percent"] == 50.0
+
+    def test_returns_none_on_missing(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=None):
+            assert _memory_info() is None
+
+
+class TestDiskInfo:
+    def test_returns_disk_stats(self):
+        result = _disk_info()
+        # Should always work on Linux
+        assert result is not None
+        assert "total_gb" in result
+        assert "used_gb" in result
+        assert "free_gb" in result
+        assert "percent" in result
+        assert 0 <= result["percent"] <= 100
+
+
+class TestLoadAvg:
+    def test_returns_three_values(self):
+        result = _load_avg()
+        assert result is not None
+        assert len(result) == 3
+        for val in result:
+            assert isinstance(val, float)
+
+
+class TestCpuCount:
+    def test_returns_positive_int(self):
+        result = _cpu_count()
+        assert result is not None
+        assert result > 0
+
+
+class TestOsInfo:
+    def test_returns_required_keys(self):
+        result = _os_info()
+        assert "os" in result
+        assert "kernel" in result
+        assert "arch" in result
+        assert "python" in result
+
+    def test_parses_pretty_name(self):
+        fake_release = 'PRETTY_NAME="Raspbian GNU/Linux 12 (bookworm)"\nID=raspbian\n'
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=fake_release):
+            result = _os_info()
+            assert result["os"] == "Raspbian GNU/Linux 12 (bookworm)"
+
+
+class TestSystemUptime:
+    def test_parses_uptime(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value="86400.55 172000.10"):
+            assert _system_uptime() == 86400
+
+    def test_returns_none_on_missing(self):
+        with patch("spoolbuddy.daemon.system_stats._read_file", return_value=None):
+            assert _system_uptime() is None
+
+
+class TestCollect:
+    def test_returns_dict_with_expected_keys(self):
+        result = collect()
+        assert isinstance(result, dict)
+        assert "os" in result
+        # These may or may not be present depending on platform, but os is always present
+
+    def test_all_values_are_json_serializable(self):
+        import json
+
+        result = collect()
+        # Should not raise
+        json.dumps(result)

+ 101 - 2
frontend/src/__tests__/pages/SpoolBuddySettingsPage.test.tsx

@@ -1,8 +1,9 @@
 /**
 /**
  * Tests for SpoolBuddySettingsPage:
  * Tests for SpoolBuddySettingsPage:
- * - Renders 4 tabs (Device, Display, Scale, Updates)
+ * - Renders 5 tabs (Device, Display, Scale, Updates, System)
  * - Device tab shows hostname, IP, NFC status
  * - Device tab shows hostname, IP, NFC status
  * - Updates tab shows "Check for Updates" button
  * - Updates tab shows "Check for Updates" button
+ * - System tab shows OS stats
  * - Tab switching works
  * - Tab switching works
  */
  */
 
 
@@ -39,6 +40,15 @@ vi.mock('../../api/client', () => ({
       uptime_s: 3600,
       uptime_s: 3600,
       update_status: null,
       update_status: null,
       update_message: null,
       update_message: null,
+      system_stats: {
+        os: { os: 'Raspbian GNU/Linux 12', kernel: '6.1.0-rpi7', arch: 'aarch64', python: '3.11.2' },
+        cpu_temp_c: 52.1,
+        cpu_count: 4,
+        load_avg: [0.15, 0.22, 0.18],
+        memory: { total_mb: 1024, available_mb: 512, used_mb: 512, percent: 50.0 },
+        disk: { total_gb: 29.7, used_gb: 8.2, free_gb: 21.5, percent: 27.6 },
+        system_uptime_s: 86400,
+      },
       online: true,
       online: true,
     }]),
     }]),
     updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
     updateDisplay: vi.fn().mockResolvedValue({ status: 'ok' }),
@@ -106,13 +116,14 @@ describe('SpoolBuddySettingsPage', () => {
     vi.clearAllMocks();
     vi.clearAllMocks();
   });
   });
 
 
-  it('renders 4 tabs', async () => {
+  it('renders 5 tabs', async () => {
     renderPage();
     renderPage();
     await waitFor(() => {
     await waitFor(() => {
       expect(screen.getByText('Device')).toBeDefined();
       expect(screen.getByText('Device')).toBeDefined();
       expect(screen.getByText('Display')).toBeDefined();
       expect(screen.getByText('Display')).toBeDefined();
       expect(screen.getByText('Scale')).toBeDefined();
       expect(screen.getByText('Scale')).toBeDefined();
       expect(screen.getByText('Updates')).toBeDefined();
       expect(screen.getByText('Updates')).toBeDefined();
+      expect(screen.getByText('System')).toBeDefined();
     });
     });
   });
   });
 
 
@@ -211,4 +222,92 @@ describe('SpoolBuddySettingsPage', () => {
       expect(screen.getByText('Calibrate')).toBeDefined();
       expect(screen.getByText('Calibrate')).toBeDefined();
     });
     });
   });
   });
+
+  it('System tab shows CPU info', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('CPU')).toBeDefined();
+      expect(screen.getByText('4')).toBeDefined(); // cpu_count
+      expect(screen.getByText('0.15 / 0.22 / 0.18')).toBeDefined(); // load_avg
+    });
+  });
+
+  it('System tab shows memory stats', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Memory')).toBeDefined();
+      expect(screen.getByText('512 / 1024 MB')).toBeDefined();
+    });
+  });
+
+  it('System tab shows disk stats', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Disk')).toBeDefined();
+      expect(screen.getByText('8.2 GB')).toBeDefined();
+    });
+  });
+
+  it('System tab shows OS info', async () => {
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Raspbian GNU/Linux 12')).toBeDefined();
+      expect(screen.getByText('aarch64')).toBeDefined();
+      expect(screen.getByText('3.11.2')).toBeDefined();
+    });
+  });
+
+  it('System tab shows waiting message when no stats', async () => {
+    const { spoolbuddyApi } = await import('../../api/client');
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([{
+      id: 1,
+      device_id: 'sb-test-001',
+      hostname: 'spoolbuddy-pi',
+      ip_address: '192.168.1.100',
+      firmware_version: '1.2.3',
+      has_nfc: true,
+      has_scale: true,
+      tare_offset: 0,
+      calibration_factor: 1.0,
+      nfc_reader_type: 'PN532',
+      nfc_connection: 'I2C',
+      display_brightness: 80,
+      display_blank_timeout: 300,
+      has_backlight: true,
+      last_calibrated_at: null,
+      last_seen: '2026-03-22T12:00:00Z',
+      pending_command: null,
+      nfc_ok: true,
+      scale_ok: true,
+      uptime_s: 3600,
+      update_status: null,
+      update_message: null,
+      system_stats: null,
+      online: true,
+    }]);
+    renderPage();
+    await waitFor(() => {
+      expect(screen.getByText('System')).toBeDefined();
+    });
+    fireEvent.click(screen.getByText('System'));
+    await waitFor(() => {
+      expect(screen.getByText('Waiting for system stats...')).toBeDefined();
+    });
+  });
 });
 });

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

@@ -5016,6 +5016,15 @@ export interface SpoolBuddyDevice {
   uptime_s: number;
   uptime_s: number;
   update_status: string | null;
   update_status: string | null;
   update_message: string | null;
   update_message: string | null;
+  system_stats: {
+    os?: { os?: string; kernel?: string; arch?: string; python?: string };
+    cpu_temp_c?: number;
+    cpu_count?: number;
+    load_avg?: number[];
+    memory?: { total_mb?: number; available_mb?: number; used_mb?: number; percent?: number };
+    disk?: { total_gb?: number; used_gb?: number; free_gb?: number; percent?: number };
+    system_uptime_s?: number;
+  } | null;
   online: boolean;
   online: boolean;
 }
 }
 
 

+ 166 - 1
frontend/src/pages/spoolbuddy/SpoolBuddySettingsPage.tsx

@@ -784,9 +784,172 @@ function UpdatesTab({ device }: { device: SpoolBuddyDevice }) {
   );
   );
 }
 }
 
 
+// --- System Tab ---
+
+function UsageBar({ percent, color }: { percent: number; color: string }) {
+  return (
+    <div className="w-full h-2 bg-zinc-700 rounded-full overflow-hidden">
+      <div
+        className={`h-full rounded-full transition-all ${color}`}
+        style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
+      />
+    </div>
+  );
+}
+
+function formatSystemUptime(seconds: number): string {
+  const d = Math.floor(seconds / 86400);
+  const h = Math.floor((seconds % 86400) / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  if (d > 0) return `${d}d ${h}h ${m}m`;
+  if (h > 0) return `${h}h ${m}m`;
+  return `${m}m`;
+}
+
+function SystemTab({ device }: { device: SpoolBuddyDevice }) {
+  const { t } = useTranslation();
+  const stats = device.system_stats;
+
+  if (!stats) {
+    return (
+      <div className="flex items-center justify-center h-32">
+        <p className="text-sm text-zinc-500">
+          {t('spoolbuddy.settings.systemStatsWaiting', 'Waiting for system stats...')}
+        </p>
+      </div>
+    );
+  }
+
+  const mem = stats.memory;
+  const disk = stats.disk;
+  const tempColor = (stats.cpu_temp_c ?? 0) >= 80 ? 'text-red-400' : (stats.cpu_temp_c ?? 0) >= 65 ? 'text-amber-400' : 'text-green-400';
+  const memColor = (mem?.percent ?? 0) >= 90 ? 'bg-red-500' : (mem?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';
+  const diskColor = (disk?.percent ?? 0) >= 90 ? 'bg-red-500' : (disk?.percent ?? 0) >= 70 ? 'bg-amber-500' : 'bg-green-500';
+
+  return (
+    <div className="space-y-2">
+      {/* CPU + Temp side by side */}
+      <div className="grid grid-cols-2 gap-2">
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-2">CPU</h3>
+          <div className="space-y-1.5 text-xs">
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.cores', 'Cores')}</span>
+              <span className="text-zinc-300 font-mono">{stats.cpu_count ?? '-'}</span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.loadAvg', 'Load Avg')}</span>
+              <span className="text-zinc-300 font-mono">
+                {stats.load_avg ? stats.load_avg.join(' / ') : '-'}
+              </span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.temp', 'Temp')}</span>
+              <span className={`font-mono font-medium ${tempColor}`}>
+                {stats.cpu_temp_c != null ? `${stats.cpu_temp_c}\u00B0C` : '-'}
+              </span>
+            </div>
+          </div>
+        </div>
+
+        {/* Memory */}
+        <div className="bg-zinc-800 rounded-lg p-3">
+          <h3 className="text-sm font-semibold text-zinc-300 mb-2">
+            {t('spoolbuddy.settings.memory', 'Memory')}
+          </h3>
+          {mem ? (
+            <div className="space-y-2">
+              <UsageBar percent={mem.percent ?? 0} color={memColor} />
+              <div className="space-y-1 text-xs">
+                <div className="flex justify-between">
+                  <span className="text-zinc-500">{t('spoolbuddy.settings.used', 'Used')}</span>
+                  <span className="text-zinc-300 font-mono">{mem.used_mb} / {mem.total_mb} MB</span>
+                </div>
+                <div className="flex justify-between">
+                  <span className="text-zinc-500">{t('spoolbuddy.settings.available', 'Free')}</span>
+                  <span className="text-zinc-300 font-mono">{mem.available_mb} MB</span>
+                </div>
+              </div>
+            </div>
+          ) : (
+            <span className="text-xs text-zinc-500">-</span>
+          )}
+        </div>
+      </div>
+
+      {/* Disk */}
+      <div className="bg-zinc-800 rounded-lg p-3">
+        <h3 className="text-sm font-semibold text-zinc-300 mb-2">
+          {t('spoolbuddy.settings.disk', 'Disk')}
+        </h3>
+        {disk ? (
+          <div className="space-y-2">
+            <UsageBar percent={disk.percent ?? 0} color={diskColor} />
+            <div className="grid grid-cols-3 gap-2 text-xs">
+              <div>
+                <span className="text-zinc-500 block">{t('spoolbuddy.settings.used', 'Used')}</span>
+                <span className="text-zinc-300 font-mono">{disk.used_gb} GB</span>
+              </div>
+              <div>
+                <span className="text-zinc-500 block">{t('spoolbuddy.settings.available', 'Free')}</span>
+                <span className="text-zinc-300 font-mono">{disk.free_gb} GB</span>
+              </div>
+              <div>
+                <span className="text-zinc-500 block">{t('spoolbuddy.settings.total', 'Total')}</span>
+                <span className="text-zinc-300 font-mono">{disk.total_gb} GB</span>
+              </div>
+            </div>
+          </div>
+        ) : (
+          <span className="text-xs text-zinc-500">-</span>
+        )}
+      </div>
+
+      {/* OS Info */}
+      <div className="bg-zinc-800 rounded-lg p-3">
+        <h3 className="text-sm font-semibold text-zinc-300 mb-2">
+          {t('spoolbuddy.settings.osInfo', 'OS')}
+        </h3>
+        <div className="space-y-1.5 text-xs">
+          {stats.os?.os && (
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.distro', 'Distro')}</span>
+              <span className="text-zinc-300 truncate ml-2">{stats.os.os}</span>
+            </div>
+          )}
+          {stats.os?.kernel && (
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.kernel', 'Kernel')}</span>
+              <span className="text-zinc-300 font-mono truncate ml-2">{stats.os.kernel}</span>
+            </div>
+          )}
+          {stats.os?.arch && (
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.arch', 'Arch')}</span>
+              <span className="text-zinc-300 font-mono">{stats.os.arch}</span>
+            </div>
+          )}
+          {stats.os?.python && (
+            <div className="flex justify-between">
+              <span className="text-zinc-500">Python</span>
+              <span className="text-zinc-300 font-mono">{stats.os.python}</span>
+            </div>
+          )}
+          {stats.system_uptime_s != null && (
+            <div className="flex justify-between">
+              <span className="text-zinc-500">{t('spoolbuddy.settings.systemUptime', 'System Uptime')}</span>
+              <span className="text-zinc-300">{formatSystemUptime(stats.system_uptime_s)}</span>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
 // --- Main Settings Page ---
 // --- Main Settings Page ---
 
 
-type SettingsTab = 'device' | 'display' | 'scale' | 'updates';
+type SettingsTab = 'device' | 'display' | 'scale' | 'updates' | 'system';
 
 
 export function SpoolBuddySettingsPage() {
 export function SpoolBuddySettingsPage() {
   const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
   const { sbState, setDisplayBrightness, setDisplayBlankTimeout } = useOutletContext<SpoolBuddyOutletContext>();
@@ -810,6 +973,7 @@ export function SpoolBuddySettingsPage() {
     { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
     { id: 'display', label: t('spoolbuddy.settings.tabDisplay', 'Display') },
     { id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },
     { id: 'scale', label: t('spoolbuddy.settings.tabScale', 'Scale') },
     { id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },
     { id: 'updates', label: t('spoolbuddy.settings.tabUpdates', 'Updates') },
+    { id: 'system', label: t('spoolbuddy.settings.tabSystem', 'System') },
   ];
   ];
 
 
   return (
   return (
@@ -862,6 +1026,7 @@ export function SpoolBuddySettingsPage() {
               />
               />
             )}
             )}
             {activeTab === 'updates' && <UpdatesTab device={device} />}
             {activeTab === 'updates' && <UpdatesTab device={device} />}
+            {activeTab === 'system' && <SystemTab device={device} />}
           </>
           </>
         )}
         )}
       </div>
       </div>

+ 14 - 10
spoolbuddy/daemon/api_client.py

@@ -108,19 +108,23 @@ class APIClient:
         nfc_reader_type: str | None = None,
         nfc_reader_type: str | None = None,
         nfc_connection: str | None = None,
         nfc_connection: str | None = None,
         backend_url: str | None = None,
         backend_url: str | None = None,
+        system_stats: dict | None = None,
     ) -> dict | None:
     ) -> dict | None:
+        payload: dict = {
+            "nfc_ok": nfc_ok,
+            "scale_ok": scale_ok,
+            "uptime_s": uptime_s,
+            "ip_address": ip_address,
+            "firmware_version": firmware_version,
+            "nfc_reader_type": nfc_reader_type,
+            "nfc_connection": nfc_connection,
+            "backend_url": backend_url,
+        }
+        if system_stats is not None:
+            payload["system_stats"] = system_stats
         result = await self._post(
         result = await self._post(
             f"/devices/{device_id}/heartbeat",
             f"/devices/{device_id}/heartbeat",
-            {
-                "nfc_ok": nfc_ok,
-                "scale_ok": scale_ok,
-                "uptime_s": uptime_s,
-                "ip_address": ip_address,
-                "firmware_version": firmware_version,
-                "nfc_reader_type": nfc_reader_type,
-                "nfc_connection": nfc_connection,
-                "backend_url": backend_url,
-            },
+            payload,
         )
         )
         if result and self._buffer:
         if result and self._buffer:
             await self._flush_buffer()
             await self._flush_buffer()

+ 3 - 1
spoolbuddy/daemon/main.py

@@ -10,7 +10,7 @@ import sys
 import time
 import time
 from pathlib import Path
 from pathlib import Path
 
 
-from . import __version__
+from . import __version__, system_stats
 from .api_client import APIClient
 from .api_client import APIClient
 from .config import Config
 from .config import Config
 from .display_control import DisplayControl
 from .display_control import DisplayControl
@@ -211,6 +211,7 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
         nfc = shared.get("nfc")
         nfc = shared.get("nfc")
         scale = shared.get("scale")
         scale = shared.get("scale")
         uptime = int(time.monotonic() - start_time)
         uptime = int(time.monotonic() - start_time)
+        stats = await asyncio.to_thread(system_stats.collect)
         result = await api.heartbeat(
         result = await api.heartbeat(
             device_id=config.device_id,
             device_id=config.device_id,
             nfc_ok=nfc.ok if nfc else False,
             nfc_ok=nfc.ok if nfc else False,
@@ -221,6 +222,7 @@ async def heartbeat_loop(config: Config, api: APIClient, start_time: float, shar
             nfc_reader_type=nfc.reader_type if nfc else None,
             nfc_reader_type=nfc.reader_type if nfc else None,
             nfc_connection=nfc.connection if nfc else None,
             nfc_connection=nfc.connection if nfc else None,
             backend_url=config.backend_url,
             backend_url=config.backend_url,
+            system_stats=stats,
         )
         )
 
 
         if result:
         if result:

+ 137 - 0
spoolbuddy/daemon/system_stats.py

@@ -0,0 +1,137 @@
+"""Collect OS-level system stats from the Raspberry Pi using stdlib only."""
+
+import os
+import platform
+
+
+def _read_file(path: str) -> str | None:
+    try:
+        with open(path) as f:
+            return f.read().strip()
+    except OSError:
+        return None
+
+
+def _cpu_temp() -> float | None:
+    raw = _read_file("/sys/class/thermal/thermal_zone0/temp")
+    if raw is None:
+        return None
+    try:
+        return round(int(raw) / 1000, 1)
+    except (ValueError, TypeError):
+        return None
+
+
+def _memory_info() -> dict | None:
+    raw = _read_file("/proc/meminfo")
+    if raw is None:
+        return None
+    info: dict[str, int] = {}
+    for line in raw.splitlines():
+        parts = line.split()
+        if len(parts) >= 2 and parts[0].endswith(":"):
+            key = parts[0][:-1]
+            try:
+                info[key] = int(parts[1])  # kB
+            except ValueError:
+                continue
+    total = info.get("MemTotal", 0)
+    available = info.get("MemAvailable", 0)
+    if total == 0:
+        return None
+    return {
+        "total_mb": round(total / 1024),
+        "available_mb": round(available / 1024),
+        "used_mb": round((total - available) / 1024),
+        "percent": round((total - available) / total * 100, 1),
+    }
+
+
+def _disk_info() -> dict | None:
+    try:
+        st = os.statvfs("/")
+    except OSError:
+        return None
+    total = st.f_frsize * st.f_blocks
+    free = st.f_frsize * st.f_bavail
+    used = total - free
+    if total == 0:
+        return None
+    return {
+        "total_gb": round(total / (1024**3), 1),
+        "used_gb": round(used / (1024**3), 1),
+        "free_gb": round(free / (1024**3), 1),
+        "percent": round(used / total * 100, 1),
+    }
+
+
+def _load_avg() -> list[float] | None:
+    try:
+        load = os.getloadavg()
+        return [round(x, 2) for x in load]
+    except OSError:
+        return None
+
+
+def _cpu_count() -> int | None:
+    return os.cpu_count()
+
+
+def _os_info() -> dict:
+    uname = platform.uname()
+    os_release = _read_file("/etc/os-release")
+    pretty_name = None
+    if os_release:
+        for line in os_release.splitlines():
+            if line.startswith("PRETTY_NAME="):
+                pretty_name = line.split("=", 1)[1].strip().strip('"')
+                break
+    return {
+        "os": pretty_name or f"{uname.system} {uname.release}",
+        "kernel": uname.release,
+        "arch": uname.machine,
+        "python": platform.python_version(),
+    }
+
+
+def _system_uptime() -> int | None:
+    raw = _read_file("/proc/uptime")
+    if raw is None:
+        return None
+    try:
+        return int(float(raw.split()[0]))
+    except (ValueError, IndexError):
+        return None
+
+
+def collect() -> dict:
+    """Collect all system stats. Returns a flat dict safe for JSON serialization."""
+    stats: dict = {}
+
+    stats["os"] = _os_info()
+
+    temp = _cpu_temp()
+    if temp is not None:
+        stats["cpu_temp_c"] = temp
+
+    cpu_count = _cpu_count()
+    if cpu_count is not None:
+        stats["cpu_count"] = cpu_count
+
+    load = _load_avg()
+    if load is not None:
+        stats["load_avg"] = load
+
+    mem = _memory_info()
+    if mem is not None:
+        stats["memory"] = mem
+
+    disk = _disk_info()
+    if disk is not None:
+        stats["disk"] = disk
+
+    uptime = _system_uptime()
+    if uptime is not None:
+        stats["system_uptime_s"] = uptime
+
+    return stats

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


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BKqUb3-A.js"></script>
+    <script type="module" crossorigin src="/assets/index-CWhQiJmo.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CZLTApPU.css">
     <link rel="stylesheet" crossorigin href="/assets/index-CZLTApPU.css">
   </head>
   </head>
   <body>
   <body>

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