mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 23:21:58 +02:00
sync
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
]},
|
||||
|
||||
@@ -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"
|
||||
|
||||
+924
-207
File diff suppressed because it is too large
Load Diff
+593
-155
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user