mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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:
parent
c31f296888
commit
018a744475
22 changed files with 652 additions and 131 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -58,3 +58,6 @@ firmware/
|
|||
node_modules/
|
||||
|
||||
data/
|
||||
|
||||
# JWT secret file (should be in data dir, but protect project root too)
|
||||
.jwt_secret
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue