Przeglądaj źródła

Add staggered batch start for multi-printer queue jobs (#752)

  When adding a print to the queue for multiple printers, users can now
  enable "Stagger printer starts" to avoid power spikes from simultaneous
  bed heating. Configurable group size and interval — first group starts
  immediately (ASAP) or at scheduled time, subsequent groups get offset
  scheduled_time values. No backend queue/scheduler changes — leverages
  existing scheduled_time field.

  Also adds a dedicated Queue tab in Settings (stagger defaults + auto-
  drying moved from Filament tab), i18n keys for all 7 locales, frontend
  and backend tests.
maziggy 5 miesięcy temu
rodzic
commit
68dce24cc9

+ 4 - 0
CHANGELOG.md

@@ -4,6 +4,10 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.3b1] - Unreleased
 
+### New Features
+- **Staggered Batch Start for Multi-Printer Jobs** ([#752](https://github.com/maziggy/bambuddy/issues/752)) — When sending a print to multiple printers via the queue, you can now stagger the starts to avoid power spikes from simultaneous bed heating. Enable "Stagger printer starts" in the schedule options to define a group size (how many printers start at once) and interval (minutes between groups). For example, 10 printers with group size 2 and interval 5 min will start in 5 waves over 25 minutes. Default group size and interval are configurable in Settings → Queue. Works with both ASAP and Scheduled timing — ASAP starts the first group immediately, subsequent groups get computed scheduled times. Requested by @maziggy.
+- **Settings Queue Tab** — New dedicated Queue tab in Settings consolidates queue-related settings: staggered start defaults and auto-drying configuration (moved from the Filament tab).
+
 ### Fixed
 - **H2D External Spool Uses Wrong Nozzle** ([#836](https://github.com/maziggy/bambuddy/issues/836)) — Prints sent from Bambuddy to dual-nozzle printers (H2D, H2D Pro) with external spools always routed to the wrong nozzle. The old `ams_mapping2` format used a shared `ams_id: 255` with `slot_id: 0/1` to differentiate external slots, but the firmware interpreted slot_id as the nozzle index (0=main/right, 1=deputy/left), routing filament to the opposite nozzle. Already fixed by the #797 `ams_mapping2` format change (per-tray `ams_id` instead of shared unit), but users on older builds still experience this. Printing the same file directly from the slicer worked correctly. Reported by @NoahTingey.
 - **SpoolBuddy "Add to Inventory" Failed Silently** — The quick-add button on the SpoolBuddy kiosk did nothing when tapped. The scale weight was sent as a float but the backend requires an integer, causing a Pydantic validation error. The error was silently caught with no user feedback, leaving the confirmation modal stuck open. Fixed by rounding the weight before sending, moving the modal close to a `finally` block, and adding an error toast with the actual API message.

+ 1 - 0
README.md

@@ -106,6 +106,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - **Background print dispatch** — FTP uploads and print-start commands run in the background with real-time WebSocket progress toasts (per-job upload bars, status badges, cancel button)
 - Print queue with drag-and-drop
 - Multi-printer selection (send to multiple printers at once)
+- Staggered batch start (start printers in groups with configurable interval to avoid power spikes)
 - Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
 - Filament override for model-based queue (swap filament colors/types before scheduling)
 - Filament validation (only assign to printers with required filaments)

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

@@ -186,6 +186,14 @@ class AppSettings(BaseModel):
         description="Enable user email notifications for print job events (requires Advanced Authentication)",
     )
 
+    # Staggered batch start for multi-printer jobs
+    stagger_group_size: int = Field(
+        default=2, ge=1, le=50, description="Number of printers to start simultaneously in staggered mode"
+    )
+    stagger_interval_minutes: int = Field(
+        default=5, ge=1, le=60, description="Minutes between staggered printer groups"
+    )
+
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
         default="",
@@ -260,6 +268,8 @@ class AppSettingsUpdate(BaseModel):
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
     user_notifications_enabled: bool | None = None
+    stagger_group_size: int | None = Field(default=None, ge=1, le=50)
+    stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     default_sidebar_order: str | None = None
 
     @field_validator("default_sidebar_order")

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

@@ -416,6 +416,64 @@ class TestSettingsAPI:
         # Default is False as defined in schema
         assert isinstance(result["per_printer_mapping_expanded"], bool)
 
+    # ========================================================================
+    # Stagger settings tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stagger_settings_defaults(self, async_client: AsyncClient):
+        """Verify stagger settings have correct defaults."""
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+
+        assert result["stagger_group_size"] == 2
+        assert result["stagger_interval_minutes"] == 5
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_stagger_settings(self, async_client: AsyncClient):
+        """Verify stagger settings can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"stagger_group_size": 3, "stagger_interval_minutes": 10},
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["stagger_group_size"] == 3
+        assert result["stagger_interval_minutes"] == 10
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stagger_settings_persist(self, async_client: AsyncClient):
+        """Verify stagger settings persist after update."""
+        await async_client.put(
+            "/api/v1/settings/",
+            json={"stagger_group_size": 4, "stagger_interval_minutes": 15},
+        )
+
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+        assert result["stagger_group_size"] == 4
+        assert result["stagger_interval_minutes"] == 15
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stagger_settings_validation(self, async_client: AsyncClient):
+        """Verify stagger settings reject out-of-range values."""
+        response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 0})
+        assert response.status_code == 422
+
+        response = await async_client.put("/api/v1/settings/", json={"stagger_group_size": 51})
+        assert response.status_code == 422
+
+        response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 0})
+        assert response.status_code == 422
+
+        response = await async_client.put("/api/v1/settings/", json={"stagger_interval_minutes": 61})
+        assert response.status_code == 422
+
     # ========================================================================
     # Home Assistant environment variable tests
     # ========================================================================

+ 127 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -713,6 +713,133 @@ describe('PrintModal', () => {
     });
   });
 
+  describe('stagger start', () => {
+    it('does not show stagger option with single printer in queue mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
+      });
+
+      // Select single printer
+      await user.click(screen.getByText('X1 Carbon'));
+
+      expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
+    });
+
+    it('shows stagger option when multiple printers selected in queue mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show stagger option in reprint mode', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText(/2 printers selected|3 printers selected/)).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('Stagger printer starts')).not.toBeInTheDocument();
+    });
+
+    it('shows stagger inputs when stagger checkbox is enabled', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByLabelText('Stagger printer starts'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Group size')).toBeInTheDocument();
+        expect(screen.getByText('Interval (min)')).toBeInTheDocument();
+      });
+    });
+
+    it('shows stagger preview with printer count', async () => {
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="add-to-queue"
+          archiveId={1}
+          archiveName="Test Print"
+          onClose={mockOnClose}
+        />
+      );
+
+      await waitFor(() => {
+        expect(screen.getByText('Select all')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Select all'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Stagger printer starts')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByLabelText('Stagger printer starts'));
+
+      await waitFor(() => {
+        // Default: 3 printers, group size 2 = 2 groups — preview text includes printer count
+        expect(screen.getByText(/3 printers.*2 groups/)).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('multi-plate selection', () => {
     const multiPlateResponse = {
       is_multi_plate: true,

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

@@ -222,6 +222,55 @@ describe('SettingsPage', () => {
     });
   });
 
+  describe('Queue tab', () => {
+    it('can switch to Queue tab', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Queue'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Staggered Start')).toBeInTheDocument();
+      });
+    });
+
+    it('shows stagger settings on Queue tab', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Queue'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Staggered Start')).toBeInTheDocument();
+        expect(screen.getByText('Group size')).toBeInTheDocument();
+        expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
+      });
+    });
+
+    it('shows auto-drying settings on Queue tab', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue')).toBeInTheDocument();
+      });
+
+      await user.click(screen.getByText('Queue'));
+
+      await waitFor(() => {
+        expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
+      });
+    });
+  });
+
   describe('API Keys tab', () => {
     it('can switch to API Keys tab', async () => {
       const user = userEvent.setup();

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

@@ -886,6 +886,9 @@ export interface AppSettings {
   low_stock_threshold: number;
   // User email notifications toggle
   user_notifications_enabled: boolean;
+  // Staggered batch start defaults
+  stagger_group_size: number;
+  stagger_interval_minutes: number;
   // Default sidebar order (admin-set for all users)
   default_sidebar_order: string;
 }

+ 74 - 1
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
-import { Calendar, Clock, Hand, Power } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Calendar, Clock, Hand, Power, Layers } from 'lucide-react';
 import type { ScheduleOptionsProps, ScheduleType } from './types';
 import {
   formatDateInput,
@@ -24,7 +25,10 @@ export function ScheduleOptionsPanel({
   dateFormat = 'system',
   timeFormat = 'system',
   canControlPrinter = true,
+  showStagger = false,
+  printerCount = 0,
 }: ScheduleOptionsProps) {
+  const { t } = useTranslation();
   const [dateValue, setDateValue] = useState('');
   const [timeValue, setTimeValue] = useState('');
   const [isDateValid, setIsDateValid] = useState(true);
@@ -247,6 +251,75 @@ export function ScheduleOptionsPanel({
         </label>
       </div>
 
+      {/* Stagger start */}
+      {showStagger && options.scheduleType !== 'manual' && (
+        <div className="space-y-3">
+          <div className="flex items-center gap-2">
+            <input
+              type="checkbox"
+              id="staggerEnabled"
+              checked={options.staggerEnabled}
+              onChange={(e) => onChange({ ...options, staggerEnabled: e.target.checked })}
+              className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+            />
+            <label htmlFor="staggerEnabled" className="text-sm flex items-center gap-1 text-bambu-gray">
+              <Layers className="w-3.5 h-3.5" />
+              {t('printModal.staggerPrinterStarts', 'Stagger printer starts')}
+            </label>
+          </div>
+
+          {options.staggerEnabled && (
+            <div className="ml-6 space-y-3">
+              <div className="flex gap-3">
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerGroupSize', 'Group size')}</label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={printerCount}
+                    value={options.staggerGroupSize}
+                    onChange={(e) => onChange({ ...options, staggerGroupSize: Math.max(1, parseInt(e.target.value) || 1) })}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                  />
+                </div>
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">{t('printModal.staggerInterval', 'Interval (min)')}</label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={60}
+                    value={options.staggerIntervalMinutes}
+                    onChange={(e) => onChange({ ...options, staggerIntervalMinutes: Math.max(1, parseInt(e.target.value) || 1) })}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                  />
+                </div>
+              </div>
+              {printerCount > 0 && (() => {
+                const groupCount = Math.ceil(printerCount / options.staggerGroupSize);
+                const lastGroupSize = printerCount % options.staggerGroupSize;
+                const totalMinutes = (groupCount - 1) * options.staggerIntervalMinutes;
+                return (
+                  <p className="text-xs text-bambu-gray">
+                    {t('printModal.staggerPreview', '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min', {
+                      printers: printerCount,
+                      groups: groupCount,
+                      size: options.staggerGroupSize,
+                      interval: options.staggerIntervalMinutes,
+                    })}
+                    {lastGroupSize !== 0 && options.staggerGroupSize < printerCount
+                      ? ` (${t('printModal.staggerLastGroup', 'last group: {{count}}', { count: lastGroupSize })})`
+                      : ''}
+                    {groupCount > 1
+                      ? ` (${t('printModal.staggerTotal', 'total: {{minutes}} min', { minutes: totalMinutes })})`
+                      : ''}
+                  </p>
+                );
+              })()}
+            </div>
+          )}
+        </div>
+      )}
+
       {/* Help text */}
       <p className="text-xs text-bambu-gray">
         {options.scheduleType === 'asap'

+ 40 - 2
frontend/src/components/PrintModal/index.tsx

@@ -1,6 +1,6 @@
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
 import { api } from '../../api/client';
@@ -121,6 +121,9 @@ export function PrintModal({
         scheduledTime,
         requirePreviousSuccess: queueItem.require_previous_success,
         autoOffAfter: queueItem.auto_off_after,
+        staggerEnabled: false,
+        staggerGroupSize: DEFAULT_SCHEDULE_OPTIONS.staggerGroupSize,
+        staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
       };
     }
     return DEFAULT_SCHEDULE_OPTIONS;
@@ -217,6 +220,18 @@ export function PrintModal({
     queryFn: api.getSettings,
   });
 
+  // Sync stagger defaults from settings once available
+  const staggerDefaultsApplied = useRef(false);
+  useEffect(() => {
+    if (!settings || staggerDefaultsApplied.current || mode === 'edit-queue-item') return;
+    staggerDefaultsApplied.current = true;
+    setScheduleOptions((prev) => ({
+      ...prev,
+      staggerGroupSize: settings.stagger_group_size ?? prev.staggerGroupSize,
+      staggerIntervalMinutes: settings.stagger_interval_minutes ?? prev.staggerIntervalMinutes,
+    }));
+  }, [settings, mode]);
+
   const currencySymbol = getCurrencySymbol(settings?.currency || 'USD');
   const defaultCostPerKg = settings?.default_filament_cost ?? 0;
 
@@ -665,6 +680,16 @@ export function PrintModal({
       }
     } else {
       // Printer-based assignment: loop through plates × printers
+      // Compute stagger base time once before the loop
+      const useStagger = scheduleOptions.staggerEnabled
+        && mode === 'add-to-queue'
+        && selectedPrinters.length > 1;
+      const staggerBaseTime = useStagger
+        ? (scheduleOptions.scheduleType === 'scheduled' && scheduleOptions.scheduledTime
+          ? new Date(scheduleOptions.scheduledTime).getTime()
+          : Date.now())
+        : 0;
+
       let progressCounter = 0;
       for (const plate of platesToQueue) {
         const plateId = plate ? plate.index : selectedPlate;
@@ -713,7 +738,18 @@ export function PrintModal({
               await updateQueueMutation.mutateAsync(updateData);
             } else {
               // Add-to-queue mode OR edit mode with additional entries
-              await addToQueueMutation.mutateAsync(getQueueData(printerId, plateId));
+              const queueData = getQueueData(printerId, plateId);
+              // Apply stagger offset for groups after the first
+              if (useStagger) {
+                const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
+                if (groupIndex > 0) {
+                  const offsetMs = groupIndex * scheduleOptions.staggerIntervalMinutes * 60_000;
+                  queueData.scheduled_time = new Date(staggerBaseTime + offsetMs).toISOString();
+                }
+                // Group 0 with ASAP: no scheduled_time (start immediately)
+                // Group 0 with scheduled: keeps the scheduled_time from getQueueData
+              }
+              await addToQueueMutation.mutateAsync(queueData);
             }
             results.success++;
           } catch (error) {
@@ -986,6 +1022,8 @@ export function PrintModal({
                 dateFormat={settings?.date_format || 'system'}
                 timeFormat={settings?.time_format || 'system'}
                 canControlPrinter={hasPermission('printers:control')}
+                showStagger={mode === 'add-to-queue' && assignmentMode === 'printer' && selectedPrinters.length > 1}
+                printerCount={selectedPrinters.length}
               />
             )}
 

+ 10 - 0
frontend/src/components/PrintModal/types.ts

@@ -69,6 +69,9 @@ export interface ScheduleOptions {
   scheduledTime: string;
   requirePreviousSuccess: boolean;
   autoOffAfter: boolean;
+  staggerEnabled: boolean;
+  staggerGroupSize: number;
+  staggerIntervalMinutes: number;
 }
 
 /**
@@ -79,6 +82,9 @@ export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
   scheduledTime: '',
   requirePreviousSuccess: false,
   autoOffAfter: false,
+  staggerEnabled: false,
+  staggerGroupSize: 2,
+  staggerIntervalMinutes: 5,
 };
 
 /**
@@ -204,4 +210,8 @@ export interface ScheduleOptionsProps {
   timeFormat?: 'system' | '12h' | '24h';
   /** Whether the user has permission to control printers (for auto power off) */
   canControlPrinter?: boolean;
+  /** Show stagger options (only when multiple printers selected in queue mode) */
+  showStagger?: boolean;
+  /** Number of selected printers (for stagger preview) */
+  printerCount?: number;
 }

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'Allgemein',
       smartPlugs: 'Smart Plugs',
       notifications: 'Benachrichtigungen',
+      queue: 'Queue',
       filament: 'Filament',
       network: 'Netzwerk',
       apiKeys: 'API-Schlüssel',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Verlaufsaufbewahrung',
     keepSensorHistory: 'Sensorverlauf behalten für',
     historyRetentionDescription: 'Ältere Feuchtigkeits- und Temperaturdaten werden automatisch gelöscht',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Automatische Trocknung',
     queueDryingDescription: 'AMS-Filament automatisch trocknen, wenn der Drucker zwischen Warteschlangen-Drucken im Leerlauf ist. Verwendet den Feuchtigkeitsschwellenwert oben.',
     queueDryingEnabled: 'Automatische Trocknung aktivieren',
@@ -3108,6 +3115,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: benötigt {{required}}g, verbleibend {{remaining}}g',
     printAnyway: 'Trotzdem drucken',
     forceColorMatch: 'Farbe erzwingen',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
 
   // Backup

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'General',
       smartPlugs: 'Smart Plugs',
       notifications: 'Notifications',
+      queue: 'Queue',
       filament: 'Filament',
       network: 'Network',
       apiKeys: 'API Keys',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'History Retention',
     keepSensorHistory: 'Keep sensor history for',
     historyRetentionDescription: 'Older humidity and temperature data will be automatically deleted',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Queue Auto-Drying',
     queueDryingDescription: 'Automatically dry AMS filament when printer is idle between queued prints. Uses humidity threshold above to trigger drying.',
     queueDryingEnabled: 'Enable auto-drying',
@@ -3112,6 +3119,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: needs {{required}}g, remaining {{remaining}}g',
     printAnyway: 'Print anyway',
     forceColorMatch: 'Force color match',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
 
   // Backup

+ 13 - 0
frontend/src/i18n/locales/fr.ts

@@ -1215,6 +1215,7 @@ export default {
       general: 'Général',
       smartPlugs: 'Prises connectées',
       notifications: 'Notifications',
+      queue: 'Queue',
       filament: 'Filament',
       network: 'Réseau',
       apiKeys: 'Clés API',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Rétention d\'historique',
     keepSensorHistory: 'Garder l\'historique pendant',
     historyRetentionDescription: 'Les anciennes données seront supprimées.',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Séchage automatique',
     queueDryingDescription: 'Sécher automatiquement le filament AMS lorsque l\'imprimante est inactive entre les impressions. Utilise le seuil d\'humidité ci-dessus.',
     queueDryingEnabled: 'Activer le séchage automatique',
@@ -3099,6 +3106,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}} : nécessite {{required}}g, restant {{remaining}}g',
     printAnyway: 'Imprimer quand même',
     forceColorMatch: 'Forcer correspondance des couleurs',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
 
   // Backup

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

@@ -1215,6 +1215,7 @@ export default {
       general: 'Generale',
       smartPlugs: 'Prese smart',
       notifications: 'Notifiche',
+      queue: 'Queue',
       filament: 'Filamento',
       network: 'Rete',
       apiKeys: 'Chiavi API',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Conservazione cronologia',
     keepSensorHistory: 'Mantieni cronologia sensori per',
     historyRetentionDescription: 'I dati più vecchi saranno eliminati automaticamente',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Asciugatura automatica',
     queueDryingDescription: 'Asciugare automaticamente il filamento AMS quando la stampante è inattiva tra le stampe in coda. Usa la soglia di umidità sopra.',
     queueDryingEnabled: 'Abilita asciugatura automatica',
@@ -3098,6 +3105,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessita di {{required}}g, rimanenti {{remaining}}g',
     printAnyway: 'Stampa comunque',
     forceColorMatch: 'Forza corrispondenza colore',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
 
   // Backup

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

@@ -1214,6 +1214,7 @@ export default {
       general: '一般',
       smartPlugs: 'スマートプラグ',
       notifications: '通知',
+      queue: 'Queue',
       filament: 'フィラメント',
       network: 'ネットワーク',
       apiKeys: 'APIキー',
@@ -1512,6 +1513,12 @@ export default {
     historyRetention: '履歴の保持',
     keepSensorHistory: 'センサー履歴の保持期間',
     historyRetentionDescription: '古い湿度と温度データは自動的に削除されます',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: '自動乾燥',
     queueDryingDescription: 'キュー印刷の合間にプリンターがアイドル状態の時、AMSフィラメントを自動的に乾燥します。上記の湿度しきい値を使用します。',
     queueDryingEnabled: '自動乾燥を有効にする',
@@ -3111,6 +3118,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: 必要 {{required}}g、残り {{remaining}}g',
     printAnyway: 'それでも印刷',
     forceColorMatch: 'カラーマッチを強制',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
 
   // Backup

+ 13 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -1215,6 +1215,7 @@ export default {
       general: 'Geral',
       smartPlugs: 'Tomadas Inteligentes',
       notifications: 'Notificações',
+      queue: 'Queue',
       filament: 'Filamento',
       network: 'Rede',
       apiKeys: 'Chaves API',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: 'Retenção de Histórico',
     keepSensorHistory: 'Manter histórico do sensor por',
     historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: 'Secagem Automática',
     queueDryingDescription: 'Secar automaticamente o filamento AMS quando a impressora estiver ociosa entre impressões na fila. Usa o limite de umidade acima.',
     queueDryingEnabled: 'Ativar secagem automática',
@@ -3098,6 +3105,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}: necessário {{required}}g, restante {{remaining}}g',
     printAnyway: 'Imprimir mesmo assim',
     forceColorMatch: 'Forçar correspondência de cor',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
 
   // Backup

+ 13 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -1215,6 +1215,7 @@ export default {
       general: '通用',
       smartPlugs: '智能插座',
       notifications: '通知',
+      queue: 'Queue',
       filament: '耗材',
       network: '网络',
       apiKeys: 'API 密钥',
@@ -1513,6 +1514,12 @@ export default {
     historyRetention: '历史保留',
     keepSensorHistory: '保留传感器历史',
     historyRetentionDescription: '较旧的湿度和温度数据将被自动删除',
+    staggeredStart: 'Staggered Start',
+    staggeredStartDescription: 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.',
+    staggerGroupSize: 'Group size',
+    staggerGroupSizeHelp: 'Printers to start simultaneously per group',
+    staggerInterval: 'Interval (minutes)',
+    staggerIntervalHelp: 'Delay between each group starting',
     queueDrying: '自动干燥',
     queueDryingDescription: '在队列打印之间,打印机空闲时自动干燥AMS耗材。使用上方的湿度阈值触发干燥。',
     queueDryingEnabled: '启用自动干燥',
@@ -3098,6 +3105,12 @@ export default {
     insufficientFilamentLine: '{{printer}} - {{slot}}:需要 {{required}}g,剩余 {{remaining}}g',
     printAnyway: '仍然打印',
     forceColorMatch: '强制颜色匹配',
+    staggerPrinterStarts: 'Stagger printer starts',
+    staggerGroupSize: 'Group size',
+    staggerInterval: 'Interval (min)',
+    staggerPreview: '{{printers}} printers → {{groups}} groups of {{size}}, starting every {{interval}} min',
+    staggerLastGroup: 'last group: {{count}}',
+    staggerTotal: 'total: {{minutes}} min',
   },
 
   // Backup

+ 232 - 177
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 } 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 } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -34,7 +34,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', 'network', 'plugs', 'notifications', 'filament', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
+const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type UsersSubTab = 'users' | 'email';
 
@@ -770,7 +770,9 @@ export function SettingsPage() {
       (settings.preferred_slicer ?? 'bambu_studio') !== (localSettings.preferred_slicer ?? 'bambu_studio') ||
       settings.prometheus_enabled !== localSettings.prometheus_enabled ||
       settings.prometheus_token !== localSettings.prometheus_token ||
-      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true);
+      (settings.user_notifications_enabled ?? true) !== (localSettings.user_notifications_enabled ?? true) ||
+      (settings.stagger_group_size ?? 2) !== (localSettings.stagger_group_size ?? 2) ||
+      (settings.stagger_interval_minutes ?? 5) !== (localSettings.stagger_interval_minutes ?? 5);
 
     if (!hasChanges) {
       return;
@@ -843,6 +845,8 @@ export function SettingsPage() {
         prometheus_enabled: localSettings.prometheus_enabled,
         prometheus_token: localSettings.prometheus_token,
         user_notifications_enabled: localSettings.user_notifications_enabled,
+        stagger_group_size: localSettings.stagger_group_size,
+        stagger_interval_minutes: localSettings.stagger_interval_minutes,
       };
       updateMutation.mutate(settingsToSave);
     }, 500);
@@ -989,6 +993,17 @@ export function SettingsPage() {
             </span>
           )}
         </button>
+        <button
+          onClick={() => handleTabChange('queue')}
+          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
+            activeTab === 'queue'
+              ? 'text-bambu-green border-bambu-green'
+              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+          }`}
+        >
+          <ListOrdered className="w-4 h-4" />
+          {t('settings.tabs.queue', 'Queue')}
+        </button>
         <button
           onClick={() => handleTabChange('filament')}
           className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px flex items-center gap-2 ${
@@ -3319,6 +3334,220 @@ export function SettingsPage() {
       )}
 
       {/* Filament Tab */}
+      {/* Queue Tab */}
+      {activeTab === 'queue' && localSettings && (
+        <div className="max-w-2xl space-y-6">
+          {/* Staggered Batch Start */}
+          <Card>
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Layers className="w-4 h-4 text-bambu-green" />
+                {t('settings.staggeredStart', 'Staggered Start')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-xs text-bambu-gray">
+                {t('settings.staggeredStartDescription', 'Default group size and interval when staggering multi-printer batch starts. Can be overridden per batch in the print modal.')}
+              </p>
+              <div className="flex gap-4">
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">
+                    {t('settings.staggerGroupSize', 'Group size')}
+                  </label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={50}
+                    value={localSettings.stagger_group_size ?? 2}
+                    onChange={(e) => updateSetting('stagger_group_size', Math.max(1, Math.min(50, parseInt(e.target.value) || 1)))}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.staggerGroupSizeHelp', 'Printers to start simultaneously per group')}
+                  </p>
+                </div>
+                <div className="flex-1">
+                  <label className="block text-xs text-bambu-gray mb-1">
+                    {t('settings.staggerInterval', 'Interval (minutes)')}
+                  </label>
+                  <input
+                    type="number"
+                    min={1}
+                    max={60}
+                    value={localSettings.stagger_interval_minutes ?? 5}
+                    onChange={(e) => updateSetting('stagger_interval_minutes', Math.max(1, Math.min(60, parseInt(e.target.value) || 1)))}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
+                  />
+                  <p className="text-xs text-bambu-gray mt-1">
+                    {t('settings.staggerIntervalHelp', 'Delay between each group starting')}
+                  </p>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+
+          {/* Auto-Drying */}
+          <Card>
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Flame className="w-4 h-4 text-amber-400" />
+                {t('settings.queueDrying')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-xs text-bambu-gray">
+                {t('settings.queueDryingDescription')}
+              </p>
+              <div className="flex items-center justify-between">
+                <div>
+                  <label className="block text-sm text-white">
+                    {t('settings.queueDryingEnabled')}
+                  </label>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.queueDryingEnabledDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.queue_drying_enabled ?? false}
+                    onChange={(e) => updateSetting('queue_drying_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+              {localSettings.queue_drying_enabled && (
+                <div className="flex items-center justify-between">
+                  <div>
+                    <label className="block text-sm text-white">
+                      {t('settings.queueDryingBlock')}
+                    </label>
+                    <p className="text-xs text-bambu-gray mt-0.5">
+                      {t('settings.queueDryingBlockDescription')}
+                    </p>
+                  </div>
+                  <label className="relative inline-flex items-center cursor-pointer">
+                    <input
+                      type="checkbox"
+                      checked={localSettings.queue_drying_block ?? false}
+                      onChange={(e) => updateSetting('queue_drying_block', e.target.checked)}
+                      className="sr-only peer"
+                    />
+                    <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500"></div>
+                  </label>
+                </div>
+              )}
+              <div className="flex items-center justify-between">
+                <div>
+                  <label className="block text-sm text-white">
+                    {t('settings.ambientDryingEnabled')}
+                  </label>
+                  <p className="text-xs text-bambu-gray mt-0.5">
+                    {t('settings.ambientDryingEnabledDescription')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.ambient_drying_enabled ?? false}
+                    onChange={(e) => updateSetting('ambient_drying_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+              {/* Drying Presets Table */}
+              <div className="space-y-2">
+                <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
+                <p className="text-xs text-bambu-gray">{t('settings.dryingPresetsDescription')}</p>
+                <div className="overflow-x-auto">
+                  <table className="w-full text-xs">
+                    <thead>
+                      <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
+                        <th className="text-left py-1.5">{t('settings.dryingFilament')}</th>
+                        <th className="text-center py-1.5" colSpan={2}>AMS 2 Pro</th>
+                        <th className="text-center py-1.5" colSpan={2}>AMS-HT</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {(() => {
+                        const defaults: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
+                          PLA: { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
+                          PETG: { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
+                          TPU: { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
+                          ABS: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                          ASA: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                          PA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
+                          PC: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
+                          PVA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
+                        };
+                        let presets = { ...defaults };
+                        try {
+                          if (localSettings.drying_presets) {
+                            const parsed = JSON.parse(localSettings.drying_presets);
+                            if (typeof parsed === 'object' && parsed !== null) {
+                              presets = { ...defaults, ...parsed };
+                            }
+                          }
+                        } catch { /* use defaults */ }
+
+                        const updatePreset = (fil: string, key: string, value: number) => {
+                          const updated = { ...presets, [fil]: { ...presets[fil], [key]: value } };
+                          updateSetting('drying_presets', JSON.stringify(updated));
+                        };
+
+                        return Object.entries(presets).map(([fil, preset]) => (
+                          <tr key={fil} className="border-b border-bambu-dark-tertiary/50">
+                            <td className="py-1.5 pr-2 text-white font-medium">{fil}</td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center justify-end gap-1">
+                                <input type="number" min={30} max={65} value={preset.n3f}
+                                  onChange={e => updatePreset(fil, 'n3f', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">°C</span>
+                              </div>
+                            </td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center gap-1">
+                                <input type="number" min={1} max={24} value={preset.n3f_hours}
+                                  onChange={e => updatePreset(fil, 'n3f_hours', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">h</span>
+                              </div>
+                            </td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center justify-end gap-1">
+                                <input type="number" min={30} max={85} value={preset.n3s}
+                                  onChange={e => updatePreset(fil, 'n3s', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">°C</span>
+                              </div>
+                            </td>
+                            <td className="py-1 px-1">
+                              <div className="flex items-center gap-1">
+                                <input type="number" min={1} max={24} value={preset.n3s_hours}
+                                  onChange={e => updatePreset(fil, 'n3s_hours', Math.max(1, parseInt(e.target.value) || 0))}
+                                  className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+                                />
+                                <span className="text-bambu-gray">h</span>
+                              </div>
+                            </td>
+                          </tr>
+                        ));
+                      })()}
+                    </tbody>
+                  </table>
+                </div>
+              </div>
+            </CardContent>
+          </Card>
+        </div>
+      )}
+
       {activeTab === 'filament' && localSettings && (
         <>
         <div className="flex flex-col lg:flex-row gap-6 lg:gap-8">
@@ -3482,180 +3711,6 @@ export function SettingsPage() {
                   </p>
                 </div>
 
-                {/* Queue Auto-Drying */}
-                <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
-                  <div className="flex items-center gap-2 text-white">
-                    <Flame className="w-4 h-4 text-amber-400" />
-                    <span className="font-medium">{t('settings.queueDrying')}</span>
-                  </div>
-                  <p className="text-xs text-bambu-gray">
-                    {t('settings.queueDryingDescription')}
-                  </p>
-                  <div className="flex items-center justify-between">
-                    <div>
-                      <label className="block text-sm text-white">
-                        {t('settings.queueDryingEnabled')}
-                      </label>
-                      <p className="text-xs text-bambu-gray mt-0.5">
-                        {t('settings.queueDryingEnabledDescription')}
-                      </p>
-                    </div>
-                    <label className="relative inline-flex items-center cursor-pointer">
-                      <input
-                        type="checkbox"
-                        checked={localSettings.queue_drying_enabled ?? false}
-                        onChange={(e) => updateSetting('queue_drying_enabled', e.target.checked)}
-                        className="sr-only peer"
-                      />
-                      <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-                    </label>
-                  </div>
-                  {localSettings.queue_drying_enabled && (
-                    <>
-                      <div className="flex items-center justify-between">
-                        <div>
-                          <label className="block text-sm text-white">
-                            {t('settings.queueDryingBlock')}
-                          </label>
-                          <p className="text-xs text-bambu-gray mt-0.5">
-                            {t('settings.queueDryingBlockDescription')}
-                          </p>
-                        </div>
-                        <label className="relative inline-flex items-center cursor-pointer">
-                          <input
-                            type="checkbox"
-                            checked={localSettings.queue_drying_block ?? false}
-                            onChange={(e) => updateSetting('queue_drying_block', e.target.checked)}
-                            className="sr-only peer"
-                          />
-                          <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-500"></div>
-                        </label>
-                      </div>
-                    </>
-                  )}
-                  <div className="flex items-center justify-between">
-                    <div>
-                      <label className="block text-sm text-white">
-                        {t('settings.ambientDryingEnabled')}
-                      </label>
-                      <p className="text-xs text-bambu-gray mt-0.5">
-                        {t('settings.ambientDryingEnabledDescription')}
-                      </p>
-                    </div>
-                    <label className="relative inline-flex items-center cursor-pointer">
-                      <input
-                        type="checkbox"
-                        checked={localSettings.ambient_drying_enabled ?? false}
-                        onChange={(e) => updateSetting('ambient_drying_enabled', e.target.checked)}
-                        className="sr-only peer"
-                      />
-                      <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
-                    </label>
-                  </div>
-                  {/* Drying Presets Table — always visible since manual drying also uses these */}
-                  <div className="space-y-2">
-                    <p className="text-sm text-white font-medium">{t('settings.dryingPresets')}</p>
-                    <p className="text-xs text-bambu-gray">{t('settings.dryingPresetsDescription')}</p>
-                    <div className="overflow-x-auto">
-                      <table className="w-full text-xs">
-                        <thead>
-                          <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
-                            <th className="text-left py-1.5">{t('settings.dryingFilament')}</th>
-                            <th className="text-center py-1.5" colSpan={2}>AMS 2 Pro</th>
-                            <th className="text-center py-1.5" colSpan={2}>AMS-HT</th>
-                          </tr>
-                        </thead>
-                        <tbody>
-                          {(() => {
-                            const defaults: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
-                              PLA: { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
-                              PETG: { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
-                              TPU: { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
-                              ABS: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
-                              ASA: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
-                              PA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
-                              PC: { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
-                              PVA: { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
-                            };
-                            let presets = { ...defaults };
-                            try {
-                              if (localSettings.drying_presets) {
-                                const parsed = JSON.parse(localSettings.drying_presets);
-                                if (typeof parsed === 'object' && parsed !== null) {
-                                  presets = { ...defaults, ...parsed };
-                                }
-                              }
-                            } catch { /* use defaults */ }
-
-                            const updatePreset = (fil: string, key: string, value: number) => {
-                              const updated = { ...presets, [fil]: { ...presets[fil], [key]: value } };
-                              updateSetting('drying_presets', JSON.stringify(updated));
-                            };
-
-                            return Object.entries(presets).map(([fil, preset]) => (
-                              <tr key={fil} className="border-b border-bambu-dark-tertiary/50">
-                                <td className="py-1.5 pr-2 text-white font-medium">{fil}</td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center justify-end gap-1">
-                                    <input
-                                      type="number"
-                                      min={30}
-                                      max={65}
-                                      value={preset.n3f}
-                                      onChange={e => updatePreset(fil, 'n3f', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">°C</span>
-                                  </div>
-                                </td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center gap-1">
-                                    <input
-                                      type="number"
-                                      min={1}
-                                      max={24}
-                                      value={preset.n3f_hours}
-                                      onChange={e => updatePreset(fil, 'n3f_hours', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">h</span>
-                                  </div>
-                                </td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center justify-end gap-1">
-                                    <input
-                                      type="number"
-                                      min={30}
-                                      max={85}
-                                      value={preset.n3s}
-                                      onChange={e => updatePreset(fil, 'n3s', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">°C</span>
-                                  </div>
-                                </td>
-                                <td className="py-1 px-1">
-                                  <div className="flex items-center gap-1">
-                                    <input
-                                      type="number"
-                                      min={1}
-                                      max={24}
-                                      value={preset.n3s_hours}
-                                      onChange={e => updatePreset(fil, 'n3s_hours', Math.max(1, parseInt(e.target.value) || 0))}
-                                      className="w-14 px-1.5 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center text-xs focus:border-amber-500/50 focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
-                                    />
-                                    <span className="text-bambu-gray">h</span>
-                                  </div>
-                                </td>
-                              </tr>
-                            ));
-                          })()}
-                        </tbody>
-                      </table>
-                    </div>
-                  </div>
-                </div>
-
                 {/* Per-Printer Mapping Default */}
                 <div className="space-y-3 pt-4 border-t border-bambu-dark-tertiary">
                   <div className="flex items-center gap-2 text-white">

Plik diff jest za duży
+ 0 - 0
static/assets/index-Dn-c66XQ.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-CdG7loGy.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dn-c66XQ.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-C0h3VoP7.css">
   </head>
   <body>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików