mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Add background print dispatching + tests
This commit is contained in:
@@ -2389,7 +2389,8 @@ async def get_archive_plates(
|
||||
for gf in gcode_files:
|
||||
# "Metadata/plate_5.gcode" -> 5
|
||||
try:
|
||||
plate_str = gf[15:-6] # Remove "Metadata/plate_" and ".gcode"
|
||||
# Remove "Metadata/plate_" and ".gcode"
|
||||
plate_str = gf[15:-6]
|
||||
plate_indices.append(int(plate_str))
|
||||
except ValueError:
|
||||
pass # Skip gcode file with non-numeric plate index
|
||||
@@ -2792,14 +2793,9 @@ async def reprint_archive(
|
||||
)
|
||||
),
|
||||
):
|
||||
"""Send an archived 3MF file to a printer and start printing."""
|
||||
from backend.app.main import register_expected_print
|
||||
"""Dispatch an archived 3MF file for send/start on a printer."""
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_ftp import (
|
||||
get_ftp_retry_settings,
|
||||
upload_file_async,
|
||||
with_ftp_retry,
|
||||
)
|
||||
from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
user, can_modify_all = auth_result
|
||||
@@ -2829,139 +2825,54 @@ async def reprint_archive(
|
||||
if not printer_manager.is_connected(printer_id):
|
||||
raise HTTPException(400, "Printer is not connected")
|
||||
|
||||
# Get the sliced 3MF file path
|
||||
if not archive.file_path:
|
||||
raise HTTPException(
|
||||
404,
|
||||
"No 3MF file available for this archive. "
|
||||
"The file could not be downloaded from the printer when the print was recorded.",
|
||||
)
|
||||
|
||||
# Validate archive file exists
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(404, "Archive file not found")
|
||||
|
||||
# Upload file to printer via FTP
|
||||
from backend.app.services.bambu_ftp import delete_file_async
|
||||
plate_name = body.plate_name
|
||||
if not plate_name and body.plate_id is not None:
|
||||
plate_name = f"Plate {body.plate_id}"
|
||||
|
||||
# Use a clean filename to avoid issues with double extensions like .gcode.3mf
|
||||
# The printer might reject filenames with unusual extensions
|
||||
base_name = archive.filename
|
||||
if base_name.endswith(".gcode.3mf"):
|
||||
base_name = base_name[:-10] # Remove .gcode.3mf
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4] # Remove .3mf
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
remote_path = f"/{remote_filename}"
|
||||
dispatch_source_name = archive.filename
|
||||
if plate_name:
|
||||
dispatch_source_name = f"{archive.filename} • {plate_name}"
|
||||
|
||||
# Get FTP retry settings
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
try:
|
||||
dispatch_result = await background_dispatch.dispatch_reprint_archive(
|
||||
archive_id=archive_id,
|
||||
archive_name=dispatch_source_name,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer.name,
|
||||
options=body.model_dump(exclude_none=True),
|
||||
requested_by_user_id=user.id if user else None,
|
||||
requested_by_username=user.username if user else None,
|
||||
)
|
||||
except DispatchEnqueueRejected as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
|
||||
logger.info(
|
||||
f"Reprint FTP upload starting: printer={printer.name} ({printer.model}), "
|
||||
f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
|
||||
f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
|
||||
)
|
||||
|
||||
# Delete existing file if present (avoids 553 error)
|
||||
logger.debug("Deleting existing file %s if present...", remote_path)
|
||||
delete_result = await delete_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
)
|
||||
logger.debug("Delete result: %s", delete_result)
|
||||
|
||||
if ftp_retry_enabled:
|
||||
uploaded = await with_ftp_retry(
|
||||
upload_file_async,
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
max_retries=ftp_retry_count,
|
||||
retry_delay=ftp_retry_delay,
|
||||
operation_name=f"Upload for reprint to {printer.name}",
|
||||
)
|
||||
else:
|
||||
uploaded = await upload_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
)
|
||||
|
||||
if not uploaded:
|
||||
logger.error(
|
||||
f"FTP upload failed for reprint: printer={printer.name}, model={printer.model}, "
|
||||
f"ip={printer.ip_address}, file={remote_filename}. "
|
||||
"Check logs above for storage diagnostics and specific error codes."
|
||||
)
|
||||
raise HTTPException(
|
||||
500,
|
||||
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
|
||||
"See server logs for detailed diagnostics.",
|
||||
)
|
||||
|
||||
# Register this as an expected print so we don't create a duplicate archive
|
||||
register_expected_print(printer_id, remote_filename, archive_id, ams_mapping=body.ams_mapping)
|
||||
|
||||
# Use plate_id from request if provided, otherwise auto-detect from 3MF file
|
||||
if body.plate_id is not None:
|
||||
plate_id = body.plate_id
|
||||
else:
|
||||
# Auto-detect plate ID from 3MF file (legacy behavior for single-plate files)
|
||||
plate_id = 1
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
|
||||
# Extract plate number from "Metadata/plate_X.gcode"
|
||||
plate_str = name[15:-6] # Remove "Metadata/plate_" and ".gcode"
|
||||
plate_id = int(plate_str)
|
||||
break
|
||||
except (ValueError, zipfile.BadZipFile, OSError):
|
||||
pass # Default to plate 1 if detection fails
|
||||
|
||||
logger.info(
|
||||
f"Reprint archive {archive_id}: plate_id={plate_id}, "
|
||||
f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}, "
|
||||
f"flow_cali={body.flow_cali}, vibration_cali={body.vibration_cali}, "
|
||||
f"layer_inspect={body.layer_inspect}, timelapse={body.timelapse}"
|
||||
)
|
||||
|
||||
# Start the print with options
|
||||
started = printer_manager.start_print(
|
||||
"Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)",
|
||||
archive_id,
|
||||
printer_id,
|
||||
remote_filename,
|
||||
plate_id,
|
||||
ams_mapping=body.ams_mapping,
|
||||
timelapse=body.timelapse,
|
||||
bed_levelling=body.bed_levelling,
|
||||
flow_cali=body.flow_cali,
|
||||
vibration_cali=body.vibration_cali,
|
||||
layer_inspect=body.layer_inspect,
|
||||
use_ams=body.use_ams,
|
||||
dispatch_result["dispatch_job_id"],
|
||||
dispatch_result["dispatch_position"],
|
||||
)
|
||||
|
||||
if not started:
|
||||
raise HTTPException(500, "Failed to start print")
|
||||
|
||||
# Track who started this print (Issue #206)
|
||||
if user:
|
||||
printer_manager.set_current_print_user(printer_id, user.id, user.username)
|
||||
logger.info("Reprint started by user: %s", user.username)
|
||||
|
||||
return {
|
||||
"status": "printing",
|
||||
"status": "dispatched",
|
||||
"printer_id": printer_id,
|
||||
"archive_id": archive_id,
|
||||
"filename": archive.filename,
|
||||
"dispatch_job_id": dispatch_result["dispatch_job_id"],
|
||||
"dispatch_position": dispatch_result["dispatch_position"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend.app.core.auth import RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
from backend.app.services.background_dispatch import background_dispatch
|
||||
|
||||
router = APIRouter(prefix="/background-dispatch", tags=["background-dispatch"])
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
async def cancel_dispatch_job(
|
||||
job_id: int,
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
|
||||
):
|
||||
"""Cancel a background-dispatch job.
|
||||
|
||||
Queued jobs are cancelled immediately. Active jobs are marked for
|
||||
cooperative cancellation and will stop at the next cancellation checkpoint.
|
||||
"""
|
||||
result = await background_dispatch.cancel_job(job_id)
|
||||
|
||||
if not result["cancelled"]:
|
||||
raise HTTPException(status_code=404, detail="Dispatch job not found")
|
||||
|
||||
return {
|
||||
"status": "cancelling" if result.get("pending") else "cancelled",
|
||||
"job_id": result["job_id"],
|
||||
"source_name": result["source_name"],
|
||||
"printer_id": result["printer_id"],
|
||||
"printer_name": result["printer_name"],
|
||||
}
|
||||
@@ -54,7 +54,7 @@ from backend.app.schemas.library import (
|
||||
ZipExtractResponse,
|
||||
ZipExtractResult,
|
||||
)
|
||||
from backend.app.services.archive import ArchiveService, ThreeMFParser
|
||||
from backend.app.services.archive import ThreeMFParser
|
||||
from backend.app.services.stl_thumbnail import generate_stl_thumbnail
|
||||
from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
|
||||
|
||||
@@ -1737,23 +1737,15 @@ async def print_library_file(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
|
||||
):
|
||||
"""Print a library file directly.
|
||||
"""Dispatch a library file for send/start on a printer.
|
||||
|
||||
This endpoint:
|
||||
1. Creates an archive from the library file
|
||||
2. Uploads the file to the printer
|
||||
3. Starts the print
|
||||
The actual send/start work is handled asynchronously by background
|
||||
dispatch so the UI can continue immediately.
|
||||
|
||||
Only sliced files (.gcode or .gcode.3mf) can be printed.
|
||||
"""
|
||||
from backend.app.main import register_expected_print
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_ftp import (
|
||||
delete_file_async,
|
||||
get_ftp_retry_settings,
|
||||
upload_file_async,
|
||||
with_ftp_retry,
|
||||
)
|
||||
from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
# Use defaults if no body provided
|
||||
@@ -1790,131 +1782,34 @@ async def print_library_file(
|
||||
if not printer_manager.is_connected(printer_id):
|
||||
raise HTTPException(status_code=400, detail="Printer is not connected")
|
||||
|
||||
# Create archive from the library file
|
||||
archive_service = ArchiveService(db)
|
||||
archive = await archive_service.archive_print(
|
||||
printer_id=printer_id,
|
||||
source_file=file_path,
|
||||
original_filename=lib_file.filename,
|
||||
)
|
||||
plate_name = body.plate_name
|
||||
if not plate_name and body.plate_id is not None:
|
||||
plate_name = f"Plate {body.plate_id}"
|
||||
|
||||
if not archive:
|
||||
raise HTTPException(status_code=500, detail="Failed to create archive")
|
||||
dispatch_source_name = lib_file.filename
|
||||
if plate_name:
|
||||
dispatch_source_name = f"{lib_file.filename} • {plate_name}"
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Prepare remote filename
|
||||
base_name = lib_file.filename
|
||||
if base_name.endswith(".gcode.3mf"):
|
||||
base_name = base_name[:-10]
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4]
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
remote_path = f"/{remote_filename}"
|
||||
|
||||
# Get FTP retry settings
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
|
||||
logger.info(
|
||||
f"Library print FTP upload starting: printer={printer.name} ({printer.model}), "
|
||||
f"ip={printer.ip_address}, file={remote_filename}, local_path={file_path}, "
|
||||
f"retry_enabled={ftp_retry_enabled}, retry_count={ftp_retry_count}, timeout={ftp_timeout}"
|
||||
)
|
||||
|
||||
# Delete existing file if present (avoids 553 error)
|
||||
logger.debug("Deleting existing file %s if present...", remote_path)
|
||||
delete_result = await delete_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
)
|
||||
logger.debug("Delete result: %s", delete_result)
|
||||
|
||||
# Upload file to printer
|
||||
if ftp_retry_enabled:
|
||||
uploaded = await with_ftp_retry(
|
||||
upload_file_async,
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
max_retries=ftp_retry_count,
|
||||
retry_delay=ftp_retry_delay,
|
||||
operation_name=f"Upload for print to {printer.name}",
|
||||
try:
|
||||
dispatch_result = await background_dispatch.dispatch_print_library_file(
|
||||
file_id=file_id,
|
||||
filename=dispatch_source_name,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer.name,
|
||||
options=body.model_dump(exclude_none=True),
|
||||
requested_by_user_id=None,
|
||||
requested_by_username=None,
|
||||
)
|
||||
else:
|
||||
uploaded = await upload_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer.model,
|
||||
)
|
||||
|
||||
if not uploaded:
|
||||
logger.error(
|
||||
f"FTP upload failed for library print: printer={printer.name}, model={printer.model}, "
|
||||
f"ip={printer.ip_address}, file={remote_filename}. "
|
||||
"Check logs above for storage diagnostics and specific error codes."
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "
|
||||
"See server logs for detailed diagnostics.",
|
||||
)
|
||||
|
||||
# Register this as an expected print so we don't create a duplicate archive
|
||||
register_expected_print(printer_id, remote_filename, archive.id, ams_mapping=body.ams_mapping)
|
||||
|
||||
# Determine plate ID
|
||||
if body.plate_id is not None:
|
||||
plate_id = body.plate_id
|
||||
else:
|
||||
plate_id = 1
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
|
||||
plate_str = name[15:-6]
|
||||
plate_id = int(plate_str)
|
||||
break
|
||||
except (ValueError, zipfile.BadZipFile, OSError):
|
||||
pass # Default plate_id=1 if archive is unreadable or has no gcode
|
||||
|
||||
logger.info(
|
||||
f"Print library file {file_id}: archive_id={archive.id}, plate_id={plate_id}, "
|
||||
f"ams_mapping={body.ams_mapping}, bed_levelling={body.bed_levelling}"
|
||||
)
|
||||
|
||||
# Start the print
|
||||
started = printer_manager.start_print(
|
||||
printer_id,
|
||||
remote_filename,
|
||||
plate_id,
|
||||
ams_mapping=body.ams_mapping,
|
||||
timelapse=body.timelapse,
|
||||
bed_levelling=body.bed_levelling,
|
||||
flow_cali=body.flow_cali,
|
||||
vibration_cali=body.vibration_cali,
|
||||
layer_inspect=body.layer_inspect,
|
||||
use_ams=body.use_ams,
|
||||
)
|
||||
|
||||
if not started:
|
||||
raise HTTPException(status_code=500, detail="Failed to start print")
|
||||
|
||||
await db.commit()
|
||||
except DispatchEnqueueRejected as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
|
||||
return {
|
||||
"status": "printing",
|
||||
"status": "dispatched",
|
||||
"printer_id": printer_id,
|
||||
"archive_id": archive.id,
|
||||
"archive_id": None,
|
||||
"filename": lib_file.filename,
|
||||
"dispatch_job_id": dispatch_result["dispatch_job_id"],
|
||||
"dispatch_position": dispatch_result["dispatch_position"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.services.background_dispatch import background_dispatch
|
||||
from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,6 +28,15 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
"data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
|
||||
}
|
||||
)
|
||||
|
||||
dispatch_state = await background_dispatch.get_state()
|
||||
if (dispatch_state.get("dispatched", 0) + dispatch_state.get("processing", 0)) > 0:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "background_dispatch",
|
||||
"data": dispatch_state,
|
||||
}
|
||||
)
|
||||
logger.info("Sent initial status for %s printers", len(statuses))
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
|
||||
+80
-8
@@ -5,6 +5,75 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import delete, or_, select
|
||||
|
||||
from backend.app.api.routes import (
|
||||
ams_history,
|
||||
api_keys,
|
||||
archives,
|
||||
auth,
|
||||
background_dispatch as background_dispatch_routes,
|
||||
camera,
|
||||
cloud,
|
||||
discovery,
|
||||
external_links,
|
||||
filaments,
|
||||
firmware,
|
||||
github_backup,
|
||||
groups,
|
||||
kprofiles,
|
||||
library,
|
||||
local_presets,
|
||||
maintenance,
|
||||
metrics,
|
||||
notification_templates,
|
||||
notifications,
|
||||
pending_uploads,
|
||||
print_queue,
|
||||
printers,
|
||||
projects,
|
||||
settings as settings_routes,
|
||||
smart_plugs,
|
||||
spoolman,
|
||||
support,
|
||||
system,
|
||||
updates,
|
||||
users,
|
||||
webhook,
|
||||
websocket,
|
||||
)
|
||||
from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
|
||||
from backend.app.api.routes.support import init_debug_logging
|
||||
from backend.app.core.config import APP_VERSION, settings as app_settings
|
||||
from backend.app.core.database import async_session, init_db
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.background_dispatch import background_dispatch
|
||||
from backend.app.services.bambu_ftp import download_file_async, get_ftp_retry_settings, with_ftp_retry
|
||||
from backend.app.services.bambu_mqtt import PrinterState
|
||||
from backend.app.services.github_backup import github_backup_service
|
||||
from backend.app.services.homeassistant import homeassistant_service
|
||||
from backend.app.services.mqtt_relay import mqtt_relay
|
||||
from backend.app.services.notification_service import notification_service
|
||||
from backend.app.services.print_scheduler import scheduler as print_scheduler
|
||||
from backend.app.services.printer_manager import (
|
||||
init_printer_connections,
|
||||
printer_manager,
|
||||
printer_state_to_dict,
|
||||
)
|
||||
from backend.app.services.smart_plug_manager import smart_plug_manager
|
||||
from backend.app.services.spoolman import close_spoolman_client, get_spoolman_client, init_spoolman_client
|
||||
from backend.app.services.spoolman_tracking import (
|
||||
cleanup_tracking as _cleanup_spoolman_tracking,
|
||||
report_usage as _report_spoolman_usage,
|
||||
store_print_data as _store_spoolman_print_data,
|
||||
)
|
||||
from backend.app.services.tasmota import tasmota_service
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Dependency Check - runs before other imports to give helpful error messages
|
||||
@@ -126,10 +195,8 @@ def check_dependencies():
|
||||
check_dependencies()
|
||||
# =============================================================================
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
# Import settings first for logging configuration
|
||||
from backend.app.core.config import APP_VERSION, settings as app_settings
|
||||
|
||||
# Configure logging based on settings
|
||||
# DEBUG=true -> DEBUG level, else use LOG_LEVEL setting
|
||||
@@ -169,9 +236,6 @@ if not app_settings.debug:
|
||||
logging.getLogger("paho.mqtt").setLevel(logging.WARNING)
|
||||
|
||||
logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, log_level_str)
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import delete, or_, select
|
||||
|
||||
from backend.app.api.routes import (
|
||||
ams_history,
|
||||
@@ -320,7 +384,8 @@ def register_expected_print(printer_id: int, filename: str, archive_id: int, ams
|
||||
|
||||
|
||||
_last_status_broadcast: dict[int, str] = {}
|
||||
_nozzle_count_updated: set[int] = set() # Track printers where we've updated nozzle_count
|
||||
# Track printers where we've updated nozzle_count
|
||||
_nozzle_count_updated: set[int] = set()
|
||||
|
||||
|
||||
async def on_printer_status_change(printer_id: int, state: PrinterState):
|
||||
@@ -2848,7 +2913,8 @@ _ams_history_task: asyncio.Task | None = None
|
||||
AMS_HISTORY_INTERVAL = 300 # Record every 5 minutes
|
||||
AMS_HISTORY_RETENTION_DAYS = 30 # Keep data for 30 days
|
||||
_ams_cleanup_counter = 0 # Track recordings to trigger periodic cleanup
|
||||
_ams_alarm_cooldown: dict[str, datetime] = {} # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
|
||||
# Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
|
||||
_ams_alarm_cooldown: dict[str, datetime] = {}
|
||||
AMS_ALARM_COOLDOWN_MINUTES = 60 # Don't send same alarm more than once per hour
|
||||
|
||||
|
||||
@@ -3232,6 +3298,9 @@ async def lifespan(app: FastAPI):
|
||||
# Start the print scheduler
|
||||
asyncio.create_task(print_scheduler.run())
|
||||
|
||||
# Start background dispatch worker for send/start operations
|
||||
await background_dispatch.start()
|
||||
|
||||
# Start the smart plug scheduler for time-based on/off
|
||||
smart_plug_manager.start_scheduler()
|
||||
|
||||
@@ -3264,6 +3333,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
print_scheduler.stop()
|
||||
await background_dispatch.stop()
|
||||
smart_plug_manager.stop_scheduler()
|
||||
notification_service.stop_digest_scheduler()
|
||||
github_backup_service.stop_scheduler()
|
||||
@@ -3297,7 +3367,8 @@ PUBLIC_API_ROUTES = {
|
||||
"/api/v1/auth/status",
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/setup", # Needed for initial setup and recovery
|
||||
"/api/v1/auth/advanced-auth/status", # Advanced auth status needed for login page
|
||||
# Advanced auth status needed for login page
|
||||
"/api/v1/auth/advanced-auth/status",
|
||||
"/api/v1/auth/forgot-password", # Password reset for advanced auth
|
||||
# Version check for updates (no sensitive data)
|
||||
"/api/v1/updates/version",
|
||||
@@ -3449,6 +3520,7 @@ app.include_router(local_presets.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(print_log.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(print_queue.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(background_dispatch_routes.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(notifications.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
|
||||
|
||||
@@ -11,13 +11,15 @@ class ArchiveBase(BaseModel):
|
||||
cost: float | None = None
|
||||
failure_reason: str | None = None
|
||||
quantity: int | None = None # Number of items printed
|
||||
external_url: str | None = None # User-defined link (Printables, Thingiverse, etc.)
|
||||
# User-defined link (Printables, Thingiverse, etc.)
|
||||
external_url: str | None = None
|
||||
|
||||
|
||||
class ArchiveUpdate(ArchiveBase):
|
||||
printer_id: int | None = None
|
||||
project_id: int | None = None
|
||||
status: str | None = None # Allow changing status (e.g., clearing failed flag)
|
||||
# Allow changing status (e.g., clearing failed flag)
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class ArchiveDuplicate(BaseModel):
|
||||
@@ -53,7 +55,8 @@ class ArchiveResponse(BaseModel):
|
||||
print_name: str | None
|
||||
print_time_seconds: int | None # Estimated time from slicer
|
||||
actual_time_seconds: int | None = None # Computed from started_at/completed_at
|
||||
time_accuracy: float | None = None # Percentage: 100 = perfect, >100 = faster than estimated
|
||||
# Percentage: 100 = perfect, >100 = faster than estimated
|
||||
time_accuracy: float | None = None
|
||||
filament_used_grams: float | None
|
||||
filament_type: str | None
|
||||
filament_color: str | None
|
||||
@@ -73,7 +76,8 @@ class ArchiveResponse(BaseModel):
|
||||
|
||||
makerworld_url: str | None
|
||||
designer: str | None
|
||||
external_url: str | None = None # User-defined link (Printables, Thingiverse, etc.)
|
||||
# User-defined link (Printables, Thingiverse, etc.)
|
||||
external_url: str | None = None
|
||||
|
||||
is_favorite: bool
|
||||
tags: str | None
|
||||
@@ -116,7 +120,8 @@ class ArchiveStats(BaseModel):
|
||||
prints_by_filament_type: dict
|
||||
prints_by_printer: dict
|
||||
# Time accuracy stats
|
||||
average_time_accuracy: float | None = None # Average across all prints with data
|
||||
# Average across all prints with data
|
||||
average_time_accuracy: float | None = None
|
||||
time_accuracy_by_printer: dict | None = None # Per-printer accuracy
|
||||
# Energy stats
|
||||
total_energy_kwh: float = 0.0
|
||||
@@ -181,6 +186,7 @@ class ReprintRequest(BaseModel):
|
||||
# Plate selection for multi-plate 3MF files
|
||||
# If not specified, auto-detects from file (legacy behavior for single-plate files)
|
||||
plate_id: int | None = None
|
||||
plate_name: str | None = None
|
||||
|
||||
# AMS slot mapping: list of tray IDs for each filament slot in the 3MF
|
||||
# Global tray ID = (ams_id * 4) + slot_id, external = 254
|
||||
|
||||
@@ -181,6 +181,7 @@ class FilePrintRequest(BaseModel):
|
||||
|
||||
# Print options (same as archive reprint)
|
||||
plate_id: int | None = None
|
||||
plate_name: str | None = None
|
||||
ams_mapping: list[int] | None = None
|
||||
bed_levelling: bool = True
|
||||
flow_cali: bool = False
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
"""Background dispatch for print/reprint jobs.
|
||||
|
||||
This service is separate from the app's print queue feature. It exists only to
|
||||
decouple "send/start print" operations (FTP upload + start command) from API
|
||||
request latency so the UI can continue immediately after dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import zipfile
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.database import async_session
|
||||
from backend.app.core.websocket import ws_manager
|
||||
from backend.app.models.library import LibraryFile
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.archive import ArchiveService
|
||||
from backend.app.services.bambu_ftp import (
|
||||
delete_file_async,
|
||||
get_ftp_retry_settings,
|
||||
upload_file_async,
|
||||
with_ftp_retry,
|
||||
)
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DispatchJobCancelled(Exception):
|
||||
"""Raised when a dispatch job is cancelled by the user."""
|
||||
|
||||
|
||||
class DispatchEnqueueRejected(Exception):
|
||||
"""Raised when a dispatch job should not be accepted."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PrintDispatchJob:
|
||||
id: int
|
||||
kind: Literal["reprint_archive", "print_library_file"]
|
||||
source_id: int
|
||||
source_name: str
|
||||
printer_id: int
|
||||
printer_name: str
|
||||
options: dict[str, Any] = field(default_factory=dict)
|
||||
requested_by_user_id: int | None = None
|
||||
requested_by_username: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ActiveDispatchState:
|
||||
job: PrintDispatchJob
|
||||
message: str
|
||||
upload_bytes: int | None = None
|
||||
upload_total_bytes: int | None = None
|
||||
|
||||
|
||||
class BackgroundDispatchService:
|
||||
def __init__(self):
|
||||
self._queued_jobs: deque[PrintDispatchJob] = deque()
|
||||
self._dispatcher_task: asyncio.Task | None = None
|
||||
self._running_tasks: dict[int, asyncio.Task] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
self._job_event = asyncio.Event()
|
||||
self._next_job_id = 1
|
||||
self._active_jobs: dict[int, ActiveDispatchState] = {}
|
||||
self._cancel_requested_job_ids: set[int] = set()
|
||||
|
||||
# Progress for the current "batch" (since queue became non-empty)
|
||||
self._batch_total = 0
|
||||
self._batch_completed = 0
|
||||
self._batch_failed = 0
|
||||
|
||||
@staticmethod
|
||||
def _printer_is_busy_printing(printer_id: int) -> bool:
|
||||
state = printer_manager.get_status(printer_id)
|
||||
if not state:
|
||||
return False
|
||||
return state.state in ("RUNNING", "PAUSE", "PAUSED") and bool(state.gcode_file)
|
||||
|
||||
async def start(self):
|
||||
async with self._lock:
|
||||
if self._dispatcher_task and not self._dispatcher_task.done():
|
||||
return
|
||||
self._dispatcher_task = asyncio.create_task(self._dispatcher_loop(), name="background-dispatch-dispatcher")
|
||||
logger.info("Background dispatch dispatcher started")
|
||||
|
||||
async def stop(self):
|
||||
dispatcher: asyncio.Task | None = None
|
||||
running_tasks: list[asyncio.Task] = []
|
||||
async with self._lock:
|
||||
dispatcher = self._dispatcher_task
|
||||
self._dispatcher_task = None
|
||||
running_tasks = list(self._running_tasks.values())
|
||||
self._running_tasks.clear()
|
||||
self._active_jobs.clear()
|
||||
self._queued_jobs.clear()
|
||||
self._cancel_requested_job_ids.clear()
|
||||
self._job_event.set()
|
||||
|
||||
if dispatcher:
|
||||
dispatcher.cancel()
|
||||
for task in running_tasks:
|
||||
task.cancel()
|
||||
|
||||
if dispatcher:
|
||||
try:
|
||||
await dispatcher
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if running_tasks:
|
||||
await asyncio.gather(*running_tasks, return_exceptions=True)
|
||||
|
||||
logger.info("Background dispatch dispatcher stopped")
|
||||
|
||||
async def dispatch_reprint_archive(
|
||||
self,
|
||||
*,
|
||||
archive_id: int,
|
||||
archive_name: str,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
options: dict[str, Any],
|
||||
requested_by_user_id: int | None,
|
||||
requested_by_username: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._dispatch(
|
||||
kind="reprint_archive",
|
||||
source_id=archive_id,
|
||||
source_name=archive_name,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
options=options,
|
||||
requested_by_user_id=requested_by_user_id,
|
||||
requested_by_username=requested_by_username,
|
||||
)
|
||||
|
||||
async def get_state(self) -> dict[str, Any]:
|
||||
"""Get current dispatch queue state snapshot for newly connected clients."""
|
||||
async with self._lock:
|
||||
return self._build_state_payload_unlocked()
|
||||
|
||||
async def dispatch_print_library_file(
|
||||
self,
|
||||
*,
|
||||
file_id: int,
|
||||
filename: str,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
options: dict[str, Any],
|
||||
requested_by_user_id: int | None,
|
||||
requested_by_username: str | None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._dispatch(
|
||||
kind="print_library_file",
|
||||
source_id=file_id,
|
||||
source_name=filename,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
options=options,
|
||||
requested_by_user_id=requested_by_user_id,
|
||||
requested_by_username=requested_by_username,
|
||||
)
|
||||
|
||||
async def cancel_job(self, job_id: int) -> dict[str, Any]:
|
||||
"""Cancel a queued dispatch job.
|
||||
|
||||
Queued jobs are removed immediately. Active jobs are cancelled
|
||||
cooperatively and will stop at the next cancellation checkpoint.
|
||||
"""
|
||||
active_cancel_payload: dict[str, Any] | None = None
|
||||
active_cancel_result: dict[str, Any] | None = None
|
||||
|
||||
async with self._lock:
|
||||
active_state = self._active_jobs.get(job_id)
|
||||
if active_state is not None:
|
||||
logger.info("Cancel requested for active dispatch job %s", job_id)
|
||||
self._cancel_requested_job_ids.add(job_id)
|
||||
active_job = active_state.job
|
||||
active_cancel_payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "cancelling",
|
||||
"job_id": active_job.id,
|
||||
"source_name": active_job.source_name,
|
||||
"printer_id": active_job.printer_id,
|
||||
"printer_name": active_job.printer_name,
|
||||
"message": "Cancelling current dispatch...",
|
||||
}
|
||||
)
|
||||
active_cancel_result = {
|
||||
"cancelled": True,
|
||||
"pending": True,
|
||||
"job_id": active_job.id,
|
||||
"source_name": active_job.source_name,
|
||||
"printer_id": active_job.printer_id,
|
||||
"printer_name": active_job.printer_name,
|
||||
}
|
||||
|
||||
if active_cancel_payload and active_cancel_result:
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": active_cancel_payload})
|
||||
return active_cancel_result
|
||||
|
||||
async with self._lock:
|
||||
cancelled_job: PrintDispatchJob | None = None
|
||||
for job in self._queued_jobs:
|
||||
if job.id == job_id:
|
||||
cancelled_job = job
|
||||
break
|
||||
|
||||
if not cancelled_job:
|
||||
logger.info("Cancel requested for unknown dispatch job %s", job_id)
|
||||
return {"cancelled": False, "reason": "not_found"}
|
||||
|
||||
self._queued_jobs.remove(cancelled_job)
|
||||
logger.info("Cancelled queued dispatch job %s", cancelled_job.id)
|
||||
self._batch_total = max(0, self._batch_total - 1)
|
||||
|
||||
if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
|
||||
self._batch_completed = 0
|
||||
self._batch_failed = 0
|
||||
|
||||
payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "cancelled",
|
||||
"job_id": cancelled_job.id,
|
||||
"source_name": cancelled_job.source_name,
|
||||
"printer_id": cancelled_job.printer_id,
|
||||
"printer_name": cancelled_job.printer_name,
|
||||
"message": "Cancelled from queue",
|
||||
}
|
||||
)
|
||||
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
|
||||
return {
|
||||
"cancelled": True,
|
||||
"pending": False,
|
||||
"job_id": cancelled_job.id,
|
||||
"source_name": cancelled_job.source_name,
|
||||
"printer_id": cancelled_job.printer_id,
|
||||
"printer_name": cancelled_job.printer_name,
|
||||
}
|
||||
|
||||
async def _dispatch(
|
||||
self,
|
||||
*,
|
||||
kind: Literal["reprint_archive", "print_library_file"],
|
||||
source_id: int,
|
||||
source_name: str,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
options: dict[str, Any],
|
||||
requested_by_user_id: int | None,
|
||||
requested_by_username: str | None,
|
||||
) -> dict[str, Any]:
|
||||
async with self._lock:
|
||||
has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
|
||||
has_active_for_printer = any(active.job.printer_id == printer_id for active in self._active_jobs.values())
|
||||
|
||||
if has_pending_for_printer or has_active_for_printer:
|
||||
raise DispatchEnqueueRejected(f"Printer {printer_name} already has a background dispatch in progress")
|
||||
|
||||
if self._printer_is_busy_printing(printer_id):
|
||||
raise DispatchEnqueueRejected(f"Printer {printer_name} is currently busy printing")
|
||||
|
||||
dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1
|
||||
job = PrintDispatchJob(
|
||||
id=self._next_job_id,
|
||||
kind=kind,
|
||||
source_id=source_id,
|
||||
source_name=source_name,
|
||||
printer_id=printer_id,
|
||||
printer_name=printer_name,
|
||||
options=options,
|
||||
requested_by_user_id=requested_by_user_id,
|
||||
requested_by_username=requested_by_username,
|
||||
)
|
||||
self._next_job_id += 1
|
||||
self._batch_total += 1
|
||||
self._queued_jobs.append(job)
|
||||
self._job_event.set()
|
||||
|
||||
payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "dispatched",
|
||||
"job_id": job.id,
|
||||
"source_name": source_name,
|
||||
"printer_id": printer_id,
|
||||
"printer_name": printer_name,
|
||||
"message": f"Dispatched to {printer_name}",
|
||||
}
|
||||
)
|
||||
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
|
||||
|
||||
return {
|
||||
"dispatch_job_id": job.id,
|
||||
"dispatch_position": dispatch_position,
|
||||
"status": "dispatched",
|
||||
"printer_id": printer_id,
|
||||
"source_id": source_id,
|
||||
"source_name": source_name,
|
||||
}
|
||||
|
||||
async def _dispatcher_loop(self):
|
||||
while True:
|
||||
await self._job_event.wait()
|
||||
self._job_event.clear()
|
||||
|
||||
while True:
|
||||
payload: dict[str, Any] | None = None
|
||||
job_to_start: PrintDispatchJob | None = None
|
||||
async with self._lock:
|
||||
busy_printer_ids = {state.job.printer_id for state in self._active_jobs.values()}
|
||||
start_index = next(
|
||||
(
|
||||
idx
|
||||
for idx, queued_job in enumerate(self._queued_jobs)
|
||||
if queued_job.printer_id not in busy_printer_ids
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if start_index is None:
|
||||
break
|
||||
|
||||
job_to_start = self._queued_jobs[start_index]
|
||||
del self._queued_jobs[start_index]
|
||||
self._active_jobs[job_to_start.id] = ActiveDispatchState(
|
||||
job=job_to_start,
|
||||
message="Preparing background dispatch...",
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._run_active_job(job_to_start), name=f"background-dispatch-job-{job_to_start.id}"
|
||||
)
|
||||
self._running_tasks[job_to_start.id] = task
|
||||
|
||||
payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "processing",
|
||||
"job_id": job_to_start.id,
|
||||
"source_name": job_to_start.source_name,
|
||||
"printer_id": job_to_start.printer_id,
|
||||
"printer_name": job_to_start.printer_name,
|
||||
"message": "Preparing background dispatch...",
|
||||
}
|
||||
)
|
||||
|
||||
if payload:
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
|
||||
|
||||
async def _run_active_job(self, job: PrintDispatchJob):
|
||||
try:
|
||||
await self._process_job(job)
|
||||
await self._mark_job_finished(job, failed=False, message="Background dispatch complete")
|
||||
except DispatchJobCancelled:
|
||||
await self._mark_job_cancelled(job)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Background dispatch job %s failed: %s", job.id, e, exc_info=True)
|
||||
await self._mark_job_finished(job, failed=True, message=str(e))
|
||||
finally:
|
||||
self._job_event.set()
|
||||
|
||||
async def _set_active_message(self, job: PrintDispatchJob, message: str):
|
||||
async with self._lock:
|
||||
active = self._active_jobs.get(job.id)
|
||||
if not active:
|
||||
return
|
||||
active.message = message
|
||||
payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "processing",
|
||||
"job_id": active.job.id,
|
||||
"source_name": active.job.source_name,
|
||||
"printer_id": active.job.printer_id,
|
||||
"printer_name": active.job.printer_name,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
|
||||
|
||||
async def _set_active_upload_progress(self, job: PrintDispatchJob, uploaded: int, total: int):
|
||||
async with self._lock:
|
||||
active = self._active_jobs.get(job.id)
|
||||
if not active:
|
||||
return
|
||||
|
||||
active.upload_bytes = max(0, int(uploaded))
|
||||
active.upload_total_bytes = max(0, int(total))
|
||||
payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "processing",
|
||||
"job_id": active.job.id,
|
||||
"source_name": active.job.source_name,
|
||||
"printer_id": active.job.printer_id,
|
||||
"printer_name": active.job.printer_name,
|
||||
"message": active.message,
|
||||
}
|
||||
)
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
|
||||
|
||||
async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):
|
||||
async with self._lock:
|
||||
if failed:
|
||||
self._batch_failed += 1
|
||||
else:
|
||||
self._batch_completed += 1
|
||||
|
||||
self._active_jobs.pop(job.id, None)
|
||||
self._running_tasks.pop(job.id, None)
|
||||
self._cancel_requested_job_ids.discard(job.id)
|
||||
|
||||
payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "failed" if failed else "completed",
|
||||
"job_id": job.id,
|
||||
"source_name": job.source_name,
|
||||
"printer_id": job.printer_id,
|
||||
"printer_name": job.printer_name,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
should_reset_batch = len(self._queued_jobs) == 0 and len(self._active_jobs) == 0
|
||||
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
|
||||
|
||||
if should_reset_batch:
|
||||
async with self._lock:
|
||||
self._batch_total = 0
|
||||
self._batch_completed = 0
|
||||
self._batch_failed = 0
|
||||
|
||||
async def _mark_job_cancelled(self, job: PrintDispatchJob):
|
||||
async with self._lock:
|
||||
self._active_jobs.pop(job.id, None)
|
||||
self._running_tasks.pop(job.id, None)
|
||||
self._cancel_requested_job_ids.discard(job.id)
|
||||
self._batch_total = max(0, self._batch_total - 1)
|
||||
|
||||
if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
|
||||
self._batch_completed = 0
|
||||
self._batch_failed = 0
|
||||
|
||||
payload = self._build_state_payload_unlocked(
|
||||
recent_event={
|
||||
"status": "cancelled",
|
||||
"job_id": job.id,
|
||||
"source_name": job.source_name,
|
||||
"printer_id": job.printer_id,
|
||||
"printer_name": job.printer_name,
|
||||
"message": "Cancelled during dispatch",
|
||||
}
|
||||
)
|
||||
|
||||
await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
|
||||
|
||||
def _is_cancel_requested(self, job_id: int) -> bool:
|
||||
return job_id in self._cancel_requested_job_ids
|
||||
|
||||
def _raise_if_cancel_requested(self, job: PrintDispatchJob):
|
||||
if self._is_cancel_requested(job.id):
|
||||
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
|
||||
|
||||
def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
processing = len(self._active_jobs)
|
||||
dispatched = len(self._queued_jobs)
|
||||
|
||||
dispatched_jobs = [
|
||||
{
|
||||
"job_id": job.id,
|
||||
"kind": job.kind,
|
||||
"source_id": job.source_id,
|
||||
"source_name": job.source_name,
|
||||
"printer_id": job.printer_id,
|
||||
"printer_name": job.printer_name,
|
||||
}
|
||||
for job in list(self._queued_jobs)
|
||||
]
|
||||
|
||||
active_jobs: list[dict[str, Any]] = []
|
||||
for active in self._active_jobs.values():
|
||||
upload_progress_pct = None
|
||||
if active.upload_total_bytes and active.upload_total_bytes > 0 and active.upload_bytes is not None:
|
||||
upload_progress_pct = round(
|
||||
max(0.0, min(100.0, (active.upload_bytes / active.upload_total_bytes) * 100.0)), 1
|
||||
)
|
||||
|
||||
active_jobs.append(
|
||||
{
|
||||
"job_id": active.job.id,
|
||||
"kind": active.job.kind,
|
||||
"source_id": active.job.source_id,
|
||||
"source_name": active.job.source_name,
|
||||
"printer_id": active.job.printer_id,
|
||||
"printer_name": active.job.printer_name,
|
||||
"message": active.message,
|
||||
"upload_bytes": active.upload_bytes,
|
||||
"upload_total_bytes": active.upload_total_bytes,
|
||||
"upload_progress_pct": upload_progress_pct,
|
||||
}
|
||||
)
|
||||
|
||||
active_jobs.sort(key=lambda item: int(item["job_id"]))
|
||||
active_job = active_jobs[0] if active_jobs else None
|
||||
|
||||
return {
|
||||
"total": self._batch_total,
|
||||
"dispatched": dispatched,
|
||||
"processing": processing,
|
||||
"completed": self._batch_completed,
|
||||
"failed": self._batch_failed,
|
||||
"dispatched_jobs": dispatched_jobs,
|
||||
"active_jobs": active_jobs,
|
||||
"active_job": active_job,
|
||||
"recent_event": recent_event,
|
||||
}
|
||||
|
||||
async def _process_job(self, job: PrintDispatchJob):
|
||||
if job.kind == "reprint_archive":
|
||||
await self._run_reprint_archive(job)
|
||||
return
|
||||
if job.kind == "print_library_file":
|
||||
await self._run_print_library_file(job)
|
||||
return
|
||||
raise RuntimeError(f"Unknown dispatch job kind: {job.kind}")
|
||||
|
||||
async def _run_reprint_archive(self, job: PrintDispatchJob):
|
||||
from backend.app.main import register_expected_print
|
||||
|
||||
async with async_session() as db:
|
||||
service = ArchiveService(db)
|
||||
archive = await service.get_archive(job.source_id)
|
||||
if not archive:
|
||||
raise RuntimeError("Archive not found")
|
||||
|
||||
printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
|
||||
if not printer:
|
||||
raise RuntimeError("Printer not found")
|
||||
|
||||
printer_name = printer.name
|
||||
printer_ip = printer.ip_address
|
||||
printer_access_code = printer.access_code
|
||||
printer_model = printer.model
|
||||
archive_filename = archive.filename
|
||||
|
||||
if not printer_manager.is_connected(job.printer_id):
|
||||
raise RuntimeError("Printer is not connected")
|
||||
|
||||
file_path = settings.base_dir / archive.file_path
|
||||
if not file_path.exists():
|
||||
raise RuntimeError("Archive file not found")
|
||||
|
||||
base_name = archive.filename
|
||||
if base_name.endswith(".gcode.3mf"):
|
||||
base_name = base_name[:-10]
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4]
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
remote_path = f"/{remote_filename}"
|
||||
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
await self._set_active_message(job, f"Preparing upload to {printer_name}...")
|
||||
await delete_file_async(
|
||||
printer_ip,
|
||||
printer_access_code,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer_model,
|
||||
)
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
def upload_progress_callback(_uploaded: int, _total: int):
|
||||
if self._is_cancel_requested(job.id):
|
||||
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
|
||||
|
||||
try:
|
||||
await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
|
||||
loop = asyncio.get_running_loop()
|
||||
progress_state = {"last_emit": 0.0, "last_bytes": 0}
|
||||
|
||||
def upload_progress_callback(uploaded: int, total: int):
|
||||
if self._is_cancel_requested(job.id):
|
||||
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
|
||||
|
||||
now = time.monotonic()
|
||||
should_emit = (
|
||||
uploaded >= total
|
||||
or now - progress_state["last_emit"] >= 0.2
|
||||
or uploaded - progress_state["last_bytes"] >= 256 * 1024
|
||||
)
|
||||
|
||||
if should_emit:
|
||||
progress_state["last_emit"] = now
|
||||
progress_state["last_bytes"] = uploaded
|
||||
loop.call_soon_threadsafe(
|
||||
lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
|
||||
)
|
||||
|
||||
if ftp_retry_enabled:
|
||||
uploaded = await with_ftp_retry(
|
||||
upload_file_async,
|
||||
printer_ip,
|
||||
printer_access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
progress_callback=upload_progress_callback,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer_model,
|
||||
max_retries=ftp_retry_count,
|
||||
retry_delay=ftp_retry_delay,
|
||||
operation_name=f"Upload for reprint to {printer_name}",
|
||||
non_retry_exceptions=(DispatchJobCancelled,),
|
||||
)
|
||||
else:
|
||||
uploaded = await upload_file_async(
|
||||
printer_ip,
|
||||
printer_access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
progress_callback=upload_progress_callback,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer_model,
|
||||
)
|
||||
|
||||
if uploaded:
|
||||
await self._set_active_upload_progress(job, 1, 1)
|
||||
|
||||
if not uploaded:
|
||||
raise RuntimeError(
|
||||
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
|
||||
)
|
||||
|
||||
register_expected_print(job.printer_id, remote_filename, job.source_id)
|
||||
|
||||
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
await self._set_active_message(job, f"Starting print on {printer_name}...")
|
||||
started = printer_manager.start_print(
|
||||
job.printer_id,
|
||||
remote_filename,
|
||||
plate_id,
|
||||
ams_mapping=job.options.get("ams_mapping"),
|
||||
timelapse=job.options.get("timelapse", False),
|
||||
bed_levelling=job.options.get("bed_levelling", True),
|
||||
flow_cali=job.options.get("flow_cali", False),
|
||||
vibration_cali=job.options.get("vibration_cali", False),
|
||||
layer_inspect=job.options.get("layer_inspect", False),
|
||||
use_ams=job.options.get("use_ams", True),
|
||||
)
|
||||
|
||||
if not started:
|
||||
raise RuntimeError("Failed to start print")
|
||||
|
||||
if job.requested_by_user_id and job.requested_by_username:
|
||||
printer_manager.set_current_print_user(
|
||||
job.printer_id,
|
||||
job.requested_by_user_id,
|
||||
job.requested_by_username,
|
||||
)
|
||||
except DispatchJobCancelled:
|
||||
await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
|
||||
raise
|
||||
|
||||
async def _run_print_library_file(self, job: PrintDispatchJob):
|
||||
from backend.app.main import register_expected_print
|
||||
|
||||
async with async_session() as db:
|
||||
lib_file = await db.scalar(select(LibraryFile).where(LibraryFile.id == job.source_id))
|
||||
if not lib_file:
|
||||
raise RuntimeError("File not found")
|
||||
|
||||
if not self._is_sliced_file(lib_file.filename):
|
||||
raise RuntimeError("Not a sliced file. Only .gcode or .gcode.3mf files can be printed.")
|
||||
|
||||
file_path = Path(settings.base_dir) / lib_file.file_path
|
||||
if not file_path.exists():
|
||||
raise RuntimeError("File not found on disk")
|
||||
|
||||
printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
|
||||
if not printer:
|
||||
raise RuntimeError("Printer not found")
|
||||
|
||||
printer_name = printer.name
|
||||
printer_ip = printer.ip_address
|
||||
printer_access_code = printer.access_code
|
||||
printer_model = printer.model
|
||||
library_filename = lib_file.filename
|
||||
|
||||
if not printer_manager.is_connected(job.printer_id):
|
||||
raise RuntimeError("Printer is not connected")
|
||||
|
||||
await self._set_active_message(job, f"Creating archive for {lib_file.filename}...")
|
||||
archive_service = ArchiveService(db)
|
||||
archive = await archive_service.archive_print(
|
||||
printer_id=job.printer_id,
|
||||
source_file=file_path,
|
||||
)
|
||||
if not archive:
|
||||
raise RuntimeError("Failed to create archive")
|
||||
|
||||
await db.flush()
|
||||
|
||||
base_name = lib_file.filename
|
||||
if base_name.endswith(".gcode.3mf"):
|
||||
base_name = base_name[:-10]
|
||||
elif base_name.endswith(".3mf"):
|
||||
base_name = base_name[:-4]
|
||||
remote_filename = f"{base_name}.3mf"
|
||||
remote_path = f"/{remote_filename}"
|
||||
|
||||
ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
await self._set_active_message(job, f"Preparing upload to {printer_name}...")
|
||||
await delete_file_async(
|
||||
printer_ip,
|
||||
printer_access_code,
|
||||
remote_path,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer_model,
|
||||
)
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
def upload_progress_callback(_uploaded: int, _total: int):
|
||||
if self._is_cancel_requested(job.id):
|
||||
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
|
||||
|
||||
try:
|
||||
await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
|
||||
loop = asyncio.get_running_loop()
|
||||
progress_state = {"last_emit": 0.0, "last_bytes": 0}
|
||||
|
||||
def upload_progress_callback(uploaded: int, total: int):
|
||||
if self._is_cancel_requested(job.id):
|
||||
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
|
||||
|
||||
now = time.monotonic()
|
||||
should_emit = (
|
||||
uploaded >= total
|
||||
or now - progress_state["last_emit"] >= 0.2
|
||||
or uploaded - progress_state["last_bytes"] >= 256 * 1024
|
||||
)
|
||||
|
||||
if should_emit:
|
||||
progress_state["last_emit"] = now
|
||||
progress_state["last_bytes"] = uploaded
|
||||
loop.call_soon_threadsafe(
|
||||
lambda u=uploaded, t=total: asyncio.create_task(self._set_active_upload_progress(job, u, t))
|
||||
)
|
||||
|
||||
if ftp_retry_enabled:
|
||||
uploaded = await with_ftp_retry(
|
||||
upload_file_async,
|
||||
printer_ip,
|
||||
printer_access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
progress_callback=upload_progress_callback,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer_model,
|
||||
max_retries=ftp_retry_count,
|
||||
retry_delay=ftp_retry_delay,
|
||||
operation_name=f"Upload for print to {printer_name}",
|
||||
non_retry_exceptions=(DispatchJobCancelled,),
|
||||
)
|
||||
else:
|
||||
uploaded = await upload_file_async(
|
||||
printer_ip,
|
||||
printer_access_code,
|
||||
file_path,
|
||||
remote_path,
|
||||
progress_callback=upload_progress_callback,
|
||||
socket_timeout=ftp_timeout,
|
||||
printer_model=printer_model,
|
||||
)
|
||||
|
||||
if uploaded:
|
||||
await self._set_active_upload_progress(job, 1, 1)
|
||||
|
||||
if not uploaded:
|
||||
await db.rollback()
|
||||
raise RuntimeError(
|
||||
"Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
|
||||
)
|
||||
|
||||
register_expected_print(job.printer_id, remote_filename, archive.id)
|
||||
|
||||
plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
await self._set_active_message(job, f"Starting print on {printer_name}...")
|
||||
started = printer_manager.start_print(
|
||||
job.printer_id,
|
||||
remote_filename,
|
||||
plate_id,
|
||||
ams_mapping=job.options.get("ams_mapping"),
|
||||
timelapse=job.options.get("timelapse", False),
|
||||
bed_levelling=job.options.get("bed_levelling", True),
|
||||
flow_cali=job.options.get("flow_cali", False),
|
||||
vibration_cali=job.options.get("vibration_cali", False),
|
||||
layer_inspect=job.options.get("layer_inspect", False),
|
||||
use_ams=job.options.get("use_ams", True),
|
||||
)
|
||||
|
||||
if not started:
|
||||
await db.rollback()
|
||||
raise RuntimeError("Failed to start print")
|
||||
|
||||
await db.commit()
|
||||
except DispatchJobCancelled:
|
||||
await db.rollback()
|
||||
await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _resolve_plate_id(file_path: Path, requested_plate_id: int | None) -> int:
|
||||
if requested_plate_id is not None:
|
||||
return requested_plate_id
|
||||
|
||||
plate_id = 1
|
||||
try:
|
||||
with zipfile.ZipFile(file_path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
|
||||
plate_str = name[15:-6]
|
||||
plate_id = int(plate_str)
|
||||
break
|
||||
except (ValueError, zipfile.BadZipFile, OSError):
|
||||
pass
|
||||
return plate_id
|
||||
|
||||
@staticmethod
|
||||
def _is_sliced_file(filename: str) -> bool:
|
||||
lower = filename.lower()
|
||||
return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
|
||||
|
||||
|
||||
background_dispatch = BackgroundDispatchService()
|
||||
@@ -76,13 +76,16 @@ class BambuFTPClient:
|
||||
"""FTP client for retrieving files from Bambu Lab printers."""
|
||||
|
||||
FTP_PORT = 990
|
||||
DEFAULT_TIMEOUT = 30 # Default timeout in seconds (increased for A1 printers)
|
||||
# Default timeout in seconds (increased for A1 printers)
|
||||
DEFAULT_TIMEOUT = 30
|
||||
# Models that may need SSL mode fallback (try prot_p first, fall back to prot_c)
|
||||
# These models have varying FTP SSL behavior depending on firmware version
|
||||
A1_MODELS = ("A1", "A1 Mini")
|
||||
# Chunk size for manual upload transfer (1MB)
|
||||
# Larger chunks reduce overhead and work better with A1 printers
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
# Chunk size for manual upload transfer (256KB)
|
||||
# Smaller chunks increase cancellation responsiveness during uploads.
|
||||
CHUNK_SIZE = 256 * 1024
|
||||
# Per-chunk data socket timeout during upload.
|
||||
UPLOAD_CHUNK_TIMEOUT = 15
|
||||
|
||||
# Cache for working FTP modes per printer IP
|
||||
# Maps IP -> "prot_p" or "prot_c"
|
||||
@@ -359,6 +362,8 @@ class BambuFTPClient:
|
||||
logger.info("FTP uploading %s (%s bytes) to %s", local_path, file_size, remote_path)
|
||||
|
||||
uploaded = 0
|
||||
callback_exception: Exception | None = None
|
||||
transfer_response_ok = True
|
||||
|
||||
# Use manual transfer instead of storbinary() for A1 compatibility
|
||||
# A1 printers have issues with storbinary's voidresp() hanging after transfer
|
||||
@@ -368,7 +373,7 @@ class BambuFTPClient:
|
||||
|
||||
# Set explicit socket options for reliable transfer
|
||||
conn.setblocking(True)
|
||||
conn.settimeout(120) # 2 minute timeout per chunk
|
||||
conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -382,14 +387,51 @@ class BambuFTPClient:
|
||||
logger.debug("FTP upload progress: %s/%s bytes", uploaded, file_size)
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(uploaded, file_size)
|
||||
try:
|
||||
progress_callback(uploaded, file_size)
|
||||
except Exception as e:
|
||||
callback_exception = e
|
||||
logger.info(
|
||||
"FTP upload callback requested stop for %s at %s/%s bytes: %s",
|
||||
remote_path,
|
||||
uploaded,
|
||||
file_size,
|
||||
e,
|
||||
)
|
||||
break
|
||||
|
||||
except OSError as e:
|
||||
logger.error("FTP connection lost during upload: %s", e)
|
||||
conn.close()
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
conn.close()
|
||||
try:
|
||||
self._ftp.voidresp()
|
||||
except (OSError, ftplib.Error) as e:
|
||||
transfer_response_ok = False
|
||||
logger.debug("FTP upload final response for %s was not clean: %s", remote_path, e)
|
||||
|
||||
if callback_exception is not None:
|
||||
cleanup_ok = False
|
||||
try:
|
||||
cleanup_ok = self.delete_file(remote_path)
|
||||
except Exception as cleanup_error:
|
||||
logger.warning("FTP cancel cleanup failed for %s: %s", remote_path, cleanup_error)
|
||||
|
||||
if cleanup_ok:
|
||||
logger.info("FTP cancel cleanup succeeded for %s", remote_path)
|
||||
raise callback_exception
|
||||
|
||||
raise RuntimeError(
|
||||
f"Upload cancelled but failed to remove partial file {remote_path} from printer"
|
||||
) from callback_exception
|
||||
|
||||
if not transfer_response_ok:
|
||||
return False
|
||||
|
||||
logger.info("FTP upload complete: %s", remote_path)
|
||||
return True
|
||||
@@ -421,7 +463,7 @@ class BambuFTPClient:
|
||||
# Use manual transfer instead of storbinary() for A1 compatibility
|
||||
conn = self._ftp.transfercmd(f"STOR {remote_path}")
|
||||
conn.setblocking(True)
|
||||
conn.settimeout(120)
|
||||
conn.settimeout(self.UPLOAD_CHUNK_TIMEOUT)
|
||||
|
||||
try:
|
||||
# Send data in chunks
|
||||
@@ -432,10 +474,12 @@ class BambuFTPClient:
|
||||
offset += len(chunk)
|
||||
except OSError as e:
|
||||
logger.error("FTP connection lost during upload_bytes: %s", e)
|
||||
conn.close()
|
||||
raise
|
||||
|
||||
conn.close()
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
except (OSError, ftplib.Error):
|
||||
return False
|
||||
@@ -827,6 +871,7 @@ async def with_ftp_retry(
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 2.0,
|
||||
operation_name: str = "FTP operation",
|
||||
non_retry_exceptions: tuple[type[BaseException], ...] = (),
|
||||
**kwargs,
|
||||
) -> T | None:
|
||||
"""Execute FTP operation with retry logic.
|
||||
@@ -837,6 +882,7 @@ async def with_ftp_retry(
|
||||
max_retries: Number of retry attempts (default: 3)
|
||||
retry_delay: Seconds to wait between retries (default: 2.0)
|
||||
operation_name: Name for logging purposes
|
||||
non_retry_exceptions: Exception types that should immediately abort retries
|
||||
**kwargs: Keyword arguments for the operation
|
||||
|
||||
Returns:
|
||||
@@ -856,6 +902,8 @@ async def with_ftp_retry(
|
||||
if attempt > 0:
|
||||
logger.info("%s attempt %s/%s returned failure", operation_name, attempt + 1, max_retries + 1)
|
||||
except Exception as e:
|
||||
if non_retry_exceptions and isinstance(e, non_retry_exceptions):
|
||||
raise
|
||||
last_error = e
|
||||
logger.warning("%s attempt %s/%s failed: %s", operation_name, attempt + 1, max_retries + 1, e)
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Integration tests for background dispatch API behavior."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.app.services.background_dispatch import DispatchEnqueueRejected
|
||||
|
||||
|
||||
class TestBackgroundDispatchArchivesAPI:
|
||||
"""Tests for archive reprint dispatch endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reprint_returns_dispatched_payload(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
|
||||
):
|
||||
"""Reprint endpoint returns background dispatch metadata."""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
printer.id,
|
||||
filename="widget.gcode.3mf",
|
||||
file_path="archives/test/widget.gcode.3mf",
|
||||
)
|
||||
|
||||
archive_file = tmp_path / archive.file_path
|
||||
archive_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
archive_file.write_bytes(b"3mf-data")
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
|
||||
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
|
||||
patch(
|
||||
"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
|
||||
new=AsyncMock(return_value={"dispatch_job_id": 15, "dispatch_position": 1}),
|
||||
) as mock_dispatch,
|
||||
):
|
||||
response = await async_client.post(
|
||||
f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
|
||||
json={"plate_id": 2},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "dispatched"
|
||||
assert data["dispatch_job_id"] == 15
|
||||
assert data["dispatch_position"] == 1
|
||||
assert data["filename"] == "widget.gcode.3mf"
|
||||
|
||||
mock_dispatch.assert_awaited_once()
|
||||
kwargs = mock_dispatch.await_args.kwargs
|
||||
assert kwargs["archive_name"].endswith("• Plate 2")
|
||||
assert kwargs["options"]["plate_id"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reprint_returns_409_when_enqueue_rejected(
|
||||
self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
|
||||
):
|
||||
"""Reprint endpoint maps enqueue rejection to HTTP 409."""
|
||||
printer = await printer_factory()
|
||||
archive = await archive_factory(
|
||||
printer.id,
|
||||
filename="widget2.gcode.3mf",
|
||||
file_path="archives/test/widget2.gcode.3mf",
|
||||
)
|
||||
|
||||
archive_file = tmp_path / archive.file_path
|
||||
archive_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
archive_file.write_bytes(b"3mf-data")
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
|
||||
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
|
||||
patch(
|
||||
"backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
|
||||
new=AsyncMock(side_effect=DispatchEnqueueRejected("already has a background dispatch")),
|
||||
),
|
||||
):
|
||||
response = await async_client.post(
|
||||
f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
|
||||
json={"plate_id": 1},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "already has a background dispatch" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestBackgroundDispatchLibraryAPI:
|
||||
"""Tests for library print dispatch endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
async def library_file_factory(self, db_session):
|
||||
"""Factory to create library files."""
|
||||
|
||||
async def _create_file(**kwargs):
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
defaults = {
|
||||
"filename": "library_part.gcode.3mf",
|
||||
"file_path": "library/files/library_part.gcode.3mf",
|
||||
"file_type": "gcode",
|
||||
"file_size": 1024,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
lib_file = LibraryFile(**defaults)
|
||||
db_session.add(lib_file)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(lib_file)
|
||||
return lib_file
|
||||
|
||||
return _create_file
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_library_print_returns_dispatched_payload(
|
||||
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
|
||||
):
|
||||
"""Library print endpoint returns dispatch job metadata."""
|
||||
printer = await printer_factory()
|
||||
lib_file = await library_file_factory()
|
||||
|
||||
disk_path = tmp_path / lib_file.file_path
|
||||
disk_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
disk_path.write_bytes(b"library data")
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
|
||||
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
|
||||
patch(
|
||||
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
|
||||
new=AsyncMock(return_value={"dispatch_job_id": 21, "dispatch_position": 2}),
|
||||
) as mock_dispatch,
|
||||
):
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
|
||||
json={"plate_id": 4},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "dispatched"
|
||||
assert data["dispatch_job_id"] == 21
|
||||
assert data["dispatch_position"] == 2
|
||||
assert data["archive_id"] is None
|
||||
|
||||
mock_dispatch.assert_awaited_once()
|
||||
kwargs = mock_dispatch.await_args.kwargs
|
||||
assert kwargs["filename"].endswith("• Plate 4")
|
||||
assert kwargs["options"]["plate_id"] == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_library_print_returns_409_when_enqueue_rejected(
|
||||
self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
|
||||
):
|
||||
"""Library print endpoint maps enqueue rejection to HTTP 409."""
|
||||
printer = await printer_factory()
|
||||
lib_file = await library_file_factory(filename="another_part.gcode")
|
||||
|
||||
disk_path = tmp_path / lib_file.file_path
|
||||
disk_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
disk_path.write_bytes(b"library data")
|
||||
|
||||
with (
|
||||
patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
|
||||
patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
|
||||
patch(
|
||||
"backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
|
||||
new=AsyncMock(side_effect=DispatchEnqueueRejected("queue conflict")),
|
||||
),
|
||||
):
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
|
||||
json={"plate_id": 1},
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert "queue conflict" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestBackgroundDispatchCancelAPI:
|
||||
"""Tests for /background-dispatch cancel endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):
|
||||
"""Cancel endpoint returns cancelled for queued job."""
|
||||
with patch(
|
||||
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"cancelled": True,
|
||||
"pending": False,
|
||||
"job_id": 9,
|
||||
"source_name": "cube.gcode.3mf",
|
||||
"printer_id": 1,
|
||||
"printer_name": "Printer A",
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await async_client.delete("/api/v1/background-dispatch/9")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "cancelled"
|
||||
assert data["job_id"] == 9
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):
|
||||
"""Cancel endpoint returns cancelling while active upload is being interrupted."""
|
||||
with patch(
|
||||
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
"cancelled": True,
|
||||
"pending": True,
|
||||
"job_id": 10,
|
||||
"source_name": "cube.gcode.3mf",
|
||||
"printer_id": 1,
|
||||
"printer_name": "Printer A",
|
||||
}
|
||||
),
|
||||
):
|
||||
response = await async_client.delete("/api/v1/background-dispatch/10")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "cancelling"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):
|
||||
"""Cancel endpoint returns 404 for unknown job id."""
|
||||
with patch(
|
||||
"backend.app.services.background_dispatch.background_dispatch.cancel_job",
|
||||
new=AsyncMock(return_value={"cancelled": False, "reason": "not_found"}),
|
||||
):
|
||||
response = await async_client.delete("/api/v1/background-dispatch/999")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json()["detail"] == "Dispatch job not found"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Unit tests for background dispatch service."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.background_dispatch import (
|
||||
ActiveDispatchState,
|
||||
BackgroundDispatchService,
|
||||
DispatchEnqueueRejected,
|
||||
PrintDispatchJob,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_rejects_when_printer_busy_printing():
|
||||
"""Reject enqueue when target printer is already printing."""
|
||||
service = BackgroundDispatchService()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.services.background_dispatch.printer_manager.get_status",
|
||||
return_value=SimpleNamespace(state="RUNNING", gcode_file="active.gcode.3mf"),
|
||||
),
|
||||
pytest.raises(DispatchEnqueueRejected, match="currently busy printing"),
|
||||
):
|
||||
await service.dispatch_reprint_archive(
|
||||
archive_id=1,
|
||||
archive_name="Test Archive",
|
||||
printer_id=10,
|
||||
printer_name="Printer A",
|
||||
options={},
|
||||
requested_by_user_id=None,
|
||||
requested_by_username=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_enqueues_job_and_broadcasts_state():
|
||||
"""Enqueue succeeds and emits websocket queue update."""
|
||||
service = BackgroundDispatchService()
|
||||
|
||||
with (
|
||||
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
|
||||
patch(
|
||||
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
|
||||
) as mock_broadcast,
|
||||
):
|
||||
result = await service.dispatch_print_library_file(
|
||||
file_id=22,
|
||||
filename="cube.gcode.3mf",
|
||||
printer_id=7,
|
||||
printer_name="Printer B",
|
||||
options={"plate_id": 2},
|
||||
requested_by_user_id=5,
|
||||
requested_by_username="tester",
|
||||
)
|
||||
|
||||
assert result["status"] == "dispatched"
|
||||
assert result["dispatch_job_id"] == 1
|
||||
assert result["dispatch_position"] == 1
|
||||
assert len(service._queued_jobs) == 1
|
||||
|
||||
mock_broadcast.assert_awaited_once()
|
||||
payload = mock_broadcast.await_args.args[0]
|
||||
assert payload["type"] == "background_dispatch"
|
||||
assert payload["data"]["recent_event"]["status"] == "dispatched"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_queued_job_removes_it_and_broadcasts():
|
||||
"""Cancelling queued job removes it immediately."""
|
||||
service = BackgroundDispatchService()
|
||||
|
||||
with (
|
||||
patch("backend.app.services.background_dispatch.printer_manager.get_status", return_value=None),
|
||||
patch(
|
||||
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
|
||||
) as mock_broadcast,
|
||||
):
|
||||
result = await service.dispatch_reprint_archive(
|
||||
archive_id=1,
|
||||
archive_name="benchy.gcode.3mf",
|
||||
printer_id=1,
|
||||
printer_name="Printer 1",
|
||||
options={},
|
||||
requested_by_user_id=None,
|
||||
requested_by_username=None,
|
||||
)
|
||||
mock_broadcast.reset_mock()
|
||||
|
||||
cancel_result = await service.cancel_job(result["dispatch_job_id"])
|
||||
|
||||
assert cancel_result["cancelled"] is True
|
||||
assert cancel_result["pending"] is False
|
||||
assert len(service._queued_jobs) == 0
|
||||
assert service._batch_total == 0
|
||||
|
||||
mock_broadcast.assert_awaited_once()
|
||||
payload = mock_broadcast.await_args.args[0]
|
||||
assert payload["data"]["recent_event"]["status"] == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_active_job_marks_pending_and_sets_cancel_flag():
|
||||
"""Cancelling active job marks it as pending cancellation."""
|
||||
service = BackgroundDispatchService()
|
||||
job = PrintDispatchJob(
|
||||
id=42,
|
||||
kind="reprint_archive",
|
||||
source_id=100,
|
||||
source_name="gearbox.gcode.3mf",
|
||||
printer_id=3,
|
||||
printer_name="Printer C",
|
||||
)
|
||||
service._active_jobs[job.id] = ActiveDispatchState(job=job, message="Uploading...")
|
||||
|
||||
with patch(
|
||||
"backend.app.services.background_dispatch.ws_manager.broadcast", new_callable=AsyncMock
|
||||
) as mock_broadcast:
|
||||
result = await service.cancel_job(job.id)
|
||||
|
||||
assert result["cancelled"] is True
|
||||
assert result["pending"] is True
|
||||
assert job.id in service._cancel_requested_job_ids
|
||||
|
||||
mock_broadcast.assert_awaited_once()
|
||||
payload = mock_broadcast.await_args.args[0]
|
||||
assert payload["data"]["recent_event"]["status"] == "cancelling"
|
||||
|
||||
|
||||
def test_resolve_plate_id_uses_request_value_when_provided(tmp_path):
|
||||
"""Explicit plate_id wins over auto-detection."""
|
||||
file_path = tmp_path / "dummy.3mf"
|
||||
file_path.write_text("not-a-zip")
|
||||
|
||||
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=9)
|
||||
assert plate_id == 9
|
||||
|
||||
|
||||
def test_resolve_plate_id_auto_detects_from_3mf(tmp_path):
|
||||
"""Auto-detect plate from Metadata/plate_X.gcode entry."""
|
||||
import zipfile
|
||||
|
||||
file_path = tmp_path / "multi.3mf"
|
||||
with zipfile.ZipFile(file_path, "w") as zf:
|
||||
zf.writestr("Metadata/plate_7.gcode", b"G1 X0 Y0")
|
||||
|
||||
plate_id = BackgroundDispatchService._resolve_plate_id(file_path, requested_plate_id=None)
|
||||
assert plate_id == 7
|
||||
|
||||
|
||||
def test_is_sliced_file_recognizes_supported_extensions():
|
||||
"""Only .gcode and .gcode.3mf should be accepted."""
|
||||
assert BackgroundDispatchService._is_sliced_file("part.gcode") is True
|
||||
assert BackgroundDispatchService._is_sliced_file("part.gcode.3mf") is True
|
||||
assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
|
||||
@@ -1630,6 +1630,15 @@ export interface NotificationTestResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BackgroundDispatchResponse {
|
||||
status: 'dispatched' | string;
|
||||
printer_id: number;
|
||||
archive_id?: number | null;
|
||||
filename: string;
|
||||
dispatch_job_id: number;
|
||||
dispatch_position: number;
|
||||
}
|
||||
|
||||
// Provider-specific config types for reference
|
||||
export interface CallMeBotConfig {
|
||||
phone: string;
|
||||
@@ -2937,6 +2946,7 @@ export const api = {
|
||||
printerId: number,
|
||||
options?: {
|
||||
plate_id?: number;
|
||||
plate_name?: string;
|
||||
ams_mapping?: number[];
|
||||
timelapse?: boolean;
|
||||
bed_levelling?: boolean;
|
||||
@@ -2946,7 +2956,7 @@ export const api = {
|
||||
use_ams?: boolean;
|
||||
}
|
||||
) =>
|
||||
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
|
||||
request<BackgroundDispatchResponse>(
|
||||
`/archives/${archiveId}/reprint?printer_id=${printerId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -4008,6 +4018,7 @@ export const api = {
|
||||
printerId: number,
|
||||
options?: {
|
||||
plate_id?: number;
|
||||
plate_name?: string;
|
||||
ams_mapping?: number[];
|
||||
bed_levelling?: boolean;
|
||||
flow_cali?: boolean;
|
||||
@@ -4017,13 +4028,23 @@ export const api = {
|
||||
use_ams?: boolean;
|
||||
}
|
||||
) =>
|
||||
request<{ status: string; printer_id: number; archive_id: number; filename: string }>(
|
||||
request<BackgroundDispatchResponse>(
|
||||
`/library/files/${fileId}/print?printer_id=${printerId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: options ? JSON.stringify(options) : undefined,
|
||||
}
|
||||
),
|
||||
cancelBackgroundDispatchJob: (jobId: number) =>
|
||||
request<{
|
||||
status: 'cancelled' | 'cancelling';
|
||||
job_id: number;
|
||||
source_name: string;
|
||||
printer_id: number;
|
||||
printer_name: string;
|
||||
}>(`/background-dispatch/${jobId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
getLibraryFilePlates: (fileId: number) =>
|
||||
request<LibraryFilePlatesResponse>(`/library/files/${fileId}/plates`),
|
||||
getLibraryFileFilamentRequirements: (fileId: number, plateId?: number) =>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, AlertTriangle, Calendar, Loader2, Pencil, Printer, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
|
||||
import { Card, CardContent } from '../Card';
|
||||
import { Button } from '../Button';
|
||||
import { api } from '../../api/client';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useFilamentMapping } from '../../hooks/useFilamentMapping';
|
||||
import { useMultiPrinterFilamentMapping, type PerPrinterConfig } from '../../hooks/useMultiPrinterFilamentMapping';
|
||||
import { isPlaceholderDate } from '../../utils/amsHelpers';
|
||||
import { toDateTimeLocalValue } from '../../utils/date';
|
||||
import { PrinterSelector } from './PrinterSelector';
|
||||
import { PlateSelector } from './PlateSelector';
|
||||
import { Button } from '../Button';
|
||||
import { Card, CardContent } from '../Card';
|
||||
import { FilamentMapping } from './FilamentMapping';
|
||||
import { PlateSelector } from './PlateSelector';
|
||||
import { PrinterSelector } from './PrinterSelector';
|
||||
import { PrintOptionsPanel } from './PrintOptions';
|
||||
import { ScheduleOptionsPanel } from './ScheduleOptions';
|
||||
import type {
|
||||
AssignmentMode,
|
||||
PrintModalProps,
|
||||
PrintOptions,
|
||||
ScheduleOptions,
|
||||
ScheduleType,
|
||||
AssignmentMode,
|
||||
} from './types';
|
||||
import { DEFAULT_PRINT_OPTIONS, DEFAULT_SCHEDULE_OPTIONS } from './types';
|
||||
|
||||
@@ -230,6 +230,12 @@ export function PrintModal({
|
||||
|
||||
// Combine filament requirements from either source
|
||||
const effectiveFilamentReqs = isLibraryFile ? libraryFilamentReqs : archiveFilamentReqs;
|
||||
const selectedPlateName = useMemo(() => {
|
||||
if (selectedPlate === null || !platesData?.plates?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return platesData.plates.find((plate) => plate.index === selectedPlate)?.name || undefined;
|
||||
}, [platesData, selectedPlate]);
|
||||
|
||||
// Only fetch printer status when single printer selected (for filament mapping)
|
||||
const { data: printerStatus } = useQuery({
|
||||
@@ -450,12 +456,15 @@ export function PrintModal({
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
if (isLibraryFile) {
|
||||
await api.printLibraryFile(libraryFileId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
plate_name: selectedPlateName,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
} else {
|
||||
await api.reprintArchive(archiveId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
plate_name: selectedPlateName,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
@@ -498,11 +507,12 @@ export function PrintModal({
|
||||
if (assignmentMode === 'model') {
|
||||
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Queued for any ${targetModel}`);
|
||||
} else {
|
||||
const action = mode === 'reprint' ? 'sent to' : (mode === 'edit-queue-item' ? 'updated/queued for' : 'queued for');
|
||||
if (results.success === 1) {
|
||||
showToast(mode === 'edit-queue-item' ? 'Queue item updated' : `Print ${action} printer`);
|
||||
if (mode === 'edit-queue-item') {
|
||||
showToast('Queue item updated');
|
||||
} else if (results.success === 1) {
|
||||
showToast('Print queued for printer');
|
||||
} else {
|
||||
showToast(`Print ${action} ${results.success} printers`);
|
||||
showToast(`Print queued for ${results.success} printers`);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['queue'] });
|
||||
@@ -743,4 +753,5 @@ export function PrintModal({
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { PrintModalProps, PrintModalMode } from './types';
|
||||
export type { PrintModalMode, PrintModalProps } from './types';
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, type ReactNode } from 'react';
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X, Loader2 } from 'lucide-react';
|
||||
import { AlertCircle, CheckCircle, ChevronDown, ChevronUp, Info, Loader2, X, XCircle } from 'lucide-react';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info' | 'loading';
|
||||
|
||||
@@ -8,6 +10,29 @@ interface Toast {
|
||||
message: string;
|
||||
type: ToastType;
|
||||
persistent?: boolean;
|
||||
dispatchData?: DispatchToastData;
|
||||
}
|
||||
|
||||
type DispatchJobStatus = 'dispatched' | 'processing' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
interface DispatchToastJob {
|
||||
jobId: number;
|
||||
sourceName: string;
|
||||
printerName: string;
|
||||
status: DispatchJobStatus;
|
||||
message?: string;
|
||||
uploadBytes?: number;
|
||||
uploadTotalBytes?: number;
|
||||
uploadProgressPct?: number;
|
||||
}
|
||||
|
||||
interface DispatchToastData {
|
||||
total: number;
|
||||
dispatched: number;
|
||||
processing: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
jobs: DispatchToastJob[];
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
@@ -43,8 +68,21 @@ const bgColors = {
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const [isDispatchCollapsed, setIsDispatchCollapsed] = useState(false);
|
||||
const [cancellingDispatchJobIds, setCancellingDispatchJobIds] = useState<Set<number>>(new Set());
|
||||
const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const dispatchToastId = 'background-dispatch';
|
||||
const lastDispatchSummaryRef = useRef<string | null>(null);
|
||||
|
||||
const formatBytes = useCallback((bytes: number) => {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}, []);
|
||||
|
||||
// Clean up all timeouts on unmount
|
||||
useEffect(() => {
|
||||
@@ -88,6 +126,279 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const cancelDispatchJob = useCallback(async (jobId: number) => {
|
||||
setCancellingDispatchJobIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(jobId);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await api.cancelBackgroundDispatchJob(jobId);
|
||||
showToast(
|
||||
result.status === 'cancelling'
|
||||
? t('backgroundDispatch.toast.cancellingUpload')
|
||||
: t('backgroundDispatch.toast.cancelled'),
|
||||
'info'
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('backgroundDispatch.toast.cancelFailed');
|
||||
showToast(message, 'error');
|
||||
} finally {
|
||||
setCancellingDispatchJobIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(jobId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
interface DispatchEventDetail {
|
||||
total?: number;
|
||||
dispatched?: number;
|
||||
processing?: number;
|
||||
completed?: number;
|
||||
failed?: number;
|
||||
dispatched_jobs?: Array<{
|
||||
job_id: number;
|
||||
source_name?: string;
|
||||
printer_name?: string;
|
||||
}>;
|
||||
active_job?: {
|
||||
job_id?: number;
|
||||
printer_name?: string;
|
||||
source_name?: string;
|
||||
message?: string;
|
||||
upload_bytes?: number;
|
||||
upload_total_bytes?: number;
|
||||
upload_progress_pct?: number;
|
||||
} | null;
|
||||
active_jobs?: Array<{
|
||||
job_id?: number;
|
||||
printer_name?: string;
|
||||
source_name?: string;
|
||||
message?: string;
|
||||
upload_bytes?: number;
|
||||
upload_total_bytes?: number;
|
||||
upload_progress_pct?: number;
|
||||
}>;
|
||||
recent_event?: {
|
||||
status?: string;
|
||||
job_id?: number;
|
||||
source_name?: string;
|
||||
printer_name?: string;
|
||||
message?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const updateJob = (
|
||||
jobs: DispatchToastJob[],
|
||||
jobId: number,
|
||||
next: Partial<DispatchToastJob> & {
|
||||
status: DispatchJobStatus;
|
||||
sourceName: string;
|
||||
printerName: string;
|
||||
}
|
||||
) => {
|
||||
const index = jobs.findIndex((job) => job.jobId === jobId);
|
||||
if (index === -1) {
|
||||
return [...jobs, { jobId, ...next }];
|
||||
}
|
||||
const copy = [...jobs];
|
||||
copy[index] = {
|
||||
...copy[index],
|
||||
...next,
|
||||
};
|
||||
return copy;
|
||||
};
|
||||
|
||||
const statusWeight = (status: DispatchJobStatus) => {
|
||||
switch (status) {
|
||||
case 'failed':
|
||||
return 0;
|
||||
case 'processing':
|
||||
return 1;
|
||||
case 'dispatched':
|
||||
return 2;
|
||||
case 'completed':
|
||||
return 3;
|
||||
case 'cancelled':
|
||||
return 4;
|
||||
}
|
||||
};
|
||||
|
||||
const onDispatchEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DispatchEventDetail>).detail || {};
|
||||
const total = detail.total ?? 0;
|
||||
const dispatched = detail.dispatched ?? 0;
|
||||
const processing = detail.processing ?? 0;
|
||||
const completed = detail.completed ?? 0;
|
||||
const failed = detail.failed ?? 0;
|
||||
|
||||
const hasActiveWork = dispatched + processing > 0;
|
||||
const allDone = total > 0 && completed + failed >= total && !hasActiveWork;
|
||||
|
||||
if (hasActiveWork) {
|
||||
setToasts((prev) => {
|
||||
const existing = prev.find((t) => t.id === dispatchToastId);
|
||||
const existingJobs = existing?.dispatchData?.jobs || [];
|
||||
|
||||
const dispatchedJobs: DispatchToastJob[] = (detail.dispatched_jobs || []).map((job) => ({
|
||||
jobId: job.job_id,
|
||||
sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
|
||||
printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
|
||||
status: 'dispatched',
|
||||
}));
|
||||
|
||||
const activeJobsPayload =
|
||||
detail.active_jobs && detail.active_jobs.length > 0
|
||||
? detail.active_jobs
|
||||
: detail.active_job?.job_id
|
||||
? [detail.active_job]
|
||||
: [];
|
||||
|
||||
const activeJobs: DispatchToastJob[] = activeJobsPayload
|
||||
.filter((job) => typeof job.job_id === 'number')
|
||||
.map((job) => ({
|
||||
jobId: job.job_id as number,
|
||||
sourceName: job.source_name || t('backgroundDispatch.unknownFile'),
|
||||
printerName: job.printer_name || t('backgroundDispatch.unknownPrinter'),
|
||||
status: 'processing',
|
||||
message: job.message,
|
||||
uploadBytes: job.upload_bytes,
|
||||
uploadTotalBytes: job.upload_total_bytes,
|
||||
uploadProgressPct: job.upload_progress_pct,
|
||||
}));
|
||||
|
||||
const activeIds = new Set([...dispatchedJobs, ...activeJobs].map((job) => job.jobId));
|
||||
const historicalJobs = existingJobs.filter(
|
||||
(job) => !activeIds.has(job.jobId) && ['completed', 'failed', 'cancelled'].includes(job.status)
|
||||
);
|
||||
|
||||
let jobs = [...dispatchedJobs, ...activeJobs, ...historicalJobs];
|
||||
|
||||
if (detail.recent_event?.job_id && detail.recent_event?.status) {
|
||||
const rawStatus = detail.recent_event.status;
|
||||
const eventStatus = (
|
||||
rawStatus === 'cancelled' ? 'cancelled' : rawStatus === 'cancelling' ? 'processing' : rawStatus
|
||||
) as DispatchJobStatus;
|
||||
const sourceName = detail.recent_event.source_name || t('backgroundDispatch.unknownFile');
|
||||
const printerName = detail.recent_event.printer_name || t('backgroundDispatch.unknownPrinter');
|
||||
jobs = updateJob(jobs, detail.recent_event.job_id, {
|
||||
status: eventStatus,
|
||||
sourceName,
|
||||
printerName,
|
||||
message: detail.recent_event.message,
|
||||
});
|
||||
}
|
||||
|
||||
activeJobs.forEach((activeJob) => {
|
||||
jobs = updateJob(jobs, activeJob.jobId, {
|
||||
status: 'processing',
|
||||
sourceName: activeJob.sourceName,
|
||||
printerName: activeJob.printerName,
|
||||
message: activeJob.message,
|
||||
uploadBytes: activeJob.uploadBytes,
|
||||
uploadTotalBytes: activeJob.uploadTotalBytes,
|
||||
uploadProgressPct: activeJob.uploadProgressPct,
|
||||
});
|
||||
});
|
||||
|
||||
const dispatchData: DispatchToastData = {
|
||||
total,
|
||||
dispatched,
|
||||
processing,
|
||||
completed,
|
||||
failed,
|
||||
jobs: [...jobs].sort((a, b) => {
|
||||
const byStatus = statusWeight(a.status) - statusWeight(b.status);
|
||||
if (byStatus !== 0) {
|
||||
return byStatus;
|
||||
}
|
||||
return a.jobId - b.jobId;
|
||||
}),
|
||||
};
|
||||
|
||||
const exists = prev.find((t) => t.id === dispatchToastId);
|
||||
if (exists) {
|
||||
return prev.map((t) =>
|
||||
t.id === dispatchToastId
|
||||
? {
|
||||
...t,
|
||||
message: t('backgroundDispatch.startingPrints'),
|
||||
type: 'loading',
|
||||
persistent: true,
|
||||
dispatchData,
|
||||
}
|
||||
: t
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: dispatchToastId,
|
||||
message: t('backgroundDispatch.startingPrints'),
|
||||
type: 'loading',
|
||||
persistent: true,
|
||||
dispatchData,
|
||||
},
|
||||
];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const recentStatus = detail.recent_event?.status;
|
||||
if (!hasActiveWork && recentStatus && ['cancelled', 'failed', 'completed', 'idle'].includes(recentStatus)) {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
|
||||
}
|
||||
|
||||
if (allDone) {
|
||||
const summaryKey = `${completed}:${failed}`;
|
||||
if (lastDispatchSummaryRef.current === summaryKey) {
|
||||
return;
|
||||
}
|
||||
lastDispatchSummaryRef.current = summaryKey;
|
||||
|
||||
setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
|
||||
const doneMessage = failed > 0
|
||||
? t('backgroundDispatch.toast.completeWithFailures', { completed, failed })
|
||||
: t('backgroundDispatch.toast.completeSuccess', { completed });
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
setToasts((prev) => [...prev, { id, message: doneMessage, type: failed > 0 ? 'warning' : 'success' }]);
|
||||
const timeout = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timeoutRefs.current.delete(id);
|
||||
}, 4000);
|
||||
timeoutRefs.current.set(id, timeout);
|
||||
}
|
||||
|
||||
if (detail.recent_event?.status === 'idle' && !hasActiveWork) {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== dispatchToastId));
|
||||
}
|
||||
|
||||
if (!hasActiveWork) {
|
||||
setCancellingDispatchJobIds(new Set());
|
||||
}
|
||||
|
||||
if (detail.dispatched_jobs) {
|
||||
const dispatchedIds = new Set(detail.dispatched_jobs.map((job) => job.job_id));
|
||||
setCancellingDispatchJobIds((prev) => {
|
||||
const next = new Set<number>();
|
||||
prev.forEach((id) => {
|
||||
if (dispatchedIds.has(id)) {
|
||||
next.add(id);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('background-dispatch', onDispatchEvent);
|
||||
return () => window.removeEventListener('background-dispatch', onDispatchEvent);
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, showPersistentToast, dismissToast }}>
|
||||
{children}
|
||||
@@ -97,16 +408,134 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]}`}
|
||||
className={`rounded-lg border shadow-lg backdrop-blur-sm animate-slide-in ${bgColors[toast.type]} ${
|
||||
toast.dispatchData ? 'w-[420px] p-3' : 'flex items-center gap-3 px-4 py-3'
|
||||
}`}
|
||||
>
|
||||
{icons[toast.type]}
|
||||
<span className="text-white text-sm">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="ml-2 text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{toast.dispatchData ? (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2">
|
||||
{icons[toast.type]}
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{t('backgroundDispatch.startingPrints')}</p>
|
||||
<p className="text-xs text-bambu-gray mt-0.5">
|
||||
{t('backgroundDispatch.progressSummary', {
|
||||
complete: toast.dispatchData.completed + toast.dispatchData.failed,
|
||||
total: toast.dispatchData.total,
|
||||
dispatched: toast.dispatchData.dispatched,
|
||||
processing: toast.dispatchData.processing,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setIsDispatchCollapsed((prev) => !prev)}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
aria-label={
|
||||
isDispatchCollapsed
|
||||
? t('backgroundDispatch.expandDetails')
|
||||
: t('backgroundDispatch.collapseDetails')
|
||||
}
|
||||
>
|
||||
{isDispatchCollapsed ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="text-bambu-gray hover:text-white transition-colors"
|
||||
aria-label={t('backgroundDispatch.dismissToast')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isDispatchCollapsed && (
|
||||
<div className="mt-3 space-y-2 max-h-64 overflow-y-auto pr-1">
|
||||
{toast.dispatchData.jobs.map((job) => {
|
||||
const progressByStatus: Record<DispatchJobStatus, number> = {
|
||||
dispatched: 15,
|
||||
processing: 60,
|
||||
completed: 100,
|
||||
failed: 100,
|
||||
cancelled: 100,
|
||||
};
|
||||
const barColorByStatus: Record<DispatchJobStatus, string> = {
|
||||
dispatched: 'bg-bambu-gray/60',
|
||||
processing: 'bg-bambu-green',
|
||||
completed: 'bg-green-500',
|
||||
failed: 'bg-red-500',
|
||||
cancelled: 'bg-yellow-500',
|
||||
};
|
||||
return (
|
||||
<div key={job.jobId} className="rounded border border-white/10 bg-black/15 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-white truncate" title={job.sourceName}>
|
||||
{job.sourceName}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{(job.status === 'dispatched' || job.status === 'processing') && (
|
||||
<button
|
||||
onClick={() => void cancelDispatchJob(job.jobId)}
|
||||
disabled={cancellingDispatchJobIds.has(job.jobId)}
|
||||
className="text-[11px] text-red-300 hover:text-red-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t('backgroundDispatch.cancelDispatchJob')}
|
||||
>
|
||||
{cancellingDispatchJobIds.has(job.jobId)
|
||||
? t('backgroundDispatch.cancelling')
|
||||
: t('backgroundDispatch.cancel')}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[11px] uppercase tracking-wide text-bambu-gray">
|
||||
{t(`backgroundDispatch.status.${job.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-bambu-gray truncate" title={job.printerName}>
|
||||
{job.printerName}
|
||||
</div>
|
||||
{job.message && (
|
||||
<div className="text-[11px] text-bambu-gray truncate" title={job.message}>
|
||||
{job.message}
|
||||
</div>
|
||||
)}
|
||||
{job.status === 'processing' && typeof job.uploadBytes === 'number' && typeof job.uploadTotalBytes === 'number' && job.uploadTotalBytes > 0 && (
|
||||
<div className="text-[11px] text-bambu-gray truncate">
|
||||
{formatBytes(job.uploadBytes)} / {formatBytes(job.uploadTotalBytes)}
|
||||
{typeof job.uploadProgressPct === 'number' ? ` (${job.uploadProgressPct.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 h-1.5 w-full rounded bg-white/10 overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${barColorByStatus[job.status]} transition-all duration-300`}
|
||||
style={{
|
||||
width: `${
|
||||
job.status === 'processing' && typeof job.uploadProgressPct === 'number'
|
||||
? Math.max(0, Math.min(100, job.uploadProgressPct))
|
||||
: progressByStatus[job.status]
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{icons[toast.type]}
|
||||
<span className="text-white text-sm">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
className="ml-2 text-bambu-gray hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
@@ -249,6 +249,13 @@ export function useWebSocket() {
|
||||
tray_uuid: (message as unknown as { tray_uuid?: string }).tray_uuid,
|
||||
}
|
||||
}));
|
||||
|
||||
case 'background_dispatch':
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('background-dispatch', {
|
||||
detail: (message as unknown as { data?: Record<string, unknown> }).data || {},
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}, [queryClient, debouncedInvalidate, throttledPrinterStatusUpdate]);
|
||||
|
||||
@@ -890,6 +890,33 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
backgroundDispatch: {
|
||||
unknownFile: 'Unbekannte Datei',
|
||||
unknownPrinter: 'Unbekannter Drucker',
|
||||
startingPrints: 'Starte Drucke',
|
||||
progressSummary: '{{complete}}/{{total}} abgeschlossen • Geplant: {{dispatched}} • In Bearbeitung: {{processing}}',
|
||||
expandDetails: 'Dispatch-Details ausklappen',
|
||||
collapseDetails: 'Dispatch-Details einklappen',
|
||||
dismissToast: 'Dispatch-Hinweis schließen',
|
||||
cancelDispatchJob: 'Dispatch-Job abbrechen',
|
||||
cancel: 'Abbrechen',
|
||||
cancelling: 'Wird abgebrochen…',
|
||||
status: {
|
||||
dispatched: 'Geplant',
|
||||
processing: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
failed: 'Fehlgeschlagen',
|
||||
cancelled: 'Abgebrochen',
|
||||
},
|
||||
toast: {
|
||||
cancellingUpload: 'Upload wird abgebrochen...',
|
||||
cancelled: 'Dispatch abgebrochen',
|
||||
cancelFailed: 'Dispatch konnte nicht abgebrochen werden',
|
||||
completeWithFailures: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich, {{failed}} fehlgeschlagen',
|
||||
completeSuccess: 'Background Dispatch abgeschlossen: {{completed}} erfolgreich',
|
||||
},
|
||||
},
|
||||
|
||||
// Statistics page
|
||||
stats: {
|
||||
title: 'Dashboard',
|
||||
|
||||
@@ -890,6 +890,33 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
backgroundDispatch: {
|
||||
unknownFile: 'Unknown file',
|
||||
unknownPrinter: 'Unknown printer',
|
||||
startingPrints: 'Starting prints',
|
||||
progressSummary: '{{complete}}/{{total}} complete • Dispatched: {{dispatched}} • Processing: {{processing}}',
|
||||
expandDetails: 'Expand dispatch details',
|
||||
collapseDetails: 'Collapse dispatch details',
|
||||
dismissToast: 'Dismiss dispatch toast',
|
||||
cancelDispatchJob: 'Cancel dispatch job',
|
||||
cancel: 'Cancel',
|
||||
cancelling: 'Cancelling…',
|
||||
status: {
|
||||
dispatched: 'Dispatched',
|
||||
processing: 'Processing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
toast: {
|
||||
cancellingUpload: 'Cancelling upload...',
|
||||
cancelled: 'Dispatch cancelled',
|
||||
cancelFailed: 'Failed to cancel dispatch',
|
||||
completeWithFailures: 'Background dispatch complete: {{completed}} succeeded, {{failed}} failed',
|
||||
completeSuccess: 'Background dispatch complete: {{completed}} succeeded',
|
||||
},
|
||||
},
|
||||
|
||||
// Statistics page
|
||||
stats: {
|
||||
title: 'Dashboard',
|
||||
|
||||
@@ -869,6 +869,33 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
backgroundDispatch: {
|
||||
unknownFile: 'File sconosciuto',
|
||||
unknownPrinter: 'Stampante sconosciuta',
|
||||
startingPrints: 'Avvio stampe',
|
||||
progressSummary: '{{complete}}/{{total}} completati • Inviati: {{dispatched}} • In elaborazione: {{processing}}',
|
||||
expandDetails: 'Espandi dettagli dispatch',
|
||||
collapseDetails: 'Comprimi dettagli dispatch',
|
||||
dismissToast: 'Chiudi notifica dispatch',
|
||||
cancelDispatchJob: 'Annulla job dispatch',
|
||||
cancel: 'Annulla',
|
||||
cancelling: 'Annullamento…',
|
||||
status: {
|
||||
dispatched: 'Inviato',
|
||||
processing: 'In elaborazione',
|
||||
completed: 'Completato',
|
||||
failed: 'Fallito',
|
||||
cancelled: 'Annullato',
|
||||
},
|
||||
toast: {
|
||||
cancellingUpload: 'Annullamento upload...',
|
||||
cancelled: 'Dispatch annullato',
|
||||
cancelFailed: 'Impossibile annullare il dispatch',
|
||||
completeWithFailures: 'Dispatch in background completato: {{completed}} riusciti, {{failed}} falliti',
|
||||
completeSuccess: 'Dispatch in background completato: {{completed}} riusciti',
|
||||
},
|
||||
},
|
||||
|
||||
// Statistics page
|
||||
stats: {
|
||||
title: 'Dashboard',
|
||||
|
||||
@@ -957,6 +957,32 @@ export default {
|
||||
noCancelItems: 'キューアイテムをキャンセルする権限がありません',
|
||||
},
|
||||
},
|
||||
backgroundDispatch: {
|
||||
unknownFile: '不明なファイル',
|
||||
unknownPrinter: '不明なプリンター',
|
||||
startingPrints: '印刷開始中',
|
||||
progressSummary: '{{complete}}/{{total}} 完了 • 配信済み: {{dispatched}} • 処理中: {{processing}}',
|
||||
expandDetails: '配信詳細を展開',
|
||||
collapseDetails: '配信詳細を折りたたむ',
|
||||
dismissToast: '配信トーストを閉じる',
|
||||
cancelDispatchJob: '配信ジョブをキャンセル',
|
||||
cancel: 'キャンセル',
|
||||
cancelling: 'キャンセル中…',
|
||||
status: {
|
||||
dispatched: '配信済み',
|
||||
processing: '処理中',
|
||||
completed: '完了',
|
||||
failed: '失敗',
|
||||
cancelled: 'キャンセル済み',
|
||||
},
|
||||
toast: {
|
||||
cancellingUpload: 'アップロードをキャンセル中...',
|
||||
cancelled: '配信をキャンセルしました',
|
||||
cancelFailed: '配信のキャンセルに失敗しました',
|
||||
completeWithFailures: 'バックグラウンド配信完了: {{completed}} 件成功、{{failed}} 件失敗',
|
||||
completeSuccess: 'バックグラウンド配信完了: {{completed}} 件成功',
|
||||
},
|
||||
},
|
||||
stats: {
|
||||
title: '統計',
|
||||
overview: '概要',
|
||||
|
||||
Reference in New Issue
Block a user