This commit is contained in:
maziggy
2025-12-08 09:56:21 +00:00
parent 71e15b0ed7
commit e47ef36630
27 changed files with 12163 additions and 9180 deletions
+104
View File
@@ -24,6 +24,9 @@ from backend.app.schemas.cloud import (
SlicerSettingsResponse,
SlicerSetting,
CloudDevice,
SlicerSettingCreate,
SlicerSettingUpdate,
SlicerSettingDeleteResponse,
)
router = APIRouter(prefix="/cloud", tags=["cloud"])
@@ -287,3 +290,104 @@ async def get_devices(db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=401, detail="Authentication expired")
except BambuCloudError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/settings")
async def create_setting(request: SlicerSettingCreate, db: AsyncSession = Depends(get_db)):
"""
Create a new slicer preset/setting.
Creates a new preset on Bambu Cloud. The preset inherits from a base preset
and only stores the delta (modified values).
Type should be: 'filament', 'print', or 'printer'
"""
token, _ = await get_stored_token(db)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
cloud = get_cloud_service()
cloud.set_token(token)
if not cloud.is_authenticated:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
data = await cloud.create_setting(
preset_type=request.type,
name=request.name,
base_id=request.base_id,
setting=request.setting,
version=request.version,
)
return data
except BambuCloudAuthError:
await clear_token(db)
raise HTTPException(status_code=401, detail="Authentication expired")
except BambuCloudError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/settings/{setting_id}")
async def update_setting(
setting_id: str,
request: SlicerSettingUpdate,
db: AsyncSession = Depends(get_db),
):
"""
Update an existing slicer preset/setting.
Updates the preset's name and/or settings on Bambu Cloud.
"""
token, _ = await get_stored_token(db)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
cloud = get_cloud_service()
cloud.set_token(token)
if not cloud.is_authenticated:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
data = await cloud.update_setting(
setting_id=setting_id,
name=request.name,
setting=request.setting,
)
return data
except BambuCloudAuthError:
await clear_token(db)
raise HTTPException(status_code=401, detail="Authentication expired")
except BambuCloudError as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/settings/{setting_id}", response_model=SlicerSettingDeleteResponse)
async def delete_setting(setting_id: str, db: AsyncSession = Depends(get_db)):
"""
Delete a slicer preset/setting.
Removes the preset from Bambu Cloud. This cannot be undone.
"""
token, _ = await get_stored_token(db)
if not token:
raise HTTPException(status_code=401, detail="Not authenticated")
cloud = get_cloud_service()
cloud.set_token(token)
if not cloud.is_authenticated:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
result = await cloud.delete_setting(setting_id)
return SlicerSettingDeleteResponse(
success=result.get("success", True),
message=result.get("message", "Setting deleted"),
)
except BambuCloudAuthError:
await clear_token(db)
raise HTTPException(status_code=401, detail="Authentication expired")
except BambuCloudError as e:
raise HTTPException(status_code=500, detail=str(e))
+177
View File
@@ -9,11 +9,14 @@ from sqlalchemy import select
from backend.app.core.database import get_db
from backend.app.models.printer import Printer
from backend.app.models.kprofile_note import KProfileNote as KProfileNoteModel
from backend.app.schemas.kprofile import (
KProfile,
KProfileCreate,
KProfileDelete,
KProfilesResponse,
KProfileNote,
KProfileNoteResponse,
)
from backend.app.services.printer_manager import printer_manager
@@ -170,6 +173,64 @@ async def set_kprofile(
return {"success": True, "message": message}
@router.post("/batch", response_model=dict)
async def set_kprofiles_batch(
printer_id: int,
profiles: list[KProfileCreate],
db: AsyncSession = Depends(get_db),
):
"""Create multiple K-profiles in a single command (for dual-nozzle).
This sends all profiles in one MQTT command, which is more reliable
for dual-nozzle printers that may not handle sequential commands well.
Args:
printer_id: ID of the printer
profiles: List of K-profiles to set
"""
if not profiles:
raise HTTPException(400, "No profiles provided")
logger.info(f"[API] set_kprofiles_batch: printer={printer_id}, {len(profiles)} profiles")
for p in profiles:
logger.info(f" - extruder_id={p.extruder_id}, name={p.name}, k_value={p.k_value}")
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get MQTT client for printer
client = printer_manager.get_client(printer_id)
if not client or not client.state.connected:
raise HTTPException(400, "Printer not connected")
# Build list of profile dicts for batch command
profile_dicts = [
{
"filament_id": p.filament_id,
"name": p.name,
"k_value": p.k_value,
"nozzle_id": p.nozzle_id,
"extruder_id": p.extruder_id,
"setting_id": p.setting_id,
"slot_id": p.slot_id,
}
for p in profiles
]
# Get nozzle_diameter from first profile (all should have same)
nozzle_diameter = profiles[0].nozzle_diameter
success = client.set_kprofiles_batch(profile_dicts, nozzle_diameter)
if not success:
raise HTTPException(500, "Failed to send K-profiles batch command")
return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
@router.delete("/", response_model=dict)
async def delete_kprofile(
printer_id: int,
@@ -194,6 +255,10 @@ async def delete_kprofile(
raise HTTPException(400, "Printer not connected")
# Send the delete command to printer
logger.info(
f"[API] delete_kprofile: printer={printer_id}, slot_id={profile.slot_id}, "
f"setting_id={profile.setting_id}, filament_id={profile.filament_id}"
)
success = client.delete_kprofile(
cali_idx=profile.slot_id,
filament_id=profile.filament_id,
@@ -207,3 +272,115 @@ async def delete_kprofile(
raise HTTPException(500, "Failed to send K-profile delete command")
return {"success": True, "message": "K-profile deleted successfully"}
@router.get("/notes", response_model=KProfileNoteResponse)
async def get_kprofile_notes(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get all K-profile notes for a printer.
Notes are stored locally since printers don't support notes.
Args:
printer_id: ID of the printer
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Get all notes for this printer
result = await db.execute(
select(KProfileNoteModel).where(KProfileNoteModel.printer_id == printer_id)
)
notes = result.scalars().all()
# Return as a dictionary mapping setting_id -> note
return KProfileNoteResponse(
notes={note.setting_id: note.note for note in notes}
)
@router.put("/notes", response_model=dict)
async def set_kprofile_note(
printer_id: int,
note_data: KProfileNote,
db: AsyncSession = Depends(get_db),
):
"""Set or update a note for a K-profile.
Args:
printer_id: ID of the printer
note_data: The note data (setting_id and note content)
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Find existing note or create new one
result = await db.execute(
select(KProfileNoteModel).where(
KProfileNoteModel.printer_id == printer_id,
KProfileNoteModel.setting_id == note_data.setting_id,
)
)
existing_note = result.scalar_one_or_none()
if note_data.note.strip():
# Save or update note
if existing_note:
existing_note.note = note_data.note
else:
new_note = KProfileNoteModel(
printer_id=printer_id,
setting_id=note_data.setting_id,
note=note_data.note,
)
db.add(new_note)
await db.commit()
return {"success": True, "message": "Note saved"}
else:
# Delete note if empty
if existing_note:
await db.delete(existing_note)
await db.commit()
return {"success": True, "message": "Note deleted"}
@router.delete("/notes/{setting_id}", response_model=dict)
async def delete_kprofile_note(
printer_id: int,
setting_id: str,
db: AsyncSession = Depends(get_db),
):
"""Delete a note for a K-profile.
Args:
printer_id: ID of the printer
setting_id: The setting_id of the K-profile
"""
# Check printer exists
result = await db.execute(select(Printer).where(Printer.id == printer_id))
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(404, "Printer not found")
# Find and delete the note
result = await db.execute(
select(KProfileNoteModel).where(
KProfileNoteModel.printer_id == printer_id,
KProfileNoteModel.setting_id == setting_id,
)
)
existing_note = result.scalar_one_or_none()
if existing_note:
await db.delete(existing_note)
await db.commit()
return {"success": True, "message": "Note deleted"}
+1
View File
@@ -30,6 +30,7 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
if item.archive:
response.archive_name = item.archive.print_name or item.archive.filename
response.archive_thumbnail = item.archive.thumbnail_path
response.print_time_seconds = item.archive.print_time_seconds
if item.printer:
response.printer_name = item.printer.name
return response
+1 -1
View File
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance # noqa: F401
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+2
View File
@@ -4,6 +4,7 @@ from backend.app.models.filament import Filament
from backend.app.models.settings import Settings
from backend.app.models.smart_plug import SmartPlug
from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
from backend.app.models.kprofile_note import KProfileNote
__all__ = [
"Printer",
@@ -14,4 +15,5 @@ __all__ = [
"MaintenanceType",
"PrinterMaintenance",
"MaintenanceHistory",
"KProfileNote",
]
+36
View File
@@ -0,0 +1,36 @@
"""Model for K-profile notes stored locally (not on printer)."""
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, func, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class KProfileNote(Base):
"""Notes for K-profiles stored locally since printers don't support notes."""
__tablename__ = "kprofile_notes"
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
# setting_id is the unique identifier for a K-profile on the printer
setting_id: Mapped[str] = mapped_column(String(100))
note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
# Relationship to printer
printer: Mapped["Printer"] = relationship(back_populates="kprofile_notes")
# Composite index for efficient lookups
__table_args__ = (
Index("ix_kprofile_notes_printer_setting", "printer_id", "setting_id", unique=True),
)
from backend.app.models.printer import Printer # noqa: E402
+4
View File
@@ -39,9 +39,13 @@ class Printer(Base):
maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
back_populates="printer", cascade="all, delete-orphan"
)
kprofile_notes: Mapped[list["KProfileNote"]] = relationship(
back_populates="printer", cascade="all, delete-orphan"
)
from backend.app.models.archive import PrintArchive # noqa: E402
from backend.app.models.kprofile_note import KProfileNote # noqa: E402
from backend.app.models.smart_plug import SmartPlug # noqa: E402
from backend.app.models.notification import NotificationProvider # noqa: E402
from backend.app.models.maintenance import PrinterMaintenance # noqa: E402
+38
View File
@@ -57,3 +57,41 @@ class CloudDevice(BaseModel):
dev_model_name: Optional[str] = None
dev_product_name: Optional[str] = None
online: bool = False
class SlicerSettingCreate(BaseModel):
"""Request to create a new slicer preset."""
type: str = Field(..., description="Preset type: 'filament', 'print', or 'printer'")
name: str = Field(..., description="Display name for the preset")
base_id: str = Field(..., description="Base preset ID to inherit from")
version: str = Field(default="2.0.0.0", description="Version string for the preset")
setting: dict = Field(default_factory=dict, description="Setting key-value pairs (delta from base)")
class SlicerSettingUpdate(BaseModel):
"""Request to update an existing slicer preset."""
name: Optional[str] = Field(None, description="New display name")
setting: Optional[dict] = Field(None, description="Setting key-value pairs to update")
class SlicerSettingDetail(BaseModel):
"""Detailed slicer setting/preset response."""
message: Optional[str] = None
code: Optional[str] = None
error: Optional[str] = None
public: bool = False
version: Optional[str] = None
type: str
name: str
update_time: Optional[str] = None
nickname: Optional[str] = None
base_id: Optional[str] = None
setting: dict = Field(default_factory=dict)
filament_id: Optional[str] = None
setting_id: Optional[str] = None # For response after create
class SlicerSettingDeleteResponse(BaseModel):
"""Response from deleting a preset."""
success: bool
message: str
+13
View File
@@ -51,3 +51,16 @@ class KProfileDelete(BaseModel):
nozzle_diameter: str # e.g., "0.4"
filament_id: str # Bambu filament identifier
setting_id: str | None = None # Setting ID (for X1C series)
class KProfileNote(BaseModel):
"""Schema for K-profile notes (stored locally, not on printer)."""
setting_id: str # Unique identifier for the K-profile
note: str # The note content
class KProfileNoteResponse(BaseModel):
"""Response containing notes for K-profiles."""
notes: dict[str, str] # mapping of setting_id -> note
+1
View File
@@ -48,6 +48,7 @@ class PrintQueueItemResponse(BaseModel):
archive_name: str | None = None
archive_thumbnail: str | None = None
printer_name: str | None = None
print_time_seconds: int | None = None # Estimated print time from archive
class Config:
from_attributes = True
+3 -1
View File
@@ -573,8 +573,10 @@ class ArchiveService:
name_conditions.append(PrintArchive.print_name.ilike(print_name))
if makerworld_model_id:
# Match by MakerWorld model ID stored in extra_data
# Use json_extract for SQLite compatibility (astext is PostgreSQL-only)
from sqlalchemy import func, cast, String
name_conditions.append(
PrintArchive.extra_data["makerworld_model_id"].astext == makerworld_model_id
func.json_extract(PrintArchive.extra_data, '$.makerworld_model_id') == str(makerworld_model_id)
)
if name_conditions:
+163
View File
@@ -220,6 +220,169 @@ class BambuCloudService:
except httpx.RequestError as e:
raise BambuCloudError(f"Request failed: {e}")
async def create_setting(self, preset_type: str, name: str, base_id: str, setting: dict, version: str = "2.0.0.0") -> dict:
"""
Create a new slicer preset/setting.
Args:
preset_type: Type of preset - "filament", "print", or "printer"
name: Display name for the preset
base_id: Base preset ID to inherit from (e.g., "GFSA00")
setting: Dict of setting key-value pairs (only modified values from base)
version: Version string for the preset (default: "2.0.0.0")
Returns:
Created preset data including the new setting_id
"""
if not self.is_authenticated:
raise BambuCloudAuthError("Not authenticated")
try:
# Add timestamp if not present
import time
if "updated_time" not in setting:
setting["updated_time"] = str(int(time.time()))
payload = {
"type": preset_type,
"name": name,
"version": version,
"base_id": base_id,
"setting": setting,
}
response = await self._client.post(
f"{self.base_url}/v1/iot-service/api/slicer/setting",
headers=self._get_headers(),
json=payload
)
data = response.json()
if response.status_code in (200, 201):
return data
error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
raise BambuCloudError(f"Failed to create setting: {error_msg}")
except httpx.RequestError as e:
raise BambuCloudError(f"Request failed: {e}")
async def update_setting(self, setting_id: str, name: str | None = None, setting: dict | None = None) -> dict:
"""
Update an existing slicer preset/setting.
Note: Bambu Cloud API doesn't support true updates. Instead, we:
1. Fetch the current setting metadata (type, base_id, version)
2. Use the provided settings as the new complete settings (NOT merged)
3. Delete the old setting first (to avoid name conflicts)
4. Create a new setting via POST
Args:
setting_id: ID of the preset to update
name: New display name (optional)
setting: Dict of setting key-value pairs - this REPLACES the old settings entirely
Returns:
Updated preset data with new setting_id
"""
if not self.is_authenticated:
raise BambuCloudAuthError("Not authenticated")
try:
# Fetch current setting to get metadata (type, base_id, version)
current = await self.get_setting_detail(setting_id)
preset_type = current.get("type", "filament")
# Use provided settings directly (complete replacement, not merge)
# This allows the frontend to edit the full settings JSON
if setting is not None:
updated_setting = setting.copy()
else:
updated_setting = current.get("setting", {}).copy()
# Extract name from settings_id field in the JSON, or use provided name, or fall back to current
# The settings_id field contains the name in quotes, e.g., '"My Preset Name"'
settings_id_key = {
"filament": "filament_settings_id",
"print": "print_settings_id",
"printer": "printer_settings_id",
}.get(preset_type, "filament_settings_id")
settings_id_value = updated_setting.get(settings_id_key, "")
if settings_id_value:
# Remove surrounding quotes if present (e.g., '"foo"' -> 'foo')
updated_name = settings_id_value.strip('"')
elif name is not None:
updated_name = name
else:
updated_name = current.get("name", "Untitled")
# Update the timestamp
import time
updated_setting["updated_time"] = str(int(time.time()))
# Ensure settings_id field matches the name
updated_setting[settings_id_key] = f'"{updated_name}"'
# Delete the old setting FIRST to avoid name conflicts
await self.delete_setting(setting_id)
# Create new setting via POST
payload = {
"type": preset_type,
"name": updated_name,
"version": current.get("version", "2.0.0.0"),
"base_id": current.get("base_id", ""),
"setting": updated_setting,
}
response = await self._client.post(
f"{self.base_url}/v1/iot-service/api/slicer/setting",
headers=self._get_headers(),
json=payload
)
data = response.json()
if response.status_code == 200:
return data
error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
raise BambuCloudError(f"Failed to update setting: {error_msg}")
except httpx.RequestError as e:
raise BambuCloudError(f"Request failed: {e}")
async def delete_setting(self, setting_id: str) -> dict:
"""
Delete a slicer preset/setting.
Args:
setting_id: ID of the preset to delete
Returns:
Deletion confirmation
"""
if not self.is_authenticated:
raise BambuCloudAuthError("Not authenticated")
try:
response = await self._client.delete(
f"{self.base_url}/v1/iot-service/api/slicer/setting/{setting_id}",
headers=self._get_headers()
)
if response.status_code in (200, 204):
return {"success": True, "message": "Setting deleted"}
data = response.json() if response.content else {}
error_msg = data.get("message") or data.get("error") or f"HTTP {response.status_code}"
raise BambuCloudError(f"Failed to delete setting: {error_msg}")
except httpx.RequestError as e:
raise BambuCloudError(f"Request failed: {e}")
async def get_devices(self) -> dict:
"""Get list of bound devices."""
if not self.is_authenticated:
+182 -115
View File
@@ -1,3 +1,12 @@
"""Bambu Lab MQTT communication service.
IMPORTANT: Always use qos=1 for all MQTT publish calls!
The printer ignores qos=0 messages when busy broadcasting status updates.
Using qos=1 ensures the printer acknowledges and processes our commands immediately.
This was discovered when K-profile requests with qos=0 took 20-30 seconds,
but with qos=1 they respond instantly.
"""
import json
import ssl
import asyncio
@@ -1411,7 +1420,7 @@ class BambuMQTTClient:
"""Request full status update from printer."""
if self._client:
message = {"pushing": {"command": "pushall"}}
self._client.publish(self.topic_publish, json.dumps(message))
self._client.publish(self.topic_publish, json.dumps(message), qos=1)
def request_status_update(self) -> bool:
"""Request a full status update from the printer (public API).
@@ -1441,7 +1450,7 @@ class BambuMQTTClient:
}
}
logger.debug(f"[{self.serial_number}] Requesting accessories info")
self._client.publish(self.topic_publish, json.dumps(message))
self._client.publish(self.topic_publish, json.dumps(message), qos=1)
def _prime_kprofile_request(self):
"""Send a priming K-profile request on connect.
@@ -1460,7 +1469,7 @@ class BambuMQTTClient:
}
}
logger.debug(f"[{self.serial_number}] Sending K-profile priming request")
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
def connect(self, loop: asyncio.AbstractEventLoop | None = None):
"""Connect to the printer MQTT broker.
@@ -1516,7 +1525,7 @@ class BambuMQTTClient:
}
}
logger.info(f"[{self.serial_number}] Sending print command: {json.dumps(command)}")
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
return True
return False
@@ -1529,7 +1538,7 @@ class BambuMQTTClient:
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
logger.info(f"[{self.serial_number}] Sent stop print command")
return True
return False
@@ -1763,7 +1772,7 @@ class BambuMQTTClient:
direction="out",
payload=command,
))
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
def enable_logging(self, enabled: bool = True):
"""Enable or disable MQTT message logging."""
@@ -1785,13 +1794,50 @@ class BambuMQTTClient:
def _handle_kprofile_response(self, data: dict):
"""Handle K-profile response from printer."""
response_nozzle = data.get("nozzle_diameter")
response_seq_id = data.get("sequence_id", "?")
filaments = data.get("filaments", [])
profiles = []
expected_nozzle = getattr(self, '_expected_kprofile_nozzle', None)
has_pending_request = self._pending_kprofile_response is not None
# Log first profile to see what fields the printer returns
if filaments and isinstance(filaments[0], dict):
logger.debug(f"[{self.serial_number}] Raw K-profile fields: {list(filaments[0].keys())}")
logger.debug(f"[{self.serial_number}] First K-profile: {filaments[0]}")
# Log all incoming responses when we have a pending request (for debugging)
if has_pending_request:
logger.info(f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, {len(filaments)} profiles, expected={expected_nozzle}")
# If we have a pending request, only accept responses with matching nozzle_diameter
# The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response
if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:
# Ignore this broadcast, keep waiting for matching response
logger.debug(f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}")
return
# If no pending request, this is just a broadcast - update state silently and return early
if not has_pending_request:
# Still parse profiles to keep state updated, but don't log
profiles = []
for f in filaments:
if isinstance(f, dict):
try:
cali_idx = f.get("cali_idx", 0)
profiles.append(KProfile(
slot_id=cali_idx,
extruder_id=int(f.get("extruder_id", 0)),
nozzle_id=str(f.get("nozzle_id", "")),
nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
filament_id=str(f.get("filament_id", "")),
name=str(f.get("name", "")),
k_value=str(f.get("k_value", "0.000000")),
n_coef=str(f.get("n_coef", "0.000000")),
ams_id=int(f.get("ams_id", 0)),
tray_id=int(f.get("tray_id", -1)),
setting_id=f.get("setting_id"),
))
except (ValueError, TypeError):
pass
self.state.kprofiles = profiles
return
profiles = []
for i, f in enumerate(filaments):
if isinstance(f, dict):
@@ -1817,17 +1863,16 @@ class BambuMQTTClient:
self.state.kprofiles = profiles
self._kprofile_response_data = profiles
# Signal that we received the response
# Signal that we received the response (only if we were waiting for one)
# Use thread-safe method since MQTT callbacks run in a different thread
if self._pending_kprofile_response:
logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={response_nozzle}")
if self._loop and self._loop.is_running():
self._loop.call_soon_threadsafe(self._pending_kprofile_response.set)
else:
# Fallback for when loop is not available
self._pending_kprofile_response.set()
logger.info(f"[{self.serial_number}] Received {len(profiles)} K-profiles")
async def get_kprofiles(self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3) -> list[KProfile]:
"""Request K-profiles from the printer with retry logic.
@@ -1858,8 +1903,9 @@ class BambuMQTTClient:
self._sequence_id += 1
self._pending_kprofile_response = asyncio.Event()
self._kprofile_response_data = None
self._expected_kprofile_nozzle = nozzle_diameter # Track which nozzle response we expect
# Send the command
# Send the command with nozzle_diameter filter
command = {
"print": {
"command": "extrusion_cali_get",
@@ -1869,14 +1915,15 @@ class BambuMQTTClient:
}
}
logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle {nozzle_diameter} (attempt {attempt + 1}/{max_retries})")
self._client.publish(self.topic_publish, json.dumps(command))
logger.info(f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})")
logger.debug(f"[{self.serial_number}] K-profile request JSON: {json.dumps(command)}")
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
# Wait for response
# Wait for response (response handler already filters by nozzle_diameter)
try:
await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
profiles = self._kprofile_response_data or []
logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles on attempt {attempt + 1}")
logger.info(f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}")
return profiles
except asyncio.TimeoutError:
logger.warning(f"[{self.serial_number}] Timeout on K-profiles request attempt {attempt + 1}/{max_retries}")
@@ -1885,6 +1932,7 @@ class BambuMQTTClient:
await asyncio.sleep(0.5)
finally:
self._pending_kprofile_response = None
self._expected_kprofile_nozzle = None
logger.error(f"[{self.serial_number}] Failed to get K-profiles after {max_retries} attempts")
return []
@@ -1912,7 +1960,7 @@ class BambuMQTTClient:
extruder_id: Extruder ID (0 or 1 for dual nozzle)
setting_id: Existing setting ID for updates, None for new
slot_id: Calibration index (cali_idx) for the profile
cali_idx: For H2D edits, the existing slot being edited (enables in-place edit)
cali_idx: For edits, the existing slot being edited (enables in-place edit)
Returns:
True if command was sent, False otherwise
@@ -1923,95 +1971,111 @@ class BambuMQTTClient:
self._sequence_id += 1
# Detect printer type by serial number prefix
# X1C/P1/A1 series (single nozzle): serial starts with "00M", "00W", "01P", "01S", "03W", etc.
# H2D series (dual nozzle): serial starts with "094"
is_dual_nozzle = self.serial_number.startswith("094")
# For H2D edits, use empty setting_id per OrcaSlicer sniff
# For new profiles, generate a setting_id
import secrets
# Build the filament entry - printer uses cali_idx for profile identification
# For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
# For edits, use the provided cali_idx or slot_id
if cali_idx is not None:
# Edit mode - use empty setting_id per OrcaSlicer sniff
setting_id = ""
elif not setting_id and slot_id == 0:
# New profile - generate setting_id
setting_id = f"PFUS{secrets.token_hex(7)}" # 7 bytes = 14 hex chars
if is_dual_nozzle:
# H2D format - exact OrcaSlicer format (captured via MQTT sniffing)
# For edits: include cali_idx (existing slot), slot_id=0, setting_id=""
# For new profiles: no cali_idx, slot_id=0, setting_id=generated
filament_entry = {
"ams_id": 0,
"extruder_id": extruder_id,
"filament_id": filament_id,
"k_value": k_value,
"n_coef": "0.000000",
"name": name,
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": setting_id if setting_id else "",
"slot_id": slot_id,
"tray_id": -1,
}
# For edits, add cali_idx field (position matters - alphabetical order)
if cali_idx is not None:
# Insert cali_idx in alphabetical position (after ams_id, before extruder_id)
# n_coef must be "0.000000" for H2D edits (matches OrcaSlicer sniff)
filament_entry = {
"ams_id": 0,
"cali_idx": cali_idx,
"extruder_id": extruder_id,
"filament_id": filament_id,
"k_value": k_value,
"n_coef": "0.000000",
"name": name,
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": "",
"slot_id": 0,
"tray_id": -1,
}
command = {
"print": {
"command": "extrusion_cali_set",
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
}
}
effective_cali_idx = cali_idx
else:
# X1C/P1/A1 format - based on actual X1C profile data:
# - n_coef: "1.000000" (NOT 0.000000 like H2D)
# - nozzle_id: "" (empty string, NOT the nozzle type)
# - tray_id: -1 (NOT 0)
filament_entry = {
"ams_id": 0,
"extruder_id": 0, # X1C is single nozzle
"filament_id": filament_id,
"k_value": k_value,
"n_coef": "1.000000", # X1C uses 1.0, not 0.0
"name": name,
effective_cali_idx = -1 if slot_id == 0 else slot_id
# Generate a setting_id for new profiles (required by printer)
# Format: "PF" + 17 random digits
import random
if not setting_id and slot_id == 0:
setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
filament_entry = {
"ams_id": 0,
"cali_idx": effective_cali_idx,
"extruder_id": extruder_id,
"filament_id": filament_id,
"k_value": k_value,
"n_coef": "0.000000",
"name": name,
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"setting_id": setting_id if setting_id else "",
"tray_id": -1,
}
command = {
"print": {
"command": "extrusion_cali_set",
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"nozzle_id": "", # X1C uses empty string
"setting_id": setting_id,
"slot_id": slot_id,
"tray_id": -1, # X1C uses -1
}
command = {
"print": {
"command": "extrusion_cali_set",
"filaments": [filament_entry],
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
}
"sequence_id": str(self._sequence_id),
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={cali_idx}, new={slot_id==0}, dual={is_dual_nozzle})")
logger.info(f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id==0})")
logger.info(f"[{self.serial_number}] K-profile SET command: {command_json}")
# Use QoS 1 for reliable delivery (at least once)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
def set_kprofiles_batch(
self,
profiles: list[dict],
nozzle_diameter: str = "0.4",
) -> bool:
"""Set multiple K-profiles in a single command (for dual-nozzle).
Args:
profiles: List of profile dicts, each with:
- filament_id, name, k_value, nozzle_id, extruder_id, setting_id (optional), slot_id
nozzle_diameter: Common nozzle diameter for all profiles
Returns:
True if command was sent, False otherwise
"""
if not self._client or not self.state.connected:
logger.warning(f"[{self.serial_number}] Cannot set K-profiles batch: not connected")
return False
import random
self._sequence_id += 1
filament_entries = []
for p in profiles:
slot_id = p.get("slot_id", 0)
cali_idx = p.get("cali_idx")
if cali_idx is not None:
effective_cali_idx = cali_idx
else:
effective_cali_idx = -1 if slot_id == 0 else slot_id
setting_id = p.get("setting_id")
if not setting_id and slot_id == 0:
setting_id = f"PF{random.randint(10000000000000000, 99999999999999999)}"
filament_entries.append({
"ams_id": 0,
"cali_idx": effective_cali_idx,
"extruder_id": p.get("extruder_id", 0),
"filament_id": p.get("filament_id", ""),
"k_value": p.get("k_value", "0.020000"),
"n_coef": "0.000000",
"name": p.get("name", ""),
"nozzle_diameter": nozzle_diameter,
"nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
"setting_id": setting_id if setting_id else "",
"tray_id": -1,
})
command = {
"print": {
"command": "extrusion_cali_set",
"filaments": filament_entries,
"nozzle_diameter": nozzle_diameter,
"sequence_id": str(self._sequence_id),
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Setting {len(filament_entries)} K-profiles in batch")
logger.info(f"[{self.serial_number}] K-profile SET batch command: {command_json}")
self._client.publish(self.topic_publish, command_json, qos=1)
return True
@@ -2061,20 +2125,23 @@ class BambuMQTTClient:
}
}
else:
# X1C/P1/A1 format: uses setting_id, nozzle_diameter, no extruder/nozzle_id fields
# X1C/P1/A1 format: include all fields like the set command
# The delete command structure should match what set uses
command = {
"print": {
"command": "extrusion_cali_del",
"sequence_id": str(self._sequence_id),
"filament_id": filament_id,
"cali_idx": cali_idx,
"setting_id": setting_id,
"setting_id": setting_id if setting_id else "",
"nozzle_diameter": nozzle_diameter,
"nozzle_id": nozzle_id,
"extruder_id": extruder_id,
}
}
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, dual={is_dual_nozzle}")
logger.info(f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}")
logger.info(f"[{self.serial_number}] K-profile DELETE command: {command_json}")
# Use QoS 1 for reliable delivery (at least once)
self._client.publish(self.topic_publish, command_json, qos=1)
@@ -2221,7 +2288,7 @@ class BambuMQTTClient:
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
logger.info(f"[{self.serial_number}] Set print speed mode to {mode}")
return True
@@ -2443,7 +2510,7 @@ class BambuMQTTClient:
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Publishing ams_change_filament command: {command_json}")
self._client.publish(self.topic_publish, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
logger.info(f"[{self.serial_number}] Loading filament from tray {tray_id} (AMS {ams_id} slot {slot_id})")
# Track this load request for H2D dual-nozzle disambiguation
@@ -2498,7 +2565,7 @@ class BambuMQTTClient:
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Publishing ams_change_filament (unload) command: {command_json}")
self._client.publish(self.topic_publish, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
logger.info(f"[{self.serial_number}] Unloading filament (tray_now was {tray_now})")
# Clear tracked load request since we're unloading
@@ -2532,7 +2599,7 @@ class BambuMQTTClient:
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
logger.info(f"[{self.serial_number}] AMS control: {action}")
return True
@@ -2576,7 +2643,7 @@ class BambuMQTTClient:
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
logger.info(f"[{self.serial_number}] Triggering RFID re-read: AMS {ams_id}, slot {tray_id}")
return True, f"Refreshing AMS {ams_id} tray {tray_id}"
@@ -2631,7 +2698,7 @@ class BambuMQTTClient:
command_json = json.dumps(command)
logger.info(f"[{self.serial_number}] Publishing ams_filament_setting: AMS {ams_id}, tray {tray_id}, k={k}")
logger.debug(f"[{self.serial_number}] ams_filament_setting command: {command_json}")
self._client.publish(self.topic_publish, command_json)
self._client.publish(self.topic_publish, command_json, qos=1)
return True
def set_timelapse(self, enable: bool) -> bool:
@@ -2661,9 +2728,9 @@ class BambuMQTTClient:
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(timelapse_cmd))
self._client.publish(self.topic_publish, json.dumps(timelapse_cmd), qos=1)
# Request status update
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
logger.info(f"[{self.serial_number}] Set timelapse {'enabled' if enable else 'disabled'}")
return True
@@ -2687,7 +2754,7 @@ class BambuMQTTClient:
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(command))
self._client.publish(self.topic_publish, json.dumps(command), qos=1)
# Request status update
pushall = {
"pushing": {
@@ -2695,6 +2762,6 @@ class BambuMQTTClient:
"sequence_id": "0"
}
}
self._client.publish(self.topic_publish, json.dumps(pushall))
self._client.publish(self.topic_publish, json.dumps(pushall), qos=1)
logger.info(f"[{self.serial_number}] Set liveview {'enabled' if enable else 'disabled'}")
return True
+322
View File
@@ -0,0 +1,322 @@
# Bambu Lab Preset Sync API Documentation
This document describes the Bambu Lab cloud API endpoints for syncing slicer presets (filament, print process, and machine profiles) between Bambu Studio and the cloud.
**Captured from:** Bambu Studio v2.4.0.70 with bambu_network_agent v02.04.00.58
**Date:** 2025-12-08
---
## Authentication
All API requests require authentication via Bearer token.
### Required Headers
```http
Host: api.bambulab.com
Authorization: Bearer <access_token>
User-Agent: bambu_network_agent/02.04.00.58
X-BBL-Client-Name: BambuStudio
X-BBL-Client-Type: slicer
X-BBL-Client-Version: 02.04.00.70
X-BBL-Device-ID: <uuid>
X-BBL-Language: en-US
X-BBL-OS-Type: macos|windows|linux
X-BBL-OS-Version: <version>
X-BBL-Agent-Version: 02.04.00.58
accept: application/json
```
---
## Endpoints
### 1. Get User Profile
```http
GET /v1/user-service/my/profile
```
Returns user account information including UID.
### 2. List All User Presets
```http
GET /v1/iot-service/api/slicer/setting?version={slicer_version}&public=false
```
**Parameters:**
- `version`: Slicer version (e.g., `2.4.0.5`)
- `public`: Set to `false` for user presets only
**Response:** Returns a list of preset IDs that the user has synced to cloud.
### 3. Get Individual Preset
```http
GET /v1/iot-service/api/slicer/setting/{preset_id}
```
**Response:**
```json
{
"message": "success",
"code": null,
"error": null,
"public": false,
"version": "1.5.0.20",
"type": "filament",
"name": "Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle",
"update_time": "2025-12-08 01:06:27",
"nickname": null,
"base_id": "GFSA00",
"setting": {
"inherits": "Bambu PLA Basic @BBL X1C",
"filament_vendor": "\"Devil Design\"",
"nozzle_temperature": "225,220",
"pressure_advance": "0.03",
"updated_time": "1765138658"
},
"filament_id": null
}
```
---
## Preset ID Naming Convention
Preset IDs follow a specific prefix pattern indicating the type:
| Prefix | Type | Description |
|--------|------|-------------|
| `PPUS` | Print Process | Print/quality settings (layer height, speeds, infill, etc.) |
| `PFUS` | Filament | Filament settings (temperatures, flow, pressure advance, etc.) |
| `PMUS` | Printer/Machine | Machine settings (gcode, bed size, kinematics, etc.) |
The suffix after the prefix is a unique hash identifier.
**Examples:**
- `PPUS1b03400426f57d` - Print process preset
- `PFUS169056f3003bb4` - Filament preset
- `PMUSbc396893c54df0` - Machine/printer preset
---
## Preset Response Schema
### Common Fields
| Field | Type | Description |
|-------|------|-------------|
| `message` | string | API response status ("success") |
| `code` | int/null | Error code if any |
| `error` | string/null | Error message if any |
| `public` | boolean | Whether preset is publicly shared |
| `version` | string | Preset version |
| `type` | string | Preset type: "filament", "print", or "printer" |
| `name` | string | Display name of the preset |
| `update_time` | string | Last update timestamp (ISO format) |
| `nickname` | string/null | Optional user-defined nickname |
| `base_id` | string | Reference ID of the parent/base preset |
| `setting` | object | Key-value pairs of customized settings |
| `filament_id` | string/null | Bambu filament ID if applicable |
### Setting Object
The `setting` object contains **only the delta/modified values** from the parent preset. Key fields include:
- `inherits`: Name of the parent preset this inherits from
- `updated_time`: Unix timestamp of last modification
- Other fields depend on preset type (see below)
---
## Preset Types and Common Settings
### Filament Presets (PFUS)
```json
{
"inherits": "Bambu PLA Basic @BBL X1C",
"filament_vendor": "\"Devil Design\"",
"filament_cost": "20",
"filament_settings_id": "\"Devil Design PLA @Bambu Lab X1 Carbon 0.6 nozzle\"",
"nozzle_temperature": "225,220",
"nozzle_temperature_initial_layer": "225,220",
"hot_plate_temp": "60",
"cool_plate_temp": "60",
"textured_plate_temp": "60",
"pressure_advance": "0.03",
"enable_pressure_advance": "1",
"filament_max_volumetric_speed": "30,29",
"activate_air_filtration": "1",
"during_print_exhaust_fan_speed": "50",
"complete_print_exhaust_fan_speed": "50",
"close_fan_the_first_x_layers": "2",
"overhang_fan_threshold": "10%",
"slow_down_layer_time": "5",
"temperature_vitrification": "65",
"filament_start_gcode": "...",
"filament_end_gcode": "..."
}
```
### Print Process Presets (PPUS)
```json
{
"inherits": "0.08mm Extra Fine @BBL H2D",
"print_settings_id": "# 0.08mm Extra Fine @BBL H2D",
"prime_tower_max_speed": "100",
"prime_tower_rib_wall": "0",
"prime_tower_width": "20"
}
```
### Machine/Printer Presets (PMUS)
```json
{
"inherits": "Bambu Lab H2D 0.4 nozzle",
"printer_settings_id": "# Bambu Lab H2D 0.4 nozzle",
"bed_custom_model": "/path/to/model.stl",
"machine_start_gcode": "...",
"machine_end_gcode": "...",
"change_filament_gcode": "...",
"printer_notes": "...",
"support_air_filtration": "1"
}
```
---
## Base ID Reference
The `base_id` field references Bambu's internal preset database:
| Prefix | Type |
|--------|------|
| `GF` | Generic Filament |
| `GP` | Generic Print Process |
| `GM` | Generic Machine |
Examples:
- `GFSA00` - Generic filament base
- `GP136` - Generic print process base
- `GM033` - Generic machine base (H2D)
---
## API Operations (Verified)
### Create Preset
```http
POST /v1/iot-service/api/slicer/setting
Content-Type: application/json
{
"type": "filament",
"name": "My Custom PLA",
"version": "2.0.0.0",
"base_id": "GFSA00",
"setting": {
"inherits": "Bambu PLA Basic @BBL X1C",
"nozzle_temperature": "210,205",
"updated_time": "1733665800"
}
}
```
**Required fields:**
- `type`: "filament", "print", or "printer"
- `name`: Display name
- `version`: Version string (e.g., "2.0.0.0", "2.3.0.2")
- `base_id`: Parent preset ID
- `setting`: Object with modified values including `updated_time` (Unix timestamp)
**Response:**
```json
{
"message": "success",
"code": null,
"error": null,
"setting_id": "PFUSe99f2ff04974b4",
"update_time": "2025-12-08 16:31:48"
}
```
### Update Preset
**Important:** The Bambu Cloud API does NOT support true updates via PUT/PATCH.
- `PUT /v1/iot-service/api/slicer/setting/{preset_id}` returns **405 Method Not Allowed**
- `PATCH /v1/iot-service/api/slicer/setting/{preset_id}` returns **500 Cloud database failed**
**Workaround:** To "update" a preset:
1. GET the existing preset details
2. Merge your changes
3. POST to create a new preset (returns new `setting_id`)
4. DELETE the old preset
This mimics how Bambu Studio handles preset updates.
### Delete Preset
```http
DELETE /v1/iot-service/api/slicer/setting/{preset_id}
```
**Response:**
```json
{
"message": "success",
"code": null,
"error": null
}
```
---
## Related Endpoints
### Slicer Resources
```http
GET /v1/iot-service/api/slicer/resource?slicer/plugins/cloud={version}
GET /v1/iot-service/api/slicer/resource?slicer/printer/bbl={version}
GET /v1/iot-service/api/slicer/resource?policy/privacy={version}
```
### User Print Status
```http
GET /v1/iot-service/api/user/print?force=true
```
### User Tasks
```http
GET /v1/user-service/my/tasks?limit=5&offset=0&status=0
```
### MQTT Certificate
```http
GET /v1/iot-service/api/user/applications/{app_id}/cert?aes256={encrypted_key}
```
---
## Notes
1. **Delta Storage**: Presets only store modified values from the parent, using the `inherits` field to reference the base preset.
2. **Version Tracking**: The `updated_time` field (Unix timestamp) is used for sync conflict resolution.
3. **Gcode Escaping**: Gcode fields use `\\n` for newlines within JSON strings.
4. **Multi-value Fields**: Some fields like `nozzle_temperature` contain comma-separated values for different conditions.
5. **Authentication**: The access token can be obtained via Bambu Lab OAuth flow or the `/v1/user-service/user/ticket/{code}` endpoint.
+76 -1
View File
@@ -283,6 +283,39 @@ export interface SlicerSettingsResponse {
process: SlicerSetting[];
}
export interface SlicerSettingDetail {
message?: string | null;
code?: string | null;
error?: string | null;
public: boolean;
version?: string | null;
type: string;
name: string;
update_time?: string | null;
nickname?: string | null;
base_id?: string | null;
setting: Record<string, unknown>;
filament_id?: string | null;
setting_id?: string | null;
}
export interface SlicerSettingCreate {
type: string; // 'filament', 'print', or 'printer'
name: string;
base_id: string;
setting: Record<string, unknown>;
}
export interface SlicerSettingUpdate {
name?: string;
setting?: Record<string, unknown>;
}
export interface SlicerSettingDeleteResponse {
success: boolean;
message: string;
}
export interface CloudDevice {
dev_id: string;
name: string;
@@ -382,6 +415,7 @@ export interface PrintQueueItem {
archive_name?: string | null;
archive_thumbnail?: string | null;
printer_name?: string | null;
print_time_seconds?: number | null; // Estimated print time from archive
}
export interface PrintQueueItemCreate {
@@ -456,6 +490,15 @@ export interface KProfilesResponse {
nozzle_diameter: string;
}
export interface KProfileNote {
setting_id: string;
note: string;
}
export interface KProfileNotesResponse {
notes: Record<string, string>; // setting_id -> note
}
// Slot Preset Mapping
export interface SlotPresetMapping {
ams_id: number;
@@ -1004,7 +1047,21 @@ export const api = {
getCloudSettings: (version = '01.09.00.00') =>
request<SlicerSettingsResponse>(`/cloud/settings?version=${version}`),
getCloudSettingDetail: (settingId: string) =>
request<Record<string, unknown>>(`/cloud/settings/${settingId}`),
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`),
createCloudSetting: (data: SlicerSettingCreate) =>
request<SlicerSettingDetail>('/cloud/settings', {
method: 'POST',
body: JSON.stringify(data),
}),
updateCloudSetting: (settingId: string, data: SlicerSettingUpdate) =>
request<SlicerSettingDetail>(`/cloud/settings/${settingId}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteCloudSetting: (settingId: string) =>
request<SlicerSettingDeleteResponse>(`/cloud/settings/${settingId}`, {
method: 'DELETE',
}),
getCloudDevices: () => request<CloudDevice[]>('/cloud/devices'),
// Smart Plugs
@@ -1079,6 +1136,24 @@ export const api = {
method: 'DELETE',
body: JSON.stringify(profile),
}),
setKProfilesBatch: (printerId: number, profiles: KProfileCreate[]) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/batch`, {
method: 'POST',
body: JSON.stringify(profiles),
}),
// K-Profile Notes (stored locally, not on printer)
getKProfileNotes: (printerId: number) =>
request<KProfileNotesResponse>(`/printers/${printerId}/kprofiles/notes`),
setKProfileNote: (printerId: number, settingId: string, note: string) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes`, {
method: 'PUT',
body: JSON.stringify({ setting_id: settingId, note }),
}),
deleteKProfileNote: (printerId: number, settingId: string) =>
request<{ success: boolean; message: string }>(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, {
method: 'DELETE',
}),
// Slot Preset Mappings
getSlotPresets: (printerId: number) =>
+15 -8
View File
@@ -31,8 +31,11 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
const batchTagMutation = useMutation({
mutationFn: async () => {
const tagsArray = Array.from(selectedTags);
await Promise.all(
selectedIds.map(async (id) => {
let successCount = 0;
// Process sequentially to avoid SQLite database locks
for (const id of selectedIds) {
try {
const archive = await api.getArchive(id);
const currentTags = archive.tags ? archive.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
@@ -45,18 +48,22 @@ export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagMo
newTags = currentTags.filter(t => !selectedTags.has(t));
}
return api.updateArchive(id, { tags: newTags.join(', ') });
})
);
return { count: selectedIds.length, mode, tags: tagsArray };
await api.updateArchive(id, { tags: newTags.join(', ') });
successCount++;
} catch (err) {
console.error(`Failed to update archive ${id}:`, err);
throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
}
return { count: successCount, mode, tags: tagsArray };
},
onSuccess: ({ count, mode, tags }) => {
queryClient.invalidateQueries({ queryKey: ['archives'] });
showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);
onClose();
},
onError: () => {
showToast('Failed to update tags', 'error');
onError: (error: Error) => {
showToast(error.message || 'Failed to update tags', 'error');
},
});
@@ -0,0 +1,243 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calendar, Clock, X, AlertCircle, Power, Pencil } from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItem, PrintQueueItemUpdate } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { useToast } from '../contexts/ToastContext';
interface EditQueueItemModalProps {
item: PrintQueueItem;
onClose: () => void;
}
export function EditQueueItemModal({ item, onClose }: EditQueueItemModalProps) {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [printerId, setPrinterId] = useState<number>(item.printer_id);
// Check if scheduled_time is a "placeholder" far-future date (more than 6 months out)
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 [scheduledTime, setScheduledTime] = useState(() => {
if (item.scheduled_time && !isPlaceholderDate) {
// Convert ISO to local datetime-local format
const date = new Date(item.scheduled_time);
return date.toISOString().slice(0, 16);
}
return '';
});
const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(item.require_previous_success);
const [autoOffAfter, setAutoOffAfter] = useState(item.auto_off_after);
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: () => api.getPrinters(),
});
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const updateMutation = useMutation({
mutationFn: (data: PrintQueueItemUpdate) => api.updateQueueItem(item.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Queue item updated');
onClose();
},
onError: (error: Error) => {
showToast(error.message || 'Failed to update queue item', 'error');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: PrintQueueItemUpdate = {
printer_id: printerId,
require_previous_success: requirePreviousSuccess,
auto_off_after: autoOffAfter,
};
if (scheduleType === 'scheduled' && scheduledTime) {
data.scheduled_time = new Date(scheduledTime).toISOString();
} else {
data.scheduled_time = null;
}
updateMutation.mutate(data);
};
// Get minimum datetime (now + 1 minute)
const getMinDateTime = () => {
const now = new Date();
now.setMinutes(now.getMinutes() + 1);
return now.toISOString().slice(0, 16);
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<Card className="w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<CardContent className="p-0">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
<div className="flex items-center gap-2">
<Pencil className="w-5 h-5 text-bambu-green" />
<h2 className="text-xl font-semibold text-white">Edit Queue Item</h2>
</div>
<button
onClick={onClose}
className="text-bambu-gray hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-4 space-y-4">
{/* Archive name */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Print Job</label>
<p className="text-white font-medium truncate">
{item.archive_name || `Archive #${item.archive_id}`}
</p>
</div>
{/* Printer selection */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Printer</label>
{printers?.length === 0 ? (
<div className="flex items-center gap-2 text-red-400 text-sm">
<AlertCircle className="w-4 h-4" />
No printers configured
</div>
) : (
<select
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={printerId}
onChange={(e) => setPrinterId(Number(e.target.value))}
required
>
{printers?.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)}
</div>
{/* Schedule type */}
<div>
<label className="block text-sm text-bambu-gray mb-2">When to print</label>
<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 ${
scheduleType === 'asap'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('asap')}
>
<Clock className="w-4 h-4" />
ASAP (when idle)
</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 ${
scheduleType === 'scheduled'
? 'bg-bambu-green border-bambu-green text-white'
: 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'
}`}
onClick={() => setScheduleType('scheduled')}
>
<Calendar className="w-4 h-4" />
Scheduled
</button>
</div>
</div>
{/* Scheduled time input */}
{scheduleType === 'scheduled' && (
<div>
<label className="block text-sm text-bambu-gray mb-1">Date & Time</label>
<input
type="datetime-local"
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={scheduledTime}
onChange={(e) => setScheduledTime(e.target.value)}
min={getMinDateTime()}
required
/>
</div>
)}
{/* Require previous success */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="requirePrevious"
checked={requirePreviousSuccess}
onChange={(e) => setRequirePreviousSuccess(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="requirePrevious" className="text-sm text-bambu-gray">
Only start if previous print succeeded
</label>
</div>
{/* Auto power off */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="autoOffAfter"
checked={autoOffAfter}
onChange={(e) => setAutoOffAfter(e.target.checked)}
className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
/>
<label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1">
<Power className="w-3.5 h-3.5" />
Power off printer when done
</label>
</div>
{/* Help text */}
<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.'}
</p>
{/* Actions */}
<div className="flex gap-3 pt-2">
<Button type="button" variant="secondary" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={updateMutation.isPending || printers?.length === 0}
>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
+651 -91
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import React, { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
Gauge,
Loader2,
@@ -11,6 +11,12 @@ import {
WifiOff,
Trash2,
Search,
Copy,
Download,
Upload,
CheckSquare,
Square,
StickyNote,
} from 'lucide-react';
import { api } from '../api/client';
import type { KProfile, KProfileCreate, KProfileDelete } from '../api/client';
@@ -21,6 +27,11 @@ import { useToast } from '../contexts/ToastContext';
interface KProfileCardProps {
profile: KProfile;
onEdit: () => void;
onCopy?: () => void;
selectionMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
note?: string; // Note text to display as preview
}
// Truncate to 3 decimal places (like Bambu Studio) instead of rounding
@@ -64,33 +75,71 @@ const extractFilamentName = (profileName: string) => {
return profileName;
};
function KProfileCard({ profile, onEdit }: KProfileCardProps) {
function KProfileCard({ profile, onEdit, onCopy, selectionMode, isSelected, onToggleSelect, note }: KProfileCardProps) {
const flowType = getFlowTypeLabel(profile.nozzle_id);
const diameter = profile.nozzle_diameter;
const profileName = profile.name || 'Unnamed';
// Extract filament name from profile name (e.g., "High Flow_eSUN ABS+" -> "eSUN ABS+")
const filamentName = extractFilamentName(profile.name || '');
const handleClick = () => {
if (selectionMode && onToggleSelect) {
onToggleSelect();
} else {
onEdit();
}
};
return (
<button
onClick={onEdit}
className="w-full text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
{truncateK(profile.k_value)}
</span>
<span className="text-white text-sm truncate flex-1" title={profileName}>
{profileName}
</span>
<span className="text-xs text-bambu-gray whitespace-nowrap">
{flowType} {diameter}
</span>
</div>
<div className="text-xs text-bambu-gray mt-0.5 truncate" title={`Filament: ${filamentName}`}>
Filament: {filamentName || profile.filament_id}
</div>
</button>
<div className="flex items-center gap-2">
{selectionMode && (
<button
onClick={onToggleSelect}
className="text-bambu-gray hover:text-white transition-colors p-1"
>
{isSelected ? (
<CheckSquare className="w-4 h-4 text-bambu-green" />
) : (
<Square className="w-4 h-4" />
)}
</button>
)}
<button
onClick={handleClick}
className={`flex-1 text-left px-3 py-2 bg-bambu-dark rounded hover:bg-bambu-dark-tertiary transition-colors ${isSelected ? 'ring-1 ring-bambu-green' : ''}`}
>
<div className="flex items-center gap-2">
<span className="text-bambu-green font-mono text-sm font-bold whitespace-nowrap">
{truncateK(profile.k_value)}
</span>
<span className="text-white text-sm truncate flex-1" title={profile.name}>
{profile.name || 'Unnamed'}
</span>
{note && (
<span title="Has note">
<StickyNote className="w-3 h-3 text-yellow-500" />
</span>
)}
<span className="text-xs text-bambu-gray whitespace-nowrap">
{flowType} {diameter}
</span>
</div>
{note && (
<div className="text-xs mt-0.5 truncate text-yellow-500/70" title={note}>
Note: {note.length > 50 ? note.substring(0, 50) + '...' : note}
</div>
)}
</button>
{!selectionMode && onCopy && (
<button
onClick={(e) => {
e.stopPropagation();
onCopy();
}}
className="text-bambu-gray hover:text-white transition-colors p-1"
title="Copy profile"
>
<Copy className="w-4 h-4" />
</button>
)}
</div>
);
}
@@ -100,8 +149,11 @@ interface KProfileModalProps {
nozzleDiameter: string;
existingProfiles?: KProfile[]; // Existing profiles for filament selection
isDualNozzle?: boolean; // Whether this is a dual-nozzle printer
initialNote?: string; // Initial note value for the profile
initialNoteKey?: string | null; // Key the note was stored under (for clearing)
onClose: () => void;
onSave: () => void;
onSaveNote?: (settingId: string, note: string) => void; // Callback to save note
}
function KProfileModal({
@@ -110,11 +162,13 @@ function KProfileModal({
nozzleDiameter,
existingProfiles = [],
isDualNozzle = false,
initialNote = '',
initialNoteKey = null,
onClose,
onSave,
onSaveNote,
}: KProfileModalProps) {
const { showToast } = useToast();
const queryClient = useQueryClient();
const [name, setName] = useState(profile?.name || '');
const [kValue, setKValue] = useState(
@@ -135,6 +189,7 @@ function KProfileModal({
);
const [isSyncing, setIsSyncing] = useState(false);
const [savingProgress, setSavingProgress] = useState({ current: 0, total: 0 });
const [note, setNote] = useState(initialNote);
// Extract unique filaments from existing K-profiles on the printer
// These have valid filament_ids that the printer recognizes
@@ -162,12 +217,26 @@ function KProfileModal({
onSuccess: (result) => {
console.log('[KProfile] Save success:', result);
showToast('K-profile saved');
// Save note if it changed (including clearing it)
if (onSaveNote && note !== initialNote) {
let profileKey: string;
if (note === '' && initialNoteKey) {
// Clearing note: use the same key it was stored under
profileKey = initialNoteKey;
} else if (profile && profile.slot_id > 0) {
// Editing: use setting_id if available, or composite key with slot_id
profileKey = profile.setting_id || `slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`;
} else {
// New profile: use name as key (will be matched when profile is loaded)
profileKey = `name_${name}_${filamentId}`;
}
onSaveNote(profileKey, note);
}
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add delay before refreshing to give printer time to process the save
// Bambu printers can be slow to apply K-profile changes
// Add delay before closing to give printer time to process the save
// onSave will trigger refetch in the parent component
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
onSave();
}, 2500);
@@ -189,13 +258,12 @@ function KProfileModal({
showToast('K-profile deleted');
// Show syncing indicator while printer processes the command
setIsSyncing(true);
// Add delay before refreshing to give printer time to process the delete
// Bambu printers can be slow to apply K-profile changes
// Add longer delay for delete - printer needs more time to process
// before it can return the updated profile list
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
onClose();
}, 2500);
}, 4000);
},
onError: (error: Error) => {
console.error('[KProfile] Delete error:', error);
@@ -249,49 +317,48 @@ function KProfileModal({
return;
}
// For new profiles with multiple extruders: save sequentially
// For new profiles with multiple extruders: use batch endpoint
setIsSyncing(true);
setSavingProgress({ current: 0, total: selectedExtruders.length });
setSavingProgress({ current: 1, total: selectedExtruders.length });
for (let i = 0; i < selectedExtruders.length; i++) {
const extruderId = selectedExtruders[i];
const payload = {
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: undefined,
slot_id: 0,
};
// Build payload for all selected extruders
const batchPayload = selectedExtruders.map(extruderId => ({
name: name,
k_value: formattedKValue,
filament_id: filamentId,
nozzle_id: nozzleId,
nozzle_diameter: modalDiameter,
extruder_id: extruderId,
setting_id: undefined,
slot_id: 0,
}));
setSavingProgress({ current: i + 1, total: selectedExtruders.length });
console.log(`[KProfile] Saving profile ${i + 1}/${selectedExtruders.length} for extruder ${extruderId}:`, payload);
console.log(`[KProfile] Saving ${batchPayload.length} profiles in batch:`, batchPayload);
try {
await api.setKProfile(printerId, payload);
// Wait between saves to let printer process
if (i < selectedExtruders.length - 1) {
await new Promise(resolve => setTimeout(resolve, 1500));
}
} catch (error) {
console.error(`[KProfile] Failed to save for extruder ${extruderId}:`, error);
showToast(`Failed to save for ${extruderId === 1 ? 'Left' : 'Right'} extruder`, 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
try {
await api.setKProfilesBatch(printerId, batchPayload);
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
// Save note for new batch profiles
if (onSaveNote && note) {
const profileKey = `name_${name}_${filamentId}`;
onSaveNote(profileKey, note);
}
} catch (error) {
console.error('[KProfile] Failed to save batch:', error);
showToast('Failed to save K-profiles', 'error');
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
return;
}
showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
setSavingProgress({ current: selectedExtruders.length, total: selectedExtruders.length });
// Wait for final sync before closing
// onSave will trigger refetch in the parent component
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ['kprofiles', printerId] });
setIsSyncing(false);
setSavingProgress({ current: 0, total: 0 });
onSave();
}, 2500);
}, 3000);
};
return (
@@ -425,7 +492,7 @@ function KProfileModal({
if (!profile && filamentId && !name) {
const selectedFilament = knownFilaments.find(f => f.id === filamentId);
if (selectedFilament) {
const flowLabel = newNozzleType === 'HH00' ? 'HF' : 'S';
const flowLabel = newNozzleType === 'HS00' ? 'HF' : 'S';
setName(`${flowLabel} ${selectedFilament.name}`);
}
}
@@ -502,6 +569,21 @@ function KProfileModal({
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Add notes about this profile..."
rows={2}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
/>
<p className="text-xs text-bambu-gray mt-1">
Notes are saved in Bambusy, not on the printer
</p>
</div>
<div className="flex gap-2 pt-4">
{profile && (
<Button
@@ -595,15 +677,52 @@ function KProfileModal({
type ExtruderFilter = 'all' | 'left' | 'right';
type FlowTypeFilter = 'all' | 'hf' | 's';
type SortOption = 'name' | 'k_value' | 'filament';
// localStorage keys
const STORAGE_KEYS = {
NOZZLE_DIAMETER: 'bambusy_kprofiles_nozzle',
SORT_OPTION: 'bambusy_kprofiles_sort',
};
export function KProfilesView() {
const { showToast } = useToast();
const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
const [nozzleDiameter, setNozzleDiameter] = useState('0.4');
// Load nozzle diameter from localStorage
const [nozzleDiameter, setNozzleDiameter] = useState(() => {
const saved = localStorage.getItem(STORAGE_KEYS.NOZZLE_DIAMETER);
return saved || '0.4';
});
const [editingProfile, setEditingProfile] = useState<KProfile | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [copyingProfile, setCopyingProfile] = useState<KProfile | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [extruderFilter, setExtruderFilter] = useState<ExtruderFilter>('all');
const [flowTypeFilter, setFlowTypeFilter] = useState<FlowTypeFilter>('all');
// Load sort option from localStorage
const [sortOption, setSortOption] = useState<SortOption>(() => {
const saved = localStorage.getItem(STORAGE_KEYS.SORT_OPTION);
return (saved as SortOption) || 'name';
});
// Bulk selection mode
// Use composite key: `${slot_id}_${extruder_id}` since slot_id alone is not unique across extruders
const [selectionMode, setSelectionMode] = useState(false);
const [selectedProfiles, setSelectedProfiles] = useState<Set<string>>(new Set());
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
const [bulkDeleteInProgress, setBulkDeleteInProgress] = useState(false);
// Helper to create unique profile key for selection
const getProfileKey = (profile: KProfile) => `${profile.slot_id}_${profile.extruder_id}`;
// Save nozzle diameter to localStorage when it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.NOZZLE_DIAMETER, nozzleDiameter);
}, [nozzleDiameter]);
// Save sort option to localStorage when it changes
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.SORT_OPTION, sortOption);
}, [sortOption]);
// Get available printers
const { data: printers, isLoading: printersLoading } = useQuery({
@@ -611,21 +730,47 @@ export function KProfilesView() {
queryFn: api.getPrinters,
});
// Get K-profiles for selected printer
// Get K-profiles for selected printer (filtered by nozzle diameter)
const {
data: kprofiles,
isLoading: kprofilesLoading,
isFetching,
error: kprofilesError,
refetch: refetchProfiles,
} = useQuery({
queryKey: ['kprofiles', selectedPrinter, nozzleDiameter],
queryFn: () => api.getKProfiles(selectedPrinter!, nozzleDiameter),
queryFn: async () => {
console.log('[KProfiles] Fetching profiles for printer', selectedPrinter, 'nozzle', nozzleDiameter);
const result = await api.getKProfiles(selectedPrinter!, nozzleDiameter);
console.log('[KProfiles] Received profiles:', result?.profiles?.length || 0, 'profiles');
return result;
},
enabled: !!selectedPrinter,
retry: false,
staleTime: 0, // Always consider data stale to ensure fresh fetch
gcTime: 0, // Don't cache results
refetchOnMount: 'always', // Always refetch when component mounts
});
// Also fetch 0.4mm profiles for the filament dropdown (most filaments are calibrated for 0.4mm)
const { data: allProfiles } = useQuery({
queryKey: ['kprofiles', selectedPrinter, '0.4'],
queryFn: () => api.getKProfiles(selectedPrinter!, '0.4'),
enabled: !!selectedPrinter,
staleTime: 60000, // Cache for 1 minute
});
// Fetch K-profile notes (stored locally)
const {
data: notesData,
refetch: refetchNotes,
} = useQuery({
queryKey: ['kprofile-notes', selectedPrinter],
queryFn: () => api.getKProfileNotes(selectedPrinter!),
enabled: !!selectedPrinter,
staleTime: 30000, // Cache for 30 seconds
});
// Check if error is due to printer not being connected
const isOfflineError = kprofilesError?.message?.includes('not connected');
@@ -653,11 +798,12 @@ export function KProfilesView() {
// Get connected printers for display
const connectedPrinters = printers?.filter((p) => p.is_active) || [];
// Filter profiles based on search query, extruder filter, and flow type
// Filter and sort profiles
// Note: nozzle diameter filtering is done server-side via MQTT request
const filteredProfiles = React.useMemo(() => {
if (!kprofiles?.profiles) return [];
return kprofiles.profiles.filter((p) => {
const filtered = kprofiles.profiles.filter((p) => {
// Search filter - match name or filament_id (case-insensitive)
const query = searchQuery.toLowerCase();
const matchesSearch =
@@ -679,12 +825,239 @@ export function KProfilesView() {
return matchesSearch && matchesExtruder && matchesFlowType;
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter]);
// Sort profiles
return filtered.sort((a, b) => {
switch (sortOption) {
case 'k_value':
return parseFloat(a.k_value) - parseFloat(b.k_value);
case 'filament':
return extractFilamentName(a.name).localeCompare(extractFilamentName(b.name));
case 'name':
default:
return a.name.localeCompare(b.name);
}
});
}, [kprofiles?.profiles, searchQuery, extruderFilter, flowTypeFilter, sortOption]);
// Check if selected printer is dual-nozzle (auto-detected from MQTT temperature data)
const selectedPrinterData = printers?.find((p) => p.id === selectedPrinter);
const isDualNozzle = selectedPrinterData?.nozzle_count === 2;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in input fields
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) {
return;
}
// Don't trigger when modal is open
if (editingProfile || showAddModal || copyingProfile) {
return;
}
if (e.key === 'r' || e.key === 'R') {
e.preventDefault();
refetchProfiles();
} else if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setShowAddModal(true);
} else if (e.key === 'Escape' && selectionMode) {
e.preventDefault();
setSelectionMode(false);
setSelectedProfiles(new Set());
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [editingProfile, showAddModal, copyingProfile, selectionMode, refetchProfiles]);
// Export profiles to JSON file
const handleExport = useCallback(() => {
if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
showToast('No profiles to export', 'error');
return;
}
const exportData = {
version: 1,
exported_at: new Date().toISOString(),
printer: selectedPrinterData?.name || 'Unknown',
nozzle_diameter: nozzleDiameter,
profiles: kprofiles.profiles.map(p => ({
name: p.name,
k_value: p.k_value,
filament_id: p.filament_id,
nozzle_id: p.nozzle_id,
nozzle_diameter: p.nozzle_diameter,
extruder_id: p.extruder_id,
})),
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kprofiles_${selectedPrinterData?.name || 'printer'}_${nozzleDiameter}mm_${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Exported ${kprofiles.profiles.length} profiles`);
}, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
// Import profiles from JSON file
const handleImport = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.profiles || !Array.isArray(data.profiles)) {
showToast('Invalid file format', 'error');
return;
}
// Import profiles one by one
let imported = 0;
for (const p of data.profiles) {
if (!p.name || !p.k_value || !p.filament_id) continue;
try {
await api.setKProfile(selectedPrinter!, {
name: p.name,
k_value: parseFloat(p.k_value).toFixed(6),
filament_id: p.filament_id,
nozzle_id: p.nozzle_id || `HH00-${nozzleDiameter}`,
nozzle_diameter: p.nozzle_diameter || nozzleDiameter,
extruder_id: p.extruder_id ?? 0,
slot_id: 0, // Always create new
});
imported++;
// Small delay between imports
await new Promise(resolve => setTimeout(resolve, 500));
} catch (err) {
console.error('Failed to import profile:', p.name, err);
}
}
showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
refetchProfiles();
} catch (err) {
console.error('Import error:', err);
showToast('Failed to parse import file', 'error');
}
};
input.click();
}, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
// Toggle profile selection using composite key
const toggleProfileSelection = useCallback((profileKey: string) => {
setSelectedProfiles(prev => {
const next = new Set(prev);
if (next.has(profileKey)) {
next.delete(profileKey);
} else {
next.add(profileKey);
}
return next;
});
}, []);
// Select all visible profiles
const selectAllProfiles = useCallback(() => {
setSelectedProfiles(new Set(filteredProfiles.map(p => getProfileKey(p))));
}, [filteredProfiles, getProfileKey]);
// Delete selected profiles
const handleBulkDelete = useCallback(() => {
if (selectedProfiles.size === 0) return;
setShowBulkDeleteConfirm(true);
}, [selectedProfiles.size]);
// Execute the actual bulk delete
const executeBulkDelete = useCallback(async () => {
const profilesToDelete = filteredProfiles.filter(p => selectedProfiles.has(getProfileKey(p)));
setBulkDeleteInProgress(true);
let deleted = 0;
for (const profile of profilesToDelete) {
try {
await api.deleteKProfile(selectedPrinter!, {
slot_id: profile.slot_id,
extruder_id: profile.extruder_id,
nozzle_id: profile.nozzle_id,
nozzle_diameter: profile.nozzle_diameter,
filament_id: profile.filament_id,
setting_id: profile.setting_id,
});
deleted++;
// Small delay between deletes
await new Promise(resolve => setTimeout(resolve, 300));
} catch (err) {
console.error('Failed to delete profile:', profile.name, err);
}
}
showToast(`Deleted ${deleted} profiles`);
setBulkDeleteInProgress(false);
setShowBulkDeleteConfirm(false);
setSelectionMode(false);
setSelectedProfiles(new Set());
refetchProfiles();
}, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
// Generate possible keys for a profile (for notes lookup)
// Returns array of keys to check: setting_id, slot-based, name-based
const getProfileKeys = useCallback((profile: KProfile): string[] => {
const keys: string[] = [];
if (profile.setting_id) {
keys.push(profile.setting_id);
}
// Slot-based key (for profiles without setting_id)
keys.push(`slot_${profile.slot_id}_${profile.filament_id}_${profile.extruder_id}`);
// Name-based key (for newly created profiles)
keys.push(`name_${profile.name}_${profile.filament_id}`);
return keys;
}, []);
// Save note for a profile
const handleSaveNote = useCallback(async (profileKey: string, noteText: string) => {
if (!selectedPrinter) return;
try {
await api.setKProfileNote(selectedPrinter, profileKey, noteText);
refetchNotes();
} catch (err) {
console.error('Failed to save note:', err);
showToast('Failed to save note', 'error');
}
}, [selectedPrinter, refetchNotes, showToast]);
// Get note for a profile (checks all possible keys)
// Returns { note, key } so we know which key the note was stored under
const getNoteWithKey = useCallback((profile: KProfile): { note: string; key: string | null } => {
if (!notesData?.notes) return { note: '', key: null };
const keys = getProfileKeys(profile);
for (const key of keys) {
if (notesData.notes[key]) {
return { note: notesData.notes[key], key };
}
}
return { note: '', key: null };
}, [notesData, getProfileKeys]);
// Simple getter for display purposes
const getNote = useCallback((profile: KProfile) => {
return getNoteWithKey(profile).note;
}, [getNoteWithKey]);
if (printersLoading) {
return (
<div className="flex justify-center py-12">
@@ -723,6 +1096,14 @@ export function KProfilesView() {
return (
<>
{/* Loading overlay when refetching profiles (not initial load) */}
{isFetching && !kprofilesLoading && (
<div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
<Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
<p className="text-white font-medium">Loading K-Profiles...</p>
</div>
)}
{/* Printer & Nozzle Selector */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-48">
@@ -758,9 +1139,9 @@ export function KProfilesView() {
<Button
variant="secondary"
onClick={() => refetchProfiles()}
disabled={kprofilesLoading}
disabled={isFetching}
>
<RefreshCw className={`w-4 h-4 ${kprofilesLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={() => setShowAddModal(true)}>
@@ -771,7 +1152,7 @@ export function KProfilesView() {
</div>
{/* Search & Filter Row */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex flex-wrap gap-4 mb-4">
<div className="flex-1 min-w-48 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
<input
@@ -806,6 +1187,81 @@ export function KProfilesView() {
<option value="s">S Only</option>
</select>
</div>
<div className="w-32">
<select
value={sortOption}
onChange={(e) => setSortOption(e.target.value as SortOption)}
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"
>
<option value="name">Sort: Name</option>
<option value="k_value">Sort: K-Value</option>
<option value="filament">Sort: Filament</option>
</select>
</div>
</div>
{/* Toolbar Row */}
<div className="flex flex-wrap gap-2 mb-6">
<Button
variant="secondary"
onClick={handleExport}
disabled={!kprofiles?.profiles?.length}
title="Export profiles to JSON"
>
<Download className="w-4 h-4" />
Export
</Button>
<Button
variant="secondary"
onClick={handleImport}
title="Import profiles from JSON"
>
<Upload className="w-4 h-4" />
Import
</Button>
<div className="flex-1" />
{selectionMode ? (
<>
<Button
variant="secondary"
onClick={selectAllProfiles}
title="Select all visible profiles"
>
<CheckSquare className="w-4 h-4" />
Select All
</Button>
<Button
variant="secondary"
onClick={handleBulkDelete}
disabled={selectedProfiles.size === 0}
className="text-red-500 hover:bg-red-500/10"
title={`Delete ${selectedProfiles.size} selected profiles`}
>
<Trash2 className="w-4 h-4" />
Delete ({selectedProfiles.size})
</Button>
<Button
variant="secondary"
onClick={() => {
setSelectionMode(false);
setSelectedProfiles(new Set());
}}
>
<X className="w-4 h-4" />
Cancel
</Button>
</>
) : (
<Button
variant="secondary"
onClick={() => setSelectionMode(true)}
disabled={!filteredProfiles.length}
title="Enter selection mode for bulk delete"
>
<CheckSquare className="w-4 h-4" />
Select
</Button>
)}
</div>
{/* K-Profiles Grid */}
@@ -839,9 +1295,14 @@ export function KProfilesView() {
.filter((p) => p.extruder_id === 1)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
key={getProfileKey(profile)}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
</div>
@@ -854,9 +1315,14 @@ export function KProfilesView() {
.filter((p) => p.extruder_id === 0)
.map((profile) => (
<KProfileCard
key={profile.slot_id}
key={getProfileKey(profile)}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
</div>
@@ -867,9 +1333,14 @@ export function KProfilesView() {
<div className="space-y-1">
{filteredProfiles.map((profile) => (
<KProfileCard
key={profile.slot_id}
key={getProfileKey(profile)}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onCopy={() => setCopyingProfile(profile)}
selectionMode={selectionMode}
isSelected={selectedProfiles.has(getProfileKey(profile))}
onToggleSelect={() => toggleProfileSelection(getProfileKey(profile))}
note={getNote(profile)}
/>
))}
</div>
@@ -901,29 +1372,118 @@ export function KProfilesView() {
)}
{/* Edit Modal */}
{editingProfile && selectedPrinter && (
<KProfileModal
profile={editingProfile}
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setEditingProfile(null)}
onSave={() => setEditingProfile(null)}
/>
)}
{editingProfile && selectedPrinter && (() => {
const { note, key } = getNoteWithKey(editingProfile);
return (
<KProfileModal
profile={editingProfile}
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
initialNote={note}
initialNoteKey={key}
onSaveNote={handleSaveNote}
onClose={() => {
console.log('[KProfiles] Edit modal onClose - refetching profiles...');
setEditingProfile(null);
refetchProfiles(); // Refetch after close (handles delete case)
}}
onSave={() => {
setEditingProfile(null);
refetchProfiles();
}}
/>
);
})()}
{/* Add Modal */}
{showAddModal && selectedPrinter && (
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={kprofiles?.profiles}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onClose={() => setShowAddModal(false)}
onSave={() => setShowAddModal(false)}
onSaveNote={handleSaveNote}
onClose={() => {
setShowAddModal(false);
refetchProfiles(); // Refetch after close
}}
onSave={() => {
setShowAddModal(false);
refetchProfiles();
}}
/>
)}
{/* Copy Modal - opens add modal with prefilled values from source profile */}
{copyingProfile && selectedPrinter && (
<KProfileModal
printerId={selectedPrinter}
nozzleDiameter={nozzleDiameter}
existingProfiles={allProfiles?.profiles || kprofiles?.profiles}
isDualNozzle={isDualNozzle}
onSaveNote={handleSaveNote}
// Pass profile data but without slot_id to create a new profile
profile={{
...copyingProfile,
slot_id: 0, // Force new profile creation
name: `${copyingProfile.name} (Copy)`, // Indicate it's a copy
}}
onClose={() => {
setCopyingProfile(null);
refetchProfiles();
}}
onSave={() => {
setCopyingProfile(null);
refetchProfiles();
}}
/>
)}
{/* Bulk Delete Confirmation Modal */}
{showBulkDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-sm">
<CardContent className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-500/20 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
<p className="text-sm text-bambu-gray">This cannot be undone</p>
</div>
</div>
<p className="text-bambu-gray mb-6">
Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
</p>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => setShowBulkDeleteConfirm(false)}
disabled={bulkDeleteInProgress}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={executeBulkDelete}
disabled={bulkDeleteInProgress}
className="flex-1 bg-red-500 hover:bg-red-600 text-white"
>
{bulkDeleteInProgress ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</>
);
}
@@ -37,6 +37,11 @@ function getShortcuts(navItems: NavItem[] | undefined, t: (key: string) => strin
{ keys: ['Esc'], description: 'Clear selection / blur input' },
{ keys: ['Right-click'], description: 'Context menu on cards' },
]},
{ category: 'K-Profiles', items: [
{ keys: ['R'], description: 'Refresh profiles' },
{ keys: ['N'], description: 'New profile' },
{ keys: ['Esc'], description: 'Exit selection mode' },
]},
{ category: 'General', items: [
{ keys: ['?'], description: 'Show this help' },
]},
+5 -4
View File
@@ -1088,6 +1088,11 @@ export function ArchivesPage() {
{/* Selection Toolbar */}
{selectionMode && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl px-4 py-3 flex items-center gap-4">
<Button variant="secondary" size="sm" onClick={clearSelection}>
<X className="w-4 h-4" />
Close
</Button>
<div className="w-px h-6 bg-bambu-dark-tertiary" />
<span className="text-white font-medium">
{selectedIds.size} selected
</span>
@@ -1095,10 +1100,6 @@ export function ArchivesPage() {
<Button variant="secondary" size="sm" onClick={selectAll}>
Select All
</Button>
<Button variant="secondary" size="sm" onClick={clearSelection}>
<X className="w-4 h-4" />
Clear
</Button>
<div className="w-px h-6 bg-bambu-dark-tertiary" />
<Button
variant="secondary"
File diff suppressed because it is too large Load Diff
+593 -155
View File
@@ -1,6 +1,23 @@
import { useState } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
Clock,
Trash2,
@@ -16,17 +33,32 @@ import {
ExternalLink,
Power,
StopCircle,
Pencil,
RefreshCw,
Timer,
ListOrdered,
Layers,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { api } from '../api/client';
import type { PrintQueueItem } from '../api/client';
import { Card } from '../components/Card';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { ConfirmModal } from '../components/ConfirmModal';
import { EditQueueItemModal } from '../components/EditQueueItemModal';
import { useToast } from '../contexts/ToastContext';
function formatDuration(seconds: number | null | undefined): string {
if (!seconds) return '--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function formatRelativeTime(dateString: string | null): string {
if (!dateString) return 'ASAP';
// Parse ISO string - it's in UTC, convert to local for display
const date = new Date(dateString);
const now = new Date();
const diff = date.getTime() - now.getTime();
@@ -41,48 +73,91 @@ function formatRelativeTime(dateString: string | null): string {
function StatusBadge({ status }: { status: PrintQueueItem['status'] }) {
const config = {
pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10', label: 'Pending' },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10', label: 'Printing' },
completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10', label: 'Completed' },
failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10', label: 'Failed' },
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10', label: 'Skipped' },
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10', label: 'Cancelled' },
pending: { icon: Clock, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20', label: 'Pending' },
printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
completed: { icon: CheckCircle, color: 'text-green-400 bg-green-400/10 border-green-400/20', label: 'Completed' },
failed: { icon: XCircle, color: 'text-red-400 bg-red-400/10 border-red-400/20', label: 'Failed' },
skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' },
cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' },
};
const { icon: Icon, color, label } = config[status];
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${color}`}>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
<Icon className="w-3.5 h-3.5" />
{label}
</span>
);
}
function QueueItemRow({
// Sortable queue item for drag and drop
function SortableQueueItem({
item,
position,
onEdit,
onCancel,
onRemove,
onStop,
onRequeue,
}: {
item: PrintQueueItem;
onCancel: (id: number) => void;
onRemove: (id: number) => void;
onStop: (id: number) => void;
position?: number;
onEdit: () => void;
onCancel: () => void;
onRemove: () => void;
onStop: () => void;
onRequeue: () => void;
}) {
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
const [showStopConfirm, setShowStopConfirm] = useState(false);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id, disabled: item.status !== 'pending' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isPrinting = item.status === 'printing';
const isPending = item.status === 'pending';
const isHistory = ['completed', 'failed', 'skipped', 'cancelled'].includes(item.status);
return (
<>
<div className="flex items-center gap-4 p-4 bg-bambu-dark-secondary rounded-lg">
{item.status === 'pending' && (
<GripVertical className="w-5 h-5 text-bambu-gray cursor-grab" />
<div
ref={setNodeRef}
style={style}
className={`
group relative bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary
transition-all duration-200 hover:border-bambu-dark-tertiary/80
${isDragging ? 'opacity-50 scale-[1.02] shadow-xl z-50' : ''}
${isPrinting ? 'border-blue-500/30 bg-gradient-to-r from-blue-500/5 to-transparent' : ''}
`}
>
<div className="flex items-center gap-4 p-4">
{/* Drag handle or position number */}
{isPending ? (
<div
{...attributes}
{...listeners}
className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark cursor-grab active:cursor-grabbing hover:bg-bambu-dark-tertiary transition-colors"
>
<GripVertical className="w-4 h-4 text-bambu-gray" />
</div>
) : position !== undefined ? (
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-bambu-dark text-bambu-gray text-sm font-medium">
#{position}
</div>
) : (
<div className="w-8" />
)}
{/* Thumbnail */}
<div className="w-16 h-16 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
<div className="w-14 h-14 flex-shrink-0 bg-bambu-dark rounded-lg overflow-hidden">
{item.archive_thumbnail ? (
<img
src={api.getArchiveThumbnail(item.archive_id)}
@@ -91,14 +166,14 @@ function QueueItemRow({
/>
) : (
<div className="w-full h-full flex items-center justify-center text-bambu-gray">
<Calendar className="w-6 h-6" />
<Layers className="w-6 h-6" />
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mb-1">
<p className="text-white font-medium truncate">
{item.archive_name || `Archive #${item.archive_id}`}
</p>
@@ -110,119 +185,121 @@ function QueueItemRow({
<ExternalLink className="w-3.5 h-3.5" />
</Link>
</div>
<div className="flex items-center gap-3 mt-1 text-sm text-bambu-gray">
<span className="flex items-center gap-1">
<div className="flex items-center gap-3 text-sm text-bambu-gray">
<span className="flex items-center gap-1.5">
<Printer className="w-3.5 h-3.5" />
{item.printer_name || `Printer #${item.printer_id}`}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{formatRelativeTime(item.scheduled_time)}
</span>
{item.print_time_seconds && (
<span className="flex items-center gap-1.5">
<Timer className="w-3.5 h-3.5" />
{formatDuration(item.print_time_seconds)}
</span>
)}
{isPending && (
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{formatRelativeTime(item.scheduled_time)}
</span>
)}
</div>
{item.require_previous_success && (
<p className="text-xs text-orange-400 mt-1">
Requires previous print to succeed
</p>
)}
{item.auto_off_after && (
<p className="text-xs text-blue-400 mt-1 flex items-center gap-1">
<Power className="w-3 h-3" />
Will power off when done
</p>
{/* Options badges */}
<div className="flex items-center gap-2 mt-2">
{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
</span>
)}
{item.auto_off_after && (
<span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
<Power className="w-3 h-3" />
Auto power off
</span>
)}
</div>
{/* Progress bar for printing items - TODO: integrate with WebSocket */}
{isPrinting && (
<div className="mt-3">
<div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
</div>
<p className="text-xs text-bambu-gray mt-1">Printing in progress...</p>
</div>
)}
{/* Error message */}
{item.error_message && (
<p className="text-xs text-red-400 mt-1 flex items-center gap-1">
<p className="text-xs text-red-400 mt-2 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{item.error_message}
</p>
)}
</div>
{/* Status */}
{/* Status badge */}
<StatusBadge status={item.status} />
{/* Actions */}
<div className="flex items-center gap-2">
{item.status === 'printing' && (
<div className="flex items-center gap-1">
{isPrinting && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowStopConfirm(true)}
onClick={onStop}
title="Stop Print"
className="text-red-400 hover:text-red-300"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<StopCircle className="w-4 h-4" />
</Button>
)}
{item.status === 'pending' && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowCancelConfirm(true)}
title="Cancel"
>
<X className="w-4 h-4" />
</Button>
{isPending && (
<>
<Button
variant="ghost"
size="sm"
onClick={onEdit}
title="Edit"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onCancel}
title="Cancel"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<X className="w-4 h-4" />
</Button>
</>
)}
{['completed', 'failed', 'skipped', 'cancelled'].includes(item.status) && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowRemoveConfirm(true)}
title="Remove"
>
<Trash2 className="w-4 h-4" />
</Button>
{isHistory && (
<>
<Button
variant="ghost"
size="sm"
onClick={onRequeue}
title="Re-queue"
className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
title="Remove"
>
<Trash2 className="w-4 h-4" />
</Button>
</>
)}
</div>
</div>
{/* Cancel Confirmation Modal */}
{showCancelConfirm && (
<ConfirmModal
title="Cancel Scheduled Print"
message={`Are you sure you want to cancel "${item.archive_name || 'this print'}"? It will be removed from the queue.`}
confirmText="Cancel Print"
variant="danger"
onConfirm={() => {
onCancel(item.id);
setShowCancelConfirm(false);
}}
onCancel={() => setShowCancelConfirm(false)}
/>
)}
{/* Remove Confirmation Modal */}
{showRemoveConfirm && (
<ConfirmModal
title="Remove from History"
message={`Are you sure you want to remove "${item.archive_name || 'this item'}" from the queue history?`}
confirmText="Remove"
variant="danger"
onConfirm={() => {
onRemove(item.id);
setShowRemoveConfirm(false);
}}
onCancel={() => setShowRemoveConfirm(false)}
/>
)}
{/* Stop Confirmation Modal */}
{showStopConfirm && (
<ConfirmModal
title="Stop Print"
message={`Are you sure you want to stop the current print "${item.archive_name || 'this print'}"? This will cancel the print job on the printer.`}
confirmText="Stop Print"
variant="danger"
onConfirm={() => {
onStop(item.id);
setShowStopConfirm(false);
}}
onCancel={() => setShowStopConfirm(false)}
/>
)}
</>
</div>
);
}
@@ -231,11 +308,55 @@ export function QueuePage() {
const { showToast } = useToast();
const [filterPrinter, setFilterPrinter] = useState<number | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('');
const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false);
const [editItem, setEditItem] = useState<PrintQueueItem | null>(null);
const [confirmAction, setConfirmAction] = useState<{
type: 'cancel' | 'remove' | 'stop';
item: PrintQueueItem;
} | null>(null);
const [historySortBy, setHistorySortBy] = useState<'date' | 'name' | 'printer'>(() => {
const saved = localStorage.getItem('queue.historySortBy');
return (saved as 'date' | 'name' | 'printer') || 'date';
});
const [historySortAsc, setHistorySortAsc] = useState(() => {
const saved = localStorage.getItem('queue.historySortAsc');
return saved !== null ? saved === 'true' : false;
});
const [pendingSortBy, setPendingSortBy] = useState<'position' | 'name' | 'printer' | 'time'>(() => {
const saved = localStorage.getItem('queue.pendingSortBy');
return (saved as 'position' | 'name' | 'printer' | 'time') || 'position';
});
const [pendingSortAsc, setPendingSortAsc] = useState(() => {
const saved = localStorage.getItem('queue.pendingSortAsc');
return saved !== null ? saved === 'true' : true;
});
// Persist sort settings to localStorage
useEffect(() => {
localStorage.setItem('queue.historySortBy', historySortBy);
}, [historySortBy]);
useEffect(() => {
localStorage.setItem('queue.historySortAsc', String(historySortAsc));
}, [historySortAsc]);
useEffect(() => {
localStorage.setItem('queue.pendingSortBy', pendingSortBy);
}, [pendingSortBy]);
useEffect(() => {
localStorage.setItem('queue.pendingSortAsc', String(pendingSortAsc));
}, [pendingSortAsc]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const { data: queue, isLoading } = useQuery({
queryKey: ['queue', filterPrinter, filterStatus],
queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
refetchInterval: 10000,
refetchInterval: 5000,
});
const { data: printers } = useQuery({
@@ -270,23 +391,195 @@ export function QueuePage() {
onError: () => showToast('Failed to stop print', 'error'),
});
const pendingItems = queue?.filter(i => i.status === 'pending') || [];
const reorderMutation = useMutation({
mutationFn: (items: { id: number; position: number }[]) => api.reorderQueue(items),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
},
onError: () => showToast('Failed to reorder queue', 'error'),
});
const requeueMutation = useMutation({
mutationFn: (item: PrintQueueItem) => {
// Schedule far in future so it doesn't start immediately
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
return api.addToQueue({
printer_id: item.printer_id,
archive_id: item.archive_id,
scheduled_time: futureDate.toISOString(),
require_previous_success: false,
auto_off_after: false,
});
},
onSuccess: (newItem) => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast('Added back to queue - please set schedule');
// Open edit modal for the new item
setEditItem(newItem);
},
onError: (error: Error) => showToast(error.message || 'Failed to re-queue item', 'error'),
});
const clearHistoryMutation = useMutation({
mutationFn: async () => {
const historyItems = queue?.filter(i =>
['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)
) || [];
for (const item of historyItems) {
await api.removeFromQueue(item.id);
}
return historyItems.length;
},
onSuccess: (count) => {
queryClient.invalidateQueries({ queryKey: ['queue'] });
showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`);
},
onError: () => showToast('Failed to clear history', 'error'),
});
const pendingItems = useMemo(() => {
const items = queue?.filter(i => i.status === 'pending') || [];
// Helper to get scheduled time as timestamp (ASAP/placeholder = 0 for earliest)
const getScheduledTime = (item: PrintQueueItem): number => {
if (!item.scheduled_time) return 0;
const time = new Date(item.scheduled_time).getTime();
// Placeholder dates (> 6 months out) are treated as ASAP
const sixMonthsFromNow = Date.now() + (180 * 24 * 60 * 60 * 1000);
return time > sixMonthsFromNow ? 0 : time;
};
return [...items].sort((a, b) => {
let cmp: number;
if (pendingSortBy === 'name') {
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
} else if (pendingSortBy === 'printer') {
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
} else if (pendingSortBy === 'time') {
// Sort by scheduled start time (when print will begin)
cmp = getScheduledTime(a) - getScheduledTime(b);
} else {
cmp = a.position - b.position;
}
return pendingSortAsc ? cmp : -cmp;
});
}, [queue, pendingSortBy, pendingSortAsc]);
const activeItems = queue?.filter(i => i.status === 'printing') || [];
const historyItems = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
const historyItems = useMemo(() => {
const items = queue?.filter(i => ['completed', 'failed', 'skipped', 'cancelled'].includes(i.status)) || [];
return [...items].sort((a, b) => {
let cmp: number;
if (historySortBy === 'name') {
cmp = (a.archive_name || '').localeCompare(b.archive_name || '');
} else if (historySortBy === 'printer') {
cmp = (a.printer_name || '').localeCompare(b.printer_name || '');
} else {
// Default: by date - most recent first (desc) is the natural order
cmp = new Date(b.completed_at || b.created_at).getTime() - new Date(a.completed_at || a.created_at).getTime();
}
return historySortAsc ? -cmp : cmp;
});
}, [queue, historySortBy, historySortAsc]);
// Calculate total queue time
const totalQueueTime = useMemo(() => {
return pendingItems.reduce((acc, item) => acc + (item.print_time_seconds || 0), 0);
}, [pendingItems]);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = pendingItems.findIndex(i => i.id === active.id);
const newIndex = pendingItems.findIndex(i => i.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(pendingItems, oldIndex, newIndex);
const updates = reordered.map((item, index) => ({
id: item.id,
position: index + 1,
}));
reorderMutation.mutate(updates);
}
};
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">Print Queue</h1>
<p className="text-bambu-gray mt-1">Schedule and manage print jobs</p>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<ListOrdered className="w-7 h-7 text-bambu-green" />
Print Queue
</h1>
<p className="text-bambu-gray mt-1">Schedule and manage your print jobs</p>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Play className="w-5 h-5 text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{activeItems.length}</p>
<p className="text-sm text-bambu-gray">Printing</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-yellow-500/10 to-transparent border-yellow-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Clock className="w-5 h-5 text-yellow-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{pendingItems.length}</p>
<p className="text-sm text-bambu-gray">Queued</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-bambu-green/10 to-transparent border-bambu-green/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-bambu-green/20 flex items-center justify-center">
<Timer className="w-5 h-5 text-bambu-green" />
</div>
<div>
<p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
<p className="text-sm text-bambu-gray">Total Queue Time</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-gray-500/10 to-transparent border-gray-500/20">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-500/20 flex items-center justify-center">
<CheckCircle className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="text-2xl font-bold text-white">{historyItems.length}</p>
<p className="text-sm text-bambu-gray">History</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4 mb-6">
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
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={filterPrinter || ''}
onChange={(e) => setFilterPrinter(e.target.value ? Number(e.target.value) : null)}
>
@@ -297,7 +590,7 @@ export function QueuePage() {
</select>
<select
className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
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={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
>
@@ -309,35 +602,51 @@ export function QueuePage() {
<option value="skipped">Skipped</option>
<option value="cancelled">Cancelled</option>
</select>
<div className="flex-1" />
{historyItems.length > 0 && (
<Button
variant="secondary"
size="sm"
onClick={() => setShowClearHistoryConfirm(true)}
>
<Trash2 className="w-4 h-4" />
Clear History
</Button>
)}
</div>
{isLoading ? (
<div className="text-center py-12 text-bambu-gray">Loading...</div>
) : queue?.length === 0 ? (
<Card className="p-12 text-center">
<Calendar className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No prints scheduled</h3>
<p className="text-bambu-gray">
Schedule a print from the Archives page using the "Schedule" option in the context menu.
<Card className="p-12 text-center border-dashed">
<Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
<h3 className="text-xl font-medium text-white mb-2">No prints scheduled</h3>
<p className="text-bambu-gray max-w-md mx-auto">
Schedule a print from the Archives page using the "Schedule" option in the context menu,
or drag and drop files to get started.
</p>
</Card>
) : (
<div className="space-y-6">
<div className="space-y-8">
{/* Active Prints */}
{activeItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<Play className="w-5 h-5 text-blue-400" />
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Currently Printing
</h2>
<div className="space-y-2">
<div className="space-y-3">
{activeItems.map((item) => (
<QueueItemRow
<SortableQueueItem
key={item.id}
item={item}
onCancel={(id) => cancelMutation.mutate(id)}
onRemove={(id) => removeMutation.mutate(id)}
onStop={(id) => stopMutation.mutate(id)}
onEdit={() => {}}
onCancel={() => {}}
onRemove={() => {}}
onStop={() => setConfirmAction({ type: 'stop', item })}
onRequeue={() => {}}
/>
))}
</div>
@@ -347,39 +656,110 @@ export function QueuePage() {
{/* Pending Queue */}
{pendingItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-400" />
Queued ({pendingItems.length})
</h2>
<div className="space-y-2">
{pendingItems.map((item) => (
<QueueItemRow
key={item.id}
item={item}
onCancel={(id) => cancelMutation.mutate(id)}
onRemove={(id) => removeMutation.mutate(id)}
onStop={(id) => stopMutation.mutate(id)}
/>
))}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-400" />
Queued
<span className="text-sm font-normal text-bambu-gray">
({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''})
</span>
<span className="text-xs text-bambu-gray ml-2" title="Position only affects ASAP items. Scheduled items run at their set time.">
Drag to reorder (ASAP only)
</span>
</h2>
<div className="flex items-center gap-2">
<select
className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={pendingSortBy}
onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
>
<option value="position">Sort by Position</option>
<option value="name">Sort by Name</option>
<option value="printer">Sort by Printer</option>
<option value="time">Sort by Schedule</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setPendingSortAsc(!pendingSortAsc)}
title={pendingSortAsc ? 'Ascending' : 'Descending'}
className="px-2"
>
{pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
</Button>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={pendingItems.map(i => i.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{pendingItems.map((item, index) => (
<SortableQueueItem
key={item.id}
item={item}
position={index + 1}
onEdit={() => setEditItem(item)}
onCancel={() => setConfirmAction({ type: 'cancel', item })}
onRemove={() => {}}
onStop={() => {}}
onRequeue={() => {}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
)}
{/* History */}
{historyItems.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-bambu-gray" />
History ({historyItems.length})
</h2>
<div className="space-y-2">
{historyItems.slice(0, 10).map((item) => (
<QueueItemRow
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-bambu-gray" />
History
<span className="text-sm font-normal text-bambu-gray">
({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})
</span>
</h2>
<div className="flex items-center gap-2">
<select
className="px-3 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
value={historySortBy}
onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="printer">Sort by Printer</option>
</select>
<Button
variant="ghost"
size="sm"
onClick={() => setHistorySortAsc(!historySortAsc)}
title={historySortAsc ? 'Ascending (oldest first)' : 'Descending (newest first)'}
className="px-2"
>
{historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
</Button>
</div>
</div>
<div className="space-y-3">
{historyItems.slice(0, 20).map((item, index) => (
<SortableQueueItem
key={item.id}
item={item}
onCancel={(id) => cancelMutation.mutate(id)}
onRemove={(id) => removeMutation.mutate(id)}
onStop={(id) => stopMutation.mutate(id)}
position={index + 1}
onEdit={() => {}}
onCancel={() => {}}
onRemove={() => setConfirmAction({ type: 'remove', item })}
onStop={() => {}}
onRequeue={() => requeueMutation.mutate(item)}
/>
))}
</div>
@@ -387,6 +767,64 @@ export function QueuePage() {
)}
</div>
)}
{/* Edit Modal */}
{editItem && (
<EditQueueItemModal
item={editItem}
onClose={() => setEditItem(null)}
/>
)}
{/* Confirm Action Modal */}
{confirmAction && (
<ConfirmModal
title={
confirmAction.type === 'cancel' ? 'Cancel Scheduled Print' :
confirmAction.type === 'stop' ? 'Stop Print' :
'Remove from History'
}
message={
confirmAction.type === 'cancel'
? `Are you sure you want to cancel "${confirmAction.item.archive_name || 'this print'}"?`
: confirmAction.type === 'stop'
? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || 'this print'}"? This will cancel the print job on the printer.`
: `Are you sure you want to remove "${confirmAction.item.archive_name || 'this item'}" from the queue history?`
}
confirmText={
confirmAction.type === 'cancel' ? 'Cancel Print' :
confirmAction.type === 'stop' ? 'Stop Print' :
'Remove'
}
variant="danger"
onConfirm={() => {
if (confirmAction.type === 'cancel') {
cancelMutation.mutate(confirmAction.item.id);
} else if (confirmAction.type === 'stop') {
stopMutation.mutate(confirmAction.item.id);
} else {
removeMutation.mutate(confirmAction.item.id);
}
setConfirmAction(null);
}}
onCancel={() => setConfirmAction(null)}
/>
)}
{/* Clear History Confirm Modal */}
{showClearHistoryConfirm && (
<ConfirmModal
title="Clear History"
message={`Are you sure you want to remove all ${historyItems.length} item${historyItems.length !== 1 ? 's' : ''} from the history?`}
confirmText="Clear History"
variant="danger"
onConfirm={() => {
clearHistoryMutation.mutate();
setShowClearHistoryConfirm(false);
}}
onCancel={() => setShowClearHistoryConfirm(false)}
/>
)}
</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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
<script type="module" crossorigin src="/assets/index-CUSzovgk.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DL3S7zom.css">
<script type="module" crossorigin src="/assets/index-CZD79C6y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BPFlIKJb.css">
</head>
<body>
<div id="root"></div>