Browse Source

Add SpoolBuddy device management settings tab

  Previously, if a SpoolBuddy daemon crashed during registration it could
  end up registered twice. The kiosk UI silently used only the first
  device and there was no UI path to remove the orphan — administrators
  had to delete the row directly in the database.

  Adds a new Settings → SpoolBuddy tab that lists every registered device
  with live connection status, system details (firmware, IP, CPU temp,
  memory, disk, OS, daemon + system uptime), hardware health flags, and
  an Unregister action gated by a confirm modal. A yellow banner appears
  whenever more than one device is registered to flag likely crash-
  duplicates. Backend adds DELETE /spoolbuddy/devices/{device_id} gated
  by inventory:delete and broadcasts spoolbuddy_unregistered over WS so
  other tabs refresh immediately.

  The tab header shows a device-count pill and a green/gray status bullet
  reflecting whether at least one registered device is online. An online
  device that is accidentally unregistered re-registers itself on its
  next heartbeat. Localized in English, German, and Japanese. The kiosk
  layout still uses devices[0] — once the orphan is unregistered, the
  remaining device naturally becomes [0].
maziggy 1 month ago
parent
commit
b5c8c2cdf5

+ 1 - 0
CHANGELOG.md

@@ -8,6 +8,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **AMS Drying Support for P2S** — Remote AMS drying and queue auto-drying now work on P2S printers with firmware 01.02.00.00 or later. Previously P2S was hard-blocked from the drying feature.
 
 ### New Features
+- **SpoolBuddy Device Management Tab** — Settings → SpoolBuddy now lists every registered SpoolBuddy device with live connection status, system details (firmware, IP, CPU temperature, memory, disk, OS, daemon and system uptime), hardware health flags (NFC / scale OK), and an Unregister button gated by a confirm modal. Previously, when a daemon crash caused SpoolBuddy to register itself twice, the kiosk UI silently used only the first device and there was no UI path to delete the orphaned duplicate — administrators had to delete the row directly in the database. A new `DELETE /spoolbuddy/devices/{device_id}` endpoint (gated by `inventory:delete`) handles the removal and broadcasts a `spoolbuddy_unregistered` websocket event so other tabs refresh immediately. A yellow warning banner appears when more than one device is registered to flag likely crash-duplicates. If an online device is accidentally unregistered, it will re-register itself on its next heartbeat. The Settings tab header also shows a device-count badge and a green/gray bullet indicating whether at least one registered device is online. Fully localized in English, German, and Japanese.
 - **Print Files Directly from Project View** ([#930](https://github.com/maziggy/bambuddy/issues/930)) — The project detail page now lists the printable files from every linked library folder inline, with Play (Print Now) and CalendarPlus (Add to Queue) action buttons on each sliced file (`.gcode` and `.gcode.3mf`). No more round-tripping through File Manager to reprint project files. Prints triggered from the project view are automatically associated with the originating project, so the resulting archive shows up in that project's history without any manual assignment. Backend adds a `project_id` query parameter to `GET /library/files` that returns all files across linked folders in a single query (replacing the prior one-request-per-folder pattern) and validates `project_id` on both the direct-print and queue paths so a stale ID yields a 404 instead of a FK-constraint 500. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **Printers Page Search and Filters** ([#852](https://github.com/maziggy/bambuddy/issues/852)) — The Printers page now has a live search bar and two filter dropdowns (status and location) to make finding specific printers in large setups easier, especially on mobile where Ctrl+F is impractical. Search matches printer name, model, location, and serial number (case-insensitive, whitespace-trimmed) and has a clear button. The status filter covers All / Printing / Paused / Idle / Finished / Error / Offline and is reactive to WebSocket status updates via a React Query cache subscription — so a print finishing while "Printing" is selected immediately removes the printer from the filtered list. The location filter is only shown when at least one printer has a location configured. All three filters are combinable; the controls are hidden when no printers are configured yet; and an empty-state message appears when no printer matches the current search/filters. Fully localized across all 7 UI languages. Thanks to @legend813 for the contribution.
 - **LDAP Default Fallback Group** — Settings → Authentication → LDAP → Advanced now has a "Default group" selector. When an LDAP user authenticates but is not listed in any mapped LDAP group, they are automatically assigned to this fallback group instead of being left without permissions. Previously such users could log in successfully but landed on empty pages because every permission check failed. Leave the setting empty to preserve the old behavior. A warning is logged each time the fallback is applied so administrators can spot missing group assignments.

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

@@ -188,6 +188,26 @@ async def list_devices(
     return [_device_to_response(d) for d in devices]
 
 
+@router.delete("/devices/{device_id}")
+async def unregister_device(
+    device_id: str,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_DELETE),
+):
+    """Unregister a SpoolBuddy device. The daemon can re-register via heartbeat later."""
+    result = await db.execute(select(SpoolBuddyDevice).where(SpoolBuddyDevice.device_id == device_id))
+    device = result.scalar_one_or_none()
+    if not device:
+        raise HTTPException(status_code=404, detail="Device not registered")
+
+    await db.delete(device)
+    await db.commit()
+    _spoolbuddy_online_last_broadcast.pop(device_id, None)
+    logger.info("SpoolBuddy device unregistered: %s (%s)", device_id, device.hostname)
+    await ws_manager.broadcast({"type": "spoolbuddy_unregistered", "device_id": device_id})
+    return {"status": "deleted", "device_id": device_id}
+
+
 @router.post("/devices/{device_id}/heartbeat", response_model=HeartbeatResponse)
 async def device_heartbeat(
     device_id: str,

+ 30 - 0
backend/tests/integration/test_spoolbuddy.py

@@ -151,6 +151,36 @@ class TestDeviceEndpoints:
         hostnames = {d["hostname"] for d in devices}
         assert hostnames == {"alpha", "beta"}
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unregister_device(self, async_client: AsyncClient, device_factory, db_session):
+        await device_factory(device_id="sb-keep", hostname="keep")
+        await device_factory(device_id="sb-drop", hostname="drop")
+        spoolbuddy_routes._spoolbuddy_online_last_broadcast["sb-drop"] = 123.0
+
+        with patch("backend.app.api.routes.spoolbuddy.ws_manager") as mock_ws:
+            mock_ws.broadcast = AsyncMock()
+            resp = await async_client.delete(f"{API}/devices/sb-drop")
+
+        assert resp.status_code == 200
+        assert resp.json() == {"status": "deleted", "device_id": "sb-drop"}
+        assert "sb-drop" not in spoolbuddy_routes._spoolbuddy_online_last_broadcast
+        mock_ws.broadcast.assert_called_once()
+        msg = mock_ws.broadcast.call_args[0][0]
+        assert msg["type"] == "spoolbuddy_unregistered"
+        assert msg["device_id"] == "sb-drop"
+
+        # Other device still present
+        resp = await async_client.get(f"{API}/devices")
+        remaining = {d["device_id"] for d in resp.json()}
+        assert remaining == {"sb-keep"}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unregister_device_not_found(self, async_client: AsyncClient):
+        resp = await async_client.delete(f"{API}/devices/sb-ghost")
+        assert resp.status_code == 404
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_heartbeat_updates_status(self, async_client: AsyncClient, device_factory):

+ 162 - 0
frontend/src/__tests__/components/SpoolBuddySettings.test.tsx

@@ -0,0 +1,162 @@
+/**
+ * Tests for the SpoolBuddySettings component.
+ *
+ * Covers:
+ * - Lists all devices (not just the first), including stale duplicates
+ * - Shows a duplicate warning when more than one device is registered
+ * - Unregister button opens a confirm modal and calls the delete API
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { SpoolBuddySettings } from '../../components/SpoolBuddySettings';
+
+vi.mock('../../api/client', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../api/client')>();
+  return {
+    ...actual,
+    spoolbuddyApi: {
+      ...actual.spoolbuddyApi,
+      getDevices: vi.fn(),
+      deleteDevice: vi.fn(),
+    },
+  };
+});
+
+import { spoolbuddyApi } from '../../api/client';
+
+const baseDevice = {
+  id: 1,
+  device_id: 'sb-0001',
+  hostname: 'spoolbuddy-kitchen',
+  ip_address: '10.0.0.11',
+  backend_url: null,
+  firmware_version: '1.2.0',
+  has_nfc: true,
+  has_scale: true,
+  tare_offset: 0,
+  calibration_factor: 1.0,
+  nfc_reader_type: 'pn532',
+  nfc_connection: 'i2c',
+  display_brightness: 100,
+  display_blank_timeout: 0,
+  has_backlight: true,
+  last_calibrated_at: null,
+  last_seen: new Date().toISOString(),
+  pending_command: null,
+  nfc_ok: true,
+  scale_ok: true,
+  uptime_s: 3600,
+  update_status: null,
+  update_message: null,
+  system_stats: {
+    os: { os: 'Raspbian', kernel: '6.1', arch: 'aarch64', python: '3.11' },
+    cpu_temp_c: 45.2,
+    memory: { total_mb: 4000, available_mb: 2500, used_mb: 1500, percent: 37 },
+    disk: { total_gb: 32, used_gb: 8, free_gb: 24, percent: 25 },
+    system_uptime_s: 86400,
+  },
+  online: true,
+};
+
+describe('SpoolBuddySettings', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(spoolbuddyApi.deleteDevice).mockResolvedValue({ status: 'deleted', device_id: 'sb-0002' });
+  });
+
+  it('renders every registered device, not just the first', async () => {
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([
+      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },
+      { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'spoolbuddy-ghost', online: false },
+    ]);
+
+    render(<SpoolBuddySettings />);
+
+    expect(await screen.findByText('spoolbuddy-kitchen')).toBeInTheDocument();
+    expect(await screen.findByText('spoolbuddy-ghost')).toBeInTheDocument();
+    expect(screen.getByText('sb-0001')).toBeInTheDocument();
+    expect(screen.getByText('sb-0002')).toBeInTheDocument();
+  });
+
+  it('shows duplicate warning when multiple devices registered', async () => {
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([
+      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },
+      { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'spoolbuddy-ghost' },
+    ]);
+
+    render(<SpoolBuddySettings />);
+
+    // Warning text mentions device count
+    expect(await screen.findByText(/2 devices registered/i)).toBeInTheDocument();
+  });
+
+  it('does not show duplicate warning with a single device', async () => {
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([
+      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },
+    ]);
+
+    render(<SpoolBuddySettings />);
+
+    await screen.findByText('spoolbuddy-kitchen');
+    expect(screen.queryByText(/devices registered/i)).not.toBeInTheDocument();
+  });
+
+  it('opens confirm modal and unregisters device on confirm', async () => {
+    const user = userEvent.setup();
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([
+      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },
+      { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'spoolbuddy-ghost', online: false },
+    ]);
+
+    render(<SpoolBuddySettings />);
+
+    // Wait for both devices to render
+    await screen.findByText('spoolbuddy-ghost');
+
+    // Click the unregister button on the ghost device card
+    const unregisterButtons = screen.getAllByRole('button', { name: /unregister/i });
+    // Two unregister buttons (one per card) — click the second one (ghost)
+    await user.click(unregisterButtons[1]);
+
+    // Confirm modal opens with title
+    expect(await screen.findByText(/unregister spoolbuddy device/i)).toBeInTheDocument();
+
+    // Click the confirm button inside the modal
+    const confirmButtons = screen.getAllByRole('button', { name: /^unregister$/i });
+    // Last one will be the modal's confirm button
+    await user.click(confirmButtons[confirmButtons.length - 1]);
+
+    await waitFor(() => {
+      expect(spoolbuddyApi.deleteDevice).toHaveBeenCalledWith('sb-0002');
+    });
+  });
+
+  it('does not call delete API when user cancels confirm modal', async () => {
+    const user = userEvent.setup();
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([
+      { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'spoolbuddy-kitchen' },
+    ]);
+
+    render(<SpoolBuddySettings />);
+
+    await screen.findByText('spoolbuddy-kitchen');
+    const unregisterButton = screen.getByRole('button', { name: /unregister/i });
+    await user.click(unregisterButton);
+
+    const cancelButton = await screen.findByRole('button', { name: /cancel/i });
+    await user.click(cancelButton);
+
+    expect(spoolbuddyApi.deleteDevice).not.toHaveBeenCalled();
+  });
+
+  it('shows empty state when no devices are registered', async () => {
+    vi.mocked(spoolbuddyApi.getDevices).mockResolvedValue([]);
+
+    render(<SpoolBuddySettings />);
+
+    expect(await screen.findByText(/no spoolbuddy devices/i)).toBeInTheDocument();
+  });
+});

+ 99 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -323,4 +323,103 @@ describe('SettingsPage', () => {
       });
     });
   });
+
+  describe('SpoolBuddy tab badge', () => {
+    const baseDevice = {
+      id: 1,
+      device_id: 'sb-0001',
+      hostname: 'sb-kitchen',
+      ip_address: '10.0.0.1',
+      backend_url: null,
+      firmware_version: '1.0.0',
+      has_nfc: true,
+      has_scale: true,
+      tare_offset: 0,
+      calibration_factor: 1.0,
+      nfc_reader_type: null,
+      nfc_connection: null,
+      display_brightness: 100,
+      display_blank_timeout: 0,
+      has_backlight: false,
+      last_calibrated_at: null,
+      last_seen: new Date().toISOString(),
+      pending_command: null,
+      nfc_ok: true,
+      scale_ok: true,
+      uptime_s: 100,
+      update_status: null,
+      update_message: null,
+      system_stats: null,
+      online: true,
+      created_at: '2024-01-01T00:00:00Z',
+      updated_at: '2024-01-01T00:00:00Z',
+    };
+
+    it('shows device count and green bullet when at least one device is online', async () => {
+      server.use(
+        http.get('/api/v1/spoolbuddy/devices', () => {
+          return HttpResponse.json([
+            { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },
+            { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },
+          ]);
+        })
+      );
+      render(<SettingsPage />);
+
+      // Find the tab button (not the header) — it's the <button> containing the SpoolBuddy text
+      const tabButton = await waitFor(() => {
+        const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
+        expect(buttons.length).toBeGreaterThan(0);
+        return buttons[0];
+      });
+
+      // Count pill rendered
+      await waitFor(() => {
+        expect(tabButton.textContent).toContain('2');
+      });
+
+      // Green status bullet (at least one device online)
+      await waitFor(() => {
+        expect(tabButton.querySelector('.bg-green-400')).not.toBeNull();
+      });
+    });
+
+    it('shows gray bullet when all devices are offline', async () => {
+      server.use(
+        http.get('/api/v1/spoolbuddy/devices', () => {
+          return HttpResponse.json([{ ...baseDevice, online: false }]);
+        })
+      );
+      render(<SettingsPage />);
+
+      const tabButton = await waitFor(() => {
+        const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
+        expect(buttons.length).toBeGreaterThan(0);
+        return buttons[0];
+      });
+
+      await waitFor(() => {
+        expect(tabButton.querySelector('.bg-gray-500')).not.toBeNull();
+        expect(tabButton.querySelector('.bg-green-400')).toBeNull();
+      });
+    });
+
+    it('hides the count pill when no devices are registered', async () => {
+      server.use(
+        http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([]))
+      );
+      render(<SettingsPage />);
+
+      const tabButton = await waitFor(() => {
+        const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
+        expect(buttons.length).toBeGreaterThan(0);
+        return buttons[0];
+      });
+
+      // The only numeric content should NOT be present — tab label only
+      await waitFor(() => {
+        expect(tabButton.textContent).toBe('SpoolBuddy');
+      });
+    });
+  });
 });

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

@@ -5217,6 +5217,11 @@ export const spoolbuddyApi = {
   getDevices: () =>
     request<SpoolBuddyDevice[]>('/spoolbuddy/devices'),
 
+  deleteDevice: (deviceId: string) =>
+    request<{ status: string; device_id: string }>(`/spoolbuddy/devices/${deviceId}`, {
+      method: 'DELETE',
+    }),
+
   tare: (deviceId: string) =>
     request<{ status: string }>(`/spoolbuddy/devices/${deviceId}/calibration/tare`, {
       method: 'POST',

+ 295 - 0
frontend/src/components/SpoolBuddySettings.tsx

@@ -0,0 +1,295 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+  Loader2,
+  Trash2,
+  Cpu,
+  HardDrive,
+  Thermometer,
+  Wifi,
+  WifiOff,
+  AlertTriangle,
+  Info,
+  CheckCircle2,
+  XCircle,
+  Clock,
+} from 'lucide-react';
+import { spoolbuddyApi, type SpoolBuddyDevice } from '../api/client';
+import { Card, CardContent, CardHeader } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import { formatRelativeTime } from '../utils/date';
+
+function formatUptime(seconds: number): string {
+  if (seconds < 60) return `${seconds}s`;
+  const m = Math.floor(seconds / 60);
+  if (m < 60) return `${m}m`;
+  const h = Math.floor(m / 60);
+  const remM = m % 60;
+  if (h < 24) return remM ? `${h}h ${remM}m` : `${h}h`;
+  const d = Math.floor(h / 24);
+  const remH = h % 24;
+  return remH ? `${d}d ${remH}h` : `${d}d`;
+}
+
+function formatMB(mb?: number): string {
+  if (mb === undefined || mb === null) return '—';
+  if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
+  return `${Math.round(mb)} MB`;
+}
+
+interface DeviceCardProps {
+  device: SpoolBuddyDevice;
+  onUnregister: (device: SpoolBuddyDevice) => void;
+  isDeleting: boolean;
+}
+
+function DeviceCard({ device, onUnregister, isDeleting }: DeviceCardProps) {
+  const { t } = useTranslation();
+  const stats = device.system_stats;
+  const mem = stats?.memory;
+  const disk = stats?.disk;
+  const online = device.online;
+
+  return (
+    <Card>
+      <CardHeader>
+        <div className="flex items-start justify-between gap-3 flex-wrap">
+          <div className="min-w-0">
+            <div className="flex items-center gap-2">
+              <h3 className="text-base font-semibold text-white truncate">{device.hostname}</h3>
+              <span
+                className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
+                  online
+                    ? 'bg-green-500/15 text-green-400 border border-green-500/40'
+                    : 'bg-gray-500/15 text-gray-400 border border-gray-500/40'
+                }`}
+              >
+                {online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
+                {online ? t('settings.spoolbuddy.online') : t('settings.spoolbuddy.offline')}
+              </span>
+            </div>
+            <p className="text-xs text-bambu-gray font-mono mt-1 truncate">{device.device_id}</p>
+          </div>
+          <Button
+            variant="danger"
+            size="sm"
+            onClick={() => onUnregister(device)}
+            disabled={isDeleting}
+            aria-label={t('settings.spoolbuddy.unregister')}
+          >
+            <Trash2 className="w-3.5 h-3.5" />
+            <span className="hidden sm:inline">{t('settings.spoolbuddy.unregister')}</span>
+          </Button>
+        </div>
+      </CardHeader>
+      <CardContent className="space-y-3">
+        {/* Connection */}
+        <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
+          <div>
+            <div className="text-bambu-gray">{t('settings.spoolbuddy.ipAddress')}</div>
+            <div className="text-white font-mono">{device.ip_address}</div>
+          </div>
+          <div>
+            <div className="text-bambu-gray">{t('settings.spoolbuddy.firmware')}</div>
+            <div className="text-white">{device.firmware_version ?? '—'}</div>
+          </div>
+          <div>
+            <div className="text-bambu-gray flex items-center gap-1">
+              <Clock className="w-3 h-3" />
+              {t('settings.spoolbuddy.lastSeen')}
+            </div>
+            <div className="text-white">
+              {device.last_seen ? formatRelativeTime(device.last_seen) : t('settings.spoolbuddy.never')}
+            </div>
+          </div>
+          <div>
+            <div className="text-bambu-gray">{t('settings.spoolbuddy.daemonUptime')}</div>
+            <div className="text-white">{formatUptime(device.uptime_s)}</div>
+          </div>
+        </div>
+
+        {/* Hardware flags */}
+        <div className="flex items-center gap-3 text-xs flex-wrap">
+          <span className="flex items-center gap-1 text-bambu-gray">
+            {device.nfc_ok ? (
+              <CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
+            ) : (
+              <XCircle className="w-3.5 h-3.5 text-red-400" />
+            )}
+            {t('settings.spoolbuddy.nfc')}
+            {device.nfc_reader_type && <span className="text-bambu-gray/70">({device.nfc_reader_type})</span>}
+          </span>
+          <span className="flex items-center gap-1 text-bambu-gray">
+            {device.scale_ok ? (
+              <CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
+            ) : (
+              <XCircle className="w-3.5 h-3.5 text-red-400" />
+            )}
+            {t('settings.spoolbuddy.scale')}
+          </span>
+        </div>
+
+        {/* System stats */}
+        {stats && (
+          <div className="pt-2 border-t border-bambu-dark-tertiary">
+            <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-xs">
+              {stats.cpu_temp_c !== undefined && (
+                <div className="flex items-center gap-2">
+                  <Thermometer className="w-3.5 h-3.5 text-bambu-gray" />
+                  <div>
+                    <div className="text-bambu-gray">{t('settings.spoolbuddy.cpuTemp')}</div>
+                    <div className="text-white">{stats.cpu_temp_c.toFixed(1)}°C</div>
+                  </div>
+                </div>
+              )}
+              {mem && mem.percent !== undefined && (
+                <div className="flex items-center gap-2">
+                  <Cpu className="w-3.5 h-3.5 text-bambu-gray" />
+                  <div>
+                    <div className="text-bambu-gray">{t('settings.spoolbuddy.memory')}</div>
+                    <div className="text-white">
+                      {mem.percent.toFixed(0)}% ({formatMB(mem.used_mb)} / {formatMB(mem.total_mb)})
+                    </div>
+                  </div>
+                </div>
+              )}
+              {disk && disk.percent !== undefined && (
+                <div className="flex items-center gap-2">
+                  <HardDrive className="w-3.5 h-3.5 text-bambu-gray" />
+                  <div>
+                    <div className="text-bambu-gray">{t('settings.spoolbuddy.disk')}</div>
+                    <div className="text-white">
+                      {disk.percent.toFixed(0)}% ({disk.used_gb?.toFixed(1)} / {disk.total_gb?.toFixed(1)} GB)
+                    </div>
+                  </div>
+                </div>
+              )}
+              {stats.system_uptime_s !== undefined && (
+                <div className="flex items-center gap-2">
+                  <Clock className="w-3.5 h-3.5 text-bambu-gray" />
+                  <div>
+                    <div className="text-bambu-gray">{t('settings.spoolbuddy.systemUptime')}</div>
+                    <div className="text-white">{formatUptime(stats.system_uptime_s)}</div>
+                  </div>
+                </div>
+              )}
+            </div>
+            {stats.os && (
+              <div className="mt-3 text-xs text-bambu-gray font-mono truncate">
+                {[stats.os.os, stats.os.kernel, stats.os.arch, stats.os.python && `Python ${stats.os.python}`]
+                  .filter(Boolean)
+                  .join(' · ')}
+              </div>
+            )}
+          </div>
+        )}
+      </CardContent>
+    </Card>
+  );
+}
+
+export function SpoolBuddySettings() {
+  const { t } = useTranslation();
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [pendingDelete, setPendingDelete] = useState<SpoolBuddyDevice | null>(null);
+
+  const { data: devices = [], isLoading } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval: 15000,
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (deviceId: string) => spoolbuddyApi.deleteDevice(deviceId),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
+      showToast(t('settings.spoolbuddy.unregisterSuccess'), 'success');
+      setPendingDelete(null);
+    },
+    onError: (err: Error) => {
+      showToast(err.message || t('settings.spoolbuddy.unregisterError'), 'error');
+    },
+  });
+
+  if (isLoading) {
+    return (
+      <Card>
+        <CardContent className="py-8 flex justify-center">
+          <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
+        </CardContent>
+      </Card>
+    );
+  }
+
+  const hasDuplicates = devices.length > 1;
+
+  return (
+    <div className="space-y-4">
+      <Card>
+        <CardContent className="py-3 px-4">
+          <div className="flex items-start gap-2 text-xs">
+            <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
+            <div className="text-bambu-gray">
+              <p className="text-white font-medium mb-1">{t('settings.spoolbuddy.infoTitle')}</p>
+              <p>{t('settings.spoolbuddy.infoBody')}</p>
+            </div>
+          </div>
+        </CardContent>
+      </Card>
+
+      {hasDuplicates && (
+        <Card className="border-l-4 border-l-yellow-500">
+          <CardContent className="py-3 px-4">
+            <div className="flex items-start gap-2 text-xs">
+              <AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
+              <div className="text-bambu-gray">
+                <p className="text-white font-medium mb-1">
+                  {t('settings.spoolbuddy.duplicatesTitle', { count: devices.length })}
+                </p>
+                <p>{t('settings.spoolbuddy.duplicatesBody')}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      )}
+
+      {devices.length === 0 ? (
+        <Card>
+          <CardContent className="py-8 text-center text-bambu-gray text-sm">
+            {t('settings.spoolbuddy.empty')}
+          </CardContent>
+        </Card>
+      ) : (
+        <div className="space-y-3">
+          {devices.map((device) => (
+            <DeviceCard
+              key={device.id}
+              device={device}
+              onUnregister={setPendingDelete}
+              isDeleting={deleteMutation.isPending && deleteMutation.variables === device.device_id}
+            />
+          ))}
+        </div>
+      )}
+
+      {pendingDelete && (
+        <ConfirmModal
+          variant="danger"
+          title={t('settings.spoolbuddy.confirmTitle')}
+          message={t('settings.spoolbuddy.confirmBody', {
+            hostname: pendingDelete.hostname,
+            deviceId: pendingDelete.device_id,
+          })}
+          confirmText={t('settings.spoolbuddy.unregister')}
+          isLoading={deleteMutation.isPending}
+          onConfirm={() => deleteMutation.mutate(pendingDelete.device_id)}
+          onCancel={() => setPendingDelete(null)}
+        />
+      )}
+    </div>
+  );
+}

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

@@ -1306,11 +1306,37 @@ export default {
       network: 'Netzwerk',
       apiKeys: 'API-Schlüssel',
       virtualPrinter: 'Virtueller Drucker',
+      spoolbuddy: 'SpoolBuddy',
       users: 'Authentifizierung',
       backup: 'Sicherung',
       emailAuth: 'E-Mail-Authentifizierung',
       ldap: 'LDAP',
     },
+    spoolbuddy: {
+      infoTitle: 'SpoolBuddy-Geräte',
+      infoBody: 'SpoolBuddy-Kioske registrieren sich automatisch per Heartbeat. Ein Gerät hier abmelden, wenn es nicht mehr verwendet wird oder wenn ein veralteter Eintrag nach einem Daemon-Absturz übrig geblieben ist.',
+      duplicatesTitle: '{{count}} Geräte registriert',
+      duplicatesBody: 'Die Kiosk-Oberfläche verwendet nur das zuerst registrierte Gerät. Falls eines davon ein veralteter Doppeleintrag nach einem Absturz ist, kann es hier entfernt werden — ein laufendes Gerät registriert sich beim nächsten Heartbeat automatisch neu.',
+      empty: 'Noch keine SpoolBuddy-Geräte registriert.',
+      online: 'Online',
+      offline: 'Offline',
+      unregister: 'Abmelden',
+      unregisterSuccess: 'Gerät abgemeldet',
+      unregisterError: 'Gerät konnte nicht abgemeldet werden',
+      confirmTitle: 'SpoolBuddy-Gerät abmelden?',
+      confirmBody: 'Dies entfernt „{{hostname}}" ({{deviceId}}) aus der Datenbank. Ein laufendes Gerät registriert sich beim nächsten Heartbeat automatisch neu.',
+      ipAddress: 'IP-Adresse',
+      firmware: 'Firmware',
+      lastSeen: 'Zuletzt gesehen',
+      daemonUptime: 'Daemon-Laufzeit',
+      systemUptime: 'System-Laufzeit',
+      never: 'nie',
+      nfc: 'NFC',
+      scale: 'Waage',
+      cpuTemp: 'CPU-Temp.',
+      memory: 'Speicher',
+      disk: 'Festplatte',
+    },
     ldap: {
       title: 'LDAP-Authentifizierung',
       enabledDesc: 'LDAP-Authentifizierung ist aktiviert',

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

@@ -1307,11 +1307,37 @@ export default {
       network: 'Network',
       apiKeys: 'API Keys',
       virtualPrinter: 'Virtual Printer',
+      spoolbuddy: 'SpoolBuddy',
       users: 'Authentication',
       backup: 'Backup',
       emailAuth: 'Email Authentication',
       ldap: 'LDAP',
     },
+    spoolbuddy: {
+      infoTitle: 'SpoolBuddy devices',
+      infoBody: 'SpoolBuddy kiosks register themselves automatically via heartbeat. Unregister a device here if it is no longer in use or if a stale duplicate was left behind by a daemon crash.',
+      duplicatesTitle: '{{count}} devices registered',
+      duplicatesBody: 'Only the first registered device is used by the kiosk UI. If one of these is a stale duplicate from a crash, unregister it — an online device will re-register itself on its next heartbeat.',
+      empty: 'No SpoolBuddy devices registered yet.',
+      online: 'Online',
+      offline: 'Offline',
+      unregister: 'Unregister',
+      unregisterSuccess: 'Device unregistered',
+      unregisterError: 'Failed to unregister device',
+      confirmTitle: 'Unregister SpoolBuddy device?',
+      confirmBody: 'This will remove "{{hostname}}" ({{deviceId}}) from the database. If the device is online, it will re-register itself on its next heartbeat.',
+      ipAddress: 'IP address',
+      firmware: 'Firmware',
+      lastSeen: 'Last seen',
+      daemonUptime: 'Daemon uptime',
+      systemUptime: 'System uptime',
+      never: 'never',
+      nfc: 'NFC',
+      scale: 'Scale',
+      cpuTemp: 'CPU temp',
+      memory: 'Memory',
+      disk: 'Disk',
+    },
     // LDAP settings
     ldap: {
       title: 'LDAP Authentication',

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

@@ -1305,11 +1305,37 @@ export default {
       network: 'ネットワーク',
       apiKeys: 'APIキー',
       virtualPrinter: '仮想プリンター',
+      spoolbuddy: 'SpoolBuddy',
       users: '認証',
       backup: 'バックアップ',
       emailAuth: 'メール認証',
       ldap: 'LDAP',
     },
+    spoolbuddy: {
+      infoTitle: 'SpoolBuddy デバイス',
+      infoBody: 'SpoolBuddy キオスクはハートビートにより自動的に登録されます。使用されなくなった場合や、デーモンのクラッシュにより古い重複が残った場合は、ここでデバイスの登録を解除してください。',
+      duplicatesTitle: '{{count}} 台のデバイスが登録されています',
+      duplicatesBody: 'キオスク UI は最初に登録されたデバイスのみを使用します。クラッシュによる古い重複がある場合は登録解除してください。オンラインのデバイスは次回のハートビートで自動的に再登録されます。',
+      empty: 'SpoolBuddy デバイスはまだ登録されていません。',
+      online: 'オンライン',
+      offline: 'オフライン',
+      unregister: '登録解除',
+      unregisterSuccess: 'デバイスの登録を解除しました',
+      unregisterError: 'デバイスの登録解除に失敗しました',
+      confirmTitle: 'SpoolBuddy デバイスの登録を解除しますか?',
+      confirmBody: '「{{hostname}}」({{deviceId}})をデータベースから削除します。デバイスがオンラインの場合、次回のハートビートで自動的に再登録されます。',
+      ipAddress: 'IPアドレス',
+      firmware: 'ファームウェア',
+      lastSeen: '最終接続',
+      daemonUptime: 'デーモン稼働時間',
+      systemUptime: 'システム稼働時間',
+      never: 'なし',
+      nfc: 'NFC',
+      scale: '計量器',
+      cpuTemp: 'CPU 温度',
+      memory: 'メモリ',
+      disk: 'ディスク',
+    },
     ldap: {
       title: 'LDAP認証',
       enabledDesc: 'LDAP認証が有効です',

+ 39 - 3
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Settings as SettingsIcon } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -23,12 +23,13 @@ import { SpoolCatalogSettings } from '../components/SpoolCatalogSettings';
 import { ColorCatalogSettings } from '../components/ColorCatalogSettings';
 import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
 import { VirtualPrinterList } from '../components/VirtualPrinterList';
+import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
 import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
 import { EmailSettings } from '../components/EmailSettings';
 import { LDAPSettings } from '../components/LDAPSettings';
 import { APIBrowser } from '../components/APIBrowser';
 import { Toggle } from '../components/Toggle';
-import { virtualPrinterApi } from '../api/client';
+import { virtualPrinterApi, spoolbuddyApi } from '../api/client';
 import { defaultNavItems, getDefaultView, setDefaultView } from '../components/Layout';
 import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
@@ -36,7 +37,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { Palette } from 'lucide-react';
 
-const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
+const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type UsersSubTab = 'users' | 'email' | 'ldap';
 
@@ -356,6 +357,15 @@ export function SettingsPage() {
   });
   const virtualPrinterRunning = virtualPrinterSettings?.status?.running ?? false;
 
+  // SpoolBuddy devices for tab indicator
+  const { data: spoolbuddyDevices } = useQuery({
+    queryKey: ['spoolbuddy-devices'],
+    queryFn: () => spoolbuddyApi.getDevices(),
+    refetchInterval: 15000,
+  });
+  const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0;
+  const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false;
+
   const { data: ffmpegStatus } = useQuery({
     queryKey: ['ffmpeg-status'],
     queryFn: api.checkFfmpeg,
@@ -1011,6 +1021,8 @@ export function SettingsPage() {
     { label: t('settings.apiBrowser'), tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' },
     // Virtual Printer
     { label: t('settings.tabs.virtualPrinter'), tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' },
+    // SpoolBuddy
+    { label: t('settings.tabs.spoolbuddy'), tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' },
     // Users / Auth
     { label: t('settings.currentUser'), tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' },
     { label: t('settings.users'), tab: 'users', subTab: 'users', keywords: 'users accounts list', anchor: 'card-users' },
@@ -1207,6 +1219,23 @@ export function SettingsPage() {
           {t('settings.tabs.virtualPrinter')}
           <span className={`w-2 h-2 rounded-full ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
         </button>
+        <button
+          onClick={() => handleTabChange('spoolbuddy')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
+            activeTab === 'spoolbuddy'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+          }`}
+        >
+          <Scale className="w-4 h-4" />
+          {t('settings.tabs.spoolbuddy')}
+          {spoolbuddyDeviceCount > 0 && (
+            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full">
+              {spoolbuddyDeviceCount}
+            </span>
+          )}
+          <span className={`w-2 h-2 rounded-full ${spoolbuddyAnyOnline ? 'bg-green-400' : 'bg-gray-500'}`} />
+        </button>
         <button
           onClick={() => handleTabChange('users')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
@@ -3528,6 +3557,13 @@ export function SettingsPage() {
         </div>
       )}
 
+      {/* SpoolBuddy Tab */}
+      {activeTab === 'spoolbuddy' && (
+        <div id="card-spoolbuddy">
+          <SpoolBuddySettings />
+        </div>
+      )}
+
       {/* Filament Tab */}
       {/* Queue Tab */}
       {activeTab === 'queue' && localSettings && (

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


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


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


+ 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-CRwdI-Pn.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-dE7PVwWf.css">
+    <script type="module" crossorigin src="/assets/index-BUImbAO9.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Cf7Yar3q.css">
   </head>
   <body>
     <div id="root"></div>

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