Location filter for queue and auth fixes (Issue #220)

Features:
- Add location filter for "Any {Model}" queue assignments
- Queue items can target a specific location (e.g., "Any X1C in Workshop")
- Location dropdown filter on Queue page to view jobs by location
- Scheduler considers location when assigning model-based jobs

Closes #220
This commit is contained in:
maziggy 2026-02-02 07:39:58 +01:00
parent c31f296888
commit 018a744475
22 changed files with 652 additions and 131 deletions

3
.gitignore vendored
View file

@ -58,3 +58,6 @@ firmware/
node_modules/
data/
# JWT secret file (should be in data dir, but protect project root too)
.jwt_secret

View file

@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
## [0.1.7b] - Not released
### Enhancements
- **Location Filter for Queue** (Issue #220):
- Filter queue jobs by printer location in the Queue page
- "Any {Model}" queue assignments can now specify a target location (e.g., "Any X1C in Workshop")
- Location filter dropdown shows all unique locations from printers and queue items
- Location is saved with queue items and displayed in the queue list
- **Ownership-Based Permissions** (Issue #205):
- Users can now only update/delete their own items unless they have elevated permissions
- Update/delete permissions split into `*_own` and `*_all` variants:
@ -51,6 +56,8 @@ All notable changes to Bambuddy will be documented in this file.
- Removed ~2000 lines of legacy JSON-based backup/restore code
### Fixes
- **JWT secret key not persistent across restarts** - Fixed JWT secret key generation to properly use data directory, ensuring tokens remain valid across container restarts
- **Images/thumbnails returning 401 when auth enabled** - Fixed auth middleware to allow public access to image/media endpoints (thumbnails, photos, QR codes, timelapses, camera streams) since browser elements like `<img>` don't send Authorization headers
- **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems:
- Library now stores relative paths in database for portability
- Automatic migration converts existing absolute paths to relative on startup

View file

@ -77,7 +77,7 @@
### ⏰ Scheduling & Automation
- Print queue with drag-and-drop
- Multi-printer selection (send to multiple printers at once)
- Model-based queue assignment (send to "any X1C" for load balancing)
- Model-based queue assignment (send to "any X1C" for load balancing) with location filtering
- Filament validation (only assign to printers with required filaments)
- Per-printer AMS mapping (individual slot configuration for print farms)
- Scheduled prints (date/time)

View file

@ -121,6 +121,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
"id": item.id,
"printer_id": item.printer_id,
"target_model": item.target_model,
"target_location": item.target_location,
"required_filament_types": required_filament_types_parsed,
"waiting_reason": item.waiting_reason,
"archive_id": item.archive_id,
@ -289,6 +290,7 @@ async def add_to_queue(
item = PrintQueueItem(
printer_id=data.printer_id,
target_model=target_model_norm,
target_location=data.target_location,
required_filament_types=required_filament_types,
archive_id=data.archive_id,
library_file_id=data.library_file_id,

View file

@ -48,7 +48,13 @@ def _get_jwt_secret() -> str:
return env_secret
# 2. Check for secret file in data directory
data_dir = Path(os.environ.get("BAMBUDDY_DATA_DIR", "/app/data"))
# Use DATA_DIR env var (same as rest of app), fallback to data/ subdirectory
data_dir_env = os.environ.get("DATA_DIR")
if data_dir_env:
data_dir = Path(data_dir_env)
else:
# Fallback to data/ subdirectory under project root (not project root itself!)
data_dir = Path(__file__).parent.parent.parent.parent / "data"
secret_file = data_dir / ".jwt_secret"
if secret_file.exists():

View file

@ -1044,6 +1044,12 @@ async def run_migrations(conn):
except Exception:
pass
# Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
try:
await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)"))
except Exception:
pass
# Migration: Convert absolute paths to relative paths in library_files table
# This ensures backup/restore portability across different installations
try:

View file

@ -2543,11 +2543,14 @@ app = FastAPI(
# =============================================================================
# Public routes that don't require authentication even when auth is enabled
PUBLIC_API_ROUTES = {
# Auth routes needed before login
# Auth routes needed before/during login
"/api/v1/auth/status",
"/api/v1/auth/login",
"/api/v1/auth/setup", # Needed for initial setup and recovery
# Version check for updates (no sensitive data)
"/api/v1/updates/version",
# Metrics endpoint handles its own prometheus_token authentication
"/api/v1/metrics",
}
# Route prefixes that are public (for routes with dynamic segments)
@ -2556,6 +2559,25 @@ PUBLIC_API_PREFIXES = [
"/api/v1/ws",
]
# Route patterns that are public (read-only display data)
# These are checked with "in path" - needed because browsers load images/videos
# via <img src> and <video src> which don't include Authorization headers
PUBLIC_API_PATTERNS = [
# Thumbnails
"/thumbnail", # /archives/{id}/thumbnail, /library/files/{id}/thumbnail
"/plate-thumbnail/", # /archives/{id}/plate-thumbnail/{plate_id}
# Images and media
"/photos/", # /archives/{id}/photos/{filename}
"/project-image/", # /archives/{id}/project-image/{path}
"/qrcode", # /archives/{id}/qrcode
"/timelapse", # /archives/{id}/timelapse (video)
"/cover", # /printers/{id}/cover
"/icon", # /external-links/{id}/icon
# Camera (streams loaded via <img> tag)
"/camera/stream", # /printers/{id}/camera/stream
"/camera/snapshot", # /printers/{id}/camera/snapshot
]
@app.middleware("http")
async def auth_middleware(request, call_next):
@ -2581,6 +2603,11 @@ async def auth_middleware(request, call_next):
if path.startswith(prefix):
return await call_next(request)
# Allow public patterns (read-only display data like thumbnails)
for pattern in PUBLIC_API_PATTERNS:
if pattern in path:
return await call_next(request)
# Check if auth is enabled
try:
async with async_session() as db:

View file

@ -18,6 +18,9 @@ class PrintQueueItem(Base):
# Target printer model for model-based assignment (mutually exclusive with printer_id)
# When set, scheduler assigns to any idle printer of matching model
target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Target location filter for model-based assignment (only used with target_model)
# When set, only printers in this location are considered
target_location: Mapped[str | None] = mapped_column(String(100), nullable=True)
# Required filament types for model-based assignment (JSON array, e.g., '["PLA", "PETG"]')
# Used by scheduler to validate printer has compatible filaments loaded
required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)

View file

@ -18,6 +18,7 @@ UTCDatetime = Annotated[datetime | None, PlainSerializer(serialize_utc_datetime)
class PrintQueueItemCreate(BaseModel):
printer_id: int | None = None # None = unassigned, user assigns later
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
target_location: str | None = None # Target location filter (only used with target_model)
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
# Either archive_id OR library_file_id must be provided
archive_id: int | None = None
@ -43,6 +44,7 @@ class PrintQueueItemCreate(BaseModel):
class PrintQueueItemUpdate(BaseModel):
printer_id: int | None = None
target_model: str | None = None # Target printer model (mutually exclusive with printer_id)
target_location: str | None = None # Target location filter (only used with target_model)
position: int | None = None
scheduled_time: datetime | None = None
require_previous_success: bool | None = None
@ -63,6 +65,7 @@ class PrintQueueItemResponse(BaseModel):
id: int
printer_id: int | None # None = unassigned
target_model: str | None = None # Target printer model for model-based assignment
target_location: str | None = None # Target location filter for model-based assignment
required_filament_types: list[str] | None = None # Required filament types for model-based assignment
waiting_reason: str | None = None # Why a model-based job hasn't started yet
archive_id: int | None # None if library_file_id is set (archive created at print start)

View file

@ -148,7 +148,7 @@ class PrintScheduler:
pass
printer_id, waiting_reason = await self._find_idle_printer_for_model(
db, item.target_model, busy_printers, required_types
db, item.target_model, busy_printers, required_types, item.target_location
)
# Update waiting_reason if changed and send notification when first waiting
@ -225,6 +225,7 @@ class PrintScheduler:
model: str,
exclude_ids: set[int],
required_filament_types: list[str] | None = None,
target_location: str | None = None,
) -> tuple[int | None, str | None]:
"""Find an idle, connected printer matching the model with compatible filaments.
@ -234,6 +235,7 @@ class PrintScheduler:
exclude_ids: Printer IDs to exclude (already busy)
required_filament_types: Optional list of filament types needed (e.g., ["PLA", "PETG"])
If provided, only printers with all required types loaded will match.
target_location: Optional location filter. If provided, only printers in this location are considered.
Returns:
Tuple of (printer_id, waiting_reason):
@ -242,15 +244,22 @@ class PrintScheduler:
"""
# Normalize model name and use case-insensitive matching
normalized_model = normalize_printer_model(model) or model
result = await db.execute(
query = (
select(Printer)
.where(func.lower(Printer.model) == normalized_model.lower())
.where(Printer.is_active == True) # noqa: E712
)
# Add location filter if specified
if target_location:
query = query.where(Printer.location == target_location)
result = await db.execute(query)
printers = list(result.scalars().all())
location_suffix = f" in {target_location}" if target_location else ""
if not printers:
return None, f"No active {normalized_model} printers configured"
return None, f"No active {normalized_model} printers{location_suffix} configured"
# Track reasons for skipping printers
printers_busy = []
@ -295,7 +304,7 @@ class PrintScheduler:
if printers_offline:
reasons.append(f"Offline: {', '.join(printers_offline)}")
return None, " | ".join(reasons) if reasons else f"No available {model} printers"
return None, " | ".join(reasons) if reasons else f"No available {model} printers{location_suffix}"
def _get_missing_filament_types(self, printer_id: int, required_types: list[str]) -> list[str]:
"""Get the list of required filament types that are not loaded on the printer.

View file

@ -689,3 +689,88 @@ class TestChangePasswordAPI:
)
assert response.status_code == 401
class TestAuthMiddlewarePublicRoutes:
"""Tests for auth middleware public route configuration.
These routes must be accessible without authentication, even when auth is enabled,
because browser elements like <img src> and <video src> don't send Authorization headers.
"""
@pytest.fixture
async def enabled_auth(self, async_client: AsyncClient):
"""Enable auth for testing middleware behavior."""
await async_client.post(
"/api/v1/auth/setup",
json={
"auth_enabled": True,
"admin_username": "middlewareadmin",
"admin_password": "adminpassword123",
},
)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):
"""Verify /api/v1/auth/status is accessible without auth."""
response = await async_client.get("/api/v1/auth/status")
assert response.status_code == 200
assert "auth_enabled" in response.json()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth):
"""Verify /api/v1/auth/login is accessible without auth."""
response = await async_client.post(
"/api/v1/auth/login",
json={"username": "middlewareadmin", "password": "adminpassword123"},
)
# Should not return 401 (unauthorized) - it should either succeed or return
# a different error (like 400 for wrong credentials)
assert response.status_code != 401 or "token" in response.json()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_auth_setup_is_public(self, async_client: AsyncClient):
"""Verify /api/v1/auth/setup is accessible without auth (needed for setup/recovery)."""
# Don't enable auth first - test that setup endpoint itself is accessible
response = await async_client.post(
"/api/v1/auth/setup",
json={"auth_enabled": False},
)
# Should not be 401
assert response.status_code != 401
@pytest.mark.asyncio
@pytest.mark.integration
async def test_updates_version_is_public(self, async_client: AsyncClient, enabled_auth):
"""Verify /api/v1/updates/version is accessible without auth."""
response = await async_client.get("/api/v1/updates/version")
# Should not be 401
assert response.status_code != 401
@pytest.mark.asyncio
@pytest.mark.integration
async def test_protected_route_requires_auth(self, async_client: AsyncClient, enabled_auth):
"""Verify non-public routes return 401 without token."""
response = await async_client.get("/api/v1/printers/")
assert response.status_code == 401
@pytest.mark.asyncio
@pytest.mark.integration
async def test_protected_route_works_with_token(self, async_client: AsyncClient, enabled_auth):
"""Verify non-public routes work with valid token."""
# Login to get token
login_response = await async_client.post(
"/api/v1/auth/login",
json={"username": "middlewareadmin", "password": "adminpassword123"},
)
token = login_response.json()["access_token"]
# Access protected route
response = await async_client.get(
"/api/v1/printers/",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200

View file

@ -963,3 +963,216 @@ class TestBulkUpdateEndpoint:
)
assert response.status_code == 400
assert "printer not found" in response.json()["detail"].lower()
class TestTargetLocationFeature:
"""Tests for queue items with target_location (Issue #220)."""
@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"Location Test Printer {counter}",
"ip_address": f"192.168.1.{50 + counter}",
"serial_number": f"TESTLOC{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"location_test_{counter}.3mf",
"print_name": f"Location Test Print {counter}",
"file_path": f"/tmp/location_test_{counter}.3mf",
"file_size": 1024,
"content_hash": f"lochash{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 and "target_model" 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_add_to_queue_with_target_location(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify item can be added with target_model and target_location."""
# Create a printer with model X1C so the API can validate
await printer_factory(model="X1C", location="Office")
archive = await archive_factory()
data = {
"target_model": "X1C",
"target_location": "Workbench",
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
assert response.status_code == 200
result = response.json()
assert result["target_model"] == "X1C"
assert result["target_location"] == "Workbench"
assert result["printer_id"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_add_to_queue_location_without_model_ignored(
self, async_client: AsyncClient, printer_factory, archive_factory, db_session
):
"""Verify target_location without target_model is allowed (location is just ignored)."""
printer = await printer_factory()
archive = await archive_factory()
data = {
"printer_id": printer.id,
"target_location": "Workbench", # This gets ignored since printer_id is set
"archive_id": archive.id,
}
response = await async_client.post("/api/v1/queue/", json=data)
# The API accepts this but the location is only used with target_model
assert response.status_code == 200
result = response.json()
assert result["printer_id"] == printer.id
# Location may or may not be stored since it's meaningless without target_model
# The important thing is the request succeeds
@pytest.mark.asyncio
@pytest.mark.integration
async def test_queue_item_target_location_in_response(
self, async_client: AsyncClient, queue_item_factory, db_session
):
"""Verify target_location is returned in queue item response."""
item = await queue_item_factory(
printer_id=None,
target_model="X1C",
target_location="Workshop",
)
response = await async_client.get(f"/api/v1/queue/{item.id}")
assert response.status_code == 200
result = response.json()
assert result["target_model"] == "X1C"
assert result["target_location"] == "Workshop"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_queue_list_includes_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify target_location is included in queue list."""
await queue_item_factory(
printer_id=None,
target_model="P1S",
target_location="Garage",
)
response = await async_client.get("/api/v1/queue/")
assert response.status_code == 200
items = response.json()
assert len(items) >= 1
# Find our item
our_item = next((i for i in items if i["target_location"] == "Garage"), None)
assert our_item is not None
assert our_item["target_model"] == "P1S"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_update_queue_item_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify target_location can be updated on existing queue item."""
item = await queue_item_factory(
printer_id=None,
target_model="X1C",
target_location="Office",
)
response = await async_client.patch(
f"/api/v1/queue/{item.id}",
json={"target_location": "Basement"},
)
assert response.status_code == 200
result = response.json()
assert result["target_location"] == "Basement"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
"""Verify target_location can be cleared (set to None)."""
item = await queue_item_factory(
printer_id=None,
target_model="X1C",
target_location="Office",
)
# Note: Setting to empty string should clear it
response = await async_client.patch(
f"/api/v1/queue/{item.id}",
json={"target_location": None},
)
assert response.status_code == 200
result = response.json()
assert result["target_location"] is None

View file

@ -1067,6 +1067,7 @@ export interface PrintQueueItem {
id: number;
printer_id: number | null; // null = unassigned
target_model: string | null; // Target printer model for model-based assignment
target_location: string | null; // Target location filter for model-based assignment
required_filament_types: string[] | null; // Required filament types for model-based assignment
waiting_reason: string | null; // Why a model-based job hasn't started yet
// Either archive_id OR library_file_id must be set (archive created at print start)
@ -1105,6 +1106,7 @@ export interface PrintQueueItem {
export interface PrintQueueItemCreate {
printer_id?: number | null; // null = unassigned
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
target_location?: string | null; // Target location filter (only used with target_model)
// Either archive_id OR library_file_id must be provided
archive_id?: number | null;
library_file_id?: number | null;
@ -1126,6 +1128,7 @@ export interface PrintQueueItemCreate {
export interface PrintQueueItemUpdate {
printer_id?: number | null; // null = unassign
target_model?: string | null; // Target printer model (mutually exclusive with printer_id)
target_location?: string | null; // Target location filter (only used with target_model)
position?: number;
scheduled_time?: string | null;
require_previous_success?: boolean;

View file

@ -38,6 +38,10 @@ interface PrinterSelectorWithMappingProps extends PrinterSelectorProps {
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Selected target location (when assignmentMode is 'model') */
targetLocation?: string | null;
/** Handler for target location change */
onTargetLocationChange?: (location: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}
@ -227,6 +231,8 @@ export function PrinterSelector({
onAssignmentModeChange,
targetModel,
onTargetModelChange,
targetLocation,
onTargetLocationChange,
slicedForModel,
}: PrinterSelectorWithMappingProps) {
// State for showing all printers vs only matching model
@ -257,6 +263,16 @@ export function PrinterSelector({
return [...new Set(models)].sort();
}, [activePrinters]);
// Get unique locations for the selected target model (for location filtering)
const uniqueLocations = useMemo(() => {
if (!targetModel) return [];
const locations = activePrinters
.filter(p => p.model === targetModel && p.location)
.map(p => p.location)
.filter((l): l is string => Boolean(l));
return [...new Set(locations)].sort();
}, [activePrinters, targetModel]);
// Check if model-based assignment is available (need callbacks and multiple printers of same model)
const modelAssignmentAvailable = onAssignmentModeChange && onTargetModelChange && uniqueModels.length > 0;
@ -370,11 +386,59 @@ export function PrinterSelector({
</div>
)}
{/* Model info (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && targetModel && (
<p className="text-xs text-bambu-gray mb-4">
Scheduler will assign to first available idle {targetModel} printer
</p>
{/* Model selection and location filter (when in model mode) */}
{assignmentMode === 'model' && modelAssignmentAvailable && (
<div className="space-y-3 mb-4">
{/* Model selector */}
<div>
<label className="block text-xs text-bambu-gray mb-1">Target Model</label>
<select
value={targetModel || ''}
onChange={(e) => {
onTargetModelChange!(e.target.value || null);
// Clear location when model changes
if (onTargetLocationChange) {
onTargetLocationChange(null);
}
}}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
>
<option value="">Select a model...</option>
{uniqueModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
{/* Location filter (only show when target model is selected and locations exist) */}
{targetModel && uniqueLocations.length > 0 && onTargetLocationChange && (
<div>
<label className="block text-xs text-bambu-gray mb-1">Location Filter (optional)</label>
<select
value={targetLocation || ''}
onChange={(e) => onTargetLocationChange(e.target.value || null)}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
>
<option value="">Any location</option>
{uniqueLocations.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
)}
{/* Info text */}
{targetModel && (
<p className="text-xs text-bambu-gray">
Scheduler will assign to first available idle {targetModel} printer
{targetLocation ? ` in ${targetLocation}` : ''}
</p>
)}
</div>
)}
{/* Multi-select header (only in printer mode) */}

View file

@ -135,6 +135,14 @@ export function PrintModal({
return null;
});
// Target location for model-based assignment (optional filter)
const [targetLocation, setTargetLocation] = useState<string | null>(() => {
if (mode === 'edit-queue-item' && queueItem?.target_location) {
return queueItem.target_location;
}
return null;
});
// Track initial values for clearing mappings on change (edit mode only)
const [initialPrinterIds] = useState(() => (mode === 'edit-queue-item' && queueItem?.printer_id ? [queueItem.printer_id] : []));
const [initialPlateId] = useState(() => (mode === 'edit-queue-item' && queueItem ? queueItem.plate_id : null));
@ -369,6 +377,7 @@ export function PrintModal({
const getQueueData = (printerId: number | null): PrintQueueItemCreate => ({
printer_id: assignmentMode === 'printer' ? printerId : null,
target_model: assignmentMode === 'model' ? targetModel : null,
target_location: assignmentMode === 'model' ? targetLocation : null,
// Use library_file_id for library files, archive_id for archives
archive_id: isLibraryFile ? undefined : archiveId,
library_file_id: isLibraryFile ? libraryFileId : undefined,
@ -397,6 +406,7 @@ export function PrintModal({
const updateData: PrintQueueItemUpdate = {
printer_id: null,
target_model: targetModel,
target_location: targetLocation,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
@ -445,6 +455,7 @@ export function PrintModal({
const updateData: PrintQueueItemUpdate = {
printer_id: printerId,
target_model: null,
target_location: null,
require_previous_success: scheduleOptions.requirePreviousSuccess,
auto_off_after: scheduleOptions.autoOffAfter,
manual_start: scheduleOptions.scheduleType === 'manual',
@ -626,6 +637,8 @@ export function PrintModal({
onAssignmentModeChange={mode !== 'reprint' ? setAssignmentMode : undefined}
targetModel={targetModel}
onTargetModelChange={mode !== 'reprint' ? setTargetModel : undefined}
targetLocation={targetLocation}
onTargetLocationChange={mode !== 'reprint' ? setTargetLocation : undefined}
slicedForModel={slicedForModel}
/>

View file

@ -130,6 +130,10 @@ export interface PrinterSelectorProps {
targetModel?: string | null;
/** Handler for target model change */
onTargetModelChange?: (model: string | null) => void;
/** Selected target location (when assignmentMode is 'model') */
targetLocation?: string | null;
/** Handler for target location change */
onTargetLocationChange?: (location: string | null) => void;
/** Suggested model from sliced file (for pre-selection) */
slicedForModel?: string | null;
}

View file

@ -3548,6 +3548,7 @@ function AddPrinterModal({
ip_address: '',
access_code: '',
model: '',
location: '',
auto_archive: true,
});
@ -3894,6 +3895,17 @@ function AddPrinterModal({
</optgroup>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">Location / Group (optional)</label>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={form.location || ''}
onChange={(e) => setForm({ ...form, location: e.target.value })}
placeholder="e.g., Workshop, Office, Basement"
/>
<p className="text-xs text-bambu-gray mt-1">Used to group printers and filter queue jobs</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
@ -4265,7 +4277,7 @@ function EditPrinterModal({
onChange={(e) => setForm({ ...form, location: e.target.value })}
placeholder="e.g., Workshop, Office, Basement"
/>
<p className="text-xs text-bambu-gray mt-1">Used to group printers on the dashboard</p>
<p className="text-xs text-bambu-gray mt-1">Used to group printers and filter queue jobs</p>
</div>
<div className="flex items-center gap-2">
<input

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
@ -419,7 +419,7 @@ function SortableQueueItem({
<span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
<Printer className="w-3.5 h-3.5" />
{item.target_model
? `Any ${item.target_model}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
? `Any ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
: item.printer_id === null
? 'Unassigned'
: (item.printer_name || `Printer #${item.printer_id}`)}
@ -579,6 +579,7 @@ export function QueuePage() {
const { hasPermission, hasAnyPermission, canModify } = useAuth();
const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('');
const [filterLocation, setFilterLocation] = useState<string>('');
const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
const [requeueItem, setRequeueItem] = useState<PrintQueueItem | null>(null);
@ -738,8 +739,39 @@ export function QueuePage() {
);
};
// Get unique locations from printers for the filter dropdown
const uniqueLocations = useMemo(() => {
const locations = new Set<string>();
printers?.forEach(p => {
if (p.location) locations.add(p.location);
});
// Also include locations from queue items (for model-based assignments)
queue?.forEach(item => {
if (item.target_location) locations.add(item.target_location);
});
return Array.from(locations).sort();
}, [printers, queue]);
// Helper to check if a queue item matches the location filter
const matchesLocationFilter = useCallback((item: PrintQueueItem): boolean => {
if (!filterLocation) return true;
// For model-based assignments, check target_location
if (item.target_location) return item.target_location === filterLocation;
// For printer-based assignments, check the printer's location
if (item.printer_id) {
const printer = printers?.find(p => p.id === item.printer_id);
return printer?.location === filterLocation;
}
return false;
}, [filterLocation, printers]);
const pendingItems = useMemo(() => {
const items = queue?.filter(i => i.status === 'pending') || [];
let items = queue?.filter(i => i.status === 'pending') || [];
// Apply location filter
if (filterLocation) {
items = items.filter(matchesLocationFilter);
}
// Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
const getScheduledTime = (item: PrintQueueItem): number => {
@ -766,7 +798,7 @@ export function QueuePage() {
}
return pendingSortAsc ? cmp : -cmp;
});
}, [queue, pendingSortBy, pendingSortAsc]);
}, [queue, pendingSortBy, pendingSortAsc, matchesLocationFilter, filterLocation]);
const handleSelectAll = () => {
const allPendingIds = pendingItems.map(i => i.id);
@ -777,9 +809,19 @@ export function QueuePage() {
}
};
const activeItems = queue?.filter(i => i.status === 'printing') || [];
const activeItems = useMemo(() => {
let items = queue?.filter(i => i.status === 'printing') || [];
if (filterLocation) {
items = items.filter(matchesLocationFilter);
}
return items;
}, [queue, filterLocation, matchesLocationFilter]);
const historyItems = useMemo(() => {
const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
let items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
if (filterLocation) {
items = items.filter(matchesLocationFilter);
}
return [...items].sort((a, b) => {
let cmp: number;
if (historySortBy === 'name') {
@ -794,7 +836,7 @@ export function QueuePage() {
}
return historySortAsc ? -cmp : cmp;
});
}, [queue, historySortBy, historySortAsc]);
}, [queue, historySortBy, historySortAsc, matchesLocationFilter, filterLocation]);
// Calculate total queue time
const totalQueueTime = useMemo(() => {
@ -923,6 +965,19 @@ export function QueuePage() {
<option value="cancelled">Cancelled</option>
</select>
{uniqueLocations.length > 0 && (
<select
className="px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={filterLocation}
onChange={(e) => setFilterLocation(e.target.value)}
>
<option value="">All Locations</option>
{uniqueLocations.map((loc) => (
<option key={loc} value={loc}>{loc}</option>
))}
</select>
)}
<div className="flex-1" />
{historyItems.length > 0 && (

View file

@ -7,6 +7,13 @@ export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
pool: 'threads',
poolOptions: {
threads: {
maxThreads: 14,
minThreads: 4,
},
},
environmentOptions: {
jsdom: {
url: 'http://localhost:3000',

View file

@ -1 +0,0 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><g><path d="m153.221 184.577c19.326 4.833 44.858 7.496 71.895 7.496 27.024 0 52.549-2.662 71.871-7.496 24.458-6.118 36.347-14.976 36.347-27.081 0-12.092-11.89-20.943-36.349-27.057-19.327-4.831-44.85-7.491-71.869-7.491-27.03 0-52.562 2.661-71.893 7.491-24.462 6.113-36.353 14.963-36.353 27.057 0 12.105 11.89 20.963 36.351 27.081zm10.048-35.938c17.113-3.67 39.078-5.691 61.847-5.691 22.759 0 44.715 2.021 61.824 5.691 14.725 3.159 22.072 6.719 25.102 8.864-3.025 2.146-10.368 5.711-25.093 8.873-17.105 3.673-39.065 5.696-61.834 5.696-22.779 0-44.748-2.023-61.857-5.696-14.727-3.162-22.071-6.727-25.098-8.873 3.033-2.145 10.381-5.705 25.109-8.864z"/><path d="m511.621 299.939c-8.759-30.997-41.987-53.503-69.154-61.298-14.206-4.075-27.122-4.542-37.871-1.528v-19.488c29.607-16.814 45.608-37.674 45.608-60.13 0-27.828-24.579-53.21-69.209-71.469-41.8-17.101-97.159-26.518-155.879-26.518-58.733 0-114.101 9.418-155.904 26.518-44.632 18.259-69.212 43.64-69.212 71.47 0 22.464 16.011 43.33 45.636 60.146v76.727c-29.882 16.917-45.636 37.623-45.636 60.135 0 27.84 24.58 53.226 69.213 71.48 41.796 17.094 97.164 26.508 155.904 26.508 58.728 0 114.087-9.414 155.879-26.508 44.63-18.255 69.209-43.64 69.209-71.48 0-22.505-15.744-43.211-45.608-60.134v-35.729c7.473-4.29 18.961-4.619 32.356-.776 26.055 7.475 49.882 27.901 55.423 47.511 1.245 4.406 5.255 7.283 9.618 7.283.899 0 1.815-.123 2.724-.379 5.314-1.5 8.404-7.026 6.903-12.341zm-445.985 30.134c31.44 17.594 91.184 23.268 113.866 24.826.233.016.465.024.695.024 5.211 0 9.604-4.039 9.967-9.315.378-5.51-3.781-10.284-9.291-10.662-34.324-2.358-65.154-7.512-86.81-14.513-23.602-7.63-28.427-14.866-28.427-16.386v-24.377c9.234 5.161 21.644 9.701 37.204 13.591 32.773 8.195 76.199 12.707 122.276 12.707 46.066 0 89.487-4.513 122.264-12.707 15.565-3.891 27.979-8.432 37.216-13.594v24.381c0 1.52-4.827 8.755-28.44 16.386-21.663 7-52.499 12.155-86.826 14.513-5.51.378-9.669 5.152-9.291 10.662.362 5.277 4.756 9.315 9.967 9.315.229 0 .462-.008.695-.024 22.687-1.559 82.449-7.234 113.895-24.83v24.435c0 2.06-7.14 11.536-42.066 20.268-31.237 7.809-72.935 12.11-117.413 12.11-44.489 0-86.191-4.301-117.424-12.11-34.918-8.731-42.055-18.208-42.055-20.268v-24.432zm-45.636-172.577c0-18.893 20.697-38.195 56.785-52.958 39.456-16.141 92.134-25.03 148.331-25.03 56.184 0 108.854 8.889 148.306 25.03 36.086 14.763 56.782 34.065 56.782 52.958 0 15.801-14.467 31.885-40.286 45.363-.047.025-.094.049-.141.074-5.032 2.621-10.486 5.144-16.355 7.546-39.449 16.142-92.118 25.033-148.306 25.033-56.2 0-108.878-8.89-148.33-25.033-5.792-2.37-11.183-4.858-16.16-7.442-.197-.114-.399-.219-.604-.319-25.652-13.449-40.022-29.475-40.022-45.222zm49.212 71.493c41.8 17.103 97.167 26.522 155.905 26.522 58.724 0 114.083-9.419 155.88-26.522 1.217-.498 2.413-1.004 3.6-1.512v26.113c0 2.06-7.14 11.536-42.066 20.268-31.236 7.81-72.935 12.11-117.413 12.11-44.489 0-86.191-4.301-117.424-12.11-34.918-8.73-42.055-18.208-42.055-20.268v-26.103c1.177.506 2.364 1.008 3.573 1.502zm360.992 125.515c0 18.903-20.696 38.209-56.781 52.969-39.445 16.134-92.115 25.019-148.307 25.019-56.205 0-108.883-8.885-148.332-25.019-36.087-14.759-56.784-34.066-56.784-52.969 0-12.419 8.988-25.106 25.637-36.587v36.587c0 16.832 19.246 30.18 57.204 39.67 32.773 8.195 76.199 12.707 122.276 12.707 46.066 0 89.487-4.513 122.264-12.707 37.966-9.491 57.216-22.838 57.216-39.67v-36.577c16.629 11.481 25.607 24.165 25.607 36.577z"/><path d="m225.116 356.425h.028c5.522 0 9.986-4.477 9.986-10s-4.492-10-10.015-10-10 4.477-10 10c.001 5.523 4.479 10 10.001 10z"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long

View file

@ -23,7 +23,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-1q7Yxq-H.js"></script>
<script type="module" crossorigin src="/assets/index-D-vJDFzo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
</head>
<body>