Browse Source

Spoolman: Show "Open in Spoolman" for linked spools (Issue #210)

When a spool is already linked in Spoolman, the FilamentHoverCard now shows
"Open in Spoolman" button instead of "Link to Spoolman". This allows users
to quickly navigate to the spool's page in Spoolman for editing.

Changes:
- Add GET /api/v1/spoolman/spools/linked endpoint returning tag->spool_id map
- FilamentHoverCard shows "Open in Spoolman" when linkedSpoolId is set
- "Link to Spoolman" only shows when spool is not linked
- Fix unlinked spools detection to strip JSON quotes from empty tags
- Add toast notifications for link success/failure
- Invalidate linked-spools query after linking
- Add backend tests for linked spools endpoint
- Add frontend tests for LinkSpoolModal

Closes #210
maziggy 3 months ago
parent
commit
7eb6058928

+ 9 - 0
CHANGELOG.md

@@ -4,6 +4,15 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.1.7b] - Not released
 
+### Enhancements
+- **Spoolman: Open in Spoolman Button** (Issue #210):
+  - FilamentHoverCard now shows "Open in Spoolman" button when spool is already linked in Spoolman
+  - Button links directly to the spool's page in Spoolman for quick editing
+  - "Link to Spoolman" button now only shows when spool is not yet linked
+  - Link button correctly disabled when no unlinked spools are available in Spoolman
+  - Toast notification shown on successful/failed spool linking
+  - Added `/api/v1/spoolman/spools/linked` endpoint returning map of linked spool tags to IDs
+
 ## [0.1.6.2] - 2026-02-02
 
 > **Security Release**: This release addresses critical security vulnerabilities. Users running authentication-enabled instances should upgrade immediately.

+ 36 - 1
backend/app/api/routes/spoolman.py

@@ -468,7 +468,9 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
         # Check if spool has a tag in extra field
         extra = spool.get("extra", {}) or {}
         tag = extra.get("tag", "")
-        if not tag:
+        # Remove quotes if present (JSON encoded string) and check if empty
+        clean_tag = tag.strip('"') if tag else ""
+        if not clean_tag:
             filament = spool.get("filament", {}) or {}
             unlinked.append(
                 UnlinkedSpool(
@@ -484,6 +486,39 @@ async def get_unlinked_spools(db: AsyncSession = Depends(get_db)):
     return unlinked
 
 
+@router.get("/spools/linked")
+async def get_linked_spools(db: AsyncSession = Depends(get_db)):
+    """Get a map of tag -> spool_id for all Spoolman spools that have a tag assigned."""
+    enabled, url, _ = await get_spoolman_settings(db)
+    if not enabled:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if url:
+            client = await init_spoolman_client(url)
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    spools = await client.get_spools()
+    linked: dict[str, int] = {}
+
+    for spool in spools:
+        # Check if spool has a tag in extra field
+        extra = spool.get("extra", {}) or {}
+        tag = extra.get("tag", "")
+        if tag:
+            # Remove quotes if present (JSON encoded string)
+            clean_tag = tag.strip('"').upper()
+            if clean_tag:
+                linked[clean_tag] = spool["id"]
+
+    return {"linked": linked}
+
+
 class LinkSpoolRequest(BaseModel):
     """Request to link a Spoolman spool to an AMS tray."""
 

+ 77 - 0
backend/tests/integration/test_spoolman_api.py

@@ -260,6 +260,83 @@ class TestSpoolmanAPI:
         assert len(data) == 1
         assert data[0]["id"] == 2  # Only unlinked spool
 
+    # =========================================================================
+    # Linked Spools Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_not_enabled(self, async_client: AsyncClient):
+        """Verify get linked spools fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify get linked spools returns map of tag -> spool_id."""
+        # Mock spool with extra.tag (linked)
+        mock_spool = {
+            "id": 42,
+            "remaining_weight": 800,
+            "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        assert "linked" in data
+        assert isinstance(data["linked"], dict)
+        # Tag should be uppercase and stripped of quotes
+        assert "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4" in data["linked"]
+        assert data["linked"]["A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"] == 42
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_excludes_unlinked(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify unlinked spools (without tag) are excluded."""
+        # Mock spool with tag (linked)
+        mock_spool_linked = {
+            "id": 1,
+            "extra": {"tag": '"ABC12345678901234567890123456789A"'},
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        # Mock spool without tag (unlinked)
+        mock_spool_unlinked = {
+            "id": 2,
+            "extra": {},
+            "filament": {"id": 2, "name": "PLA Blue", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["linked"]) == 1  # Only linked spool
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_linked_spools_empty_tag_excluded(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify spools with empty tag (JSON-encoded empty string) are excluded."""
+        # Mock spool with empty JSON-encoded tag
+        mock_spool = {
+            "id": 1,
+            "extra": {"tag": '""'},  # JSON-encoded empty string
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA"},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/linked")
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["linked"]) == 0  # Empty tag should be excluded
+
     # =========================================================================
     # Link Spool Tests
     # =========================================================================

+ 290 - 0
frontend/src/__tests__/components/LinkSpoolModal.test.tsx

@@ -0,0 +1,290 @@
+/**
+ * Tests for the LinkSpoolModal component.
+ *
+ * Tests the Spoolman link spool modal including:
+ * - Displaying unlinked spools
+ * - Selecting a spool to link
+ * - Link success with toast notification
+ * - Link error with toast notification
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { LinkSpoolModal } from '../../components/LinkSpoolModal';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getUnlinkedSpools: vi.fn(),
+    linkSpool: vi.fn(),
+    getSettings: vi.fn().mockResolvedValue({}),
+    getAuthStatus: vi.fn().mockResolvedValue({ enabled: false, configured: false }),
+  },
+}));
+
+// Mock the toast context
+const mockShowToast = vi.fn();
+vi.mock('../../contexts/ToastContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/ToastContext')>();
+  return {
+    ...actual,
+    useToast: () => ({ showToast: mockShowToast }),
+  };
+});
+
+// Import mocked module
+import { api } from '../../api/client';
+
+describe('LinkSpoolModal', () => {
+  const defaultProps = {
+    isOpen: true,
+    onClose: vi.fn(),
+    trayUuid: 'A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4',
+    trayInfo: {
+      type: 'PLA Basic',
+      color: 'FF0000',
+      location: 'AMS A1',
+    },
+  };
+
+  const mockUnlinkedSpools = [
+    {
+      id: 1,
+      filament_name: 'PLA Red',
+      filament_material: 'PLA',
+      filament_color_hex: 'FF0000',
+      remaining_weight: 800,
+      location: 'Shelf A',
+    },
+    {
+      id: 2,
+      filament_name: 'PETG Blue',
+      filament_material: 'PETG',
+      filament_color_hex: '0000FF',
+      remaining_weight: 500,
+      location: null,
+    },
+  ];
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(api.getUnlinkedSpools).mockResolvedValue(mockUnlinkedSpools);
+    vi.mocked(api.linkSpool).mockResolvedValue({ success: true, message: 'Linked' });
+  });
+
+  describe('rendering', () => {
+    it('renders modal title', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
+      });
+    });
+
+    it('displays tray info', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Basic')).toBeInTheDocument();
+        expect(screen.getByText('(AMS A1)')).toBeInTheDocument();
+      });
+    });
+
+    it('displays tray UUID', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText(defaultProps.trayUuid)).toBeInTheDocument();
+      });
+    });
+
+    it('shows loading state while fetching spools', async () => {
+      // Delay the response
+      vi.mocked(api.getUnlinkedSpools).mockImplementation(
+        () => new Promise(() => {})
+      );
+
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+      });
+    });
+
+    it('displays unlinked spools list', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+        expect(screen.getByText('PETG Blue')).toBeInTheDocument();
+      });
+    });
+
+    it('shows message when no unlinked spools', async () => {
+      vi.mocked(api.getUnlinkedSpools).mockResolvedValue([]);
+
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument();
+      });
+    });
+
+    it('does not render when isOpen is false', () => {
+      render(<LinkSpoolModal {...defaultProps} isOpen={false} />);
+      expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('spool selection', () => {
+    it('allows selecting a spool', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      // Click to select spool
+      fireEvent.click(screen.getByText('PLA Red'));
+
+      // Should show check mark (via visual styling)
+      const selectedButton = screen.getByText('PLA Red').closest('button');
+      expect(selectedButton).toHaveClass('border-bambu-green');
+    });
+
+    it('link button is disabled until spool is selected', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      const linkButton = screen.getByRole('button', { name: /link spool/i });
+      expect(linkButton).toBeDisabled();
+
+      // Select a spool
+      fireEvent.click(screen.getByText('PLA Red'));
+
+      expect(linkButton).not.toBeDisabled();
+    });
+  });
+
+  describe('linking', () => {
+    it('calls linkSpool API on submit', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      // Select a spool
+      fireEvent.click(screen.getByText('PLA Red'));
+
+      // Click link button
+      fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
+
+      await waitFor(() => {
+        expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid);
+      });
+    });
+
+    it('shows success toast on successful link', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('PLA Red'));
+      fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
+
+      await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalledWith(
+          'Spool linked to Spoolman successfully',
+          'success'
+        );
+      });
+    });
+
+    it('calls onClose after successful link', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('PLA Red'));
+      fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
+
+      await waitFor(() => {
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      });
+    });
+
+    it('shows error toast on link failure', async () => {
+      const errorMessage = 'Failed to update spool';
+      vi.mocked(api.linkSpool).mockRejectedValue(new Error(errorMessage));
+
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('PLA Red')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('PLA Red'));
+      fireEvent.click(screen.getByRole('button', { name: /link spool/i }));
+
+      await waitFor(() => {
+        expect(mockShowToast).toHaveBeenCalledWith(
+          `Failed to link spool: ${errorMessage}`,
+          'error'
+        );
+      });
+    });
+  });
+
+  describe('modal actions', () => {
+    it('calls onClose when cancel button is clicked', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Cancel')).toBeInTheDocument();
+      });
+
+      fireEvent.click(screen.getByText('Cancel'));
+      expect(defaultProps.onClose).toHaveBeenCalled();
+    });
+
+    it('calls onClose when backdrop is clicked', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
+      });
+
+      // Click the backdrop (the element with bg-black/60)
+      const backdrop = document.querySelector('.bg-black\\/60');
+      if (backdrop) {
+        fireEvent.click(backdrop);
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      }
+    });
+
+    it('calls onClose when X button is clicked', async () => {
+      render(<LinkSpoolModal {...defaultProps} />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Link to Spoolman')).toBeInTheDocument();
+      });
+
+      // Find and click the X button in the header
+      const closeButtons = screen.getAllByRole('button');
+      const xButton = closeButtons.find(btn => btn.querySelector('svg.lucide-x'));
+      if (xButton) {
+        fireEvent.click(xButton);
+        expect(defaultProps.onClose).toHaveBeenCalled();
+      }
+    });
+  });
+});

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

@@ -1604,6 +1604,10 @@ export interface UnlinkedSpool {
   location: string | null;
 }
 
+export interface LinkedSpoolsMap {
+  linked: Record<string, number>; // tag (uppercase) -> spool_id
+}
+
 // Update types
 export interface VersionInfo {
   version: string;
@@ -2944,6 +2948,8 @@ export const api = {
     request<{ filaments: unknown[] }>('/spoolman/filaments'),
   getUnlinkedSpools: () =>
     request<UnlinkedSpool[]>('/spoolman/spools/unlinked'),
+  getLinkedSpools: () =>
+    request<LinkedSpoolsMap>('/spoolman/spools/linked'),
   linkSpool: (spoolId: number, trayUuid: string) =>
     request<{ success: boolean; message: string }>(`/spoolman/spools/${spoolId}/link`, {
       method: 'POST',

+ 20 - 3
frontend/src/components/FilamentHoverCard.tsx

@@ -1,5 +1,5 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
-import { Droplets, Link2, Copy, Check, Settings2 } from 'lucide-react';
+import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
 
 interface FilamentData {
   vendor: 'Bambu Lab' | 'Generic';
@@ -15,6 +15,8 @@ interface SpoolmanConfig {
   enabled: boolean;
   onLinkSpool?: (trayUuid: string) => void;
   hasUnlinkedSpools?: boolean; // Whether there are spools available to link
+  linkedSpoolId?: number | null; // Spoolman spool ID if this tray is already linked
+  spoolmanUrl?: string | null; // Base URL for Spoolman (for "Open in Spoolman" link)
 }
 
 interface ConfigureSlotConfig {
@@ -270,8 +272,23 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     </button>
                   </div>
 
-                  {/* Link Spool button */}
-                  {spoolman.onLinkSpool && (
+                  {/* Open in Spoolman button (when already linked) */}
+                  {spoolman.linkedSpoolId && spoolman.spoolmanUrl && (
+                    <a
+                      href={`${spoolman.spoolmanUrl.replace(/\/$/, '')}/spool/show/${spoolman.linkedSpoolId}`}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      onClick={(e) => e.stopPropagation()}
+                      className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
+                      title="View this spool in Spoolman"
+                    >
+                      <ExternalLink className="w-3.5 h-3.5" />
+                      Open in Spoolman
+                    </a>
+                  )}
+
+                  {/* Link Spool button (when not linked) */}
+                  {!spoolman.linkedSpoolId && spoolman.onLinkSpool && (
                     <button
                       onClick={(e) => {
                         e.stopPropagation();

+ 7 - 0
frontend/src/components/LinkSpoolModal.tsx

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { X, Loader2, Link2, Check } from 'lucide-react';
 import { api } from '../api/client';
 import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
 
 interface LinkSpoolModalProps {
   isOpen: boolean;
@@ -17,6 +18,7 @@ interface LinkSpoolModalProps {
 
 export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
   const queryClient = useQueryClient();
+  const { showToast } = useToast();
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
 
   // Fetch unlinked spools
@@ -31,9 +33,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
     mutationFn: (spoolId: number) => api.linkSpool(spoolId, trayUuid),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
+      queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
+      showToast('Spool linked to Spoolman successfully', 'success');
       onClose();
     },
+    onError: (error: Error) => {
+      showToast(`Failed to link spool: ${error.message}`, 'error');
+    },
   });
 
   if (!isOpen) return null;

+ 23 - 0
frontend/src/pages/PrintersPage.tsx

@@ -914,6 +914,8 @@ function PrinterCard({
   amsThresholds,
   spoolmanEnabled = false,
   hasUnlinkedSpools = false,
+  linkedSpools,
+  spoolmanUrl,
   timeFormat = 'system',
   cameraViewMode = 'window',
   onOpenEmbeddedCamera,
@@ -932,6 +934,8 @@ function PrinterCard({
   };
   spoolmanEnabled?: boolean;
   hasUnlinkedSpools?: boolean;
+  linkedSpools?: Record<string, number>;
+  spoolmanUrl?: string | null;
   timeFormat?: 'system' | '12h' | '24h';
   cameraViewMode?: 'window' | 'embedded';
   onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
@@ -2316,6 +2320,8 @@ function PrinterCard({
                                         spoolman={{
                                           enabled: spoolmanEnabled,
                                           hasUnlinkedSpools,
+                                          linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
+                                          spoolmanUrl,
                                           onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                             setLinkSpoolModal({
                                               trayUuid: uuid,
@@ -2505,6 +2511,8 @@ function PrinterCard({
                                     spoolman={{
                                       enabled: spoolmanEnabled,
                                       hasUnlinkedSpools,
+                                      linkedSpoolId: filamentData.trayUuid ? linkedSpools?.[filamentData.trayUuid.toUpperCase()] : undefined,
+                                      spoolmanUrl,
                                       onLinkSpool: spoolmanEnabled && filamentData.trayUuid ? (uuid) => {
                                         setLinkSpoolModal({
                                           trayUuid: uuid,
@@ -2633,6 +2641,8 @@ function PrinterCard({
                               spoolman={{
                                 enabled: spoolmanEnabled,
                                 hasUnlinkedSpools,
+                                linkedSpoolId: extFilamentData.trayUuid ? linkedSpools?.[extFilamentData.trayUuid.toUpperCase()] : undefined,
+                                spoolmanUrl,
                                 onLinkSpool: spoolmanEnabled && extFilamentData.trayUuid ? (uuid) => {
                                   setLinkSpoolModal({
                                     trayUuid: uuid,
@@ -4471,6 +4481,15 @@ export function PrintersPage() {
   });
   const hasUnlinkedSpools = unlinkedSpools && unlinkedSpools.length > 0;
 
+  // Fetch linked spools map (tag -> spool_id) to know which spools are already in Spoolman
+  const { data: linkedSpoolsData } = useQuery({
+    queryKey: ['linked-spools'],
+    queryFn: api.getLinkedSpools,
+    enabled: !!spoolmanEnabled,
+    staleTime: 30 * 1000, // 30 seconds
+  });
+  const linkedSpools = linkedSpoolsData?.linked;
+
   // Create a map of printer_id -> maintenance info for quick lookup
   const maintenanceByPrinter = maintenanceOverview?.reduce(
     (acc, overview) => {
@@ -4777,6 +4796,8 @@ export function PrintersPage() {
                     } : undefined}
                     spoolmanEnabled={spoolmanEnabled}
                     hasUnlinkedSpools={hasUnlinkedSpools}
+                    linkedSpools={linkedSpools}
+                    spoolmanUrl={spoolmanStatus?.url}
                     timeFormat={settings?.time_format || 'system'}
                     cameraViewMode={settings?.camera_view_mode || 'window'}
                     onOpenEmbeddedCamera={(id, name) => setEmbeddedCameraPrinters(prev => new Map(prev).set(id, { id, name }))}
@@ -4800,6 +4821,8 @@ export function PrintersPage() {
               cardSize={cardSize}
               spoolmanEnabled={spoolmanEnabled}
               hasUnlinkedSpools={hasUnlinkedSpools}
+              linkedSpools={linkedSpools}
+              spoolmanUrl={spoolmanStatus?.url}
               amsThresholds={settings ? {
                 humidityGood: Number(settings.ams_humidity_good) || 40,
                 humidityFair: Number(settings.ams_humidity_fair) || 60,

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


+ 1 - 1
static/index.html

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

+ 1 - 1
test_frontend.sh

@@ -2,5 +2,5 @@
 
 cd frontend
 npm run lint
-npm test
+npm run test:run
 cd ..

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