Browse Source

fix(queue): say when an unscheduled item runs instead of calling it ASAP (issue #3018)

The print dialog offers ASAP, Queue and Schedule. ASAP and Queue differ only
in where the item is inserted, and neither is stored on the item -- scheduleType
is a frontend-only concept, and grep finds no "asap" anywhere in the backend. So
the queue's time column had nothing to read but scheduled_time, and labelled
every unscheduled item "ASAP": the name of the one mode the user may well have
chosen against.

Someone who picked Queue then watched their row appear as ASAP and start
immediately, and concluded Bambuddy had overridden them. Two reporters wrote
that same sentence thirteen months apart, and #2557 was closed as A2L-specific
after the first of them -- kilrah replied there with an X1C before filing this.

The column answers when an item runs, so it now says that. The key is renamed
whenFree rather than just retranslated: left called asap, the next translator
puts ASAP back.

The dispatch is unchanged, because it was right. A print scheduled for later
does not reserve the printer until then; an unscheduled item behind it uses the
idle printer rather than leaving an X1C dark until 6 AM. Two of the new tests
pin that, so it does not get "fixed" later on the strength of a report like this
one.

What genuinely could not answer the question was the queue's own log. Its
per-printer line called every entry in busy_printers "not available" -- but that
set holds both printers that cannot take work and printers the pass has just
claimed for some, which are opposite facts. It also read printer state at
logging time rather than at the decision, so #3018's bundle carries

    Queue: printer 1 not available — connected=True, state=IDLE, ...
    Launching 1 upload(s) (pool 0/4 in flight)
    Starting queue item 18

a printer reported unavailable, evidence that it was available, and a dispatch
to it, in three consecutive lines. It is the first line anyone greps for "why
did my item not go out".

Each of the nine sites that removes a printer from a pass now records why, and
the summary reports a claim as a reservation and everything else as an
obstruction with its reason. The live fields stay, since a bundle reader wants
them next, but are labelled as read now rather than offered as the cause.
print_scheduler.py:1210 already documented that these two meanings differ -- the
dispatching_printers snapshot exists for it. This carries that distinction into
the log.
maziggy 12 hours ago
parent
commit
09b4584d5f

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


+ 59 - 23
backend/app/services/print_scheduler.py

@@ -1186,6 +1186,38 @@ class PrintScheduler:
             )
             busy_printers: set[int] = {pid for (pid,) in busy_result.all() if pid is not None}
 
+            # Why each printer left this pass, recorded where the decision is
+            # made rather than re-derived when the summary is logged. #3018's
+            # bundle shows what the old summary produced: "printer 1 not
+            # available -- connected=True, state=IDLE" immediately followed by a
+            # dispatch to printer 1. Two things went wrong at once. The line read
+            # live state at log time, which by then no longer matched the state
+            # the decision was made on; and `busy_printers` holds both printers
+            # that cannot take work and printers this pass has claimed for it,
+            # which are opposite facts. It is the first line anyone greps for
+            # "why did my item not go out", so it has to say which.
+            busy_reasons: dict[int, str] = dict.fromkeys(busy_printers, "an item is already printing on it")
+
+            # Printers this pass is dispatching to. They are in busy_printers so
+            # nothing else in the pass targets them -- that is a reservation, not
+            # an obstruction, and the summary says so.
+            claimed_printers: set[int] = set()
+
+            def mark_busy(printer_id: int, reason: str) -> None:
+                """Take ``printer_id`` out of this pass, recording why.
+
+                First reason wins: a printer already excluded by a stronger fact
+                -- a print running on it -- must not be relabelled by a weaker
+                check that ran later and would have excluded it anyway.
+                """
+                busy_printers.add(printer_id)
+                busy_reasons.setdefault(printer_id, reason)
+
+            def claim_printer(printer_id: int) -> None:
+                """Reserve ``printer_id`` for an item this pass is dispatching."""
+                claimed_printers.add(printer_id)
+                mark_busy(printer_id, "selected for dispatch in this pass")
+
             # Defense-in-depth (#1157): augment busy_printers with any printer
             # still in its post-dispatch hold window. Empirically, the DB seed
             # above can miss in-flight items in a multi-plate batch — same-file
