Jelajahi Sumber

fix(notifications): sync finish-photo producer→consumer to land the photo on FINISH-state fallback (#1790)

      On the FINISH-state fallback path bambu_mqtt.py:3258 dispatches
      on_finish_photo_moment and on_print_complete back-to-back, so the
      moment-producer's RTSP grab and the print-complete consumer's cache
      read race — consumer wins the empty pop, then its own RTSP fallback
      times out against the producer's in-flight grab (Bambu printers allow
      one RTSP client). 394 KB frame captured, notification went text-only.

      Add a per-printer asyncio.Event in _stage22_finish_in_flight: producer
      registers before first await, sets it in finally on every exit;
      consumer awaits with a 20s timeout (15s producer grab + headroom)
      before the cache pop. Closes the race AND the concurrent-RTSP timeout
      in one change. Timelapse path skips the event, so its branch is
      unchanged.
maziggy 2 bulan lalu
induk
melakukan
a9bf6f1f79

File diff ditekan karena terlalu besar
+ 0 - 0
CHANGELOG.md


+ 38 - 0
backend/app/main.py

@@ -346,6 +346,15 @@ _active_prints: dict[tuple[int, str], int] = {}
 # nozzle parking on slicer profiles with Timelapse Type = Smooth).
 _stage22_finish_frames: dict[int, bytes] = {}
 
+# #1790: per-printer producer-done event. Set by `on_finish_photo_moment` in its
+# `finally` block (whether it captured a frame or not). The consumer in
+# `_background_finish_photo` waits on it before reading `_stage22_finish_frames`
+# so the FINISH-state fallback path — where moment and completion are dispatched
+# back-to-back — doesn't race past the producer with an empty pop, and the
+# consumer's RTSP fallback can't collide with the producer's still-in-flight RTSP
+# grab (Bambu printers allow only one RTSP client at a time).
+_stage22_finish_in_flight: dict[int, asyncio.Event] = {}
+
 # Per-printer "connected" edge tracker. Used by `on_printer_status_change`
 # to fire `reconcile_stale_active_prints` exactly once per (re)connection
 # (#1542 follow-up — power-cycle ghost prints). The value is True after
@@ -3735,6 +3744,14 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
         )
         return
 
+    # #1790: register the producer-done event BEFORE the first await so the
+    # consumer in `_background_finish_photo` — which is dispatched back-to-back
+    # with us on the FINISH-state fallback path — sees it as soon as it polls.
+    # The `finally` below guarantees `set()` runs on every exit, including
+    # early returns and exceptions, so the consumer's bounded wait can't hang.
+    producer_done = asyncio.Event()
+    _stage22_finish_in_flight[printer_id] = producer_done
+
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
@@ -3807,6 +3824,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
             printer_id,
             e,
         )
+    finally:
+        # #1790: always unblock the consumer's bounded wait — whether we stored
+        # a frame, gave up, or hit an exception. Local ref means cleanup of the
+        # dict entry by the consumer doesn't affect signalling.
+        producer_done.set()
 
 
 async def on_print_complete(printer_id: int, data: dict):
@@ -4678,6 +4700,22 @@ async def on_print_complete(printer_id: int, data: dict):
                             # has the better framing instead of the post-bed-drop angle
                             # the live-camera fallback below would give.
                             if not photo_filename:
+                                # #1790: on the FINISH-state fallback path the producer
+                                # task is dispatched back-to-back with this consumer, so
+                                # a bare pop would race past with an empty result and
+                                # the RTSP fallback below would collide with the
+                                # producer's still-in-flight grab (single-client RTSP
+                                # on Bambu printers). Wait for the producer to finish
+                                # or give up before touching the cache.
+                                in_flight = _stage22_finish_in_flight.pop(printer_id, None)
+                                if in_flight is not None:
+                                    try:
+                                        await asyncio.wait_for(in_flight.wait(), timeout=20.0)
+                                    except asyncio.TimeoutError:
+                                        logger.warning(
+                                            "[PHOTO-BG] timed out waiting for stage-22 producer for printer %s — proceeding to fallback",
+                                            printer_id,
+                                        )
                                 cached_frame = _stage22_finish_frames.pop(printer_id, None)
                                 if cached_frame:
                                     photos_dir = archive_dir / "photos"

+ 208 - 0
backend/tests/unit/test_finish_photo_moment_sync.py

@@ -0,0 +1,208 @@
+"""Regression tests for the #1790 producer-consumer synchronization.
+
+`on_finish_photo_moment` (producer) and `_background_finish_photo`
+(consumer) are dispatched back-to-back on the FINISH-state fallback path
+(`bambu_mqtt.py:3258-3297`). Before #1790, the consumer ran a single
+`pop()` on `_stage22_finish_frames` with no wait — racing past the
+producer with an empty result, then doing its own RTSP grab that
+collided with the producer's still-in-flight grab (Bambu printers allow
+one RTSP client). Net result: a captured frame was logged, the cache
+was populated ~1s later, but the notification went text-only.
+
+The fix is an `asyncio.Event` per printer registered in
+`_stage22_finish_in_flight` by the producer and awaited (with timeout)
+by the consumer. These tests pin the producer side of that contract.
+"""
+
+import asyncio
+from contextlib import asynccontextmanager
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+
+from backend.app import main as main_module
+from backend.app.main import on_finish_photo_moment
+
+
+@asynccontextmanager
+async def _fake_session(printer):
+    """Async-session stub that returns `printer` from scalar_one_or_none()."""
+    result = SimpleNamespace(scalar_one_or_none=lambda: printer)
+    session = SimpleNamespace(execute=AsyncMock(return_value=result))
+    yield session
+
+
+@pytest.fixture
+def fake_printer():
+    return SimpleNamespace(
+        id=7,
+        ip_address="192.0.2.7",
+        access_code="x",
+        model="X1C",
+        external_camera_enabled=False,
+        external_camera_url=None,
+        external_camera_type=None,
+        external_camera_snapshot_url=None,
+    )
+
+
+@pytest.fixture(autouse=True)
+def _clean_state():
+    """Don't leak event/cache dict entries across tests."""
+    main_module._stage22_finish_in_flight.clear()
+    main_module._stage22_finish_frames.clear()
+    yield
+    main_module._stage22_finish_in_flight.clear()
+    main_module._stage22_finish_frames.clear()
+
+
+@pytest.fixture
+def patched_env(fake_printer, monkeypatch):
+    monkeypatch.setattr(main_module, "async_session", lambda: _fake_session(fake_printer))
+
+    async def _get_setting(_db, key):
+        if key == "capture_finish_photo":
+            return "true"
+        return None
+
+    monkeypatch.setattr(
+        "backend.app.api.routes.settings.get_setting",
+        _get_setting,
+    )
+    monkeypatch.setattr(
+        "backend.app.api.routes.camera.get_buffered_frame",
+        lambda _pid: None,
+    )
+    return fake_printer
+
+
+async def test_event_registered_before_first_await(patched_env, monkeypatch):
+    """The consumer needs to find the event the moment it polls — that
+    means registration must complete BEFORE any `await` yields control
+    back to the loop."""
+    # Slow the first await (DB session entry) so we can observe the dict
+    # before the producer makes any real progress.
+    seen_during_capture = {}
+
+    async def _slow_capture(**_kwargs):
+        seen_during_capture["registered"] = patched_env.id in main_module._stage22_finish_in_flight
+        await asyncio.sleep(0)
+        return b"\xff\xd8frame"
+
+    monkeypatch.setattr(
+        "backend.app.services.camera.capture_camera_frame_bytes",
+        _slow_capture,
+    )
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    assert seen_during_capture["registered"] is True
+
+
+async def test_event_set_after_successful_capture(patched_env, monkeypatch):
+    async def _capture(**_kwargs):
+        return b"\xff\xd8frame"
+
+    monkeypatch.setattr(
+        "backend.app.services.camera.capture_camera_frame_bytes",
+        _capture,
+    )
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    event = main_module._stage22_finish_in_flight[patched_env.id]
+    assert event.is_set()
+    assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
+
+
+async def test_event_set_when_capture_returns_no_frame(patched_env, monkeypatch):
+    """Producer gives up (RTSP timeout, no buffered frame, no external
+    camera) — consumer must NOT wait the full 20s for nothing."""
+
+    async def _capture(**_kwargs):
+        return None
+
+    monkeypatch.setattr(
+        "backend.app.services.camera.capture_camera_frame_bytes",
+        _capture,
+    )
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    event = main_module._stage22_finish_in_flight[patched_env.id]
+    assert event.is_set()
+    assert patched_env.id not in main_module._stage22_finish_frames
+
+
+async def test_event_set_even_when_capture_raises(patched_env, monkeypatch):
+    """Producer hit a bug or network error — `finally` still has to
+    release the consumer."""
+
+    async def _capture(**_kwargs):
+        raise RuntimeError("camera went away")
+
+    monkeypatch.setattr(
+        "backend.app.services.camera.capture_camera_frame_bytes",
+        _capture,
+    )
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    event = main_module._stage22_finish_in_flight[patched_env.id]
+    assert event.is_set()
+
+
+async def test_no_event_when_timelapse_was_active(patched_env):
+    """On the timelapse-on path the consumer takes the
+    `_capture_finish_photo_from_timelapse` branch and shouldn't be
+    blocked by a producer wait — the producer doesn't enter the
+    lifecycle."""
+    await on_finish_photo_moment(
+        patched_env.id,
+        {"trigger": "stage_22", "timelapse_was_active": True},
+    )
+
+    assert patched_env.id not in main_module._stage22_finish_in_flight
+
+
+async def test_event_set_when_capture_setting_disabled(patched_env, monkeypatch):
+    """Even on the early-return-before-capture path, the event must be
+    released so the consumer doesn't hang on a no-op producer."""
+
+    async def _disabled_setting(_db, _key):
+        return "false"
+
+    monkeypatch.setattr(
+        "backend.app.api.routes.settings.get_setting",
+        _disabled_setting,
+    )
+
+    await on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"})
+
+    event = main_module._stage22_finish_in_flight[patched_env.id]
+    assert event.is_set()
+
+
+async def test_consumer_wait_unblocked_when_producer_completes(patched_env, monkeypatch):
+    """End-to-end sync check: a consumer-style waiter awaiting the
+    event finishes promptly once the producer's finally fires."""
+
+    async def _capture(**_kwargs):
+        await asyncio.sleep(0.05)
+        return b"\xff\xd8frame"
+
+    monkeypatch.setattr(
+        "backend.app.services.camera.capture_camera_frame_bytes",
+        _capture,
+    )
+
+    producer = asyncio.create_task(on_finish_photo_moment(patched_env.id, {"trigger": "finish_state"}))
+
+    await asyncio.sleep(0)  # let the producer register
+
+    event = main_module._stage22_finish_in_flight[patched_env.id]
+    await asyncio.wait_for(event.wait(), timeout=1.0)
+
+    assert main_module._stage22_finish_frames[patched_env.id] == b"\xff\xd8frame"
+    await producer

+ 173 - 0
frontend/src/__tests__/hooks/usePageFileDrop.test.tsx

@@ -0,0 +1,173 @@
+/**
+ * Tests for usePageFileDrop. Each "cancel path" gets its own case so a future
+ * regression on any of the three (drag-out-of-window, Escape, dragend) is
+ * pinned independently — #1510 reported the Archives overlay sticking after
+ * cancel, and these cases enforce the document-level reset.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, fireEvent, act, createEvent } from '@testing-library/react';
+import { usePageFileDrop } from '../../hooks/usePageFileDrop';
+
+function makeFile(name: string, size = 1024): File {
+  return new File(['x'.repeat(size)], name, { type: 'application/octet-stream' });
+}
+
+function Harness(props: {
+  onFiles: (f: File[]) => void;
+  onRejected?: () => void;
+  extensions?: string[];
+  disabled?: boolean;
+}) {
+  const { isDraggingOver, dragHandlers } = usePageFileDrop(props);
+  return (
+    <div data-testid="wrapper" {...dragHandlers}>
+      {isDraggingOver && <div data-testid="overlay">overlay</div>}
+      <div data-testid="child">child</div>
+    </div>
+  );
+}
+
+describe('usePageFileDrop', () => {
+  it('shows the overlay on dragenter with files', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+  });
+
+  it('ignores dragenter for non-file payloads (text selection, dnd-kit)', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['text/plain'], files: [] } });
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+  });
+
+  // JSDOM doesn't propagate relatedTarget through fireEvent.dragLeave(elem, {...}),
+  // so these three cases build the DragEvent manually and defineProperty the
+  // field before dispatching.
+  function dispatchDragLeave(wrapper: HTMLElement, related: Node | null) {
+    const ev = createEvent.dragLeave(wrapper);
+    Object.defineProperty(ev, 'relatedTarget', { value: related, configurable: true });
+    fireEvent(wrapper, ev);
+  }
+
+  it('keeps the overlay when dragging over a child (relatedTarget inside wrapper)', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    const child = screen.getByTestId('child');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+
+    dispatchDragLeave(wrapper, child);
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+  });
+
+  it('hides the overlay when dragLeave targets something outside the wrapper', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+
+    const outside = document.createElement('div');
+    document.body.appendChild(outside);
+    dispatchDragLeave(wrapper, outside);
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+    document.body.removeChild(outside);
+  });
+
+  it('hides the overlay when relatedTarget is null (cursor left the window)', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+
+    dispatchDragLeave(wrapper, null);
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+  });
+
+  it('resets on document drop (cancel path: release outside any drop target)', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+
+    act(() => {
+      document.dispatchEvent(new Event('drop'));
+    });
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+  });
+
+  it('resets on document dragend (cancel path: drag aborted)', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+
+    act(() => {
+      document.dispatchEvent(new Event('dragend'));
+    });
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+  });
+
+  it('resets on Escape (cancel path: user aborts mid-drag)', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+
+    act(() => {
+      document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
+    });
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+  });
+
+  it('passes dropped files to onFiles', () => {
+    const onFiles = vi.fn();
+    render(<Harness onFiles={onFiles} />);
+    const wrapper = screen.getByTestId('wrapper');
+    const file = makeFile('model.3mf');
+    fireEvent.drop(wrapper, { dataTransfer: { files: [file] } });
+    expect(onFiles).toHaveBeenCalledWith([file]);
+  });
+
+  it('filters by extensions and calls onRejected when nothing matches', () => {
+    const onFiles = vi.fn();
+    const onRejected = vi.fn();
+    render(<Harness onFiles={onFiles} onRejected={onRejected} extensions={['.3mf']} />);
+    const wrapper = screen.getByTestId('wrapper');
+    const file = makeFile('image.png');
+    fireEvent.drop(wrapper, { dataTransfer: { files: [file] } });
+    expect(onFiles).not.toHaveBeenCalled();
+    expect(onRejected).toHaveBeenCalled();
+  });
+
+  it('only passes matched files through when extensions filter mixed types', () => {
+    const onFiles = vi.fn();
+    render(<Harness onFiles={onFiles} extensions={['.3mf']} />);
+    const wrapper = screen.getByTestId('wrapper');
+    const a = makeFile('a.3mf');
+    const b = makeFile('b.txt');
+    fireEvent.drop(wrapper, { dataTransfer: { files: [a, b] } });
+    expect(onFiles).toHaveBeenCalledWith([a]);
+  });
+
+  it('clears the overlay on a successful drop', () => {
+    render(<Harness onFiles={vi.fn()} />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.getByTestId('overlay')).toBeInTheDocument();
+    fireEvent.drop(wrapper, { dataTransfer: { files: [makeFile('a.3mf')] } });
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+  });
+
+  it('is a no-op when disabled', () => {
+    const onFiles = vi.fn();
+    render(<Harness onFiles={onFiles} disabled />);
+    const wrapper = screen.getByTestId('wrapper');
+    fireEvent.dragEnter(wrapper, { dataTransfer: { types: ['Files'], files: [] } });
+    expect(screen.queryByTestId('overlay')).not.toBeInTheDocument();
+    fireEvent.drop(wrapper, { dataTransfer: { files: [makeFile('a.3mf')] } });
+    expect(onFiles).not.toHaveBeenCalled();
+  });
+});

+ 16 - 2
frontend/src/components/FileUploadModal.tsx

@@ -1,4 +1,4 @@
-import { useState, useRef, type DragEvent } from 'react';
+import { useState, useRef, useEffect, type DragEvent } from 'react';
 import { useTranslation } from 'react-i18next';
 import {
   Upload,
@@ -36,9 +36,11 @@ interface FileUploadModalProps {
   validateFile?: (file: File) => string | undefined;
   /** Restrict file picker to specific file types (e.g. ".gcode,.gcode.3mf") */
   accept?: string;
+  /** Pre-seed the modal with files (e.g. from a page-wide drop) on first mount. */
+  initialFiles?: File[];
 }
 
-export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept }: FileUploadModalProps) {
+export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUploaded, autoUpload, validateFile, accept, initialFiles }: FileUploadModalProps) {
   const { t } = useTranslation();
   const [files, setFiles] = useState<UploadFile[]>([]);
   const [isDragging, setIsDragging] = useState(false);
@@ -153,6 +155,18 @@ export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUpl
     setFiles((prev) => prev.filter((_, i) => i !== index));
   };
 
+  // Seed once on mount when the parent passed initialFiles (page-wide drop).
+  // The ref/list shape means a subsequent re-render with the same files won't
+  // double-add — only the first non-empty initialFiles arg ever flows through.
+  const seededInitialRef = useRef(false);
+  useEffect(() => {
+    if (seededInitialRef.current) return;
+    if (!initialFiles || initialFiles.length === 0) return;
+    seededInitialRef.current = true;
+    addFiles(initialFiles);
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
   const hasZipFiles = files.some((f) => f.isZip && f.status === 'pending');
   const hasStlFiles = files.some((f) => f.file.name.toLowerCase().endsWith('.stl') && f.status === 'pending');
   const has3mfFiles = files.some((f) => f.is3mf && f.status === 'pending');

+ 116 - 0
frontend/src/hooks/usePageFileDrop.ts

@@ -0,0 +1,116 @@
+import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
+
+interface UsePageFileDropOptions {
+  /** Called when files are dropped that pass the extension filter. */
+  onFiles: (files: File[]) => void;
+  /** Called when a drop event had files but none matched `extensions`. */
+  onRejected?: () => void;
+  /** Lowercase extensions including the dot (e.g. ['.3mf']). Omit to accept all. */
+  extensions?: string[];
+  /** Disable the drop zone entirely (e.g. when the user lacks upload permission). */
+  disabled?: boolean;
+}
+
+interface UsePageFileDropResult {
+  isDraggingOver: boolean;
+  dragHandlers: {
+    onDragOver: (e: DragEvent) => void;
+    onDragEnter: (e: DragEvent) => void;
+    onDragLeave: (e: DragEvent) => void;
+    onDrop: (e: DragEvent) => void;
+  };
+}
+
+/**
+ * Page-wide drag-and-drop file zone. Survives the three cancel paths that
+ * dragLeave alone misses: drag-out-of-window, Escape during drag, and drag
+ * release outside any drop target. Each fix is captured by a separate test
+ * case in usePageFileDrop.test.tsx.
+ */
+export function usePageFileDrop({
+  onFiles,
+  onRejected,
+  extensions,
+  disabled = false,
+}: UsePageFileDropOptions): UsePageFileDropResult {
+  const [isDraggingOver, setIsDraggingOver] = useState(false);
+
+  const onFilesRef = useRef(onFiles);
+  const onRejectedRef = useRef(onRejected);
+  const extensionsRef = useRef(extensions);
+  useEffect(() => { onFilesRef.current = onFiles; }, [onFiles]);
+  useEffect(() => { onRejectedRef.current = onRejected; }, [onRejected]);
+  useEffect(() => { extensionsRef.current = extensions; }, [extensions]);
+
+  const handleDragOver = useCallback((e: DragEvent) => {
+    if (disabled) return;
+    e.preventDefault();
+    if (e.dataTransfer.types.includes('Files')) {
+      setIsDraggingOver(true);
+    }
+  }, [disabled]);
+
+  const handleDragEnter = useCallback((e: DragEvent) => {
+    if (disabled) return;
+    e.preventDefault();
+    if (e.dataTransfer.types.includes('Files')) {
+      setIsDraggingOver(true);
+    }
+  }, [disabled]);
+
+  const handleDragLeave = useCallback((e: DragEvent) => {
+    if (disabled) return;
+    e.preventDefault();
+    const wrapper = e.currentTarget as Node;
+    const next = e.relatedTarget as Node | null;
+    if (!next || !wrapper.contains(next)) {
+      setIsDraggingOver(false);
+    }
+  }, [disabled]);
+
+  const handleDrop = useCallback((e: DragEvent) => {
+    if (disabled) return;
+    e.preventDefault();
+    setIsDraggingOver(false);
+
+    const all = Array.from(e.dataTransfer.files);
+    if (all.length === 0) return;
+
+    const exts = extensionsRef.current;
+    const matched = exts && exts.length > 0
+      ? all.filter(f => exts.some(ext => f.name.toLowerCase().endsWith(ext)))
+      : all;
+
+    if (matched.length > 0) {
+      onFilesRef.current(matched);
+    } else {
+      onRejectedRef.current?.();
+    }
+  }, [disabled]);
+
+  useEffect(() => {
+    if (!isDraggingOver) return;
+    const reset = () => setIsDraggingOver(false);
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') reset();
+    };
+    document.addEventListener('drop', reset);
+    document.addEventListener('dragend', reset);
+    document.addEventListener('keydown', handleKey);
+    return () => {
+      document.removeEventListener('drop', reset);
+      document.removeEventListener('dragend', reset);
+      document.removeEventListener('keydown', handleKey);
+    };
+  }, [isDraggingOver]);
+
+  return {
+    isDraggingOver,
+    dragHandlers: {
+      onDragOver: handleDragOver,
+      onDragEnter: handleDragEnter,
+      onDragLeave: handleDragLeave,
+      onDrop: handleDrop,
+    },
+  };
+}

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

@@ -182,6 +182,7 @@ export default {
       status: 'Status',
       model: 'Modell',
       location: 'Standort',
+      eta: 'Restzeit',
       ascending: 'Aufsteigend sortieren',
       descending: 'Absteigend sortieren',
     },
@@ -3276,6 +3277,7 @@ export default {
     link: 'Verknüpfen',
     dragDropFiles: 'Dateien hierher ziehen',
     dropFilesHere: 'Dateien hier ablegen',
+    releaseToUpload: 'Loslassen zum Hochladen',
     orClickToBrowse: 'oder klicken zum Durchsuchen',
     allFileTypesSupported: 'Alle Dateitypen werden unterstützt. ZIP-Dateien werden extrahiert.',
     zipFilesDetected: 'ZIP-Dateien erkannt',

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

@@ -182,6 +182,7 @@ export default {
       status: 'Status',
       model: 'Model',
       location: 'Location',
+      eta: 'ETA',
       ascending: 'Sort ascending',
       descending: 'Sort descending',
     },
@@ -3291,6 +3292,7 @@ export default {
     link: 'Link',
     dragDropFiles: 'Drag & drop files here',
     dropFilesHere: 'Drop files here',
+    releaseToUpload: 'Release to upload',
     orClickToBrowse: 'or click to browse',
     allFileTypesSupported: 'All file types supported. ZIP files will be extracted.',
     zipFilesDetected: 'ZIP files detected',

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -182,6 +182,7 @@ export default {
       status: 'Estado',
       model: 'Modelo',
       location: 'Ubicación',
+      eta: 'Tiempo restante',
       ascending: 'Orden ascendente',
       descending: 'Orden descendente',
     },
@@ -3279,6 +3280,7 @@ export default {
     link: 'Vincular',
     dragDropFiles: 'Arrastre y suelte archivos aquí',
     dropFilesHere: 'Suelte archivos aquí',
+    releaseToUpload: 'Suelte para subir',
     orClickToBrowse: 'o haga clic para examinar',
     allFileTypesSupported: 'Se admiten todos los tipos de archivo. Los archivos ZIP se extraerán.',
     zipFilesDetected: 'Archivos ZIP detectados',

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

@@ -182,6 +182,7 @@ export default {
       status: 'Statut',
       model: 'Modèle',
       location: 'Emplacement',
+      eta: 'Temps restant',
       ascending: 'Tri croissant',
       descending: 'Tri décroissant',
     },
@@ -3265,6 +3266,7 @@ export default {
     link: 'Lier',
     dragDropFiles: 'Glissez les fichiers ici',
     dropFilesHere: 'Déposez ici',
+    releaseToUpload: 'Relâcher pour téléverser',
     orClickToBrowse: 'ou cliquez pour parcourir',
     allFileTypesSupported: 'Tous types supportés. ZIP extraits.',
     zipFilesDetected: 'ZIP détectés',

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

@@ -182,6 +182,7 @@ export default {
       status: 'Stato',
       model: 'Modello',
       location: 'Posizione',
+      eta: 'Tempo rimanente',
       ascending: 'Ordina crescente',
       descending: 'Ordina decrescente',
     },
@@ -3264,6 +3265,7 @@ export default {
     link: 'Collega',
     dragDropFiles: 'Trascina e rilascia file qui',
     dropFilesHere: 'Rilascia file qui',
+    releaseToUpload: 'Rilascia per caricare',
     orClickToBrowse: 'oppure clicca per sfogliare',
     allFileTypesSupported: 'Tutti i tipi di file supportati. I file ZIP saranno estratti.',
     zipFilesDetected: 'File ZIP rilevati',

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

@@ -181,6 +181,7 @@ export default {
       status: 'ステータス',
       model: 'モデル',
       location: 'ロケーション',
+      eta: '残り時間',
       ascending: '昇順で並べ替え',
       descending: '降順で並べ替え',
     },
@@ -3276,6 +3277,7 @@ export default {
     link: 'リンク',
     dragDropFiles: 'ファイルをここにドラッグ&ドロップ',
     dropFilesHere: 'ここにファイルをドロップ',
+    releaseToUpload: '離してアップロード',
     orClickToBrowse: 'またはクリックして選択',
     allFileTypesSupported: 'すべてのファイルタイプに対応。ZIPファイルは展開されます。',
     zipFilesDetected: 'ZIPファイルを検出',

+ 2 - 0
frontend/src/i18n/locales/ko.ts

@@ -170,6 +170,7 @@ export default {
       status: '상태',
       model: '모델',
       location: '위치',
+      eta: '남은 시간',
       ascending: '오름차순 정렬',
       descending: '내림차순 정렬'
     },
@@ -3089,6 +3090,7 @@ export default {
     link: '연결',
     dragDropFiles: '파일을 여기에 드래그 앤 드롭',
     dropFilesHere: '파일을 여기에 드롭',
+    releaseToUpload: '놓아서 업로드',
     orClickToBrowse: '또는 클릭하여 탐색',
     allFileTypesSupported: '모든 파일 형식 지원. ZIP 파일은 압축 해제됩니다.',
     zipFilesDetected: 'ZIP 파일 감지됨',

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

@@ -182,6 +182,7 @@ export default {
       status: 'Status',
       model: 'Modelo',
       location: 'Localização',
+      eta: 'Tempo restante',
       ascending: 'Ordem crescente',
       descending: 'Ordem decrescente',
     },
@@ -3264,6 +3265,7 @@ export default {
     link: 'Vincular',
     dragDropFiles: 'Arraste e solte os arquivos aqui',
     dropFilesHere: 'Solte os arquivos aqui',
+    releaseToUpload: 'Solte para enviar',
     orClickToBrowse: 'ou clique para procurar',
     allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',
     zipFilesDetected: 'Arquivos ZIP detectados',

+ 2 - 0
frontend/src/i18n/locales/tr.ts

@@ -182,6 +182,7 @@ export default {
       status: 'Durum',
       model: 'Model',
       location: 'Konum',
+      eta: 'Kalan süre',
       ascending: 'Artan sırala',
       descending: 'Azalan sırala',
     },
@@ -3271,6 +3272,7 @@ export default {
     link: 'Bağla',
     dragDropFiles: 'Dosyaları buraya sürükleyip bırakın',
     dropFilesHere: 'Dosyaları buraya bırakın',
+    releaseToUpload: 'Yüklemek için bırakın',
     orClickToBrowse: 'veya göz atmak için tıklayın',
     allFileTypesSupported: 'Tüm dosya türleri desteklenir. ZIP dosyaları çıkarılacak.',
     zipFilesDetected: 'ZIP dosyaları algılandı',

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

@@ -182,6 +182,7 @@ export default {
       status: '状态',
       model: '型号',
       location: '位置',
+      eta: '剩余时间',
       ascending: '升序排列',
       descending: '降序排列',
     },
@@ -3264,6 +3265,7 @@ export default {
     link: '链接',
     dragDropFiles: '将文件拖放到此处',
     dropFilesHere: '将文件放在此处',
+    releaseToUpload: '释放以上传',
     orClickToBrowse: '或点击浏览',
     allFileTypesSupported: '支持所有文件类型。ZIP 文件将被解压。',
     zipFilesDetected: '检测到 ZIP 文件',

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -182,6 +182,7 @@ export default {
       status: '狀態',
       model: '型號',
       location: '位置',
+      eta: '剩餘時間',
       ascending: '升序排列',
       descending: '降序排列',
     },
@@ -3264,6 +3265,7 @@ export default {
     link: '連結',
     dragDropFiles: '將檔案拖放到此處',
     dropFilesHere: '將檔案放在此處',
+    releaseToUpload: '釋放以上傳',
     orClickToBrowse: '或點選瀏覽',
     allFileTypesSupported: '支援所有檔案類型。ZIP 檔案將被解壓。',
     zipFilesDetected: '偵測到 ZIP 檔案',

+ 16 - 32
frontend/src/pages/ArchivesPage.tsx

@@ -64,6 +64,7 @@ import { formatDateTime, formatDateOnly, parseUTCDate, type TimeFormat, formatDu
 import { getCurrencySymbol } from '../utils/currency';
 import { getBedTypeInfo } from '../utils/bedType';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import type { Archive, PrintLogEntry, ProjectListItem } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
@@ -2618,7 +2619,6 @@ export function ArchivesPage() {
   );
   const [showUpload, setShowUpload] = useState(false);
   const [uploadFiles, setUploadFiles] = useState<File[]>([]);
-  const [isDraggingOver, setIsDraggingOver] = useState(false);
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
   // Install-step-4 nudge — covers the slicer-side variant of "Store sent files
   // on external storage" that the connection diagnostic can't detect (printer
@@ -3122,34 +3122,20 @@ export function ArchivesPage() {
 
   const hasTopFilters = search || filterPrinter || filterMaterial || filterFavorites || hideFailed || hideDuplicates || filterTag || filterFileType !== 'all';
 
-  // Drag & drop handlers for page-wide upload
-  const handleDragOver = useCallback((e: React.DragEvent) => {
-    e.preventDefault();
-    if (e.dataTransfer.types.includes('Files')) {
-      setIsDraggingOver(true);
-    }
-  }, []);
-
-  const handleDragLeave = useCallback((e: React.DragEvent) => {
-    e.preventDefault();
-    // Only hide if leaving the page (not entering a child)
-    if (e.currentTarget === e.target) {
-      setIsDraggingOver(false);
-    }
-  }, []);
-
-  const handleDrop = useCallback((e: React.DragEvent) => {
-    e.preventDefault();
-    setIsDraggingOver(false);
-
-    const droppedFiles = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.3mf'));
-    if (droppedFiles.length > 0) {
-      setUploadFiles(droppedFiles);
+  // Page-wide drag-and-drop upload (#1510). The hook covers the three cancel
+  // paths the previous inline implementation missed (drag-out-of-window, Escape,
+  // dragend outside any drop target). Disabled while the upload modal is open
+  // so drags into the modal's own drop zone don't bubble up and flash the page
+  // overlay behind it.
+  const { isDraggingOver, dragHandlers } = usePageFileDrop({
+    disabled: showUpload,
+    extensions: ['.3mf'],
+    onFiles: (files) => {
+      setUploadFiles(files);
       setShowUpload(true);
-    } else if (e.dataTransfer.files.length > 0) {
-      showToast(t('archives.page.only3mfSupported'), 'warning');
-    }
-  }, [showToast, t]);
+    },
+    onRejected: () => showToast(t('archives.page.only3mfSupported'), 'warning'),
+  });
 
   // Keyboard shortcuts
   const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -3190,16 +3176,14 @@ export function ArchivesPage() {
   return (
     <div
       className="p-4 md:p-8 relative"
-      onDragOver={handleDragOver}
-      onDragLeave={handleDragLeave}
-      onDrop={handleDrop}
+      {...dragHandlers}
     >
       {/* Drag & Drop Overlay */}
       {isDraggingOver && (
         <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
           <div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
             <Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
-            <p className="text-2xl font-semibold text-white mb-2">Drop .3mf files here</p>
+            <p className="text-2xl font-semibold text-white mb-2">{t('archives.page.dropFilesHere')}</p>
             <p className="text-bambu-gray">{t('archives.releaseToUpload')}</p>
           </div>
         </div>

+ 36 - 2
frontend/src/pages/FileManagerPage.tsx

@@ -62,6 +62,7 @@ import { FileUploadModal } from '../components/FileUploadModal';
 import { PurgeOldFilesModal } from '../components/PurgeOldFilesModal';
 import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
+import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
@@ -959,6 +960,7 @@ export function FileManagerPage() {
   const [showExternalFolderModal, setShowExternalFolderModal] = useState(false);
   const [showMoveModal, setShowMoveModal] = useState(false);
   const [showUploadModal, setShowUploadModal] = useState(false);
+  const [droppedFiles, setDroppedFiles] = useState<File[]>([]);
   const [showPurgeModal, setShowPurgeModal] = useState(false);
   const [linkFolder, setLinkFolder] = useState<LibraryFolderTree | null>(null);
   const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'file' | 'folder' | 'bulk'; id: number; count?: number } | null>(null);
@@ -1450,6 +1452,20 @@ export function FileManagerPage() {
     queryClient.invalidateQueries({ queryKey: ['library-stats'] });
   };
 
+  // Page-wide drag-and-drop upload (#1510). Disabled when the user lacks
+  // library:upload so a non-uploader can't accidentally show the overlay,
+  // and also disabled while the upload modal itself is open so drags into
+  // the modal's own drop zone don't bubble up and flash the page overlay
+  // behind it.
+  const canUpload = hasPermission('library:upload');
+  const { isDraggingOver, dragHandlers } = usePageFileDrop({
+    disabled: !canUpload || showUploadModal,
+    onFiles: (files) => {
+      setDroppedFiles(files);
+      setShowUploadModal(true);
+    },
+  });
+
   const handleDownload = (id: number) => {
     api.downloadLibraryFile(id).catch((err) => {
       console.error('Library file download failed:', err);
@@ -1491,7 +1507,21 @@ export function FileManagerPage() {
   }, [selectedFolderId, folders]);
 
   return (
-    <div className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col">
+    <div
+      className="p-4 md:p-8 min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] flex flex-col relative"
+      {...dragHandlers}
+    >
+      {/* Drag & Drop Overlay — page-wide file upload (#1510) */}
+      {isDraggingOver && (
+        <div className="fixed inset-0 z-50 bg-bambu-dark/90 flex items-center justify-center pointer-events-none">
+          <div className="border-4 border-dashed border-bambu-green rounded-xl p-12 text-center">
+            <Upload className="w-16 h-16 mx-auto mb-4 text-bambu-green" />
+            <p className="text-2xl font-semibold text-white mb-2">{t('fileManager.dropFilesHere')}</p>
+            <p className="text-bambu-gray">{t('fileManager.releaseToUpload')}</p>
+          </div>
+        </div>
+      )}
+
       {/* Header */}
       <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
         <div>
@@ -2417,8 +2447,12 @@ export function FileManagerPage() {
       {showUploadModal && (
         <FileUploadModal
           folderId={selectedFolderId}
-          onClose={() => setShowUploadModal(false)}
+          onClose={() => {
+            setShowUploadModal(false);
+            setDroppedFiles([]);
+          }}
           onUploadComplete={handleUploadComplete}
+          initialFiles={droppedFiles.length > 0 ? droppedFiles : undefined}
         />
       )}
 

+ 25 - 2
frontend/src/pages/PrintersPage.tsx

@@ -1095,7 +1095,7 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
   );
 }
 
-type SortOption = 'name' | 'status' | 'model' | 'location';
+type SortOption = 'name' | 'status' | 'model' | 'location' | 'eta';
 type ViewMode = 'expanded' | 'compact';
 
 type ToolbarDropdownOption<T extends string> = {
@@ -8014,6 +8014,28 @@ export function PrintersPage() {
           return getPriority(statusA) - getPriority(statusB);
         });
         break;
+      case 'eta':
+        sorted.sort((a, b) => {
+          const statusA = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null }>(['printerStatus', a.id]);
+          const statusB = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null }>(['printerStatus', b.id]);
+
+          const tier = (s: typeof statusA) => {
+            if (!s?.connected) return 3; // offline last
+            if (s.state === 'RUNNING' && s.remaining_time != null && s.remaining_time > 0) return 0; // printing with ETA
+            if (s.state === 'RUNNING') return 1; // printing without ETA
+            return 2; // idle
+          };
+
+          const ta = tier(statusA);
+          const tb = tier(statusB);
+          if (ta !== tb) return ta - tb;
+          if (ta === 0) {
+            const diff = (statusA!.remaining_time ?? 0) - (statusB!.remaining_time ?? 0);
+            if (diff !== 0) return diff;
+          }
+          return a.name.localeCompare(b.name);
+        });
+        break;
     }
 
     // Apply ascending/descending
@@ -8069,7 +8091,7 @@ export function PrintersPage() {
 
   // Group printers when sorted by location, status, or model
   const groupedPrinters = useMemo(() => {
-    if (sortBy === 'name') return null;
+    if (sortBy === 'name' || sortBy === 'eta') return null;
 
     const groups: Record<string, typeof sortedPrinters> = {};
 
@@ -8207,6 +8229,7 @@ export function PrintersPage() {
             { value: 'status', label: t('printers.sort.status') },
             { value: 'model', label: t('printers.sort.model') },
             { value: 'location', label: t('printers.sort.location') },
+            { value: 'eta', label: t('printers.sort.eta') },
           ]}
         />
         <button

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini