Kaynağa Gözat

Let a busy or offline printer take a dropped file (#2849)

Dragging a sliced file onto a printer card refused the drop unless the
printer was connected and neither RUNNING nor PAUSE. The overlay went red
with "Printer busy", handleCardDrop returned early, and the file was
discarded with no toast and nothing uploaded. The card's Print button was
hidden by the same condition, so both routes into "Print from Printer
Card" closed at once and the way through was the File Manager, uploading
and queueing by hand.

The gate never described a real constraint. Every print Bambuddy sends
becomes a queue item; dropping onto an idle printer only looks instant
because the scheduler dispatches it on the next pass. Busy is a timing
difference, not a different path. The modal has always passed
disableBusy={false} to PrinterSelector, and asapToastShouldPromiseLaterStart
exists precisely to say "this will start later" when the target cannot
take it now. cleanup_library_after_dispatch is a print_queue column
consumed at dispatch, not on close, so the transient upload survives
however long the item waits.

Offline is included for the same reason: the queue dispatches when the
printer comes back, so a machine that is powered down can be given work.

The overlay now says which one is happening -- "Drop to print" when the
job would start immediately, "Drop to queue" when it would wait, covering
a print in progress, a paused job, an AMS mid-cycle, a plate not yet
cleared, and a printer that is offline. The predicate behind that wording
is the one the modal already used for its own later-start notice, lifted
out of PrintModal into utils/printer as isPrinterCurrentlyDispatchable so
the card cannot promise something the modal contradicts a second later.

The drop is also gated on the permissions the flow actually exercises. It
uploads to the library and creates a queue item, so library:upload and
queue:create -- the pair the Print button beside it has always checked.
printers:control, which it checked before and never uses, meant someone
holding that alone got the file uploaded and then rejected by the queue,
leaving a library row behind with nothing pointing at it. The refusal now
names whichever of the two is missing instead of claiming the printer is
busy.

printers.cannotPrint is dropped in favour of printers.dropToQueue across
all 13 locales; its text was both unused and, after this, wrong.

The Print button stays inside the expanded-card block, so S-size cards
still show the drop zone and no button, exactly as before.
maziggy 3 hafta önce
ebeveyn
işleme
47a37618a0

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Fixed
+- **A printer being busy or offline no longer blocks printing from its card (#2849)** — Dragging a sliced file onto a printer card refused the drop unless the printer was connected and neither printing nor paused, showing a red "Printer busy" and discarding the file; the card's **Print** button disappeared under the same condition. The workaround was to upload to the File Manager and queue it from there by hand, which is the same thing with more steps. It was never a real restriction: every print Bambuddy sends becomes a queue item, and dropping onto an idle printer only looks instant because the scheduler dispatches it right away. Both routes now work whatever the printer is doing, and a busy one simply queues the job. Offline printers are included, so you can queue work for a machine that is powered down and have it dispatch when it reconnects. The overlay says which you are getting — "Drop to print" when the job would start immediately, "Drop to queue" when it would wait, covering a print in progress, a paused job, an AMS mid-drying and a plate not yet cleared. That wording comes from the same check the Print Modal uses for its own "will start later" notice, so the card and the modal cannot disagree. The drop is also now gated on the permissions the flow actually uses, **Library Upload** and **Queue Create**, matching the Print button beside it; it previously checked Printer Control, which meant someone holding that but neither of the others got the file uploaded and then rejected by the queue, leaving it stranded in the library. Translated in all locales; wiki updated. Covered by frontend tests.
 - **Drying an AMS no longer sends an hourly "temperature high" alert for the whole cycle (#1802)** — The AMS temperature alert compares against the same threshold that colours the printer card, which defaults to 35 C, while drying deliberately runs at 45 C for PLA, 65 C for PETG and up to 85 C on an AMS-HT. The alert repeats once an hour for as long as the condition holds, so a twelve-hour dry sent twelve notifications about a temperature you asked for, and then kept sending them while the unit cooled back down. The alert is now held back for the length of a cycle and through the cool-down that follows it, using the drying state the firmware already reports rather than anything you have to configure. Suppression lifts as soon as the unit reads back at or below your threshold, so a 65 C cycle in a cold basement and a 45 C one in a warm room each get exactly the cool-down they need instead of a fixed guess. Two things are deliberately left alone: the humidity alert, which is the one you want during drying because the number falling is the point, and a unit reporting `HeatOutOfControl`, where an AMS that has lost thermal control is precisely when the alert should still reach you. Because a cycle plus its cool-down can outlast a restart, the suppression is stored rather than held in memory. No new setting — the alert simply stops firing for heat you asked for. Wiki updated. Covered by backend tests.
 - **Closing the bug-report panel no longer throws the capture away and leaves the logs running (#2847)** — Step 2 of the report flow asks you to reproduce the problem, and the panel sits over the part of the app you have to reach to do it. Closing it was the obvious move and it was the wrong one twice over. Reopening put you back on an empty step 1 — while the server was still logging at DEBUG, with nothing left in the flow that could stop it, because only **Stop & Submit** ever did. Leave it closed instead and the five-minute cap eventually fired behind your back: logging stopped and the report was filed with no window open and no confirmation that it had happened. Which of the two you got depended only on whether you reopened the panel inside five minutes. A capture is now a thing that outlives the panel. Closing keeps it running and says so — the bug button turns amber for as long as a capture is going, and clicking it returns you to step 2 with your description, your screenshot and the elapsed timer where you left them. If the cap does fire while the panel is closed, the panel reopens so the submission happens in front of you rather than behind you. The timer is measured against the capture's start time rather than counted in ticks, so a background tab, where browsers throttle timers hard, no longer stretches five minutes into something else. A capture also survives a page reload, which matters because reloading is a perfectly ordinary step in reproducing a bug: the report picks it back up where it was. One that outlived the cap while nobody was watching is not resumed and not filed — a description written an hour ago is not a report you are still expecting — but the log level is put back, which is the part that previously stayed wrong indefinitely. Translated in all locales; wiki updated. Covered by frontend tests.
 - **The File Manager's card menu no longer loses its top entry (#2846)** — In grid view a file card's action menu was drawn inside the card, and the card clipped anything its children painted outside it. A card is as tall as its square thumbnail plus whatever metadata the file has, so an STL — which has none beyond a name and a size — produced the shortest card in the library, about 270px against a seven-entry menu that needs closer to 310px. The difference was one row, and the row it took was the top one: **Slice**, since **Print** is only offered for a file that is already sliced. A 3MF carries a target model and a print count, two more rows, and its card was tall enough, which is why the button appeared there and looked like a file-type rule rather than a layout accident. Nothing about STL was special; the shortest card simply lost the first item, whichever it happened to be. The menu now opens against the viewport, the way the archive card's menu already did, so no card can crop it, and the card no longer clips its own children. List view was never affected — it has no menu, only inline buttons. Covered by a frontend test.

+ 200 - 0
frontend/src/__tests__/pages/PrintersPageDropOnBusy.test.tsx

@@ -0,0 +1,200 @@
+/**
+ * Dropping a file onto a busy or offline printer queues it (#2849).
+ *
+ * The card used to refuse the drop unless the printer was connected and
+ * neither RUNNING nor PAUSE, showing a red "Printer busy" and silently
+ * discarding the file. That gate was never needed: a dropped file always
+ * becomes a queue item — dropping onto an idle printer just dispatches it
+ * straight away — so a busy printer only means the item waits its turn. The
+ * workaround was to upload to Archives and queue it from there by hand, which
+ * is the same thing with more steps.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+function makeStatus(over: Record<string, unknown>) {
+  return {
+    connected: true,
+    state: 'IDLE',
+    progress: 0,
+    layer_num: 0,
+    total_layers: 0,
+    temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+    remaining_time: 0,
+    filename: null,
+    wifi_signal: -29,
+    speed_level: 2,
+    vt_tray: [],
+    ams: [],
+    ...over,
+  };
+}
+
+/** Records every library upload the page attempts. */
+const uploads: string[] = [];
+
+function renderWith(statusOver: Record<string, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(makeStatus(statusOver))),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.post('/api/v1/library/files', async ({ request }) => {
+      const form = await request.formData();
+      uploads.push((form.get('file') as File).name);
+      return HttpResponse.json({ id: 7, filename: 'part.gcode', metadata: {} });
+    }),
+  );
+  return render(<PrintersPage />);
+}
+
+async function card(): Promise<HTMLElement> {
+  await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
+  return document.getElementById('printer-card-1') as HTMLElement;
+}
+
+const gcode = () => new File(['G28\n'], 'part.gcode', { type: 'text/plain' });
+
+describe('PrintersPage — drop onto a busy printer (#2849)', () => {
+  beforeEach(() => {
+    uploads.length = 0;
+    vi.clearAllMocks();
+  });
+
+  it('offers to queue instead of refusing while a print is running', async () => {
+    renderWith({ state: 'RUNNING' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+    // The old refusal copy must be gone, not merely restyled.
+    expect(screen.queryByText('Printer busy')).toBeNull();
+    expect(screen.queryByText('Drop to print')).toBeNull();
+  });
+
+  it('offers to queue while a print is paused', async () => {
+    renderWith({ state: 'PAUSE' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('offers to queue for an offline printer so it can be scheduled', async () => {
+    // The queue dispatches when the printer comes back, so refusing the drop
+    // helped nobody.
+    renderWith({ connected: false, state: 'IDLE' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('still says "print" when the printer would start it immediately', async () => {
+    renderWith({ state: 'IDLE' });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to print')).toBeInTheDocument();
+    expect(screen.queryByText('Drop to queue')).toBeNull();
+  });
+
+  it('says "queue" when an idle printer is drying, which also defers the start', async () => {
+    renderWith({ state: 'IDLE', ams: [{ id: 0, dry_time: 240, tray: [] }] });
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('actually uploads the dropped file while the printer is running', async () => {
+    // The regression itself: handleCardDrop returned early, so nothing was
+    // uploaded and the file vanished with no feedback at all.
+    renderWith({ state: 'RUNNING' });
+    const el = await card();
+
+    fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+
+    // Asserted by count, not name: the test environment's FormData does not
+    // preserve the filename through the multipart round trip. That an upload
+    // happened at all is the whole regression.
+    await waitFor(() => expect(uploads).toHaveLength(1));
+  });
+
+  it('keeps the Print button available while a print is running', async () => {
+    // The button is the other half of "Print from Printer Card" and was hidden
+    // by the same condition. Leaving it hidden while the drop zone accepted the
+    // same file would have had the two routes disagree on the same card.
+    renderWith({ state: 'RUNNING' });
+    await card();
+
+    expect(await screen.findByTitle('Print')).toBeInTheDocument();
+  });
+
+  it('keeps the Print button available while the printer is offline', async () => {
+    renderWith({ connected: false, state: 'IDLE' });
+    await card();
+
+    expect(await screen.findByTitle('Print')).toBeInTheDocument();
+  });
+
+  it('opens the Print Modal after a drop on a printer that is mid-print', async () => {
+    // Reaching the modal from a busy printer is what this change newly allows,
+    // so it is worth proving the modal actually renders rather than the drop
+    // ending in a dead end.
+    renderWith({ state: 'RUNNING' });
+    const el = await card();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Print Job')).toBeInTheDocument();
+    expect(await screen.findByText('part.gcode')).toBeInTheDocument();
+  });
+
+  it('opens the Print Modal after a drop on an offline printer', async () => {
+    // Offline is the further reach: the queue holds the job until the printer
+    // reconnects, so the modal has to cope with having no live AMS data.
+    renderWith({ connected: false, state: 'IDLE' });
+    const el = await card();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Print Job')).toBeInTheDocument();
+    expect(await screen.findByText('part.gcode')).toBeInTheDocument();
+
+    // And the job is actually submittable — an offline printer must not leave
+    // the user in a modal whose Print button never enables. The backend puts no
+    // connectivity condition on queue creation either.
+    await waitFor(() => {
+      const submit = document.querySelector('button[type="submit"]') as HTMLButtonElement | null;
+      expect(submit).not.toBeNull();
+      expect(submit!.disabled).toBe(false);
+    });
+  });
+
+  it('rejects a file that is not printable, busy or not', async () => {
+    renderWith({ state: 'RUNNING' });
+    const el = await card();
+
+    const stl = new File(['solid'], 'model.stl', { type: 'model/stl' });
+    fireEvent.drop(el, { dataTransfer: { files: [stl] } });
+
+    await waitFor(() =>
+      expect(screen.getByText('Only .gcode and .gcode.3mf files can be printed')).toBeInTheDocument()
+    );
+    expect(uploads).toEqual([]);
+  });
+});

+ 145 - 0
frontend/src/__tests__/pages/PrintersPageDropPermission.test.tsx

@@ -0,0 +1,145 @@
+/**
+ * The printer card's drop zone is gated on the permissions it actually uses (#2849).
+ *
+ * Dropping a file uploads it to the library and creates a queue item, so the
+ * two permissions that matter are `library:upload` and `queue:create` -- the
+ * same pair the card's Print button has always checked. The drop zone instead
+ * checked `printers:control`, which the flow never exercises, so a user with
+ * control but neither of the other two got the drop accepted, the file
+ * uploaded, and then a 403 from the queue with an orphaned library row left
+ * behind.
+ */
+import React from 'react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const permissions = { granted: ['library:upload', 'queue:create'] as string[] };
+
+const mockUseAuth = {
+  user: { id: 1, username: 'operator', permissions: [] as string[] },
+  authEnabled: true,
+  requiresSetup: false,
+  loading: false,
+  isAdmin: false,
+  login: vi.fn(),
+  loginWithToken: vi.fn(),
+  logout: vi.fn(),
+  refreshUser: vi.fn(),
+  refreshAuth: vi.fn(),
+  hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
+  hasAnyPermission: vi.fn(() => true),
+  hasAllPermissions: vi.fn(() => true),
+  canModify: vi.fn(() => true),
+};
+
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => mockUseAuth };
+});
+
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const uploads: string[] = [];
+
+function renderPage() {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+    http.get('/api/v1/printers/:id/status', () =>
+      HttpResponse.json({
+        connected: true,
+        state: 'RUNNING',
+        progress: 40,
+        layer_num: 10,
+        total_layers: 100,
+        temperatures: { nozzle: 220, bed: 60, chamber: 30 },
+        remaining_time: 600,
+        filename: 'running.gcode',
+        wifi_signal: -29,
+        speed_level: 2,
+        vt_tray: [],
+        ams: [],
+      })
+    ),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.post('/api/v1/library/files', () => {
+      uploads.push('upload');
+      return HttpResponse.json({ id: 7, filename: 'part.gcode', metadata: {} });
+    }),
+  );
+  render(<PrintersPage />);
+}
+
+async function card(): Promise<HTMLElement> {
+  await waitFor(() => expect(document.getElementById('printer-card-1')).not.toBeNull());
+  return document.getElementById('printer-card-1') as HTMLElement;
+}
+
+const gcode = () => new File(['G28\n'], 'part.gcode', { type: 'text/plain' });
+
+describe('PrintersPage — drop zone permissions (#2849)', () => {
+  beforeEach(() => {
+    uploads.length = 0;
+    permissions.granted = ['library:upload', 'queue:create'];
+  });
+
+  it('accepts the drop when the user can upload and queue', async () => {
+    renderPage();
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('Drop to queue')).toBeInTheDocument();
+  });
+
+  it('refuses when the user cannot upload to the library', async () => {
+    permissions.granted = ['queue:create'];
+    renderPage();
+    const el = await card();
+    fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('You do not have permission to upload files')).toBeInTheDocument();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+    // Nothing is uploaded, so no orphaned library row is left behind.
+    await waitFor(() => expect(uploads).toEqual([]));
+  });
+
+  it('refuses when the user cannot create queue items', async () => {
+    // The case the old printers:control gate got wrong: the upload would have
+    // succeeded and the queue POST then 403'd.
+    permissions.granted = ['library:upload'];
+    renderPage();
+    const el = await card();
+    fireEvent.dragEnter(el, { dataTransfer: { files: [gcode()] } });
+
+    expect(await screen.findByText('You do not have permission to add to queue')).toBeInTheDocument();
+    fireEvent.drop(el, { dataTransfer: { files: [gcode()] } });
+    await waitFor(() => expect(uploads).toEqual([]));
+  });
+
+  it('is not granted by printers:control alone', async () => {
+    permissions.granted = ['printers:control'];
+    renderPage();
+    fireEvent.dragEnter(await card(), { dataTransfer: { files: [gcode()] } });
+
+    // printers:control grants neither, so the more specific upload message wins.
+    expect(await screen.findByText('You do not have permission to upload files')).toBeInTheDocument();
+    expect(screen.queryByText('Drop to queue')).toBeNull();
+  });
+});

+ 58 - 0
frontend/src/__tests__/utils/isPrinterCurrentlyDispatchable.test.ts

@@ -0,0 +1,58 @@
+/**
+ * Tests for the shared "would ASAP mean now" predicate (#2849).
+ *
+ * Every print goes through the queue, so this is not "can we print at all" —
+ * it is whether a queue item aimed at this printer starts immediately or
+ * waits. The PrintModal uses it to promise a later start; the printer card
+ * uses it to decide whether a dropped file says "Drop to print" or "Drop to
+ * queue". They share it so the card cannot promise one thing and the modal
+ * immediately contradict it.
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { PrinterStatus } from '../../api/client';
+import { isPrinterCurrentlyDispatchable } from '../../utils/printer';
+
+const status = (over: Partial<PrinterStatus> = {}): PrinterStatus =>
+  ({ connected: true, state: 'IDLE', ...over }) as PrinterStatus;
+
+describe('isPrinterCurrentlyDispatchable', () => {
+  it('accepts the states that can take a print right now', () => {
+    for (const state of ['IDLE', 'FINISH', 'FAILED']) {
+      expect(isPrinterCurrentlyDispatchable(status({ state }))).toBe(true);
+    }
+  });
+
+  it('rejects a printer that is mid-print', () => {
+    // The #2849 case: the drop is still allowed, it just queues.
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'RUNNING' }))).toBe(false);
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'PAUSE' }))).toBe(false);
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'PREPARE' }))).toBe(false);
+  });
+
+  it('rejects a disconnected printer whatever its last known state', () => {
+    expect(isPrinterCurrentlyDispatchable(status({ connected: false, state: 'IDLE' }))).toBe(false);
+  });
+
+  it('rejects a printer still awaiting plate clear', () => {
+    // FINISH alone would pass; the plate is physically in the way.
+    expect(isPrinterCurrentlyDispatchable(status({ state: 'FINISH', awaiting_plate_clear: true }))).toBe(false);
+  });
+
+  it('rejects a printer whose AMS is drying', () => {
+    expect(
+      isPrinterCurrentlyDispatchable(status({ ams: [{ dry_time: 240 }] as PrinterStatus['ams'] }))
+    ).toBe(false);
+  });
+
+  it('ignores an AMS that is loaded but not drying', () => {
+    expect(
+      isPrinterCurrentlyDispatchable(status({ ams: [{ dry_time: 0 }] as PrinterStatus['ams'] }))
+    ).toBe(true);
+  });
+
+  it('treats an unknown or missing status as not dispatchable', () => {
+    expect(isPrinterCurrentlyDispatchable(undefined)).toBe(false);
+    expect(isPrinterCurrentlyDispatchable(status({ state: undefined }))).toBe(false);
+  });
+});

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

@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/rea
 import { AlertCircle, AlertTriangle, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import type { CostCenterSummary, PrinterStatus, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
+import type { CostCenterSummary, PrintQueueItemCreate, PrintQueueItemUpdate, SlotMaterial } from '../../api/client';
 import { api } from '../../api/client';
 import { useAuth } from '../../contexts/AuthContext';
 import { Card, CardContent } from '../Card';
@@ -17,7 +17,7 @@ import {
 } from '../../hooks/useFilamentMapping';
 import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
 import { getColorName } from '../../utils/colors';
-import { isGcodeCompatible } from '../../utils/printer';
+import { isGcodeCompatible, isPrinterCurrentlyDispatchable } from '../../utils/printer';
 import { getCurrencySymbol } from '../../utils/currency';
 import { getBedTypeInfo } from '../../utils/bedType';
 import { toDateTimeLocalValue, parseUTCDate } from '../../utils/date';
@@ -514,13 +514,6 @@ export function PrintModal({
     printerStatus?.ams_filament_backup,
   );
 
-  const isPrinterCurrentlyDispatchable = (status: PrinterStatus | undefined): boolean => {
-    if (!status?.connected) return false;
-    if (status.awaiting_plate_clear) return false;
-    if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
-    return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
-  };
-
   const asapToastShouldPromiseLaterStart = async (): Promise<boolean> => {
     if (scheduleOptions.scheduleType !== 'asap' || assignmentMode !== 'printer') return false;
     if (selectedPrinters.length === 0) return false;

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Diese Datei wurde für {{slicedFor}} geslicet, aber dieser Drucker ist ein {{printerModel}}',
     dropNotPrintable: 'Nur .gcode- und .gcode.3mf-Dateien können gedruckt werden',
     dropToPrint: 'Zum Drucken ablegen',
-    cannotPrint: 'Drucker beschäftigt',
+    dropToQueue: 'Zum Einreihen ablegen',
   },
 
   // Archives page

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

@@ -869,7 +869,7 @@ export default {
     incompatibleFile: 'This file was sliced for {{slicedFor}}, but this printer is a {{printerModel}}',
     dropNotPrintable: 'Only .gcode and .gcode.3mf files can be printed',
     dropToPrint: 'Drop to print',
-    cannotPrint: 'Printer busy',
+    dropToQueue: 'Drop to queue',
   },
 
   // Archives page

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Este archivo se laminó para {{slicedFor}}, pero esta impresora es una {{printerModel}}',
     dropNotPrintable: 'Solo se pueden imprimir archivos .gcode y .gcode.3mf',
     dropToPrint: 'Suelte para imprimir',
-    cannotPrint: 'Impresora ocupada',
+    dropToQueue: 'Suelte para poner en cola',
   },
 
   // Archives page

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Ce fichier a été tranché pour {{slicedFor}}, mais cette imprimante est une {{printerModel}}',
     dropNotPrintable: 'Seuls les fichiers .gcode et .gcode.3mf peuvent être imprimés',
     dropToPrint: 'Déposer pour imprimer',
-    cannotPrint: 'Imprimante occupée',
+    dropToQueue: 'Déposer pour mettre en file',
   },
 
   // Archives page

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Questo file è stato preparato per {{slicedFor}}, ma questa stampante è una {{printerModel}}',
     dropNotPrintable: 'Solo i file .gcode e .gcode.3mf possono essere stampati',
     dropToPrint: 'Rilascia per stampare',
-    cannotPrint: 'Stampante occupata',
+    dropToQueue: 'Rilascia per accodare',
   },
 
   // Archives page

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

@@ -863,7 +863,7 @@ export default {
     incompatibleFile: 'このファイルは{{slicedFor}}用にスライスされていますが、このプリンターは{{printerModel}}です',
     dropNotPrintable: '.gcodeおよび.gcode.3mfファイルのみ印刷できます',
     dropToPrint: 'ドロップして印刷',
-    cannotPrint: 'プリンター使用中',
+    dropToQueue: 'ドロップしてキューに追加',
   },
 
   // Archives page

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

@@ -816,7 +816,7 @@ export default {
     incompatibleFile: '이 파일은 {{slicedFor}}용으로 슬라이싱되었지만, 이 프린터는 {{printerModel}}입니다',
     dropNotPrintable: '.gcode 및 .gcode.3mf 파일만 인쇄할 수 있습니다',
     dropToPrint: '놓아서 인쇄',
-    cannotPrint: '프린터 사용 중',
+    dropToQueue: '놓아서 대기열에 추가',
     addPreflight: {
       checking: '연결 확인 중...',
       warning: '일부 연결 확인이 실패했습니다. 이 프린터가 오프라인으로 표시될 수 있습니다. 아래 항목을 검토하고 수정하거나 그냥 저장하세요.',

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Este arquivo foi fatiado para {{slicedFor}}, mas esta impressora é uma {{printerModel}}',
     dropNotPrintable: 'Apenas arquivos .gcode e .gcode.3mf podem ser impressos',
     dropToPrint: 'Solte para imprimir',
-    cannotPrint: 'Impressora ocupada',
+    dropToQueue: 'Solte para adicionar à fila',
   },
 
   // Archives page

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

@@ -821,7 +821,7 @@ export default {
     incompatibleFile: "Файл подготовлен для {{slicedFor}}, а выбранный принтер — {{printerModel}}",
     dropNotPrintable: "Для печати подходят только файлы .gcode и .gcode.3mf",
     dropToPrint: "Перетащите файл для печати",
-    cannotPrint: "Принтер занят",
+    dropToQueue: "Перетащите файл в очередь",
   },
   archives: {
     title: "Архив печати",

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: 'Bu dosya {{slicedFor}} için dilimlendi, ancak bu yazıcı bir {{printerModel}}',
     dropNotPrintable: 'Yalnızca .gcode ve .gcode.3mf dosyaları yazdırılabilir',
     dropToPrint: 'Yazdırmak için bırakın',
-    cannotPrint: 'Yazıcı meşgul',
+    dropToQueue: 'Kuyruğa eklemek için bırakın',
   },
 
   // Arşivler sayfası

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

@@ -868,7 +868,7 @@ export default {
     incompatibleFile: "Цей файл нарізано для {{slicedFor}}, а модель цього принтера — {{printerModel}}",
     dropNotPrintable: "Можна роздрукувати лише файли .gcode і .gcode.3mf.",
     dropToPrint: "Відпустіть для друку",
-    cannotPrint: "Принтер зайнятий",
+    dropToQueue: "Відпустіть, щоб додати в чергу",
   },
 
   // Archives page

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: '此文件是为 {{slicedFor}} 切片的,但该打印机是 {{printerModel}}',
     dropNotPrintable: '只能打印 .gcode 和 .gcode.3mf 文件',
     dropToPrint: '拖放以打印',
-    cannotPrint: '打印机忙碌',
+    dropToQueue: '拖放以加入队列',
   },
 
   // Archives page

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

@@ -864,7 +864,7 @@ export default {
     incompatibleFile: '此檔案是為 {{slicedFor}} 切片的,但該印表機是 {{printerModel}}',
     dropNotPrintable: '只能列印 .gcode 和 .gcode.3mf 檔案',
     dropToPrint: '拖放以列印',
-    cannotPrint: '印表機忙碌',
+    dropToQueue: '拖放以加入佇列',
   },
 
   // Archives page

+ 46 - 22
frontend/src/pages/PrintersPage.tsx

@@ -167,7 +167,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PrintModal } from '../components/PrintModal';
 import { PrinterInfoModal } from '../components/PrinterInfoModal';
 import { getAmsLabel, getGlobalTrayId, getFillBarColor, getSpoolmanFillLevel, getFallbackSpoolTag, isBambuLabSpool, resolveSlotNozzleDiameter } from '../utils/amsHelpers';
-import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems } from '../utils/printer';
+import { MAX_CHAMBER_TEMP_C, getPrinterImage, getWifiStrength, filterCompatibleQueueItems, isPrinterCurrentlyDispatchable } from '../utils/printer';
 import { FilamentSlotCircle } from '../components/FilamentSlotCircle';
 import { Collapsible } from '../components/Collapsible';
 import { ConnectionDiagnosticModal, DiagnosticChecklist } from '../components/ConnectionDiagnostic';
@@ -3288,7 +3288,22 @@ function PrinterCard({
     }
   };
 
-  const canDrop = isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && hasPermission('printers:control');
+  // A dropped file always becomes a queue item, so a printer that is busy,
+  // offline or mid-drying is no reason to refuse the drop — it only means the
+  // item waits its turn instead of starting now (#2849). Rejecting RUNNING /
+  // PAUSE / disconnected sent people to the File Manager to do by hand exactly
+  // what this would have done for them.
+  //
+  // What remains is what the flow actually performs: upload the file, then
+  // create a queue item. It never touches printers:control, which is what this
+  // used to check — so someone holding that but neither of these had the file
+  // uploaded and then rejected by the queue, leaving it stranded. The Print
+  // button below has always checked this pair.
+  const canDrop = hasPermission('library:upload') && hasPermission('queue:create');
+
+  // Drives the wording alone. Shared with the PrintModal so the card's promise
+  // and the modal's own "will start later" toast cannot disagree.
+  const dropWouldQueue = !isPrinterCurrentlyDispatchable(status);
 
   const handleCardDragEnter = (e: React.DragEvent) => {
     e.preventDefault();
@@ -3601,12 +3616,18 @@ function PrinterCard({
             ) : canDrop ? (
               <>
                 <PrinterIcon className="w-8 h-8 mx-auto mb-2 text-bambu-green" />
-                <p className="text-sm font-medium text-bambu-green">{t('printers.dropToPrint', 'Drop to print')}</p>
+                <p className="text-sm font-medium text-bambu-green">
+                  {dropWouldQueue ? t('printers.dropToQueue') : t('printers.dropToPrint')}
+                </p>
               </>
             ) : (
               <>
                 <X className="w-8 h-8 mx-auto mb-2 text-red-600 dark:text-red-400" />
-                <p className="text-sm font-medium text-red-700 dark:text-red-400">{t('printers.cannotPrint', 'Printer busy')}</p>
+                <p className="text-sm font-medium text-red-700 dark:text-red-400">
+                  {!hasPermission('library:upload')
+                    ? t('fileManager.noPermissionUpload')
+                    : t('fileManager.noPermissionAddToQueue')}
+                </p>
               </>
             )}
           </div>
@@ -6289,24 +6310,27 @@ function PrinterCard({
                 >
                   <HardDrive className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
                 </Button>
-                {isConnected && status?.state !== 'RUNNING' && status?.state !== 'PAUSE' && (
-                  <Button
-                    size="sm"
-                    onClick={() => setShowUploadForPrint(true)}
-                    disabled={!hasPermission('library:upload') || !hasPermission('queue:create')}
-                    title={
-                      !hasPermission('library:upload')
-                        ? t('fileManager.noPermissionUpload')
-                        : !hasPermission('queue:create')
-                          ? t('fileManager.noPermissionAddToQueue')
-                          : t('common.print')
-                    }
-                    className={`${footerActionButtonClass} !bg-bambu-green hover:!bg-bambu-green/80 !text-white`}
-                  >
-                    <PrinterIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
-                    {t('common.print')}
-                  </Button>
-                )}
+                {/* Shown whatever the printer is doing (#2849): this uploads a
+                    file and queues it, which a busy or offline printer is no
+                    reason to refuse -- it only means the item waits. Hiding it
+                    while the drop zone accepted the same file would have left
+                    the two routes into this flow disagreeing. */}
+                <Button
+                  size="sm"
+                  onClick={() => setShowUploadForPrint(true)}
+                  disabled={!hasPermission('library:upload') || !hasPermission('queue:create')}
+                  title={
+                    !hasPermission('library:upload')
+                      ? t('fileManager.noPermissionUpload')
+                      : !hasPermission('queue:create')
+                        ? t('fileManager.noPermissionAddToQueue')
+                        : t('common.print')
+                  }
+                  className={`${footerActionButtonClass} !bg-bambu-green hover:!bg-bambu-green/80 !text-white`}
+                >
+                  <PrinterIcon className="w-[var(--pc-i4,1rem)] h-[var(--pc-i4,1rem)]" />
+                  {t('common.print')}
+                </Button>
               </div>
             </div>
         </div>

+ 17 - 1
frontend/src/utils/printer.ts

@@ -56,7 +56,23 @@ export function getWifiStrength(rssi: number): { labelKey: string; color: string
   return { labelKey: 'printers.wifiSignal.veryWeak', color: 'text-red-400', bars: 1 };
 }
 
-import type { PrintQueueItem } from '../api/client';
+import type { PrinterStatus, PrintQueueItem } from '../api/client';
+
+/**
+ * True when a queue item aimed at this printer would start now rather than wait.
+ *
+ * Every print Bambuddy sends goes through the queue, so this is not "can we
+ * print at all" — it is "will ASAP mean now". The PrintModal uses it to promise
+ * a later start, and the printer card uses it to say whether a dropped file
+ * prints or queues. Both must agree, or the card promises one thing and the
+ * modal immediately says another.
+ */
+export function isPrinterCurrentlyDispatchable(status: PrinterStatus | undefined): boolean {
+  if (!status?.connected) return false;
+  if (status.awaiting_plate_clear) return false;
+  if (status.ams?.some((ams) => ams.dry_time > 0)) return false;
+  return ['IDLE', 'FINISH', 'FAILED'].includes(status.state ?? '');
+}
 
 /**
  * Filters queue items based on printer compatibility (filament types and colors).

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-61kdXEBh.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-D5Du0dc3.js"></script>
+    <script type="module" crossorigin src="/assets/index-61kdXEBh.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-VSpFxsmE.css">
   </head>
   <body>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor