Add resizable printer cards and Queue Only mode

Features:
  - Resizable printer cards (S/M/L/XL) with toolbar controls on Printers page
  - Queue Only mode for staging prints without automatic scheduling
    - "Queue Only" option in add/edit queue modals
    - Purple "Staged" badge and Play button to release to queue
    - manual_start field in database with migration

  Fixes:
  - Improved camera stream stuck detection with automatic reconnection

  Tests:
  - Added 16 integration tests for print queue API endpoints
This commit is contained in:
maziggy
2026-01-04 09:10:06 +01:00
parent 4a80888d95
commit 8639f39028
22 changed files with 1023 additions and 442 deletions
+22
View File
@@ -2,6 +2,28 @@
All notable changes to Bambuddy will be documented in this file.
## [0.1.6b6] - 2026-01-04
### Added
- **Resizable printer cards** - Adjust printer card size from the Printers page toolbar:
- Four sizes: Small, Medium (default), Large, XL
- Plus/minus buttons in toolbar header
- Size preference saved to localStorage
- Responsive grid adapts to selected size
- **Queue Only mode** - Stage prints without automatic scheduling:
- New "Queue Only" option when adding prints to queue
- Staged prints show purple "Staged" badge
- Play button to manually release staged prints to the queue
- Edit queue items to switch between ASAP, Scheduled, and Queue Only modes
- Useful for preparing print batches before activating
### Fixed
- **Camera stream reconnection** - Improved detection of stuck camera streams with automatic reconnection
### Tests
- Added integration tests for print queue API endpoints (16 new tests)
- Tests cover queue CRUD, manual_start flag, and start/cancel endpoints
## [0.1.6b5] - 2026-01-02
### Added
+2
View File
@@ -57,6 +57,7 @@
- Live camera streaming (MJPEG) & snapshots
- Fan status monitoring (part cooling, auxiliary, chamber)
- Printer control (stop, pause, resume)
- Resizable printer cards (S/M/L/XL)
- Skip objects during print
- AMS slot RFID re-read
- HMS error monitoring with history
@@ -68,6 +69,7 @@
### ⏰ Scheduling & Automation
- Print queue with drag-and-drop
- Scheduled prints (date/time)
- Queue Only mode (stage without auto-start)
- Smart plug integration (Tasmota)
- Energy consumption tracking
- Auto power-on before print
+2 -2
View File
@@ -167,7 +167,7 @@ async def generate_rtsp_mjpeg_stream(
ffmpeg = get_ffmpeg_path()
if not ffmpeg:
logger.error("ffmpeg not found - camera streaming requires ffmpeg")
yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
return
port = get_camera_port(model)
@@ -303,7 +303,7 @@ async def generate_rtsp_mjpeg_stream(
except FileNotFoundError:
logger.error("ffmpeg not found - camera streaming requires ffmpeg")
yield (b"--frame\r\n" b"Content-Type: text/plain\r\n\r\n" b"Error: ffmpeg not installed\r\n")
yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
except asyncio.CancelledError:
logger.info(f"Camera stream task cancelled (stream_id={stream_id})")
except GeneratorExit:
+32
View File
@@ -89,6 +89,7 @@ async def add_to_queue(
scheduled_time=data.scheduled_time,
require_previous_success=data.require_previous_success,
auto_off_after=data.auto_off_after,
manual_start=data.manual_start,
position=max_pos + 1,
status="pending",
)
@@ -272,3 +273,34 @@ async def stop_queue_item(
asyncio.create_task(cooldown_and_poweroff())
return {"message": "Print stopped" if stop_sent else "Queue item cancelled (printer was offline)"}
@router.post("/{item_id}/start")
async def start_queue_item(
item_id: int,
db: AsyncSession = Depends(get_db),
):
"""Manually start a staged (manual_start) queue item.
This clears the manual_start flag so the scheduler will pick it up,
or starts immediately if the printer is ready.
"""
result = await db.execute(
select(PrintQueueItem)
.options(selectinload(PrintQueueItem.archive), selectinload(PrintQueueItem.printer))
.where(PrintQueueItem.id == item_id)
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(404, "Queue item not found")
if item.status != "pending":
raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
# Clear manual_start flag so scheduler picks it up
item.manual_start = False
await db.commit()
await db.refresh(item, ["archive", "printer"])
logger.info(f"Manually started queue item {item_id} (cleared manual_start flag)")
return _enrich_response(item)
+2
View File
@@ -478,6 +478,7 @@ async def export_backup(
"scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None,
"require_previous_success": qi.require_previous_success,
"auto_off_after": qi.auto_off_after,
"manual_start": qi.manual_start,
"status": qi.status,
"started_at": qi.started_at.isoformat() if qi.started_at else None,
"completed_at": qi.completed_at.isoformat() if qi.completed_at else None,
@@ -1468,6 +1469,7 @@ async def import_backup(
position=qi_data.get("position", 0),
require_previous_success=qi_data.get("require_previous_success", False),
auto_off_after=qi_data.get("auto_off_after", False),
manual_start=qi_data.get("manual_start", False),
status=qi_data.get("status", "pending"),
error_message=qi_data.get("error_message"),
)
+6
View File
@@ -381,6 +381,12 @@ async def run_migrations(conn):
except Exception:
pass
# Migration: Add manual_start column to print_queue for staged prints
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0"))
except Exception:
pass
async def seed_notification_templates():
"""Seed default notification templates if they don't exist."""
+1 -1
View File
@@ -1616,7 +1616,7 @@ async def track_printer_runtime():
needs_commit = True
logger.debug(
f"[{printer.name}] Runtime tracking: added {int(elapsed)}s, "
f"total={printer.runtime_seconds}s ({printer.runtime_seconds/3600:.2f}h)"
f"total={printer.runtime_seconds}s ({printer.runtime_seconds / 3600:.2f}h)"
)
else:
logger.warning(
+1
View File
@@ -21,6 +21,7 @@ class PrintQueueItem(Base):
# Scheduling
position: Mapped[int] = mapped_column(Integer, default=0) # Queue order
scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = ASAP
manual_start: Mapped[bool] = mapped_column(Boolean, default=False) # Requires manual trigger to start
# Conditions
require_previous_success: Mapped[bool] = mapped_column(Boolean, default=False)
+3
View File
@@ -21,6 +21,7 @@ class PrintQueueItemCreate(BaseModel):
scheduled_time: datetime | None = None # None = ASAP (next when idle)
require_previous_success: bool = False
auto_off_after: bool = False # Power off printer after print completes
manual_start: bool = False # Requires manual trigger to start (staged)
class PrintQueueItemUpdate(BaseModel):
@@ -29,6 +30,7 @@ class PrintQueueItemUpdate(BaseModel):
scheduled_time: datetime | None = None
require_previous_success: bool | None = None
auto_off_after: bool | None = None
manual_start: bool | None = None
class PrintQueueItemResponse(BaseModel):
@@ -39,6 +41,7 @@ class PrintQueueItemResponse(BaseModel):
scheduled_time: UTCDatetime
require_previous_success: bool
auto_off_after: bool
manual_start: bool
status: Literal["pending", "printing", "completed", "failed", "skipped", "cancelled"]
started_at: UTCDatetime
completed_at: UTCDatetime
+1 -1
View File
@@ -566,7 +566,7 @@ class TasmotaScanner:
try:
await asyncio.gather(*tasks, return_exceptions=True)
except Exception as e:
logger.warning(f"Batch {i//batch_size} error: {e}")
logger.warning(f"Batch {i // batch_size} error: {e}")
self._scanned = min(i + batch_size, len(hosts))
logger.info(f"Tasmota scan complete. Found {len(self._discovered)} devices.")
+4
View File
@@ -72,6 +72,10 @@ class PrintScheduler:
if item.scheduled_time and item.scheduled_time > datetime.utcnow():
continue
# Skip items that require manual start
if item.manual_start:
continue
# Check if printer is idle
printer_idle = self._is_printer_idle(item.printer_id)
printer_connected = printer_manager.is_connected(item.printer_id)
+5 -9
View File
@@ -162,9 +162,7 @@ class SmartPlugManager:
)
return
logger.info(
f"Print completed successfully on printer {printer_id}, " f"scheduling turn-off for plug '{plug.name}'"
)
logger.info(f"Print completed successfully on printer {printer_id}, scheduling turn-off for plug '{plug.name}'")
if plug.off_delay_mode == "time":
self._schedule_delayed_off(plug, printer_id, plug.off_delay_minutes * 60)
@@ -227,7 +225,7 @@ class SmartPlugManager:
# Cancel any existing task for this plug
self._cancel_pending_off(plug.id)
logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' " f"(threshold: {temp_threshold}°C)")
logger.info(f"Scheduling temperature-based turn-off for plug '{plug.name}' (threshold: {temp_threshold}°C)")
# Mark as pending in database (survives restarts)
asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
@@ -281,9 +279,7 @@ class SmartPlugManager:
f"threshold={temp_threshold}°C"
)
else:
logger.info(
f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, " f"threshold={temp_threshold}°C"
)
logger.info(f"Temp check plug {plug_id}: nozzle={nozzle_temp}°C, threshold={temp_threshold}°C")
if max_nozzle_temp < temp_threshold:
# All nozzles are below threshold, turn off
@@ -398,7 +394,7 @@ class SmartPlugManager:
elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
if elapsed > 7200: # 2 hours
logger.warning(
f"Auto-off for plug '{plug.name}' was pending for {elapsed/60:.0f} minutes, "
f"Auto-off for plug '{plug.name}' was pending for {elapsed / 60:.0f} minutes, "
f"clearing stale pending state"
)
plug.auto_off_pending = False
@@ -406,7 +402,7 @@ class SmartPlugManager:
await db.commit()
continue
logger.info(f"Resuming pending auto-off for plug '{plug.name}' " f"(printer {plug.printer_id})")
logger.info(f"Resuming pending auto-off for plug '{plug.name}' (printer {plug.printer_id})")
# Resume the appropriate off mode
if plug.off_delay_mode == "temperature":
+1 -3
View File
@@ -577,9 +577,7 @@ class SpoolmanClient:
)
# Spool not found - auto-create it
logger.info(
f"Creating new spool in Spoolman for {tray.tray_sub_brands} " f"(tray_uuid: {tray.tray_uuid[:16]}...)"
)
logger.info(f"Creating new spool in Spoolman for {tray.tray_sub_brands} (tray_uuid: {tray.tray_uuid[:16]}...)")
# First find or create the filament type
filament = await self._find_or_create_filament(tray)
@@ -0,0 +1,446 @@
"""Integration tests for Print Queue API endpoints."""
import pytest
from httpx import AsyncClient
class TestPrintQueueAPI:
"""Integration tests for /api/v1/queue endpoints."""
@pytest.fixture
async def printer_factory(self, db_session):
"""Factory to create test printers."""
_counter = [0]
async def _create_printer(**kwargs):
from backend.app.models.printer import Printer
_counter[0] += 1
counter = _counter[0]
defaults = {
"name": f"Test Printer {counter}",
"ip_address": f"192.168.1.{100 + counter}",
"serial_number": f"TESTSERIAL{counter:04d}",
"access_code": "12345678",
"model": "X1C",
}
defaults.update(kwargs)
printer = Printer(**defaults)
db_session.add(printer)
await db_session.commit()
await db_session.refresh(printer)
return printer
return _create_printer
@pytest.fixture
async def archive_factory(self, db_session):
"""Factory to create test archives."""
_counter = [0]
async def _create_archive(**kwargs):
from backend.app.models.archive import PrintArchive
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"test_print_{counter}.3mf",
"print_name": f"Test Print {counter}",
"file_path": f"/tmp/test_print_{counter}.3mf",
"file_size": 1024,
"content_hash": f"testhash{counter:08d}",
"status": "completed",
}
defaults.update(kwargs)
archive = PrintArchive(**defaults)
db_session.add(archive)
await db_session.commit()
await db_session.refresh(archive)
return archive
return _create_archive
@pytest.fixture
async def queue_item_factory(self, db_session, printer_factory, archive_factory):
"""Factory to create test queue items."""
_counter = [0]
async def _create_queue_item(**kwargs):
from backend.app.models.print_queue import PrintQueueItem
_counter[0] += 1
counter = _counter[0]
# Create printer and archive if not provided
if "printer_id" not in kwargs:
printer = await printer_factory()
kwargs["printer_id"] = printer.id
if "archive_id" not in kwargs:
archive = await archive_factory()
kwargs["archive_id"] = archive.id
defaults = {
"status": "pending",
"position": counter,
}
defaults.update(kwargs)
item = PrintQueueItem(**defaults)
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
return item
return _create_queue_item
@pytest.mark.asyncio
@pytest.mark.integration
async def test_list_queue_empty(self, async_client: AsyncClient):
"""Verify empty list when no queue items exist."""
response = await async_client.get("/api/v1/queue/")
assert response.status_code == 200
assert isinstance(response.json(), list)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
"""Verify item can be added to queue."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["printer_id"] == printer.id
assert result["archive_id"] == archive.id
assert result["status"] == "pending"
assert result["manual_start"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_with_manual_start(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify item can be added to queue with manual_start=True."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"archive_id": archive.id,
"manual_start": True,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["printer_id"] == printer.id
assert result["archive_id"] == archive.id
assert result["status"] == "pending"
assert result["manual_start"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify single queue item can be retrieved."""
item = await queue_item_factory()
response = await async_client.get(f"/api/v1/queue/{item.id}")
assert response.status_code == 200
assert response.json()["id"] == item.id
@pytest.mark.asyncio
@pytest.mark.integration
async def test_get_queue_item_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent queue item."""
response = await async_client.get("/api/v1/queue/9999")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify queue item can be updated."""
item = await queue_item_factory()
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"auto_off_after": True})
assert response.status_code == 200
result = response.json()
assert result["auto_off_after"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_queue_item_manual_start(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify queue item manual_start can be updated."""
item = await queue_item_factory(manual_start=False)
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
assert response.status_code == 200
result = response.json()
assert result["manual_start"] is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify queue item can be deleted."""
item = await queue_item_factory()
response = await async_client.delete(f"/api/v1/queue/{item.id}")
assert response.status_code == 200
assert response.json()["message"] == "Queue item deleted"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
"""Verify 404 for deleting non-existent queue item."""
response = await async_client.delete("/api/v1/queue/9999")
assert response.status_code == 404
class TestQueueStartEndpoint:
"""Tests for the /queue/{item_id}/start endpoint."""
@pytest.fixture
async def printer_factory(self, db_session):
"""Factory to create test printers."""
_counter = [0]
async def _create_printer(**kwargs):
from backend.app.models.printer import Printer
_counter[0] += 1
counter = _counter[0]
defaults = {
"name": f"Test Printer {counter}",
"ip_address": f"192.168.1.{100 + counter}",
"serial_number": f"TESTSERIAL{counter:04d}",
"access_code": "12345678",
"model": "X1C",
}
defaults.update(kwargs)
printer = Printer(**defaults)
db_session.add(printer)
await db_session.commit()
await db_session.refresh(printer)
return printer
return _create_printer
@pytest.fixture
async def archive_factory(self, db_session):
"""Factory to create test archives."""
_counter = [0]
async def _create_archive(**kwargs):
from backend.app.models.archive import PrintArchive
_counter[0] += 1
counter = _counter[0]
defaults = {
"filename": f"test_print_{counter}.3mf",
"print_name": f"Test Print {counter}",
"file_path": f"/tmp/test_print_{counter}.3mf",
"file_size": 1024,
"content_hash": f"testhash{counter:08d}",
"status": "completed",
}
defaults.update(kwargs)
archive = PrintArchive(**defaults)
db_session.add(archive)
await db_session.commit()
await db_session.refresh(archive)
return archive
return _create_archive
@pytest.fixture
async def queue_item_factory(self, db_session, printer_factory, archive_factory):
"""Factory to create test queue items."""
_counter = [0]
async def _create_queue_item(**kwargs):
from backend.app.models.print_queue import PrintQueueItem
_counter[0] += 1
counter = _counter[0]
if "printer_id" not in kwargs:
printer = await printer_factory()
kwargs["printer_id"] = printer.id
if "archive_id" not in kwargs:
archive = await archive_factory()
kwargs["archive_id"] = archive.id
defaults = {
"status": "pending",
"position": counter,
}
defaults.update(kwargs)
item = PrintQueueItem(**defaults)
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
return item
return _create_queue_item
@pytest.mark.asyncio
@pytest.mark.integration
async def test_start_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify starting a staged (manual_start=True) queue item clears the flag."""
item = await queue_item_factory(manual_start=True)
assert item.manual_start is True
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
assert response.status_code == 200
result = response.json()
assert result["manual_start"] is False
assert result["status"] == "pending"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify starting a non-staged queue item still works (idempotent)."""
item = await queue_item_factory(manual_start=False)
assert item.manual_start is False
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
assert response.status_code == 200
result = response.json()
assert result["manual_start"] is False
@pytest.mark.asyncio
@pytest.mark.integration
async def test_start_queue_item_not_found(self, async_client: AsyncClient):
"""Verify 404 for non-existent queue item."""
response = await async_client.post("/api/v1/queue/9999/start")
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_start_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify 400 error when trying to start a non-pending queue item."""
item = await queue_item_factory(status="printing", manual_start=True)
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
assert response.status_code == 400
assert "pending" in response.json()["detail"].lower()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_start_completed_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify 400 error when trying to start a completed queue item."""
item = await queue_item_factory(status="completed", manual_start=True)
response = await async_client.post(f"/api/v1/queue/{item.id}/start")
assert response.status_code == 400
class TestQueueCancelEndpoint:
"""Tests for the /queue/{item_id}/cancel endpoint."""
@pytest.fixture
async def printer_factory(self, db_session):
"""Factory to create test printers."""
async def _create_printer(**kwargs):
from backend.app.models.printer import Printer
defaults = {
"name": "Cancel Test Printer",
"ip_address": "192.168.1.200",
"serial_number": "TESTCANCEL001",
"access_code": "12345678",
"model": "X1C",
}
defaults.update(kwargs)
printer = Printer(**defaults)
db_session.add(printer)
await db_session.commit()
await db_session.refresh(printer)
return printer
return _create_printer
@pytest.fixture
async def archive_factory(self, db_session):
"""Factory to create test archives."""
async def _create_archive(**kwargs):
from backend.app.models.archive import PrintArchive
defaults = {
"filename": "cancel_test.3mf",
"print_name": "Cancel Test Print",
"file_path": "/tmp/cancel_test.3mf",
"file_size": 1024,
"content_hash": "cancelhash001",
"status": "completed",
}
defaults.update(kwargs)
archive = PrintArchive(**defaults)
db_session.add(archive)
await db_session.commit()
await db_session.refresh(archive)
return archive
return _create_archive
@pytest.fixture
async def queue_item_factory(self, db_session, printer_factory, archive_factory):
"""Factory to create test queue items."""
async def _create_queue_item(**kwargs):
from backend.app.models.print_queue import PrintQueueItem
if "printer_id" not in kwargs:
printer = await printer_factory()
kwargs["printer_id"] = printer.id
if "archive_id" not in kwargs:
archive = await archive_factory()
kwargs["archive_id"] = archive.id
defaults = {
"status": "pending",
"position": 1,
}
defaults.update(kwargs)
item = PrintQueueItem(**defaults)
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
return item
return _create_queue_item
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify cancelling a pending queue item."""
item = await queue_item_factory(status="pending")
response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
assert response.status_code == 200
assert response.json()["message"] == "Queue item cancelled"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_cancel_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify 400 error when trying to cancel a non-pending queue item."""
item = await queue_item_factory(status="printing")
response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
assert response.status_code == 400
+5
View File
@@ -789,6 +789,7 @@ export interface PrintQueueItem {
scheduled_time: string | null;
require_previous_success: boolean;
auto_off_after: boolean;
manual_start: boolean; // Requires manual trigger to start (staged)
status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
started_at: string | null;
completed_at: string | null;
@@ -806,6 +807,7 @@ export interface PrintQueueItemCreate {
scheduled_time?: string | null;
require_previous_success?: boolean;
auto_off_after?: boolean;
manual_start?: boolean; // Requires manual trigger to start (staged)
}
export interface PrintQueueItemUpdate {
@@ -814,6 +816,7 @@ export interface PrintQueueItemUpdate {
scheduled_time?: string | null;
require_previous_success?: boolean;
auto_off_after?: boolean;
manual_start?: boolean;
}
// MQTT Logging types
@@ -1914,6 +1917,8 @@ export const api = {
request<{ message: string }>(`/queue/${id}/cancel`, { method: 'POST' }),
stopQueueItem: (id: number) =>
request<{ message: string }>(`/queue/${id}/stop`, { method: 'POST' }),
startQueueItem: (id: number) =>
request<PrintQueueItem>(`/queue/${id}/start`, { method: 'POST' }),
// K-Profiles
getKProfiles: (printerId: number, nozzleDiameter = '0.4') =>
+21 -6
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calendar, Clock, X, AlertCircle, Power } from 'lucide-react';
import { Calendar, Clock, X, AlertCircle, Power, Hand } from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItemCreate } from '../api/client';
import { Card, CardContent } from './Card';
@@ -18,7 +18,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
const { showToast } = useToast();
const [printerId, setPrinterId] = useState<number | null>(null);
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>('asap');
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>('asap');
const [scheduledTime, setScheduledTime] = useState('');
const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);
const [autoOffAfter, setAutoOffAfter] = useState(false);
@@ -68,6 +68,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
archive_id: archiveId,
require_previous_success: requirePreviousSuccess,
auto_off_after: autoOffAfter,
manual_start: scheduleType === 'manual',
};
if (scheduleType === 'scheduled' && scheduledTime) {
@@ -142,7 +143,7 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'asap'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -150,11 +151,11 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
onClick={() => setScheduleType('asap')}
>
<Clock className="w-4 h-4" />
ASAP (when idle)
ASAP
</button>
<button
type="button"
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'scheduled'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -164,6 +165,18 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
<Calendar className="w-4 h-4" />
Scheduled
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'manual'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('manual')}
>
<Hand className="w-4 h-4" />
Queue Only
</button>
</div>
</div>
@@ -215,7 +228,9 @@ export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueM
<p className="text-xs text-bambu-gray">
{scheduleType === 'asap'
? 'Print will start as soon as the printer is idle.'
: 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
: scheduleType === 'scheduled'
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
: 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
</p>
{/* Actions */}
+25 -8
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calendar, Clock, X, AlertCircle, Power, Pencil } from 'lucide-react';
import { Calendar, Clock, X, AlertCircle, Power, Pencil, Hand } from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
import { Card, CardContent } from './Card';
@@ -22,9 +22,11 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
const isPlaceholderDate = item.scheduled_time &&
new Date(item.scheduled_time).getTime() > Date.now() + (180 * 24 * 60 * 60 * 1000);
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled'>(
item.scheduled_time && !isPlaceholderDate ? 'scheduled' : 'asap'
);
const [scheduleType, setScheduleType] = useState<'asap' | 'scheduled' | 'manual'>(() => {
if (item.manual_start) return 'manual';
if (item.scheduled_time && !isPlaceholderDate) return 'scheduled';
return 'asap';
});
const [scheduledTime, setScheduledTime] = useState(() => {
if (item.scheduled_time && !isPlaceholderDate) {
// Convert ISO to local datetime-local format
@@ -69,6 +71,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
printer_id: printerId,
require_previous_success: requirePreviousSuccess,
auto_off_after: autoOffAfter,
manual_start: scheduleType === 'manual',
};
if (scheduleType === 'scheduled' && scheduledTime) {
@@ -146,7 +149,7 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
<div className="flex gap-2">
<button
type="button"
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'asap'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -154,11 +157,11 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
onClick={() => setScheduleType('asap')}
>
<Clock className="w-4 h-4" />
ASAP (when idle)
ASAP
</button>
<button
type="button"
className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'scheduled'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
@@ -168,6 +171,18 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
<Calendar className="w-4 h-4" />
Scheduled
</button>
<button
type="button"
className={`flex-1 px-2 py-2 rounded-lg border text-sm flex items-center justify-center gap-1.5 transition-colors ${
scheduleType === 'manual'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('manual')}
>
<Hand className="w-4 h-4" />
Queue Only
</button>
</div>
</div>
@@ -219,7 +234,9 @@ export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
<p className="text-xs text-bambu-gray">
{scheduleType === 'asap'
? 'Print will start as soon as the printer is idle.'
: 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}
: scheduleType === 'scheduled'
? 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'
: 'Print will be staged but won\'t start automatically. Use the Start button to release it to the queue.'}
</p>
{/* Actions */}
+33 -1
View File
@@ -40,6 +40,7 @@ import {
Layers,
ArrowUp,
ArrowDown,
Hand,
} from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItem } from '../api/client';
@@ -101,6 +102,7 @@ function SortableQueueItem({
onRemove,
onStop,
onRequeue,
onStart,
}: {
item: PrintQueueItem;
position?: number;
@@ -109,6 +111,7 @@ function SortableQueueItem({
onRemove: () => void;
onStop: () => void;
onRequeue: () => void;
onStart: () => void;
}) {
const {
attributes,
@@ -198,7 +201,7 @@ function SortableQueueItem({
{formatDuration(item.print_time_seconds)}
</span>
)}
{isPending && (
{isPending && !item.manual_start && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{formatRelativeTime(item.scheduled_time)}
@@ -208,6 +211,12 @@ function SortableQueueItem({
{/* Options badges */}
<div className="flex items-center gap-2 mt-2">
{item.manual_start && (
<span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
<Hand className="w-3 h-3" />
Staged
</span>
)}
{item.require_previous_success && (
<span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
Requires previous success
@@ -258,6 +267,17 @@ function SortableQueueItem({
)}
{isPending && (
<>
{item.manual_start && (
<Button
variant="ghost"
size="sm"
onClick={onStart}
title="Start Print"
className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
>
<Play className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
@@ -393,6 +413,15 @@ export function QueuePage() {
onError: () => showToast('Failed to stop print', 'error'),
});
const startMutation = useMutation({
mutationFn: (id: number) => api.startQueueItem(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Print released to queue');
},
onError: () => showToast('Failed to start print', 'error'),
});
const reorderMutation = useMutation({
mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
onSuccess: () => {
@@ -627,6 +656,7 @@ export function QueuePage() {
onRemove={() => {}}
onStop={() => setConfirmAction({ type: 'stop', item })}
onRequeue={() => {}}
onStart={() => {}}
/>
))}
</div>
@@ -689,6 +719,7 @@ export function QueuePage() {
onRemove={() => {}}
onStop={() => {}}
onRequeue={() => {}}
onStart={() => startMutation.mutate(item.id)}
/>
))}
</div>
@@ -740,6 +771,7 @@ export function QueuePage() {
onRemove={() => setConfirmAction({ type: 'remove', item })}
onStop={() => {}}
onRequeue={() => setRequeueItem(item)}
onStart={() => {}}
/>
))}
</div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-BWv5Xlm9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ta6Ao_MI.css">
<script type="module" crossorigin src="/assets/index-DXIzheUf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YKsbWJQ9.css">
</head>
<body>
<div id="root"></div>