Browse Source

Add shortest-job-first queue scheduling with starvation guard (#879)

  New SJF toggle badge on the queue page. When enabled, the scheduler
  picks shorter print jobs before longer ones instead of FIFO. A
  starvation guard flags jobs that get skipped once, moving them to
  the front on the next cycle so long jobs can't be postponed indefinitely.

  - Add print_time_seconds and been_jumped columns to PrintQueueItem
  - Cache print duration from 3MF metadata at queue item creation
  - SJF query: printer_id, target_model, been_jumped DESC, print_time_seconds ASC, position
  - Mark jumped items in-memory after each print start
  - Toggle badge on queue page header with live state indicator
  - Frontend auto-sorts to match scheduler order when SJF enabled
  - Settings schema, boolean parsing, and migration (SQLite + PostgreSQL)
  - i18n badge keys for all 7 locales
  - 10 integration tests for SJF ordering and starvation logic
  - Wiki, website, README, and changelog updated
maziggy 1 month ago
parent
commit
039db1217d

+ 1 - 0
CHANGELOG.md

@@ -6,6 +6,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### New Features
 - **Optional PostgreSQL Database Support** — Bambuddy can now use an external PostgreSQL database instead of the built-in SQLite. Set the `DATABASE_URL` environment variable (e.g., `postgresql+asyncpg://user:pass@host:5432/bambuddy`) to connect to Postgres. SQLite remains the default when no `DATABASE_URL` is set. All features work with both backends including full-text archive search (FTS5 on SQLite, tsvector+GIN on PostgreSQL), backup/restore (file copy vs pg_dump/pg_restore), health diagnostics, and cross-database restore (import a SQLite backup into PostgreSQL with automatic type conversion and FK handling).
+- **Shortest Job First Queue Scheduling** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — New SJF toggle badge on the queue page header. When enabled, the scheduler starts shorter print jobs before longer ones instead of FIFO order. A starvation guard ensures long jobs that get skipped once are protected from being skipped again — they move to the front of the queue on the next cycle. The queue display automatically reorders to show the scheduler's actual execution order. Print duration is cached on queue items at creation time from the 3MF metadata.
 
 ### Improved
 - **Database Engine Info on System Page** — The System Information page now shows the active database engine (SQLite or PostgreSQL) and its version in the Database section, making it easy to verify which backend is in use.

+ 1 - 0
README.md

@@ -117,6 +117,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Prefer lowest remaining filament (consume partial spools first when multiple match)
 - Per-printer AMS mapping (individual slot configuration for print farms)
 - Scheduled prints (date/time)
+- Shortest Job First scheduling (SJF toggle on queue page — scheduler picks shorter prints first, with starvation guard)
 - Queue Only mode (stage without auto-start)
 - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
 - Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)

+ 24 - 0
backend/app/api/routes/print_queue.py

@@ -214,6 +214,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         # Batch grouping
         "batch_id": item.batch_id,
         "batch_name": item.batch.name if item.batch else None,
+        # SJF scheduling
+        "been_jumped": item.been_jumped,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -459,6 +461,27 @@ async def add_to_queue(
         )
     max_pos = result.scalar() or 0
 
+    # Resolve print_time_seconds for SJF scheduling (cache on item at creation)
+    cached_print_time = None
+    if archive:
+        cached_print_time = archive.print_time_seconds
+        if data.plate_id:
+            archive_path = settings.base_dir / archive.file_path
+            if archive_path.exists():
+                plate_time = _extract_print_time_from_3mf(archive_path, data.plate_id)
+                if plate_time is not None:
+                    cached_print_time = plate_time
+    elif library_file:
+        if library_file.file_metadata:
+            cached_print_time = library_file.file_metadata.get("print_time_seconds")
+        if data.plate_id:
+            lib_path = Path(library_file.file_path)
+            library_file_path = lib_path if lib_path.is_absolute() else settings.base_dir / library_file.file_path
+            if library_file_path.exists():
+                plate_time = _extract_print_time_from_3mf(library_file_path, data.plate_id)
+                if plate_time is not None:
+                    cached_print_time = plate_time
+
     ams_mapping_json = json.dumps(data.ams_mapping) if data.ams_mapping else None
     items = []
     for i in range(quantity):
@@ -486,6 +509,7 @@ async def add_to_queue(
             status="pending",
             created_by_id=current_user.id if current_user else None,
             batch_id=batch_id,
+            print_time_seconds=cached_print_time,
         )
         db.add(item)
         items.append(item)

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

@@ -100,6 +100,7 @@ async def get_settings(
                 "queue_drying_block",
                 "ambient_drying_enabled",
                 "require_plate_clear",
+                "queue_shortest_first",
                 "default_bed_levelling",
                 "default_flow_cali",
                 "default_vibration_cali",

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

@@ -1284,6 +1284,10 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
         pass
 
+    # Migration: Shortest-job-first scheduling columns on print_queue
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
+
     # Migration: Add backup_spools and backup_archives columns to github_backup_config
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")

+ 4 - 0
backend/app/models/print_queue.py

@@ -58,6 +58,10 @@ class PrintQueueItem(Base):
     # Plate ID for multi-plate 3MF files (1-indexed, None = auto-detect/plate 1)
     plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
 
+    # Shortest-job-first scheduling
+    print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Cached from archive/library
+    been_jumped: Mapped[bool] = mapped_column(Boolean, default=False)  # Starvation guard for SJF
+
     # Print options
     bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
     flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)

+ 3 - 0
backend/app/schemas/print_queue.py

@@ -117,6 +117,9 @@ class PrintQueueItemResponse(BaseModel):
     batch_id: int | None = None
     batch_name: str | None = None
 
+    # Shortest-job-first scheduling
+    been_jumped: bool = False
+
     class Config:
         from_attributes = True
 

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

@@ -214,6 +214,10 @@ class AppSettings(BaseModel):
         default=True,
         description="Require per-printer plate-clear confirmation before starting queued prints on finished printers",
     )