@@ -1196,7 +1228,7 @@ class PrintScheduler:
             # timing.
             for held_printer_id in list(self._dispatch_holds.keys()):
                 if self._printer_in_dispatch_hold(held_printer_id):
-                    busy_printers.add(held_printer_id)
+                    mark_busy(held_printer_id, "still inside its post-dispatch hold window")
 
             # Exclude printers whose upload is still in flight from an earlier
             # pass (#2602). The row is `pending` until the upload finishes and
@@ -1205,7 +1237,7 @@ class PrintScheduler:
             # busy_printers, its auto-drying) out of the pass during the upload.
             for _task, inflight_pid in self._inflight.values():
                 if inflight_pid is not None:
-                    busy_printers.add(inflight_pid)
+                    mark_busy(inflight_pid, "an upload to it is still in flight")
 
             # Snapshot taken here, before the item loop adds anything (#2801).
             #
@@ -1374,16 +1406,16 @@ class PrintScheduler:
                                 printer_idle = self._is_printer_idle(item.printer_id, require_plate_clear)
                             else:
                                 logger.warning("Could not power on printer %s via smart plug", item.printer_id)
-                                busy_printers.add(item.printer_id)
+                                mark_busy(item.printer_id, "smart-plug power-on failed")
                                 continue
                         else:
                             # No plug or auto_on disabled
-                            busy_printers.add(item.printer_id)
+                            mark_busy(item.printer_id, "offline, with no smart plug to power it on")
                             continue
 
                     # Check if printer is idle (busy with another print)
                     if not printer_idle:
-                        busy_printers.add(item.printer_id)
+                        mark_busy(item.printer_id, "not idle")
                         continue
 
                     # Drying blocks the queue, if the user asked it to. A hold
@@ -1392,7 +1424,7 @@ class PrintScheduler:
                     if self._drying_in_progress.get(item.printer_id) and await self._get_bool_setting(
                         db, "queue_drying_block"
                     ):
-                        busy_printers.add(item.printer_id)
+                        mark_busy(item.printer_id, "drying, and drying is set to block the queue")
                         continue
 
                     # Check condition (previous print success)
@@ -1438,7 +1470,7 @@ class PrintScheduler:
                     # its place in this printer's queue.
                     if _library_row_conflict(item):
                         skip_reasons["library_row_in_use"] = skip_reasons.get("library_row_in_use", 0) + 1
-                        busy_printers.add(item.printer_id)
+                        mark_busy(item.printer_id, "holding its place while another item releases a library row")
                         continue
 
                     # Print takes priority: stop a cycle Bambuddy armed, now
@@ -1468,7 +1500,7 @@ class PrintScheduler:
                     # immediately, so nothing else in this pass can target it.
                     _claim_library_row(item)
                     dispatch_ids.append(item.id)
-                    busy_printers.add(item.printer_id)
+                    claim_printer(item.printer_id)
 
                     # SJF starvation guard: mark items that were jumped
                     if sjf_enabled and item.print_time_seconds is not None:
@@ -1674,7 +1706,7 @@ class PrintScheduler:
 
                         _claim_library_row(item)
                         dispatch_ids.append(item.id)
-                        busy_printers.add(printer_id)
+                        claim_printer(printer_id)
 
                         # SJF starvation guard: mark model-based items that were jumped
                         if sjf_enabled and item.print_time_seconds is not None:
@@ -1701,20 +1733,24 @@ class PrintScheduler:
             # useless for working out why an item did not go out.
             if skip_reasons:
                 logger.info("Queue skip summary: %s", skip_reasons)
-            if busy_printers:
-                # Log why each printer was busy (first time it was checked)
-                for pid in busy_printers:
-                    state = printer_manager.get_status(pid)
-                    connected = printer_manager.is_connected(pid)
-                    awaiting = printer_manager.is_awaiting_plate_clear(pid)
-                    state_name = state.state if state else "NO_STATUS"
-                    logger.info(
-                        "Queue: printer %d not available — connected=%s, state=%s, awaiting_plate_clear=%s",
-                        pid,
-                        connected,
-                        state_name,
-                        awaiting,
-                    )
+            for pid in sorted(busy_printers):
+                reason = busy_reasons.get(pid, "no reason recorded")
+                if pid in claimed_printers:
+                    logger.info("Queue: printer %d reserved — %s", pid, reason)
+                    continue
+                # The three live fields stay, because they are what someone
+                # reading a bundle wants next -- but they are labelled as read
+                # now, not as the state the decision was made on, which is what
+                # made the old line contradict itself.
+                state = printer_manager.get_status(pid)
+                logger.info(
+                    "Queue: printer %d unavailable — %s (now: connected=%s, state=%s, awaiting_plate_clear=%s)",
+                    pid,
+                    reason,
+                    printer_manager.is_connected(pid),
+                    state.state if state else "NO_STATUS",
+                    printer_manager.is_awaiting_plate_clear(pid),
+                )
 
             # Keep-warm is a comfort feature; dispatch is not. It sits between
             # selection and `_launch_uploads`, so anything raising here would

+ 251 - 0
backend/tests/unit/test_scheduler_busy_reasons_3018.py

@@ -0,0 +1,251 @@
+"""The queue's per-printer summary says which fact took a printer out of the pass (#3018).
+
+``busy_printers`` holds two opposite things: printers that cannot take work, and
+printers this pass has claimed *for* work. The old summary called every one of
+them "not available" and printed printer state read at log time rather than the
+state the decision was made on. #3018's bundle shows both faults landing at once::
+
+    Queue: printer 1 not available — connected=True, state=IDLE, awaiting_plate_clear=False
+    Launching 1 upload(s) (pool 0/4 in flight)
+    Starting queue item 18
+
+That is the first line anyone greps when asking why an item did not go out, and
+there it is, on the printer that just received one.
+
+The dispatch in that trace is correct and these tests pin it as such: a print
+scheduled for later does not reserve the printer, so an unscheduled item behind
+it runs while the printer is free. Only the reporting changed.
+"""
+
+import asyncio
+import logging
+from contextlib import ExitStack, asynccontextmanager
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401 - populate Base.metadata
+import backend.app.services.archive as archive_module
+import backend.app.services.print_scheduler as scheduler_module
+from backend.app.core.database import Base
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.print_scheduler import PrintScheduler
+
+SCHEDULER_LOGGER = "backend.app.services.print_scheduler"
+
+
+@pytest.fixture
+async def one_printer(tmp_path):
+    """One printer, and a factory for the queue items a test needs on it."""
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    session_maker = async_sessionmaker(engine, expire_on_commit=False)
+
+    base_dir = tmp_path / "farm"
+    (base_dir / "archives").mkdir(parents=True, exist_ok=True)
+
+    async with session_maker() as db:
+        printer = Printer(
+            name="Printer",
+            serial_number="SERIAL",
+            ip_address="10.0.0.1",
+            access_code="access-code",
+            model="X1C",
+        )
+        db.add(printer)
+        await db.flush()
+        printer_id = printer.id
+        await db.commit()
+
+    async def add_item(*, scheduled_in: timedelta | None = None, status: str = "pending", position: int = 0):
+        async with session_maker() as db:
+            archive_rel = Path("archives") / f"job-{position}.3mf"
+            (base_dir / archive_rel).write_bytes(b"archive payload")
+            archive = PrintArchive(
+                printer_id=printer_id,
+                filename=f"job-{position}.3mf",
+                file_path=str(archive_rel),
+                file_size=15,
+                print_time_seconds=120,
+                status="completed",
+            )
+            db.add(archive)
+            await db.flush()
+            item = PrintQueueItem(
+                printer_id=printer_id,
+                archive_id=archive.id,
+                status=status,
+                position=position,
+                scheduled_time=(datetime.now(timezone.utc) + scheduled_in) if scheduled_in else None,
+            )
+            db.add(item)
+            await db.commit()
+            return item.id
+
+    try:
+        yield SimpleNamespace(
+            session_maker=session_maker,
+            base_dir=base_dir,
+            printer_id=printer_id,
+            add_item=add_item,
+        )
+    finally:
+        await engine.dispose()
+
+
+@asynccontextmanager
+async def _scheduler(ctx, *, idle: bool):
+    scheduler = PrintScheduler()
+
+    def _real_spawn(coro, *, name=None):
+        return asyncio.create_task(coro, name=name)
+
+    patches = [
+        patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
+        patch.object(archive_module.settings, "base_dir", ctx.base_dir),
+        patch.object(archive_module.settings, "archive_dir", ctx.base_dir / "archive"),
+        patch("backend.app.services.print_scheduler.async_session", ctx.session_maker),
+        patch("backend.app.core.database.async_session", ctx.session_maker),
+        patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
+        patch(
+            "backend.app.services.print_scheduler.printer_manager.get_status",
+            MagicMock(
+                return_value=SimpleNamespace(state="IDLE" if idle else "RUNNING", subtask_id=None, gcode_file=None)
+            ),
+        ),
+        patch(
+            "backend.app.services.print_scheduler.printer_manager.is_awaiting_plate_clear",
+            MagicMock(return_value=False),
+        ),
+        patch("backend.app.services.print_scheduler.printer_manager.start_print", MagicMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
+        patch("backend.app.services.print_scheduler.upload_file_async", AsyncMock(return_value=True)),
+        patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
+        patch(
+            "backend.app.services.print_scheduler.get_ftp_retry_settings",
+            AsyncMock(return_value=(False, 0, 0, 1.0)),
+        ),
+        patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
+        patch("backend.app.services.print_scheduler.spawn_background_task", _real_spawn),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_started",
+            AsyncMock(),
+        ),
+        patch(
+            "backend.app.services.notification_service.notification_service.on_queue_job_failed",
+            AsyncMock(),
+        ),
+        patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
+        patch.object(scheduler, "_is_printer_idle", MagicMock(return_value=idle)),
+        patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
+        patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
+        patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
+        patch.object(scheduler, "_check_auto_drying", AsyncMock()),
+        patch.object(scheduler, "_watchdog_print_start", AsyncMock()),
+    ]
+    with ExitStack() as stack:
+        for patcher in patches:
+            stack.enter_context(patcher)
+        yield scheduler
+        tasks = [task for (task, _pid) in scheduler._inflight.values()]
+        if tasks:
+            await asyncio.gather(*tasks, return_exceptions=True)
+
+
+def _printer_lines(caplog) -> list[str]:
+    return [r.getMessage() for r in caplog.records if r.getMessage().startswith("Queue: printer")]
+
+
+class TestAPrinterTheQueueIsUsing:
+    """#3018's trace: an item goes out, and the summary must not call that unavailable."""
+
+    @pytest.mark.asyncio
+    async def test_a_reserved_printer_is_not_called_unavailable(self, one_printer, caplog):
+        await one_printer.add_item(scheduled_in=timedelta(hours=6), position=0)
+        await one_printer.add_item(position=1)
+
+        with caplog.at_level(logging.INFO, logger=SCHEDULER_LOGGER):
+            async with _scheduler(one_printer, idle=True) as scheduler:
+                await scheduler.check_queue()
+
+        lines = _printer_lines(caplog)
+        assert len(lines) == 1, f"expected one line for the one printer, got {lines}"
+        assert "reserved" in lines[0]
+        assert "selected for dispatch in this pass" in lines[0]
+        assert "unavailable" not in lines[0], (
+            "the printer that just received the item must not be reported as unable to take one"
+        )
+
+    @pytest.mark.asyncio
+    async def test_the_scheduled_item_stays_behind_and_the_other_goes(self, one_printer, caplog):
+        """The behaviour #3018 reported as the bug. It is the intended one.
+
+        A print scheduled for later does not hold the printer until then -- it is
+        skipped as 'scheduled_future' while an unscheduled item uses the idle
+        printer. Pinned here because the report turned on the label, not on this.
+        """
+        scheduled_id = await one_printer.add_item(scheduled_in=timedelta(hours=6), position=0)
+        queued_id = await one_printer.add_item(position=1)
+
+        with caplog.at_level(logging.INFO, logger=SCHEDULER_LOGGER):
+            async with _scheduler(one_printer, idle=True) as scheduler:
+                await scheduler.check_queue()
+
+        assert any("'scheduled_future': 1" in r.getMessage() for r in caplog.records)
+        async with one_printer.session_maker() as db:
+            assert (await db.get(PrintQueueItem, scheduled_id)).status == "pending"
+            assert (await db.get(PrintQueueItem, queued_id)).status != "pending"
+
+
+class TestAPrinterThatCannotTakeWork:
+    """The other half of the set still reports, and now says which fact stopped it."""
+
+    @pytest.mark.asyncio
+    async def test_an_unavailable_printer_names_the_reason(self, one_printer, caplog):
+        await one_printer.add_item(position=0)
+
+        with caplog.at_level(logging.INFO, logger=SCHEDULER_LOGGER):
+            async with _scheduler(one_printer, idle=False) as scheduler:
+                await scheduler.check_queue()
+
+        lines = _printer_lines(caplog)
+        assert len(lines) == 1, f"expected one line, got {lines}"
+        assert "unavailable" in lines[0]
+        assert "not idle" in lines[0]
+        assert "reserved" not in lines[0]
+
+    @pytest.mark.asyncio
+    async def test_the_live_fields_are_labelled_as_read_now(self, one_printer, caplog):
+        """They stay, because a bundle reader wants them -- but not as the reason.
+
+        The old line offered them as the explanation, which is how it came to
+        print state=IDLE under the heading 'not available'.
+        """
+        await one_printer.add_item(position=0)
+
+        with caplog.at_level(logging.INFO, logger=SCHEDULER_LOGGER):
+            async with _scheduler(one_printer, idle=False) as scheduler:
+                await scheduler.check_queue()
+
+        line = _printer_lines(caplog)[0]
+        assert "(now: connected=True" in line
+        assert line.index("not idle") < line.index("now:"), "the recorded reason leads, the live read follows"
+
+    @pytest.mark.asyncio
+    async def test_two_items_on_one_printer_report_once(self, one_printer, caplog):
+        """One line per printer, not per item it turned away."""
+        await one_printer.add_item(position=0)
+        await one_printer.add_item(position=1)
+
+        with caplog.at_level(logging.INFO, logger=SCHEDULER_LOGGER):
+            async with _scheduler(one_printer, idle=False) as scheduler:
+                await scheduler.check_queue()
+
+        assert len(_printer_lines(caplog)) == 1

+ 23 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -457,6 +457,29 @@ describe('QueuePage', () => {
       ).not.toBeInTheDocument();
     });
 
+    it('tells an unscheduled item when it runs, without naming a dispatch mode', async () => {
+      // ASAP and Queue differ only in where the item is inserted; neither is
+      // stored on it, so this column cannot tell them apart. Labelling every
+      // unscheduled item "ASAP" made a Queue choice look overridden, which is
+      // what both #2557 and #3018 opened on.
+      server.use(
+        http.get('/api/v1/queue/', () => {
+          return HttpResponse.json([
+            { ...mockQueueItems[0], archive_name: 'Queued Print', scheduled_time: null },
+          ]);
+        }),
+      );
+
+      render(<QueuePage />);
+
+      const name = await screen.findByText('Queued Print');
+      const row = name.closest('.group') as HTMLElement;
+
+      expect(row).not.toBeNull();
+      expect(within(row).getByText('When a printer is free')).toBeInTheDocument();
+      expect(within(row).queryByText('ASAP')).not.toBeInTheDocument();
+    });
+
     it('does not render a dangling ETA for an invalid duration', async () => {
       server.use(
         http.get('/api/v1/queue/', () => {

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

@@ -1489,7 +1489,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'Fertigstellungszeit, wenn dieser Auftrag jetzt starten würde',
-      asap: 'Sofort',
+      whenFree: 'Sobald ein Drucker frei ist',
       overdue: 'Überfällig',
       now: 'Jetzt',
       lessThanMinute: 'In weniger als einer Minute',

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

@@ -1505,7 +1505,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'Completion time if this job started now',
-      asap: 'ASAP',
+      whenFree: 'When a printer is free',
       overdue: 'Overdue',
       now: 'Now',
       lessThanMinute: 'In less than a minute',

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

@@ -1489,7 +1489,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'Hora de finalización si este trabajo comenzara ahora',
-      asap: 'Lo antes posible',
+      whenFree: 'Cuando haya una impresora libre',
       overdue: 'Atrasada',
       now: 'Ahora',
       lessThanMinute: 'En menos de un minuto',

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

@@ -1489,7 +1489,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'Heure de fin si cette tâche démarrait maintenant',
-      asap: 'Dès que possible',
+      whenFree: 'Dès qu\'une imprimante est libre',
       overdue: 'En retard',
       now: 'Maintenant',
       lessThanMinute: 'Dans moins d\'une minute',

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

@@ -1489,7 +1489,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'Orario di completamento se questo lavoro iniziasse ora',
-      asap: 'ASAP',
+      whenFree: 'Quando una stampante è libera',
       overdue: 'Scaduto',
       now: 'Ora',
       lessThanMinute: 'Tra meno di un minuto',

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

@@ -1488,7 +1488,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'このジョブを今開始した場合の完了予定時刻',
-      asap: '即時',
+      whenFree: 'プリンターが空き次第',
       overdue: '期限超過',
       now: '今すぐ',
       lessThanMinute: '1分以内',

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

@@ -1418,7 +1418,7 @@ export default {
     },
     time: {
       etaIfStartedNow: '이 작업을 지금 시작할 경우의 완료 예정 시각',
-      asap: '즉시',
+      whenFree: '프린터가 사용 가능해지면',
       overdue: '기한 초과',
       now: '지금',
       lessThanMinute: '1분 이내',

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

@@ -1505,7 +1505,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'Voltooiingstijd als deze taak nu zou starten',
-      asap: 'Zo snel mogelijk',
+      whenFree: 'Zodra een printer vrij is',
       overdue: 'Te laat',
       now: 'Nu',
       lessThanMinute: 'Binnen een minuut',

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

@@ -1489,7 +1489,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: 'Horário de conclusão se este trabalho começasse agora',
-      asap: 'ASAP',
+      whenFree: 'Quando uma impressora estiver livre',
       overdue: 'Atrasado',
       now: 'Agora',
       lessThanMinute: 'Em menos de um minuto',

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

@@ -1428,7 +1428,7 @@ export default {
     },
     time: {
       etaIfStartedNow: "Время завершения, если запустить это задание сейчас",
-      asap: "Как можно скорее",
+      whenFree: "Когда принтер освободится",
       overdue: "Просрочено",
       now: "Сейчас",
       lessThanMinute: "Меньше чем через минуту",

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

@@ -1489,7 +1489,7 @@ export default {
     // Zaman
     time: {
       etaIfStartedNow: 'Bu iş şimdi başlatılırsa tamamlanma saati',
-      asap: 'ASAP',
+      whenFree: 'Bir yazıcı boşaldığında',
       overdue: 'Gecikmiş',
       now: 'Şimdi',
       lessThanMinute: 'Bir dakikadan az',

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

@@ -1504,7 +1504,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: "Час завершення, якщо запустити це завдання зараз",
-      asap: "Якнайшвидше",
+      whenFree: "Коли принтер звільниться",
       overdue: "Прострочено",
       now: "Зараз",
       lessThanMinute: "Менше ніж за хвилину",

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -1489,7 +1489,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: '若此任务现在开始的预计完成时间',
-      asap: '尽快',
+      whenFree: '有空闲打印机时',
       overdue: '已逾期',
       now: '现在',
       lessThanMinute: '不到一分钟',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -1489,7 +1489,7 @@ export default {
     // Time
     time: {
       etaIfStartedNow: '若此工作現在開始的預計完成時間',
-      asap: '儘快',
+      whenFree: '有空閒印表機時',
       overdue: '已逾期',
       now: '現在',
       lessThanMinute: '不到一分鐘',

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

@@ -664,11 +664,18 @@ function SortableQueueItem({
             {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
+                {/* An item with no scheduled time used to render as "ASAP", which is the
+                    name of a dispatch mode the user may well not have picked -- ASAP and
+                    Queue differ only in insert position, and neither is stored on the
+                    item, so the two are indistinguishable here. Someone who chose Queue
+                    saw their row labelled ASAP and read it as Bambuddy overriding them
+                    (#2557, #3018). This column answers "when does it run", so it now says
+                    that instead of borrowing a mode name. */}
                 {item.scheduled_time
                   ? ((parseUTCDate(item.scheduled_time)?.getTime() ?? 0) - Date.now() < -60000
                       ? t?.('queue.time.overdue') ?? 'Overdue'
                       : formatRelativeTime(item.scheduled_time, timeFormat, t))
-                  : t?.('queue.time.asap') ?? 'ASAP'}
+                  : t?.('queue.time.whenFree') ?? 'When a printer is free'}
               </span>
             )}
           </div>

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


+ 1 - 1
static/index.html

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

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