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.
This commit is contained in:
maziggy
2026-06-28 12:42:05 +02:00
20 changed files with 658 additions and 38 deletions
+8
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -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"
@@ -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
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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