+    queue_shortest_first: bool = Field(
+        default=False,
+        description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
+    )
 
     # Default sidebar order (admin-set for all users)
     default_sidebar_order: str = Field(
@@ -298,6 +302,7 @@ class AppSettingsUpdate(BaseModel):
     stagger_group_size: int | None = Field(default=None, ge=1, le=50)
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
+    queue_shortest_first: bool | None = None
     default_sidebar_order: str | None = None
 
     @field_validator("default_sidebar_order")

+ 61 - 6
backend/app/services/print_scheduler.py

@@ -93,12 +93,31 @@ class PrintScheduler:
     async def check_queue(self):
         """Check for prints ready to start."""
         async with async_session() as db:
-            # Get all pending items, ordered by printer and position
-            result = await db.execute(
-                select(PrintQueueItem)
-                .where(PrintQueueItem.status == "pending")
-                .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
-            )
+            # Check if shortest-job-first scheduling is enabled
+            sjf_enabled = await self._get_bool_setting(db, "queue_shortest_first")
+
+            # Get all pending items, ordered by printer and position (or SJF order)
+            if sjf_enabled:
+                # SJF: group by printer (and target_model for model-based jobs),
+                # then items already jumped get top priority (starvation guard),
+                # then sort by print_time ascending. Items with no print time go last.
+                result = await db.execute(
+                    select(PrintQueueItem)
+                    .where(PrintQueueItem.status == "pending")
+                    .order_by(
+                        PrintQueueItem.printer_id,
+                        PrintQueueItem.target_model,
+                        PrintQueueItem.been_jumped.desc(),
+                        PrintQueueItem.print_time_seconds.asc().nullslast(),
+                        PrintQueueItem.position,
+                    )
+                )
+            else:
+                result = await db.execute(
+                    select(PrintQueueItem)
+                    .where(PrintQueueItem.status == "pending")
+                    .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
+                )
             items = list(result.scalars().all())
 
             # Read plate-clear setting once per queue check
@@ -219,6 +238,23 @@ class PrintScheduler:
                     await self._start_print(db, item)
                     busy_printers.add(item.printer_id)
 
+                    # SJF starvation guard: mark items that were jumped
+                    if sjf_enabled and item.print_time_seconds is not None:
+                        for other in items:
+                            if (
+                                other.id != item.id
+                                and other.status == "pending"
+                                and other.printer_id == item.printer_id
+                                and not other.been_jumped
+                                and other.position < item.position
+                                and (
+                                    other.print_time_seconds is None
+                                    or other.print_time_seconds > item.print_time_seconds
+                                )
+                            ):
+                                other.been_jumped = True
+                        await db.commit()
+
                 elif item.target_model:
                     # Model-based assignment - find any idle printer of matching model
                     # Parse required filament types if present
@@ -324,6 +360,25 @@ class PrintScheduler:
                         await self._start_print(db, item)
                         busy_printers.add(printer_id)
 
+                        # SJF starvation guard: mark model-based items that were jumped
+                        if sjf_enabled and item.print_time_seconds is not None:
+                            for other in items:
+                                if (
+                                    other.id != item.id
+                                    and other.status == "pending"
+                                    and other.printer_id is None
+                                    and other.target_model
+                                    and other.target_model.upper() == item.target_model.upper()
+                                    and not other.been_jumped
+                                    and other.position < item.position
+                                    and (
+                                        other.print_time_seconds is None
+                                        or other.print_time_seconds > item.print_time_seconds
+                                    )
+                                ):
+                                    other.been_jumped = True
+                            await db.commit()
+
             # Log summary of skip reasons (helps diagnose why queue items aren't starting)
             if skip_reasons:
                 logger.info("Queue skip summary: %s", skip_reasons)

+ 325 - 0
backend/tests/integration/test_sjf_scheduling.py

@@ -0,0 +1,325 @@
+"""Integration tests for Shortest Job First (SJF) queue scheduling."""
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.settings import Settings
+
+
+class TestSJFScheduling:
+    """Tests for shortest-job-first queue ordering and starvation guard."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        """Factory to create test printers."""
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Test Printer {counter}",
+                "ip_address": f"192.168.1.{100 + counter}",
+                "serial_number": f"TESTSERIAL{counter:04d}",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        """Factory to create test archives."""
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "filename": f"test_print_{counter}.3mf",
+                "print_name": f"Test Print {counter}",
+                "file_path": f"/tmp/test_print_{counter}.3mf",
+                "file_size": 1024,
+                "content_hash": f"testhash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    @pytest.fixture
+    async def queue_item_factory(self, db_session, printer_factory, archive_factory):
+        """Factory to create test queue items with print_time_seconds."""
+
+        async def _create_queue_item(**kwargs):
+            if "printer_id" not in kwargs:
+                printer = await printer_factory()
+                kwargs["printer_id"] = printer.id
+
+            if "archive_id" not in kwargs:
+                archive = await archive_factory()
+                kwargs["archive_id"] = archive.id
+
+            defaults = {
+                "status": "pending",
+                "position": 0,
+            }
+            defaults.update(kwargs)
+
+            item = PrintQueueItem(**defaults)
+            db_session.add(item)
+            await db_session.commit()
+            await db_session.refresh(item)
+            return item
+
+        return _create_queue_item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_item_has_print_time_seconds(self, queue_item_factory):
+        """Verify print_time_seconds can be stored on queue items."""
+        item = await queue_item_factory(print_time_seconds=3600, position=1)
+        assert item.print_time_seconds == 3600
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_item_has_been_jumped(self, queue_item_factory):
+        """Verify been_jumped defaults to False and can be set."""
+        item = await queue_item_factory(position=1)
+        assert item.been_jumped is False
+
+        item2 = await queue_item_factory(been_jumped=True, position=2)
+        assert item2.been_jumped is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sjf_ordering_shorter_jobs_first(self, db_session, queue_item_factory, printer_factory):
+        """Verify SJF query orders by print_time_seconds ascending."""
+        printer = await printer_factory()
+
+        # Add items in FIFO order: long, medium, short
+        long_job = await queue_item_factory(
+            printer_id=printer.id,
+            position=1,
+            print_time_seconds=28800,  # 8 hours
+        )
+        medium_job = await queue_item_factory(
+            printer_id=printer.id,
+            position=2,
+            print_time_seconds=3600,  # 1 hour
+        )
+        short_job = await queue_item_factory(
+            printer_id=printer.id,
+            position=3,
+            print_time_seconds=1200,  # 20 min
+        )
+
+        # SJF query: been_jumped DESC, print_time_seconds ASC NULLS LAST, position
+        result = await db_session.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.status == "pending")
+            .where(PrintQueueItem.printer_id == printer.id)
+            .order_by(
+                PrintQueueItem.been_jumped.desc(),
+                PrintQueueItem.print_time_seconds.asc().nullslast(),
+                PrintQueueItem.position,
+            )
+        )
+        items = list(result.scalars().all())
+
+        assert len(items) == 3
+        assert items[0].id == short_job.id  # 20 min first
+        assert items[1].id == medium_job.id  # 1 hour second
+        assert items[2].id == long_job.id  # 8 hours last
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sjf_null_print_time_goes_last(self, db_session, queue_item_factory, printer_factory):
+        """Verify items without print_time_seconds are sorted last in SJF mode."""
+        printer = await printer_factory()
+
+        no_time = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=None)
+        short_job = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=600)
+
+        result = await db_session.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.status == "pending")
+            .where(PrintQueueItem.printer_id == printer.id)
+            .order_by(
+                PrintQueueItem.been_jumped.desc(),
+                PrintQueueItem.print_time_seconds.asc().nullslast(),
+                PrintQueueItem.position,
+            )
+        )
+        items = list(result.scalars().all())
+
+        assert items[0].id == short_job.id  # Known duration first
+        assert items[1].id == no_time.id  # Unknown duration last
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_starvation_guard_jumped_items_first(self, db_session, queue_item_factory, printer_factory):
+        """Verify been_jumped items are sorted before non-jumped items."""
+        printer = await printer_factory()
+
+        # Long job that was jumped (should go first now)
+        jumped_long = await queue_item_factory(
+            printer_id=printer.id, position=1, print_time_seconds=28800, been_jumped=True
+        )
+        # Short job (would normally go first, but jumped_long has priority)
+        short_job = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=600)
+
+        result = await db_session.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.status == "pending")
+            .where(PrintQueueItem.printer_id == printer.id)
+            .order_by(
+                PrintQueueItem.been_jumped.desc(),
+                PrintQueueItem.print_time_seconds.asc().nullslast(),
+                PrintQueueItem.position,
+            )
+        )
+        items = list(result.scalars().all())
+
+        assert items[0].id == jumped_long.id  # Jumped item gets priority
+        assert items[1].id == short_job.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_fifo_ordering_ignores_print_time(self, db_session, queue_item_factory, printer_factory):
+        """Verify default FIFO ordering uses position only, not print_time_seconds."""
+        printer = await printer_factory()
+
+        long_first = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=28800)
+        short_second = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=600)
+
+        # Default FIFO query (no SJF)
+        result = await db_session.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.status == "pending")
+            .where(PrintQueueItem.printer_id == printer.id)
+            .order_by(PrintQueueItem.position)
+        )
+        items = list(result.scalars().all())
+
+        assert items[0].id == long_first.id  # Position 1 first (FIFO)
+        assert items[1].id == short_second.id  # Position 2 second
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sjf_position_as_tiebreaker(self, db_session, queue_item_factory, printer_factory):
+        """Verify position is used as tiebreaker when print times are equal."""
+        printer = await printer_factory()
+
+        first = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=3600)
+        second = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=3600)
+
+        result = await db_session.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.status == "pending")
+            .where(PrintQueueItem.printer_id == printer.id)
+            .order_by(
+                PrintQueueItem.been_jumped.desc(),
+                PrintQueueItem.print_time_seconds.asc().nullslast(),
+                PrintQueueItem.position,
+            )
+        )
+        items = list(result.scalars().all())
+
+        assert items[0].id == first.id  # Same duration, lower position wins
+        assert items[1].id == second.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_starvation_flag_set_on_jumped_items(self, db_session, queue_item_factory, printer_factory):
+        """Verify the starvation flag logic marks jumped items correctly."""
+        printer = await printer_factory()
+
+        # Simulate: long job at position 1, short job at position 2
+        long_job = await queue_item_factory(printer_id=printer.id, position=1, print_time_seconds=28800)
+        short_job = await queue_item_factory(printer_id=printer.id, position=2, print_time_seconds=1200)
+
+        # Simulate what the scheduler does when SJF picks short_job first:
+        # Mark items that were jumped (lower position, longer duration)
+        items = [long_job, short_job]
+        winning_item = short_job  # SJF would pick this
+
+        for other in items:
+            if (
+                other.id != winning_item.id
+                and other.status == "pending"
+                and other.printer_id == winning_item.printer_id
+                and not other.been_jumped
+                and other.position < winning_item.position
+                and (other.print_time_seconds is None or other.print_time_seconds > winning_item.print_time_seconds)
+            ):
+                other.been_jumped = True
+
+        await db_session.commit()
+        await db_session.refresh(long_job)
+
+        assert long_job.been_jumped is True
+        assert short_job.been_jumped is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_starvation_guard_prevents_double_jump(self, db_session, queue_item_factory, printer_factory):
+        """Verify an already-jumped item won't be jumped again."""
+        printer = await printer_factory()
+
+        # Long job already jumped once
+        long_job = await queue_item_factory(
+            printer_id=printer.id, position=1, print_time_seconds=28800, been_jumped=True
+        )
+        # Even shorter job arrives
+        tiny_job = await queue_item_factory(printer_id=printer.id, position=3, print_time_seconds=300)
+
+        # SJF order: jumped items first, then by duration
+        result = await db_session.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.status == "pending")
+            .where(PrintQueueItem.printer_id == printer.id)
+            .order_by(
+                PrintQueueItem.been_jumped.desc(),
+                PrintQueueItem.print_time_seconds.asc().nullslast(),
+                PrintQueueItem.position,
+            )
+        )
+        items = list(result.scalars().all())
+
+        # long_job goes first because it was already jumped (starvation protection)
+        assert items[0].id == long_job.id
+        assert items[1].id == tiny_job.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_shortest_first_setting(self, db_session):
+        """Verify the queue_shortest_first setting can be stored and read."""
+        setting = Settings(key="queue_shortest_first", value="true")
+        db_session.add(setting)
+        await db_session.commit()
+
+        result = await db_session.execute(select(Settings).where(Settings.key == "queue_shortest_first"))
+        stored = result.scalar_one_or_none()
+        assert stored is not None
+        assert stored.value == "true"

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

