Просмотр исходного кода

Add customizable low stock threshold, add low stock filter (#531)

* feat(queue): show spool grams left in filament slot mapping

* Bumped version

* Add SpoolBuddy AMS slot config, external slots, and dashboard redesign

- AMS page: external spool slots (Ext/Ext-L/Ext-R), click-to-configure
  modal on all slots, temperature/humidity threshold-colored indicators,
  nozzle L/R badges for dual-nozzle printers, compact AMS-HT layout
- Dashboard: two-column layout with device status + printers list (left)
  and current spool card (right), state-colored scale/NFC icons, dashed
  border card styling
- Daemon: suppress redundant scale reports (±2g threshold + stability
  state change detection) to prevent weight display bouncing
- TopBar: auto-select online printers only, SpoolBuddy logo

* Fix SpoolBuddy daemon crash when read_tag module is missing

NFCReader.__init__ imported read_tag and instantiated PN5180() outside
the try/except block, so a missing module crashed the entire daemon.
Moved the import inside the existing try/except so the daemon gracefully
skips NFC polling — matching the scale reader's existing behavior.

* Fix SpoolBuddy daemon failing to import hardware drivers

The daemon imports read_tag and scale_diag as bare modules, but they
live in spoolbuddy/scripts/ which isn't on sys.path when systemd runs
the daemon. Added scripts/ to sys.path at startup, resolved relative
to the module file. Also moved the read_tag import inside NFCReader's
try/except (was crashing the daemon instead of skipping gracefully)
and demoted hardware-not-available messages from ERROR to INFO.

* Increase scale moving average window to reduce weight bouncing

5 samples at 100ms (500ms window) wasn't enough to smooth NAU7802 ADC
noise — the averaged value still varied by >2g between 1s report
intervals, and the stability state kept flipping, triggering a report
every cycle. Increased to 20 samples (2s window) so noise is smoothed
before reaching the reporting layer.

* Remove stability flipping as scale report trigger

When ADC noise kept the spread hovering around the 2g stability
threshold, the stable flag toggled every cycle, forcing a report with
a slightly different weight each time. Now only actual weight changes
of >=2g trigger reports. The stable flag is still included in each
report for consumers that need it.

* Fix formatting of option elements in FilamentMapping

* Make low stock threshold editable

* Add new filter for low spools

* Update bug report template to require additional fields

* Added toast for invalid imputs with locales, updated inputb field restrictions

* Minor Spoolbuddy frontend improvements

* Updated test_backend.sh

* Updated Spoolbuddy install script to strip down Raspbian

* Updated Spoolbuddy install script

* Add API key auth support to /auth/me for SpoolBuddy kiosk

When Bambuddy auth is enabled, the SpoolBuddy kiosk gets redirected to
the login page because ProtectedRoute requires a user from GET /auth/me,
which only handled JWT tokens. The kiosk daemon already has an API key
but couldn't use it to satisfy the frontend auth check.

- Backend: /auth/me now accepts API keys (Bearer bb_xxx or X-API-Key)
  and returns a synthetic admin UserResponse with all permissions
- Frontend: AuthContext reads ?token= from URL on first load, stores in
  localStorage, and strips from URL (prevents history/referrer leakage)
- Install script: kiosk URL now includes ?token=${API_KEY}
- Tests: 3 new integration tests (Bearer API key, X-API-Key header,
  invalid key rejection)

* SpoolBuddy touch-friendly UI overhaul for 1024x600 kiosk display

Enlarge all interactive elements across 9 SpoolBuddy components to meet
44px minimum tap targets on the RPi touchscreen. Increase nav icons
(20→24px), labels (10→12px), bar heights, section headers, printer
buttons, spool visualizations, fill bars, and status indicators.
Compact the dashboard stats bar and remove the printers card. Add
fullScreen prop to ConfigureAmsSlotModal with two-column layout
(filament list left, K-profile + color right) to eliminate scrolling.

* Minor changes, CSS fixes

* Refactor usageFilter state to remove 'lowstock' option for clarity

* Move var saving to API, add test coverage

* fix: threshold validation and cleanup

* Change test input from '150' to '0'

---------

Co-authored-by: tridev <c.tripod@gmx.ch>
Co-authored-by: MartinNYHC <mz@v8w.de>
Keybored 2 месяцев назад
Родитель
Сommit
97f6250a0b

+ 1 - 0
backend/app/api/routes/settings.py

@@ -105,6 +105,7 @@ async def get_settings(
                 "ams_temp_good",
                 "ams_temp_fair",
                 "library_disk_warning_gb",
+                "low_stock_threshold",
             ]:
                 settings_dict[setting.key] = float(setting.value)
             elif setting.key in [

+ 9 - 0
backend/app/schemas/settings.py

@@ -149,6 +149,14 @@ class AppSettings(BaseModel):
         default="", description="Bearer token for Prometheus metrics authentication (optional)"
     )
 
+    # Inventory low stock threshold
+    low_stock_threshold: float = Field(
+        default=20.0,
+        ge=0.1,
+        le=99.9,
+        description="Low stock threshold percentage (%) for inventory filtering and display",
+    )
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -210,3 +218,4 @@ class AppSettingsUpdate(BaseModel):
     preferred_slicer: str | None = None
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
+    low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)

+ 21 - 0
backend/tests/integration/test_settings_api.py

@@ -166,6 +166,27 @@ class TestSettingsAPI:
         assert result["ams_temp_good"] == 25.0
         assert result["ams_temp_fair"] == 32.0
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_low_stock_threshold(self, async_client: AsyncClient):
+        """Verify low stock threshold setting can be updated."""
+        # Get default value
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 20.0
+
+        # Update to custom value
+        response = await async_client.put("/api/v1/settings/", json={"low_stock_threshold": 15.5})
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["low_stock_threshold"] == 15.5
+
+        # Verify persistence
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["low_stock_threshold"] == 15.5
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_update_notification_language(self, async_client: AsyncClient):

+ 392 - 0
frontend/src/__tests__/pages/InventoryPageLowStock.test.tsx

@@ -0,0 +1,392 @@
+/**
+ * Tests for low stock threshold functionality in InventoryPage.
+ *
+ * Tests that the low stock threshold:
+ * - Is loaded from backend settings API
+ * - Can be updated via the UI
+ * - Persists changes to the backend
+ * - Does not use localStorage
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockSettings = {
+  auto_archive: true,
+  save_thumbnails: true,
+  capture_finish_photo: true,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  spoolman_enabled: false,
+  spoolman_url: '',
+  spoolman_sync_mode: 'auto',
+  spoolman_disable_weight_sync: false,
+  spoolman_report_partial_usage: true,
+  check_updates: true,
+  check_printer_firmware: true,
+  include_beta_updates: false,
+  language: 'en',
+  notification_language: 'en',
+  bed_cooled_threshold: 35,
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 28,
+  ams_temp_fair: 35,
+  ams_history_retention_days: 30,
+  per_printer_mapping_expanded: false,
+  date_format: 'system',
+  time_format: 'system',
+  default_printer_id: null,
+  virtual_printer_enabled: false,
+  virtual_printer_access_code: '',
+  virtual_printer_mode: 'immediate',
+  dark_style: 'classic',
+  dark_background: 'neutral',
+  dark_accent: 'green',
+  light_style: 'classic',
+  light_background: 'neutral',
+  light_accent: 'green',
+  ftp_retry_enabled: true,
+  ftp_retry_count: 3,
+  ftp_retry_delay: 2,
+  ftp_timeout: 30,
+  mqtt_enabled: false,
+  mqtt_broker: '',
+  mqtt_port: 1883,
+  mqtt_username: '',
+  mqtt_password: '',
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  external_url: '',
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  ha_url_from_env: false,
+  ha_token_from_env: false,
+  ha_env_managed: false,
+  library_archive_mode: 'ask',
+  library_disk_warning_gb: 5.0,
+  camera_view_mode: 'window',
+  preferred_slicer: 'bambu_studio',
+  prometheus_enabled: false,
+  prometheus_token: '',
+  low_stock_threshold: 20.0,
+};
+
+const mockSpools = [
+  {
+    id: 1,
+    material: 'PLA',
+    subtype: null,
+    brand: 'Polymaker',
+    color_name: 'Red',
+    rgba: 'FF0000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 900, // 10% remaining - low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-01T00:00:00Z',
+    updated_at: '2025-01-01T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+  {
+    id: 2,
+    material: 'PETG',
+    subtype: null,
+    brand: 'eSun',
+    color_name: 'Blue',
+    rgba: '0000FFFF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 200, // 80% remaining - not low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-02T00:00:00Z',
+    updated_at: '2025-01-02T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+  {
+    id: 3,
+    material: 'ABS',
+    subtype: null,
+    brand: 'Hatchbox',
+    color_name: 'Black',
+    rgba: '000000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 850, // 15% remaining - low stock
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-03T00:00:00Z',
+    updated_at: '2025-01-03T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+];
+
+describe('InventoryPage - Low Stock Threshold', () => {
+  beforeEach(() => {
+    // Clear localStorage to ensure we're not relying on it
+    localStorage.clear();
+
+    server.use(
+      http.get('/api/v1/settings/', () => {
+        return HttpResponse.json(mockSettings);
+      }),
+      http.put('/api/v1/settings/', async ({ request }) => {
+        const body = (await request.json()) as Partial<typeof mockSettings>;
+        return HttpResponse.json({ ...mockSettings, ...body });
+      }),
+      http.get('/api/v1/spools/', () => {
+        return HttpResponse.json(mockSpools);
+      }),
+      http.get('/api/v1/spool-assignments/', () => {
+        return HttpResponse.json([]);
+      }),
+      http.get('/api/v1/spoolman/settings', () => {
+        return HttpResponse.json({ spoolman_enabled: 'false' });
+      })
+    );
+  });
+
+  describe('default threshold from backend', () => {
+    it('loads the default threshold of 20% from backend settings', async () => {
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // Find the low stock stat showing the threshold
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+
+    it('calculates low stock count based on default threshold', async () => {
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)
+        const lowStockSection = screen.getByText(/low stock/i).closest('div');
+        expect(lowStockSection).toBeInTheDocument();
+      });
+    });
+
+    it('does not use localStorage for threshold', async () => {
+      // Set a value in localStorage that should be ignored
+      localStorage.setItem('bambuddy-low-stock-threshold', '50');
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        // Should show backend value (20%), not localStorage value (50%)
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('updating threshold via UI', () => {
+    it('shows edit button for threshold', async () => {
+      const user = userEvent.setup();
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      const editButton = screen.getByTitle(/edit/i);
+      expect(editButton).toBeInTheDocument();
+
+      await user.click(editButton);
+
+      // Input field should appear
+      await waitFor(() => {
+        const input = screen.getByRole('textbox');
+        expect(input).toBeInTheDocument();
+        expect(input).toHaveValue('20');
+      });
+    });
+
+    it('updates threshold and persists to backend', async () => {
+      const user = userEvent.setup();
+      let updatedSettings: Partial<typeof mockSettings> | null = null;
+
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Partial<typeof mockSettings>;
+          updatedSettings = body;
+          return HttpResponse.json({ ...mockSettings, ...body });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button
+      const editButton = screen.getByTitle(/edit/i);
+      await user.click(editButton);
+
+      // Enter new value
+      const input = screen.getByRole('textbox');
+      await user.clear(input);
+      await user.type(input, '15.5');
+
+      // Submit form
+      const saveButton = screen.getByRole('button', { name: /save/i });
+      await user.click(saveButton);
+
+      // Verify API was called with correct value
+      await waitFor(() => {
+        expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });
+      });
+    });
+
+    it('validates threshold input range', async () => {
+      const user = userEvent.setup();
+      let updatedSettings: Partial<typeof mockSettings> | null = null;
+
+      server.use(
+        http.put('/api/v1/settings/', async ({ request }) => {
+          const body = (await request.json()) as Partial<typeof mockSettings>;
+          updatedSettings = body;
+          return HttpResponse.json({ ...mockSettings, ...body });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button
+      const editButton = screen.getByTitle(/edit/i);
+      await user.click(editButton);
+
+      // Try invalid values
+      const input = screen.getByRole('textbox');
+
+      // Too high
+      await user.clear(input);
+      await user.type(input, '0');
+
+      const saveButton = screen.getByRole('button', { name: /save/i });
+      await user.click(saveButton);
+
+      // Should show error and NOT call the PUT endpoint
+      await waitFor(() => {
+        expect(updatedSettings).toBeNull();
+      });
+    });
+
+    it('allows canceling threshold edit', async () => {
+      const user = userEvent.setup();
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+
+      // Click edit button
+      const editButton = screen.getByTitle(/edit/i);
+      await user.click(editButton);
+
+      // Change value
+      const input = screen.getByRole('textbox');
+      await user.clear(input);
+      await user.type(input, '30');
+
+      // Cancel
+      const cancelButton = screen.getByRole('button', { name: /cancel/i });
+      await user.click(cancelButton);
+
+      // Should revert to original display
+      await waitFor(() => {
+        expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('custom threshold from backend', () => {
+    it('loads custom threshold value from backend', async () => {
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 25%/i)).toBeInTheDocument();
+      });
+    });
+
+    it('applies custom threshold to low stock filtering', async () => {
+      // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)
+      server.use(
+        http.get('/api/v1/settings/', () => {
+          return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });
+        })
+      );
+
+      render(<InventoryPageRouter />);
+
+      await waitFor(() => {
+        expect(screen.getByText(/< 30%/i)).toBeInTheDocument();
+      });
+
+      // The low stock count should reflect the new threshold
+      // Implementation would show appropriate count based on 30% threshold
+    });
+  });
+});

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

@@ -837,6 +837,8 @@ export interface AppSettings {
   prometheus_token: string;
   // Bed cooled threshold
   bed_cooled_threshold: number;
+  // Inventory low stock threshold
+  low_stock_threshold: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;

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

@@ -2691,7 +2691,6 @@ export default {
     sinceTracking: 'Seit Beginn der Erfassung',
     loadedInAms: 'Im AMS/Ext geladen',
     remaining: 'Verbleibend',
-    lowStockThreshold: '<20% verbleibend',
     weightCheck: 'Gewichtskontrolle',
     lastWeighed: 'Zuletzt gewogen',
     neverWeighed: 'Nie gewogen',
@@ -2744,6 +2743,7 @@ export default {
     clearHistory: 'Löschen',
     historyCleared: 'Verbrauchshistorie gelöscht',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'Der Schwellenwert muss zwischen 0.1 und 99.9 liegen',
   },
 
   // Timelapse

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

@@ -2695,7 +2695,6 @@ export default {
     sinceTracking: 'Since tracking started',
     loadedInAms: 'Loaded in AMS/Ext',
     remaining: 'Remaining',
-    lowStockThreshold: '<20% remaining',
     weightCheck: 'Weight Check',
     lastWeighed: 'Last weighed',
     neverWeighed: 'Never weighed',
@@ -2748,6 +2747,7 @@ export default {
     clearHistory: 'Clear',
     historyCleared: 'Usage history cleared',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'Threshold must be between 0.1 and 99.9',
   },
 
   // Timelapse

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -2683,7 +2683,6 @@ export default {
     sinceTracking: 'Depuis le début du suivi',
     loadedInAms: 'Chargé dans AMS/Ext',
     remaining: 'Restant',
-    lowStockThreshold: '<20% restant',
     weightCheck: 'Vérification poids',
     lastWeighed: 'Dernière pesée',
     neverWeighed: 'Jamais pesé',
@@ -2732,6 +2731,7 @@ export default {
     clearHistory: 'Effacer',
     historyCleared: 'Historique effacé',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'Le seuil doit être compris entre 0.1 et 99.9',
   },
 
   // Timelapse

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

@@ -2454,6 +2454,7 @@ export default {
     spoolRestored: 'Bobina ripristinata',
     deleteConfirm: 'Sei sicuro di voler eliminare questa bobina? Questa azione non può essere annullata.',
     advancedSettings: 'Impostazioni Avanzate',
+    lowStockThresholdError: 'La soglia deve essere tra 0.1 e 99.9',
     weightCheck: 'Controllo Peso',
     lastWeighed: 'Ultima pesatura',
     neverWeighed: 'Mai pesato',

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -2617,7 +2617,6 @@ export default {
     sinceTracking: '追跡開始以降',
     loadedInAms: 'AMS/Extに装填中',
     remaining: '残り',
-    lowStockThreshold: '残り20%未満',
     weightCheck: '重量チェック',
     lastWeighed: '最終計量',
     neverWeighed: '未計量',
@@ -2668,6 +2667,7 @@ export default {
     clearHistory: 'クリア',
     historyCleared: '使用履歴がクリアされました',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'しきい値は0.1から99.9の間でなければなりません',
   },
   timelapse: {
     download: 'ダウンロード',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -2695,7 +2695,6 @@ export default {
     sinceTracking: 'Desde o início do rastreamento',
     loadedInAms: 'Carregado no AMS/Ext',
     remaining: 'Restante',
-    lowStockThreshold: '<20% restante',
     weightCheck: 'Verificação de Peso',
     lastWeighed: 'Última pesagem',
     neverWeighed: 'Nunca pesado',
@@ -2744,6 +2743,7 @@ export default {
     clearHistory: 'Limpar',
     historyCleared: 'Histórico de uso limpo',
     fillSourceLabel: '(Inv)',
+    lowStockThresholdError: 'O limite deve estar entre 0.1 e 99.9',
   },
 
   // Timelapse

+ 100 - 7
frontend/src/pages/InventoryPage.tsx

@@ -1,4 +1,4 @@
-import { useState, useMemo, type ReactNode } from 'react';
+import { useState, useMemo, useEffect, type ReactNode } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import {
@@ -20,7 +20,7 @@ import { formatDateInput, parseUTCDate, type DateFormat } from '../utils/date';
 import { formatSlotLabel } from '../utils/amsHelpers';
 
 type ArchiveFilter = 'active' | 'archived';
-type UsageFilter = 'all' | 'used' | 'new';
+type UsageFilter = 'all' | 'used' | 'new' | 'lowstock';
 type ViewMode = 'table' | 'cards';
 type SortDirection = 'asc' | 'desc';
 type SortState = { column: string; direction: SortDirection } | null;
@@ -513,6 +513,30 @@ function InventoryPage() {
     }
   };
 
+  // Low stock threshold from backend settings
+  const lowStockThreshold = settings?.low_stock_threshold ?? 20;
+  const [showThresholdInput, setShowThresholdInput] = useState(false);
+  const [thresholdInput, setThresholdInput] = useState(lowStockThreshold.toString());
+
+  // Sync thresholdInput when lowStockThreshold changes and input is not shown
+  useEffect(() => {
+    if (!showThresholdInput) {
+      setThresholdInput(lowStockThreshold.toString());
+    }
+  }, [lowStockThreshold, showThresholdInput]);
+
+  const updateThresholdMutation = useMutation({
+    mutationFn: (threshold: number) => api.updateSettings({ low_stock_threshold: threshold }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+      showToast(t('common.saved'), 'success');
+      setShowThresholdInput(false);
+    },
+    onError: () => {
+      showToast(t('inventory.lowStockThresholdError'), 'error');
+    },
+  });
+
   // Stats calculation (active spools only)
   const stats = useMemo(() => {
     if (!spools) return null;
@@ -528,14 +552,14 @@ function InventoryPage() {
       totalWeight += remaining;
       totalConsumed += s.weight_used;
       const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
-      if (pct < 20) lowStock++;
+      if (pct < lowStockThreshold) lowStock++;
       const mat = s.material || 'Unknown';
       if (!byMaterial[mat]) byMaterial[mat] = { count: 0, weight: 0 };
       byMaterial[mat].count++;
       byMaterial[mat].weight += remaining;
     }
     return { totalWeight, totalConsumed, lowStock, byMaterial, totalSpools: activeCount };
-  }, [spools]);
+  }, [spools, lowStockThreshold]);
 
   const inPrinterCount = assignments?.length ?? 0;
 
@@ -574,6 +598,12 @@ function InventoryPage() {
       filtered = filtered.filter((s) => s.weight_used > 0);
     } else if (usageFilter === 'new') {
       filtered = filtered.filter((s) => s.weight_used === 0);
+    } else if (usageFilter === 'lowstock') {
+      filtered = filtered.filter((s) => {
+        const remaining = Math.max(0, s.label_weight - s.weight_used);
+        const pct = s.label_weight > 0 ? (remaining / s.label_weight) * 100 : 0;
+        return pct < lowStockThreshold;
+      });
     }
 
     // Material dropdown
@@ -607,7 +637,7 @@ function InventoryPage() {
     }
 
     return filtered;
-  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search]);
+  }, [spools, archiveFilter, usageFilter, materialFilter, brandFilter, stockFilter, search, lowStockThreshold]);
 
   // Reset page on filter changes
   const resetPage = () => setPageIndex(0);
@@ -818,7 +848,59 @@ function InventoryPage() {
               <span className="text-xs text-bambu-gray font-medium uppercase tracking-wide">{t('inventory.lowStock')}</span>
             </div>
             <div className={`text-xl font-bold ${stats.lowStock > 0 ? 'text-yellow-400' : 'text-white'}`}>{stats.lowStock}</div>
-            <div className="text-xs text-bambu-gray mt-1">{t('inventory.lowStockThreshold')}</div>
+            <div className="text-xs text-bambu-gray mt-1 flex items-center gap-2">
+              {showThresholdInput ? (
+                <form
+                  onSubmit={e => {
+                    e.preventDefault();
+                    const val = parseFloat(thresholdInput);
+                    if (!isNaN(val) && val >= 0.1 && val <= 99.9) {
+                      updateThresholdMutation.mutate(val);
+                    } else {
+                      showToast(t('inventory.lowStockThresholdError'), 'error');
+                    }
+                  }}
+                  className="flex items-center gap-2"
+                >
+                  <span className="text-xs text-bambu-gray">{'<'}</span>
+                  <input
+                    type="text"
+                    inputMode="decimal"
+                    pattern="^\d{0,2}(\.\d?)?$"
+                    maxLength={4}
+                    value={thresholdInput}
+                    onChange={e => {
+                      // Only allow up to 2 digits before decimal and 1 after
+                      const val = e.target.value.replace(/[^\d.]/g, '');
+                      if (/^\d{0,2}(\.\d?)?$/.test(val)) {
+                        setThresholdInput(val);
+                      }
+                    }}
+                    className="px-1.5 py-1 rounded border border-bambu-dark-tertiary text-xs text-white bg-bambu-dark-secondary focus:outline-none focus:border-bambu-green w-14 text-center"
+                    onWheel={e => e.currentTarget.blur()}
+                    disabled={updateThresholdMutation.isPending}
+                  />
+
+                  <span className="text-xs text-bambu-gray">%</span>
+                  <Button type="submit" size="sm" disabled={updateThresholdMutation.isPending}>{t('common.save')}</Button>
+                  <Button type="button" size="sm" variant="ghost" onClick={() => setShowThresholdInput(false)} disabled={updateThresholdMutation.isPending}>{t('common.cancel')}</Button>
+                </form>
+              ) : (
+                <>
+                  <span className="text-bambu-gray">{'< '}{lowStockThreshold}%</span>
+                  <button
+                    className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors"
+                    title={t('common.edit')}
+                    onClick={() => {
+                      setThresholdInput(lowStockThreshold.toString());
+                      setShowThresholdInput(true);
+                    }}
+                  >
+                    <Edit2 className="w-4 h-4" />
+                  </button>
+                </>
+              )}
+            </div>
           </div>
         </div>
       )}
@@ -960,6 +1042,17 @@ function InventoryPage() {
           >
             {t('inventory.new')}
           </button>
+          <button
+            onClick={() => { setUsageFilter('lowstock'); resetPage(); }}
+            className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors ${
+              usageFilter === 'lowstock'
+                ? 'bg-yellow-500/20 text-yellow-400'
+                : 'text-bambu-gray hover:bg-bambu-dark-tertiary'
+            }`}
+          >
+            <AlertTriangle className="w-3.5 h-3.5" />
+            {t('inventory.lowStock')}
+          </button>
         </div>
 
         {/* Stock filter chips */}
@@ -1541,7 +1634,7 @@ function SpoolTableRow({
       ))}
       <td className="py-3 px-4">
         <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
-          <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('inventory.editSpool')}>
+          <button onClick={onEdit} className="p-1.5 text-bambu-gray hover:text-white rounded transition-colors" title={t('common.edit')}>
             <Edit2 className="w-4 h-4" />
           </button>
           {spool.archived_at ? (