@@ -898,6 +898,8 @@ export interface AppSettings {
   stagger_interval_minutes: number;
   // Plate-clear confirmation
   require_plate_clear: boolean;
+  // Shortest job first scheduling
+  queue_shortest_first: boolean;
   // Default sidebar order (admin-set for all users)
   default_sidebar_order: string;
 }
@@ -1380,6 +1382,8 @@ export interface PrintQueueItem {
   // Batch grouping
   batch_id?: number | null;
   batch_name?: string | null;
+  // Shortest-job-first scheduling
+  been_jumped?: boolean;
 }
 
 export interface PrintBatch {

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

@@ -851,6 +851,10 @@ export default {
     itemCount_plural: '{{count}} Elemente',
     dragToReorder: 'Ziehen zum Neuordnen (nur Sofort)',
     reorderHint: 'Position betrifft nur Sofort-Elemente. Geplante Elemente werden zur festgelegten Zeit ausgeführt.',
+    sjf: {
+      label: 'SJF',
+      tooltip: 'Kürzester Auftrag zuerst — Scheduler bevorzugt kürzere Drucke',
+    },
     addedBy: 'Hinzugefügt von {{name}}',
     nextInQueue: 'Nächster in der Warteschlange',
     clearPlate: 'Druckplatte freigeben & Nächsten starten',

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

@@ -851,6 +851,10 @@ export default {
     itemCount_plural: '{{count}} items',
     dragToReorder: 'Drag to reorder (ASAP only)',
     reorderHint: 'Position only affects ASAP items. Scheduled items run at their set time.',
+    sjf: {
+      label: 'SJF',
+      tooltip: 'Shortest Job First — scheduler prioritizes shorter prints',
+    },
     addedBy: 'Added by {{name}}',
     nextInQueue: 'Next in queue',
     clearPlate: 'Clear Plate & Start Next',

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

@@ -851,6 +851,10 @@ export default {
     itemCount_plural: '{{count}} éléments',
     dragToReorder: 'Glisser pour réordonner (ASAP uniquement)',
     reorderHint: 'La position n\'affecte que les éléments ASAP.',
+    sjf: {
+      label: 'SJF',
+      tooltip: 'Travail le plus court en premier — le planificateur priorise les impressions plus courtes',
+    },
     addedBy: 'Ajouté par {{name}}',
     nextInQueue: 'Prochain en file',
     clearPlate: 'Vider plateau & lancer suivant',

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

@@ -851,6 +851,10 @@ export default {
     itemCount_plural: '{{count}} elementi',
     dragToReorder: 'Trascina per riordinare (solo ASAP)',
     reorderHint: 'La posizione influisce solo sugli elementi ASAP. Quelli programmati partono all\'orario.',
+    sjf: {
+      label: 'SJF',
+      tooltip: 'Lavoro più breve prima — lo scheduler dà priorità alle stampe più brevi',
+    },
     addedBy: 'Aggiunto da {{name}}',
     nextInQueue: 'Prossimo in coda',
     clearPlate: 'Libera piatto e avvia il prossimo',

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

@@ -850,6 +850,10 @@ export default {
     itemCount_plural: '{{count}}件のアイテム',
     dragToReorder: 'ドラッグして並べ替え(ASAPのみ)',
     reorderHint: '順番はASAPアイテムのみに影響します。スケジュール済みアイテムは設定時刻に実行されます。',
+    sjf: {
+      label: 'SJF',
+      tooltip: '短いジョブ優先 — スケジューラーが短い印刷を優先します',
+    },
     addedBy: '{{username}}が追加',
     nextInQueue: '次のキュー',
     clearPlate: 'プレートをクリアして次を開始',

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

@@ -851,6 +851,10 @@ export default {
     itemCount_plural: '{{count}} itens',
     dragToReorder: 'Arraste para reordenar (apenas ASAP)',
     reorderHint: 'A posição afeta apenas itens ASAP. Itens agendados são executados no horário definido.',
+    sjf: {
+      label: 'SJF',
+      tooltip: 'Trabalho mais curto primeiro — o agendador prioriza impressões mais curtas',
+    },
     addedBy: 'Adicionado por {{name}}',
     nextInQueue: 'Próximo na fila',
     clearPlate: 'Limpar Placa e Iniciar Próximo',

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

@@ -851,6 +851,10 @@ export default {
     itemCount_plural: '{{count}} 个项目',
     dragToReorder: '拖动以重新排序(仅限尽快)',
     reorderHint: '位置仅影响"尽快"项目。排程项目按设定时间运行。',
+    sjf: {
+      label: 'SJF',
+      tooltip: '最短任务优先 — 调度器优先处理较短的打印任务',
+    },
     addedBy: '由 {{name}} 添加',
     nextInQueue: '队列中的下一个',
     clearPlate: '清理打印板并开始下一个',

+ 44 - 1
frontend/src/pages/QueuePage.tsx

@@ -772,6 +772,13 @@ export function QueuePage() {
     queryFn: () => api.getPrinters(),
   });
 
+  const sjfMutation = useMutation({
+    mutationFn: (enabled: boolean) => api.updateSettings({ queue_shortest_first: enabled }),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['settings'] });
+    },
+  });
+
   const cancelMutation = useMutation({
     mutationFn: (id: number) => api.cancelQueueItem(id),
     onSuccess: () => {
@@ -908,6 +915,26 @@ export function QueuePage() {
       return time > sixMonthsFromNow ? 0 : time;
     };
 
+    // When SJF is enabled, override sort to match scheduler order
+    if (settings?.queue_shortest_first) {
+      return [...items].sort((a, b) => {
+        // Group by printer first (nulls = model-based, grouped by target_model)
+        const aPrinter = a.printer_id ?? -(a.target_model?.charCodeAt(0) ?? 0);
+        const bPrinter = b.printer_id ?? -(b.target_model?.charCodeAt(0) ?? 0);
+        if (aPrinter !== bPrinter) return aPrinter - bPrinter;
+        // Within same printer/model: jumped items first (starvation guard)
+        const aJumped = a.been_jumped ? 1 : 0;
+        const bJumped = b.been_jumped ? 1 : 0;
+        if (aJumped !== bJumped) return bJumped - aJumped;
+        // Shortest print time next (nulls last)
+        const aTime = a.print_time_seconds ?? Infinity;
+        const bTime = b.print_time_seconds ?? Infinity;
+        if (aTime !== bTime) return aTime - bTime;
+        // Position as tiebreaker
+        return a.position - b.position;
+      });
+    }
+
     return [...items].sort((a, b) => {
       let cmp: number;
       if (pendingSortBy === 'name') {
@@ -924,7 +951,7 @@ export function QueuePage() {
       }
       return pendingSortAsc ? cmp : -cmp;
     });
-  }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation]);
+  }, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation, settings?.queue_shortest_first]);
 
   const handleSelectAll = () => {
     const allPendingIds = pendingItems.map(i => i.id);
@@ -1214,6 +1241,22 @@ export function QueuePage() {
                   </span>
                 </h2>
                 <div className="flex items-center gap-2">
+                  <button
+                    onClick={() => {
+                      const newValue = !(settings?.queue_shortest_first ?? false);
+                      sjfMutation.mutate(newValue);
+                    }}
+                    className={`flex items-center gap-1 px-2 py-1.5 text-xs rounded-lg border transition-colors ${
+                      settings?.queue_shortest_first
+                        ? 'bg-bambu-green/20 border-bambu-green text-bambu-green'
+                        : 'bg-bambu-dark-secondary border-bambu-dark-tertiary text-bambu-gray hover:text-white hover:border-bambu-gray'
+                    }`}
+                    title={t('queue.sjf.tooltip', 'Shortest Job First — scheduler prioritizes shorter prints')}
+                  >
+                    <Timer className="w-3.5 h-3.5" />
+                    <span className="hidden sm:inline">{t('queue.sjf.label', 'SJF')}</span>
+                    <span className={`w-1.5 h-1.5 rounded-full ${settings?.queue_shortest_first ? 'bg-bambu-green' : 'bg-bambu-gray'}`} />
+                  </button>
                   <select
                     className="px-2 sm:px-3 py-1.5 text-xs sm:text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                     value={pendingSortBy}

+ 1 - 0
frontend/src/pages/SettingsPage.tsx

@@ -3471,6 +3471,7 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+
           </div>
           {/* Right Column */}
           <div className="lg:w-1/2 space-y-6">

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-L6LzNswF.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-k-ZBfy94.js"></script>
+    <script type="module" crossorigin src="/assets/index-L6LzNswF.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-BGA3I7Jb.css">
   </head>
   <body>

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