Added auto app update; Added maintenance module with notifications

This commit is contained in:
Martin Ziegler
2025-12-01 08:39:07 +01:00
parent 8571236bc0
commit f126b0a075
26 changed files with 2278 additions and 511 deletions
+520
View File
@@ -0,0 +1,520 @@
"""Maintenance tracking API routes."""
import logging
from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from backend.app.core.database import get_db
from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory
from backend.app.models.printer import Printer
from backend.app.models.archive import PrintArchive
from backend.app.services.notification_service import notification_service
from backend.app.schemas.maintenance import (
MaintenanceTypeCreate,
MaintenanceTypeUpdate,
MaintenanceTypeResponse,
PrinterMaintenanceCreate,
PrinterMaintenanceUpdate,
PrinterMaintenanceResponse,
MaintenanceHistoryResponse,
MaintenanceStatus,
PrinterMaintenanceOverview,
PerformMaintenanceRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/maintenance", tags=["maintenance"])
# Default maintenance types
DEFAULT_MAINTENANCE_TYPES = [
{
"name": "Lubricate Linear Rails",
"description": "Apply lubricant to linear rails and rods for smooth motion",
"default_interval_hours": 50.0,
"icon": "Droplet",
},
{
"name": "Clean Nozzle/Hotend",
"description": "Clean nozzle exterior and perform cold pull if needed",
"default_interval_hours": 100.0,
"icon": "Flame",
},
{
"name": "Check Belt Tension",
"description": "Verify and adjust belt tension for X/Y axes",
"default_interval_hours": 200.0,
"icon": "Ruler",
},
{
"name": "Clean Carbon Rods",
"description": "Wipe carbon rods with a dry cloth",
"default_interval_hours": 100.0,
"icon": "Sparkles",
},
{
"name": "Clean Build Plate",
"description": "Deep clean build plate with IPA or soap",
"default_interval_hours": 25.0,
"icon": "Square",
},
{
"name": "Check PTFE Tube",
"description": "Inspect PTFE tube for wear or discoloration",
"default_interval_hours": 500.0,
"icon": "Cable",
},
]
async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
"""Calculate total print hours for a printer from archives plus offset."""
# Get archive hours
result = await db.execute(
select(func.sum(PrintArchive.print_time_seconds))
.where(PrintArchive.printer_id == printer_id)
.where(PrintArchive.status == "completed")
)
total_seconds = result.scalar() or 0
archive_hours = total_seconds / 3600.0
# Get printer offset
result = await db.execute(
select(Printer.print_hours_offset).where(Printer.id == printer_id)
)
offset = result.scalar() or 0.0
return archive_hours + offset
async def ensure_default_types(db: AsyncSession) -> None:
"""Ensure default maintenance types exist."""
result = await db.execute(
select(MaintenanceType).where(MaintenanceType.is_system == True)
)
existing = result.scalars().all()
existing_names = {t.name for t in existing}
for type_def in DEFAULT_MAINTENANCE_TYPES:
if type_def["name"] not in existing_names:
new_type = MaintenanceType(
name=type_def["name"],
description=type_def["description"],
default_interval_hours=type_def["default_interval_hours"],
icon=type_def["icon"],
is_system=True,
)
db.add(new_type)
await db.commit()
# ============== Maintenance Types ==============
@router.get("/types", response_model=List[MaintenanceTypeResponse])
async def get_maintenance_types(db: AsyncSession = Depends(get_db)):
"""Get all maintenance types."""
await ensure_default_types(db)
result = await db.execute(
select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
)
return result.scalars().all()
@router.post("/types", response_model=MaintenanceTypeResponse)
async def create_maintenance_type(
data: MaintenanceTypeCreate,
db: AsyncSession = Depends(get_db),
):
"""Create a custom maintenance type."""
new_type = MaintenanceType(
name=data.name,
description=data.description,
default_interval_hours=data.default_interval_hours,
icon=data.icon,
is_system=False,
)
db.add(new_type)
await db.commit()
await db.refresh(new_type)
return new_type
@router.patch("/types/{type_id}", response_model=MaintenanceTypeResponse)
async def update_maintenance_type(
type_id: int,
data: MaintenanceTypeUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a maintenance type."""
result = await db.execute(
select(MaintenanceType).where(MaintenanceType.id == type_id)
)
maint_type = result.scalar_one_or_none()
if not maint_type:
raise HTTPException(status_code=404, detail="Maintenance type not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(maint_type, key, value)
await db.commit()
await db.refresh(maint_type)
return maint_type
@router.delete("/types/{type_id}")
async def delete_maintenance_type(
type_id: int,
db: AsyncSession = Depends(get_db),
):
"""Delete a custom maintenance type."""
result = await db.execute(
select(MaintenanceType).where(MaintenanceType.id == type_id)
)
maint_type = result.scalar_one_or_none()
if not maint_type:
raise HTTPException(status_code=404, detail="Maintenance type not found")
if maint_type.is_system:
raise HTTPException(status_code=400, detail="Cannot delete system maintenance type")
await db.delete(maint_type)
await db.commit()
return {"status": "deleted"}
# ============== Printer Maintenance ==============
async def _get_printer_maintenance_internal(
printer_id: int,
db: AsyncSession,
commit: bool = True,
) -> PrinterMaintenanceOverview:
"""Internal helper to get maintenance overview for a specific printer."""
await ensure_default_types(db)
# Get printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
total_hours = await get_printer_total_hours(db, printer_id)
# Get all maintenance types
result = await db.execute(select(MaintenanceType))
all_types = result.scalars().all()
# Get printer's maintenance items
result = await db.execute(
select(PrinterMaintenance)
.where(PrinterMaintenance.printer_id == printer_id)
.options(selectinload(PrinterMaintenance.maintenance_type))
)
existing_items = {item.maintenance_type_id: item for item in result.scalars().all()}
maintenance_items = []
due_count = 0
warning_count = 0
for maint_type in all_types:
item = existing_items.get(maint_type.id)
if item:
interval = item.custom_interval_hours or maint_type.default_interval_hours
enabled = item.enabled
last_performed_hours = item.last_performed_hours
last_performed_at = item.last_performed_at
item_id = item.id
else:
# Create default entry for this printer/type
item = PrinterMaintenance(
printer_id=printer_id,
maintenance_type_id=maint_type.id,
enabled=True,
last_performed_hours=0.0,
)
db.add(item)
await db.flush()
interval = maint_type.default_interval_hours
enabled = True
last_performed_hours = 0.0
last_performed_at = None
item_id = item.id
hours_since = total_hours - last_performed_hours
hours_until = interval - hours_since
is_due = hours_until <= 0
is_warning = hours_until <= (interval * 0.1) and not is_due
if enabled:
if is_due:
due_count += 1
elif is_warning:
warning_count += 1
maintenance_items.append(MaintenanceStatus(
id=item_id,
printer_id=printer_id,
printer_name=printer.name,
maintenance_type_id=maint_type.id,
maintenance_type_name=maint_type.name,
maintenance_type_icon=maint_type.icon,
enabled=enabled,
interval_hours=interval,
current_hours=total_hours,
hours_since_maintenance=hours_since,
hours_until_due=hours_until,
is_due=is_due,
is_warning=is_warning,
last_performed_at=last_performed_at,
))
if commit:
await db.commit()
return PrinterMaintenanceOverview(
printer_id=printer_id,
printer_name=printer.name,
total_print_hours=total_hours,
maintenance_items=maintenance_items,
due_count=due_count,
warning_count=warning_count,
)
@router.get("/printers/{printer_id}", response_model=PrinterMaintenanceOverview)
async def get_printer_maintenance(
printer_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get maintenance overview for a specific printer."""
return await _get_printer_maintenance_internal(printer_id, db, commit=True)
@router.get("/overview", response_model=List[PrinterMaintenanceOverview])
async def get_all_maintenance_overview(db: AsyncSession = Depends(get_db)):
"""Get maintenance overview for all active printers."""
await ensure_default_types(db)
result = await db.execute(
select(Printer).where(Printer.is_active == True)
)
printers = result.scalars().all()
overviews = []
for printer in printers:
# Don't commit after each printer, commit once at the end
overview = await _get_printer_maintenance_internal(printer.id, db, commit=False)
overviews.append(overview)
# Commit any new maintenance items created
await db.commit()
return overviews
@router.patch("/items/{item_id}", response_model=PrinterMaintenanceResponse)
async def update_printer_maintenance(
item_id: int,
data: PrinterMaintenanceUpdate,
db: AsyncSession = Depends(get_db),
):
"""Update a printer maintenance item (e.g., custom interval, enabled)."""
result = await db.execute(
select(PrinterMaintenance)
.where(PrinterMaintenance.id == item_id)
.options(selectinload(PrinterMaintenance.maintenance_type))
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Maintenance item not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(item, key, value)
await db.commit()
await db.refresh(item)
return item
@router.post("/items/{item_id}/perform", response_model=MaintenanceStatus)
async def perform_maintenance(
item_id: int,
data: PerformMaintenanceRequest,
db: AsyncSession = Depends(get_db),
):
"""Mark maintenance as performed (reset the counter)."""
result = await db.execute(
select(PrinterMaintenance)
.where(PrinterMaintenance.id == item_id)
.options(selectinload(PrinterMaintenance.maintenance_type))
)
item = result.scalar_one_or_none()
if not item:
raise HTTPException(status_code=404, detail="Maintenance item not found")
# Get printer for name
result = await db.execute(
select(Printer).where(Printer.id == item.printer_id)
)
printer = result.scalar_one()
# Get current hours
current_hours = await get_printer_total_hours(db, item.printer_id)
# Create history entry
history = MaintenanceHistory(
printer_maintenance_id=item.id,
hours_at_maintenance=current_hours,
notes=data.notes,
)
db.add(history)
# Update item
item.last_performed_at = datetime.utcnow()
item.last_performed_hours = current_hours
await db.commit()
# Calculate status
interval = item.custom_interval_hours or item.maintenance_type.default_interval_hours
hours_since = current_hours - item.last_performed_hours
hours_until = interval - hours_since
return MaintenanceStatus(
id=item.id,
printer_id=item.printer_id,
printer_name=printer.name,
maintenance_type_id=item.maintenance_type_id,
maintenance_type_name=item.maintenance_type.name,
maintenance_type_icon=item.maintenance_type.icon,
enabled=item.enabled,
interval_hours=interval,
current_hours=current_hours,
hours_since_maintenance=hours_since,
hours_until_due=hours_until,
is_due=False,
is_warning=False,
last_performed_at=item.last_performed_at,
)
@router.get("/items/{item_id}/history", response_model=List[MaintenanceHistoryResponse])
async def get_maintenance_history(
item_id: int,
db: AsyncSession = Depends(get_db),
):
"""Get maintenance history for a specific item."""
result = await db.execute(
select(MaintenanceHistory)
.where(MaintenanceHistory.printer_maintenance_id == item_id)
.order_by(MaintenanceHistory.performed_at.desc())
)
return result.scalars().all()
@router.get("/summary")
async def get_maintenance_summary(db: AsyncSession = Depends(get_db)):
"""Get a summary of maintenance status across all printers."""
await ensure_default_types(db)
result = await db.execute(
select(Printer).where(Printer.is_active == True)
)
printers = result.scalars().all()
total_due = 0
total_warning = 0
printers_with_issues = []
for printer in printers:
overview = await get_printer_maintenance(printer.id, db)
total_due += overview.due_count
total_warning += overview.warning_count
if overview.due_count > 0 or overview.warning_count > 0:
printers_with_issues.append({
"printer_id": printer.id,
"printer_name": printer.name,
"due_count": overview.due_count,
"warning_count": overview.warning_count,
})
return {
"total_due": total_due,
"total_warning": total_warning,
"printers_with_issues": printers_with_issues,
}
@router.patch("/printers/{printer_id}/hours")
async def set_printer_hours(
printer_id: int,
total_hours: float,
db: AsyncSession = Depends(get_db),
):
"""Set the total print hours for a printer (adjusts offset to match)."""
# Get printer
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
if not printer:
raise HTTPException(status_code=404, detail="Printer not found")
# Get current archive hours
result = await db.execute(
select(func.sum(PrintArchive.print_time_seconds))
.where(PrintArchive.printer_id == printer_id)
.where(PrintArchive.status == "completed")
)
total_seconds = result.scalar() or 0
archive_hours = total_seconds / 3600.0
# Calculate needed offset
printer.print_hours_offset = max(0, total_hours - archive_hours)
await db.commit()
# Check for maintenance items that need attention and send notification
try:
await ensure_default_types(db)
overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
items_needing_attention = [
{
"name": item.maintenance_type_name,
"is_due": item.is_due,
"is_warning": item.is_warning,
}
for item in overview.maintenance_items
if item.enabled and (item.is_due or item.is_warning)
]
if items_needing_attention:
await notification_service.on_maintenance_due(
printer_id, printer.name, items_needing_attention, db
)
logger.info(
f"Sent maintenance notification for printer {printer_id}: "
f"{len(items_needing_attention)} items need attention"
)
except Exception as e:
logger.warning(f"Failed to send maintenance notification: {e}")
return {
"printer_id": printer_id,
"total_hours": total_hours,
"archive_hours": archive_hours,
"offset_hours": printer.print_hours_offset,
}
+2
View File
@@ -35,11 +35,13 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
"on_print_start": provider.on_print_start,
"on_print_complete": provider.on_print_complete,
"on_print_failed": provider.on_print_failed,
"on_print_stopped": provider.on_print_stopped,
"on_print_progress": provider.on_print_progress,
# Printer status events
"on_printer_offline": provider.on_printer_offline,
"on_printer_error": provider.on_printer_error,
"on_filament_low": provider.on_filament_low,
"on_maintenance_due": provider.on_maintenance_due,
# Quiet hours
"quiet_hours_enabled": provider.quiet_hours_enabled,
"quiet_hours_start": provider.quiet_hours_start,
+10 -1
View File
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
async def init_db():
# Import models to register them with SQLAlchemy
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification # noqa: F401
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -91,3 +91,12 @@ async def run_migrations(conn):
except Exception:
# Column already exists
pass
# Migration: Add on_maintenance_due column to notification_providers
try:
await conn.execute(text(
"ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"
))
except Exception:
# Column already exists
pass
+43 -1
View File
@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
from backend.app.core.database import init_db, async_session
from sqlalchemy import select, or_
from backend.app.core.websocket import ws_manager
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates
from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance
from backend.app.api.routes import settings as settings_routes
from backend.app.services.notification_service import notification_service
from backend.app.services.printer_manager import (
@@ -70,6 +70,7 @@ from backend.app.services.smart_plug_manager import smart_plug_manager
from backend.app.services.tasmota import tasmota_service
from backend.app.models.smart_plug import SmartPlug
from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client, close_spoolman_client
from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
# Track active prints: {(printer_id, filename): archive_id}
@@ -864,6 +865,46 @@ async def on_print_complete(printer_id: int, data: dict):
import logging
logging.getLogger(__name__).warning(f"Notification on_print_complete failed: {e}")
# Check for maintenance due and send notifications (only for completed prints)
if data.get("status") == "completed":
try:
async with async_session() as db:
from backend.app.models.printer import Printer
# Get printer name
result = await db.execute(
select(Printer).where(Printer.id == printer_id)
)
printer = result.scalar_one_or_none()
printer_name = printer.name if printer else f"Printer {printer_id}"
# Get maintenance overview for this printer
await ensure_default_types(db)
overview = await _get_printer_maintenance_internal(printer_id, db, commit=True)
# Check for any items that are due or have warnings
items_needing_attention = [
{
"name": item.maintenance_type_name,
"is_due": item.is_due,
"is_warning": item.is_warning,
}
for item in overview.maintenance_items
if item.enabled and (item.is_due or item.is_warning)
]
if items_needing_attention:
await notification_service.on_maintenance_due(
printer_id, printer_name, items_needing_attention, db
)
logger.info(
f"Sent maintenance notification for printer {printer_id}: "
f"{len(items_needing_attention)} items need attention"
)
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Maintenance notification check failed: {e}")
# Update queue item if this was a scheduled print
try:
async with async_session() as db:
@@ -979,6 +1020,7 @@ app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
app.include_router(notifications.router, prefix=app_settings.api_prefix)
app.include_router(spoolman.router, prefix=app_settings.api_prefix)
app.include_router(updates.router, prefix=app_settings.api_prefix)
app.include_router(maintenance.router, prefix=app_settings.api_prefix)
app.include_router(websocket.router, prefix=app_settings.api_prefix)
+11 -1
View File
@@ -3,5 +3,15 @@ from backend.app.models.archive import PrintArchive
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
__all__ = ["Printer", "PrintArchive", "Filament", "Settings", "SmartPlug"]
__all__ = [
"Printer",
"PrintArchive",
"Filament",
"Settings",
"SmartPlug",
"MaintenanceType",
"PrinterMaintenance",
"MaintenanceHistory",
]
+78
View File
@@ -0,0 +1,78 @@
"""Maintenance tracking models."""
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Integer, Float, ForeignKey, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class MaintenanceType(Base):
"""Defines a type of maintenance task with default interval."""
__tablename__ = "maintenance_types"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
description: Mapped[str | None] = mapped_column(Text)
default_interval_hours: Mapped[float] = mapped_column(Float, default=100.0)
icon: Mapped[str | None] = mapped_column(String(50)) # Icon name for UI
is_system: Mapped[bool] = mapped_column(Boolean, default=False) # Pre-defined vs custom
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
# Relationships
printer_maintenance: Mapped[list["PrinterMaintenance"]] = relationship(
back_populates="maintenance_type", cascade="all, delete-orphan"
)
class PrinterMaintenance(Base):
"""Tracks maintenance status for a specific printer."""
__tablename__ = "printer_maintenance"
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
maintenance_type_id: Mapped[int] = mapped_column(ForeignKey("maintenance_types.id", ondelete="CASCADE"))
# Custom interval for this printer (overrides default if set)
custom_interval_hours: Mapped[float | None] = mapped_column(Float, nullable=True)
# Tracking
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
last_performed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_performed_hours: Mapped[float] = mapped_column(Float, default=0.0) # Hours at last reset
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()
)
# Relationships
printer: Mapped["Printer"] = relationship(back_populates="maintenance_items")
maintenance_type: Mapped["MaintenanceType"] = relationship(back_populates="printer_maintenance")
history: Mapped[list["MaintenanceHistory"]] = relationship(
back_populates="printer_maintenance", cascade="all, delete-orphan"
)
class MaintenanceHistory(Base):
"""Log of maintenance actions performed."""
__tablename__ = "maintenance_history"
id: Mapped[int] = mapped_column(primary_key=True)
printer_maintenance_id: Mapped[int] = mapped_column(
ForeignKey("printer_maintenance.id", ondelete="CASCADE")
)
performed_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
hours_at_maintenance: Mapped[float] = mapped_column(Float, default=0.0)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships
printer_maintenance: Mapped["PrinterMaintenance"] = relationship(back_populates="history")
# Import at end to avoid circular imports
from backend.app.models.printer import Printer # noqa: E402
+1
View File
@@ -32,6 +32,7 @@ class NotificationProvider(Base):
on_printer_offline = Column(Boolean, default=False)
on_printer_error = Column(Boolean, default=False) # AMS issues, etc.
on_filament_low = Column(Boolean, default=False)
on_maintenance_due = Column(Boolean, default=False) # Maintenance reminder
# Quiet hours (do not disturb)
quiet_hours_enabled = Column(Boolean, default=False)
+6 -1
View File
@@ -1,5 +1,5 @@
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, func
from sqlalchemy import String, Boolean, DateTime, Float, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
@@ -17,6 +17,7 @@ class Printer(Base):
nozzle_count: Mapped[int] = mapped_column(default=1) # 1 or 2, auto-detected from MQTT
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
auto_archive: Mapped[bool] = mapped_column(Boolean, default=True)
print_hours_offset: Mapped[float] = mapped_column(Float, default=0.0) # Baseline hours to add
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
)
@@ -34,8 +35,12 @@ class Printer(Base):
notification_providers: Mapped[list["NotificationProvider"]] = relationship(
back_populates="printer"
)
maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship(
back_populates="printer", cascade="all, delete-orphan"
)
from backend.app.models.archive import PrintArchive # 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
+118
View File
@@ -0,0 +1,118 @@
"""Maintenance tracking schemas."""
from datetime import datetime
from pydantic import BaseModel, Field
# Maintenance Type schemas
class MaintenanceTypeBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: str | None = None
default_interval_hours: float = Field(default=100.0, ge=1.0)
icon: str | None = None
class MaintenanceTypeCreate(MaintenanceTypeBase):
pass
class MaintenanceTypeUpdate(BaseModel):
name: str | None = None
description: str | None = None
default_interval_hours: float | None = Field(default=None, ge=1.0)
icon: str | None = None
class MaintenanceTypeResponse(MaintenanceTypeBase):
id: int
is_system: bool
created_at: datetime
class Config:
from_attributes = True
# Printer Maintenance schemas
class PrinterMaintenanceBase(BaseModel):
printer_id: int
maintenance_type_id: int
custom_interval_hours: float | None = None
enabled: bool = True
class PrinterMaintenanceCreate(PrinterMaintenanceBase):
pass
class PrinterMaintenanceUpdate(BaseModel):
custom_interval_hours: float | None = None
enabled: bool | None = None
class PrinterMaintenanceResponse(BaseModel):
id: int
printer_id: int
maintenance_type_id: int
maintenance_type: MaintenanceTypeResponse
custom_interval_hours: float | None
enabled: bool
last_performed_at: datetime | None
last_performed_hours: float
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Maintenance History schemas
class MaintenanceHistoryBase(BaseModel):
notes: str | None = None
class MaintenanceHistoryCreate(MaintenanceHistoryBase):
pass
class MaintenanceHistoryResponse(MaintenanceHistoryBase):
id: int
printer_maintenance_id: int
performed_at: datetime
hours_at_maintenance: float
class Config:
from_attributes = True
# Combined status response for frontend
class MaintenanceStatus(BaseModel):
"""Maintenance status for a printer with calculated values."""
id: int
printer_id: int
printer_name: str
maintenance_type_id: int
maintenance_type_name: str
maintenance_type_icon: str | None
enabled: bool
interval_hours: float # custom or default
current_hours: float # total print hours for printer
hours_since_maintenance: float # current - last_performed
hours_until_due: float # interval - hours_since
is_due: bool # hours_until_due <= 0
is_warning: bool # hours_until_due <= 10% of interval
last_performed_at: datetime | None
class PrinterMaintenanceOverview(BaseModel):
"""Overview of all maintenance items for a printer."""
printer_id: int
printer_name: str
total_print_hours: float
maintenance_items: list[MaintenanceStatus]
due_count: int
warning_count: int
class PerformMaintenanceRequest(BaseModel):
"""Request to mark maintenance as performed."""
notes: str | None = None
+2
View File
@@ -36,6 +36,7 @@ class NotificationProviderBase(BaseModel):
on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
# Quiet hours
quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
@@ -87,6 +88,7 @@ class NotificationProviderUpdate(BaseModel):
on_printer_offline: bool | None = None
on_printer_error: bool | None = None
on_filament_low: bool | None = None
on_maintenance_due: bool | None = None
# Quiet hours
quiet_hours_enabled: bool | None = None
+2
View File
@@ -22,12 +22,14 @@ class PrinterUpdate(BaseModel):
model: str | None = None
is_active: bool | None = None
auto_archive: bool | None = None
print_hours_offset: float | None = None
class PrinterResponse(PrinterBase):
id: int
is_active: bool
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
print_hours_offset: float = 0.0
created_at: datetime
updated_at: datetime
@@ -163,6 +163,18 @@ class NotificationService:
message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
return title, message
def _build_maintenance_due_message(
self, printer_name: str, maintenance_items: list[dict]
) -> tuple[str, str]:
"""Build notification message for maintenance due event."""
title = "Maintenance Due"
lines = [f"{printer_name}:"]
for item in maintenance_items:
status = "OVERDUE" if item.get("is_due") else "Soon"
lines.append(f"• {item['name']} ({status})")
message = "\n".join(lines)
return title, message
async def send_test_notification(
self, provider_type: str, config: dict[str, Any]
) -> tuple[bool, str]:
@@ -529,6 +541,26 @@ class NotificationService:
title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
await self._send_to_providers(providers, title, message, db)
async def on_maintenance_due(
self,
printer_id: int,
printer_name: str,
maintenance_items: list[dict],
db: AsyncSession,
):
"""Handle maintenance due event - sends notification when maintenance is due or warning."""
if not maintenance_items:
return
providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
if not providers:
logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
return
logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
title, message = self._build_maintenance_due_message(printer_name, maintenance_items)
await self._send_to_providers(providers, title, message, db)
# Global instance
notification_service = NotificationService()
+160 -1
View File
@@ -22,10 +22,14 @@
"@tiptap/starter-kit": "^3.11.1",
"@types/three": "^0.181.0",
"gcode-preview": "^2.18.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.5",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.1",
"three": "^0.181.2"
@@ -297,6 +301,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -3072,6 +3085,15 @@
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3770,6 +3792,65 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.6.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz",
"integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4405,6 +4486,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -4831,6 +4932,33 @@
"react": "^19.2.0"
}
},
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
"integrity": "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
@@ -5187,6 +5315,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -5223,7 +5357,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
@@ -5425,12 +5559,37 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+4
View File
@@ -24,10 +24,14 @@
"@tiptap/starter-kit": "^3.11.1",
"@types/three": "^0.181.0",
"gcode-preview": "^2.18.0",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.5",
"react-router-dom": "^7.9.6",
"recharts": "^3.5.1",
"three": "^0.181.2"
+2
View File
@@ -7,6 +7,7 @@ import { QueuePage } from './pages/QueuePage';
import { StatsPage } from './pages/StatsPage';
import { SettingsPage } from './pages/SettingsPage';
import { ProfilesPage } from './pages/ProfilesPage';
import { MaintenancePage } from './pages/MaintenancePage';
import { useWebSocket } from './hooks/useWebSocket';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
@@ -39,6 +40,7 @@ function App() {
<Route path="queue" element={<QueuePage />} />
<Route path="stats" element={<StatsPage />} />
<Route path="profiles" element={<ProfilesPage />} />
<Route path="maintenance" element={<MaintenancePage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
+102
View File
@@ -376,6 +376,7 @@ export interface NotificationProvider {
on_printer_offline: boolean;
on_printer_error: boolean;
on_filament_low: boolean;
on_maintenance_due: boolean;
// Quiet hours
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
@@ -406,6 +407,7 @@ export interface NotificationProviderCreate {
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_filament_low?: boolean;
on_maintenance_due?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -429,6 +431,7 @@ export interface NotificationProviderUpdate {
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_filament_low?: boolean;
on_maintenance_due?: boolean;
// Quiet hours
quiet_hours_enabled?: boolean;
quiet_hours_start?: string | null;
@@ -518,6 +521,69 @@ export interface UpdateStatus {
error: string | null;
}
// Maintenance types
export interface MaintenanceType {
id: number;
name: string;
description: string | null;
default_interval_hours: number;
icon: string | null;
is_system: boolean;
created_at: string;
}
export interface MaintenanceTypeCreate {
name: string;
description?: string | null;
default_interval_hours?: number;
icon?: string | null;
}
export interface MaintenanceStatus {
id: number;
printer_id: number;
printer_name: string;
maintenance_type_id: number;
maintenance_type_name: string;
maintenance_type_icon: string | null;
enabled: boolean;
interval_hours: number;
current_hours: number;
hours_since_maintenance: number;
hours_until_due: number;
is_due: boolean;
is_warning: boolean;
last_performed_at: string | null;
}
export interface PrinterMaintenanceOverview {
printer_id: number;
printer_name: string;
total_print_hours: number;
maintenance_items: MaintenanceStatus[];
due_count: number;
warning_count: number;
}
export interface MaintenanceHistory {
id: number;
printer_maintenance_id: number;
performed_at: string;
hours_at_maintenance: number;
notes: string | null;
}
export interface MaintenanceSummary {
total_due: number;
total_warning: number;
printers_with_issues: Array<{
printer_id: number;
printer_name: string;
due_count: number;
warning_count: number;
}>;
}
// API functions
export const api = {
// Printers
@@ -941,4 +1007,40 @@ export const api = {
method: 'POST',
}),
getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
// Maintenance
getMaintenanceTypes: () => request<MaintenanceType[]>('/maintenance/types'),
createMaintenanceType: (data: MaintenanceTypeCreate) =>
request<MaintenanceType>('/maintenance/types', {
method: 'POST',
body: JSON.stringify(data),
}),
updateMaintenanceType: (id: number, data: Partial<MaintenanceTypeCreate>) =>
request<MaintenanceType>(`/maintenance/types/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteMaintenanceType: (id: number) =>
request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }),
getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),
getPrinterMaintenance: (printerId: number) =>
request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; enabled?: boolean }) =>
request<MaintenanceStatus>(`/maintenance/items/${itemId}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
performMaintenance: (itemId: number, notes?: string) =>
request<MaintenanceStatus>(`/maintenance/items/${itemId}/perform`, {
method: 'POST',
body: JSON.stringify({ notes }),
}),
getMaintenanceHistory: (itemId: number) =>
request<MaintenanceHistory[]>(`/maintenance/items/${itemId}/history`),
getMaintenanceSummary: () => request<MaintenanceSummary>('/maintenance/summary'),
setPrinterHours: (printerId: number, totalHours: number) =>
request<{ printer_id: number; total_hours: number; archive_hours: number; offset_hours: number }>(
`/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
{ method: 'PATCH' }
),
};
@@ -4,6 +4,7 @@ import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
import { Button } from './Button';
import { Toggle } from './Toggle';
interface AddNotificationModalProps {
provider?: NotificationProvider | null;
@@ -350,15 +351,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={quietHoursEnabled}
onChange={(e) => setQuietHoursEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={quietHoursEnabled}
onChange={setQuietHoursEnabled}
/>
</div>
{quietHoursEnabled && (
<div className="grid grid-cols-2 gap-3">
+2 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, type LucideIcon } from 'lucide-react';
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
import { useQuery } from '@tanstack/react-query';
@@ -19,6 +19,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
{ id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
{ id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
{ id: 'maintenance', to: '/maintenance', icon: Wrench, label: 'Maintenance' },
{ id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
];
@@ -6,6 +6,7 @@ import type { NotificationProvider, NotificationProviderUpdate } from '../api/cl
import { Card, CardContent } from './Card';
import { Button } from './Button';
import { ConfirmModal } from './ConfirmModal';
import { Toggle } from './Toggle';
interface NotificationProviderCardProps {
provider: NotificationProvider;
@@ -134,6 +135,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
)}
{provider.on_maintenance_due && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
)}
{provider.quiet_hours_enabled && (
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded flex items-center gap-1">
<Moon className="w-3 h-3" />
@@ -204,15 +208,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<p className="text-sm text-white">Enabled</p>
<p className="text-xs text-bambu-gray">Send notifications from this provider</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.enabled}
onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.enabled}
onChange={(checked) => updateMutation.mutate({ enabled: checked })}
/>
</div>
{/* Print Lifecycle Events */}
@@ -221,54 +220,34 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Started</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_start}
onChange={(e) => updateMutation.mutate({ on_print_start: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_print_start}
onChange={(checked) => updateMutation.mutate({ on_print_start: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Completed</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_complete}
onChange={(e) => updateMutation.mutate({ on_print_complete: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_print_complete}
onChange={(checked) => updateMutation.mutate({ on_print_complete: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Failed</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_failed}
onChange={(e) => updateMutation.mutate({ on_print_failed: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_print_failed}
onChange={(checked) => updateMutation.mutate({ on_print_failed: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Print Stopped</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_stopped}
onChange={(e) => updateMutation.mutate({ on_print_stopped: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_print_stopped}
onChange={(checked) => updateMutation.mutate({ on_print_stopped: checked })}
/>
</div>
<div className="flex items-center justify-between">
@@ -276,15 +255,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<p className="text-sm text-white">Progress Milestones</p>
<p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_print_progress}
onChange={(e) => updateMutation.mutate({ on_print_progress: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_print_progress}
onChange={(checked) => updateMutation.mutate({ on_print_progress: checked })}
/>
</div>
</div>
@@ -294,41 +268,37 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Offline</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_printer_offline}
onChange={(e) => updateMutation.mutate({ on_printer_offline: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_printer_offline}
onChange={(checked) => updateMutation.mutate({ on_printer_offline: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Printer Error</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_printer_error}
onChange={(e) => updateMutation.mutate({ on_printer_error: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_printer_error}
onChange={(checked) => updateMutation.mutate({ on_printer_error: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">Low Filament</p>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.on_filament_low}
onChange={(e) => updateMutation.mutate({ on_filament_low: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.on_filament_low}
onChange={(checked) => updateMutation.mutate({ on_filament_low: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">Maintenance Due</p>
<p className="text-xs text-bambu-gray">Notify when maintenance is needed</p>
</div>
<Toggle
checked={provider.on_maintenance_due ?? false}
onChange={(checked) => updateMutation.mutate({ on_maintenance_due: checked })}
/>
</div>
</div>
@@ -339,15 +309,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
<Moon className="w-4 h-4 text-purple-400" />
<p className="text-sm text-white">Quiet Hours</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={provider.quiet_hours_enabled}
onChange={(e) => updateMutation.mutate({ quiet_hours_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
</label>
<Toggle
checked={provider.quiet_hours_enabled}
onChange={(checked) => updateMutation.mutate({ quiet_hours_enabled: checked })}
/>
</div>
{provider.quiet_hours_enabled && (
+38
View File
@@ -0,0 +1,38 @@
interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
onChange(!checked);
}
};
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={handleClick}
className={`relative inline-flex w-9 h-5 rounded-full transition-colors flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-bambu-green focus:ring-offset-2 focus:ring-offset-bambu-dark ${
disabled
? 'bg-bambu-dark-tertiary/50 cursor-not-allowed opacity-50'
: checked
? 'bg-bambu-green cursor-pointer'
: 'bg-bambu-dark-tertiary cursor-pointer hover:bg-bambu-dark-tertiary/80'
}`}
>
<span
className={`pointer-events-none absolute top-[2px] left-[2px] w-4 h-4 bg-white rounded-full shadow transition-transform duration-200 ease-in-out ${
checked ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
);
}
+616
View File
@@ -0,0 +1,616 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Wrench,
Loader2,
Check,
AlertTriangle,
Clock,
Plus,
Trash2,
Settings2,
ChevronDown,
ChevronUp,
RotateCcw,
Droplet,
Flame,
Ruler,
Sparkles,
Square,
Cable,
Edit3,
} from 'lucide-react';
import { api } from '../api/client';
import type { MaintenanceStatus, PrinterMaintenanceOverview } from '../api/client';
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { Toggle } from '../components/Toggle';
import { useToast } from '../contexts/ToastContext';
// Icon mapping for maintenance types
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Droplet,
Flame,
Ruler,
Sparkles,
Square,
Cable,
Wrench,
};
function getIcon(iconName: string | null) {
if (!iconName) return Wrench;
return iconMap[iconName] || Wrench;
}
function formatHours(hours: number): string {
if (hours < 1) {
return `${Math.round(hours * 60)}m`;
}
return `${hours.toFixed(1)}h`;
}
function formatHoursLong(hours: number): string {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
if (h === 0) {
return `${m} minutes`;
}
if (m === 0) {
return `${h} hours`;
}
return `${h}h ${m}m`;
}
// Simple row for a maintenance item
function MaintenanceRow({
item,
onPerform,
onToggle,
}: {
item: MaintenanceStatus;
onPerform: (id: number) => void;
onToggle: (id: number, enabled: boolean) => void;
}) {
const Icon = getIcon(item.maintenance_type_icon);
const progressPercent = Math.max(0, Math.min(100,
((item.interval_hours - item.hours_until_due) / item.interval_hours) * 100
));
const getStatusColor = () => {
if (!item.enabled) return 'text-bambu-gray';
if (item.is_due) return 'text-red-400';
if (item.is_warning) return 'text-yellow-400';
return 'text-bambu-green';
};
const getProgressColor = () => {
if (!item.enabled) return 'bg-bambu-gray/30';
if (item.is_due) return 'bg-red-500';
if (item.is_warning) return 'bg-yellow-500';
return 'bg-bambu-green';
};
const getStatusText = () => {
if (!item.enabled) return 'Disabled';
if (item.is_due) return `Overdue by ${formatHours(Math.abs(item.hours_until_due))}`;
if (item.is_warning) return `Due in ${formatHours(item.hours_until_due)}`;
return `${formatHours(item.hours_until_due)} left`;
};
return (
<div className={`flex items-center gap-4 p-3 rounded-lg ${
item.is_due ? 'bg-red-500/10' :
item.is_warning ? 'bg-yellow-500/10' :
'bg-bambu-dark'
}`}>
{/* Icon & Name */}
<div className="flex items-center gap-3 min-w-[180px]">
<Icon className={`w-4 h-4 ${getStatusColor()}`} />
<span className={`text-sm ${item.enabled ? 'text-white' : 'text-bambu-gray'}`}>
{item.maintenance_type_name}
</span>
</div>
{/* Progress bar */}
<div className="flex-1 max-w-[200px]">
<div className="w-full h-1.5 bg-bambu-dark-tertiary rounded-full overflow-hidden">
<div
className={`h-full transition-all ${getProgressColor()}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Status */}
<div className={`text-xs min-w-[120px] ${getStatusColor()}`}>
{item.is_due && <AlertTriangle className="w-3 h-3 inline mr-1" />}
{item.is_warning && <Clock className="w-3 h-3 inline mr-1" />}
{!item.is_due && !item.is_warning && item.enabled && <Check className="w-3 h-3 inline mr-1" />}
{getStatusText()}
</div>
{/* Enable/Disable toggle */}
<Toggle
checked={item.enabled}
onChange={(checked) => onToggle(item.id, checked)}
/>
{/* Reset button */}
<Button
size="sm"
variant={item.is_due ? 'primary' : 'secondary'}
onClick={() => onPerform(item.id)}
disabled={!item.enabled}
className="min-w-[70px]"
>
<RotateCcw className="w-3 h-3" />
Done
</Button>
</div>
);
}
// Printer section
function PrinterSection({
overview,
onPerform,
onToggle,
onSetHours,
}: {
overview: PrinterMaintenanceOverview;
onPerform: (id: number) => void;
onToggle: (id: number, enabled: boolean) => void;
onSetHours: (printerId: number, hours: number) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [editingHours, setEditingHours] = useState(false);
const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1));
// Sort items: first by maintenance_type_id for consistency, then by urgency
const sortedItems = [...overview.maintenance_items].sort((a, b) => {
// Primary sort by maintenance type ID for consistent ordering across printers
return a.maintenance_type_id - b.maintenance_type_id;
});
// Find the next upcoming task (most urgent enabled item)
const nextTask = [...overview.maintenance_items]
.filter(item => item.enabled)
.sort((a, b) => {
// Sort by urgency: overdue first, then warnings, then by hours until due
if (a.is_due && !b.is_due) return -1;
if (!a.is_due && b.is_due) return 1;
if (a.is_warning && !b.is_warning) return -1;
if (!a.is_warning && b.is_warning) return 1;
return a.hours_until_due - b.hours_until_due;
})[0];
const handleSaveHours = () => {
const hours = parseFloat(hoursInput);
if (!isNaN(hours) && hours >= 0) {
onSetHours(overview.printer_id, hours);
setEditingHours(false);
}
};
return (
<Card>
<div className="p-4">
{/* Header row with printer name and status */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-white">{overview.printer_name}</h2>
{overview.due_count > 0 && (
<span className="px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{overview.due_count} overdue
</span>
)}
{overview.warning_count > 0 && (
<span className="px-2.5 py-1 bg-yellow-500/20 text-yellow-400 text-xs font-medium rounded-full flex items-center gap-1">
<Clock className="w-3 h-3" />
{overview.warning_count} due soon
</span>
)}
{overview.due_count === 0 && overview.warning_count === 0 && (
<span className="px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1">
<Check className="w-3 h-3" />
All good
</span>
)}
</div>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded transition-colors"
>
{expanded ? (
<>
<ChevronUp className="w-4 h-4" />
Hide
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Details
</>
)}
</button>
</div>
{/* Info cards row */}
<div className="grid grid-cols-2 gap-3">
{/* Print Hours Card */}
<div className="p-3 bg-bambu-dark rounded-lg">
<div className="text-xs text-bambu-gray mb-1 uppercase tracking-wide">Total Print Time</div>
{editingHours ? (
<div className="flex items-center gap-2">
<input
type="number"
value={hoursInput}
onChange={(e) => setHoursInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveHours();
if (e.key === 'Escape') setEditingHours(false);
}}
className="w-20 px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-lg font-semibold"
min="0"
step="1"
autoFocus
/>
<span className="text-sm text-bambu-gray">hours</span>
<div className="flex gap-1 ml-auto">
<Button size="sm" onClick={handleSaveHours}>Save</Button>
<Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>✕</Button>
</div>
</div>
) : (
<button
onClick={() => {
setHoursInput(Math.round(overview.total_print_hours).toString());
setEditingHours(true);
}}
className="flex items-center gap-2 group"
title="Click to edit total print hours"
>
<span className="text-xl font-semibold text-white group-hover:text-bambu-green transition-colors">
{formatHoursLong(overview.total_print_hours)}
</span>
<Edit3 className="w-4 h-4 text-bambu-gray group-hover:text-bambu-green transition-colors" />
</button>
)}
</div>
{/* Next Maintenance Card */}
<div className={`p-3 rounded-lg ${
nextTask?.is_due ? 'bg-red-500/10' :
nextTask?.is_warning ? 'bg-yellow-500/10' :
'bg-bambu-dark'
}`}>
<div className="text-xs text-bambu-gray mb-1 uppercase tracking-wide">Next Maintenance</div>
{nextTask ? (
<div>
<div className={`text-lg font-semibold ${
nextTask.is_due ? 'text-red-400' :
nextTask.is_warning ? 'text-yellow-400' :
'text-white'
}`}>
{nextTask.maintenance_type_name}
</div>
<div className={`text-sm ${
nextTask.is_due ? 'text-red-400' :
nextTask.is_warning ? 'text-yellow-400' :
'text-bambu-gray'
}`}>
{nextTask.is_due ? (
<>Overdue by {formatHours(Math.abs(nextTask.hours_until_due))}</>
) : (
<>Due in {formatHours(nextTask.hours_until_due)}</>
)}
</div>
</div>
) : (
<div className="text-white">No tasks enabled</div>
)}
</div>
</div>
</div>
{expanded && (
<CardContent className="pt-0 space-y-2 border-t border-bambu-dark-tertiary mt-4">
<div className="pt-4">
{sortedItems.map((item) => (
<MaintenanceRow
key={item.id}
item={item}
onPerform={onPerform}
onToggle={onToggle}
/>
))}
</div>
</CardContent>
)}
</Card>
);
}
// Settings modal for managing custom types
function SettingsModal({
onClose,
types,
onAddType,
onDeleteType,
}: {
onClose: () => void;
types: Array<{ id: number; name: string; default_interval_hours: number; icon: string | null; is_system: boolean }>;
onAddType: (data: { name: string; description?: string; default_interval_hours: number; icon?: string }) => void;
onDeleteType: (id: number) => void;
}) {
const [name, setName] = useState('');
const [interval, setInterval] = useState('100');
const [icon, setIcon] = useState('Wrench');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim() && parseFloat(interval) > 0) {
onAddType({
name: name.trim(),
default_interval_hours: parseFloat(interval),
icon,
});
setName('');
setInterval('100');
}
};
const customTypes = types.filter(t => !t.is_system);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-bambu-dark-secondary rounded-lg p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-white mb-4">Maintenance Settings</h3>
{/* Existing custom types */}
{customTypes.length > 0 && (
<div className="mb-6">
<h4 className="text-sm text-bambu-gray mb-2">Custom Maintenance Types</h4>
<div className="space-y-2">
{customTypes.map((type) => {
const Icon = getIcon(type.icon);
return (
<div key={type.id} className="flex items-center justify-between p-2 bg-bambu-dark rounded">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-bambu-gray" />
<span className="text-white text-sm">{type.name}</span>
<span className="text-bambu-gray text-xs">({type.default_interval_hours}h)</span>
</div>
<button
onClick={() => {
if (confirm(`Delete "${type.name}"?`)) {
onDeleteType(type.id);
}
}}
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</div>
)}
{/* Add new type */}
<form onSubmit={handleSubmit}>
<h4 className="text-sm text-bambu-gray mb-2">Add Custom Type</h4>
<div className="flex gap-2 mb-3">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
placeholder="Name (e.g., Replace HEPA Filter)"
/>
<input
type="number"
value={interval}
onChange={(e) => setInterval(e.target.value)}
className="w-20 px-2 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm"
placeholder="Hours"
min="1"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-1">
{Object.keys(iconMap).map((iconName) => {
const IconComp = iconMap[iconName];
return (
<button
key={iconName}
type="button"
onClick={() => setIcon(iconName)}
className={`p-1.5 rounded ${
icon === iconName
? 'bg-bambu-green text-white'
: 'bg-bambu-dark text-bambu-gray hover:text-white'
}`}
>
<IconComp className="w-4 h-4" />
</button>
);
})}
</div>
<Button type="submit" size="sm" disabled={!name.trim()}>
<Plus className="w-4 h-4" />
Add
</Button>
</div>
</form>
<div className="mt-6 pt-4 border-t border-bambu-dark-tertiary flex justify-end">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
export function MaintenancePage() {
const queryClient = useQueryClient();
const { showToast } = useToast();
const [showSettings, setShowSettings] = useState(false);
const { data: overview, isLoading } = useQuery({
queryKey: ['maintenanceOverview'],
queryFn: api.getMaintenanceOverview,
});
const { data: types } = useQuery({
queryKey: ['maintenanceTypes'],
queryFn: api.getMaintenanceTypes,
});
const performMutation = useMutation({
mutationFn: ({ id, notes }: { id: number; notes?: string }) =>
api.performMaintenance(id, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
showToast('Maintenance marked as done');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: { custom_interval_hours?: number | null; enabled?: boolean } }) =>
api.updateMaintenanceItem(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const addTypeMutation = useMutation({
mutationFn: api.createMaintenanceType,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Maintenance type added');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const deleteTypeMutation = useMutation({
mutationFn: api.deleteMaintenanceType,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast('Maintenance type deleted');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const setHoursMutation = useMutation({
mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) =>
api.setPrinterHours(printerId, hours),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
showToast('Print hours updated');
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const handlePerform = (id: number) => {
performMutation.mutate({ id });
};
const handleToggle = (id: number, enabled: boolean) => {
updateMutation.mutate({ id, data: { enabled } });
};
const handleSetHours = (printerId: number, hours: number) => {
setHoursMutation.mutate({ printerId, hours });
};
if (isLoading) {
return (
<div className="p-8 flex justify-center">
<Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
</div>
);
}
// Calculate totals
const totalDue = overview?.reduce((sum, p) => sum + p.due_count, 0) || 0;
const totalWarning = overview?.reduce((sum, p) => sum + p.warning_count, 0) || 0;
return (
<div className="p-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Maintenance</h1>
<p className="text-bambu-gray text-sm">
{totalDue > 0 && <span className="text-red-400">{totalDue} tasks overdue</span>}
{totalDue > 0 && totalWarning > 0 && ' · '}
{totalWarning > 0 && <span className="text-yellow-400">{totalWarning} due soon</span>}
{totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'}
</p>
</div>
<Button variant="secondary" onClick={() => setShowSettings(true)}>
<Settings2 className="w-4 h-4" />
Settings
</Button>
</div>
{/* Printers - sorted alphabetically */}
<div className="space-y-4">
{overview && overview.length > 0 ? (
[...overview].sort((a, b) => a.printer_name.localeCompare(b.printer_name)).map((printerOverview) => (
<PrinterSection
key={printerOverview.printer_id}
overview={printerOverview}
onPerform={handlePerform}
onToggle={handleToggle}
onSetHours={handleSetHours}
/>
))
) : (
<Card>
<CardContent className="text-center py-12">
<Wrench className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
<p className="text-bambu-gray">No printers configured</p>
<p className="text-sm text-bambu-gray/70 mt-1">
Add printers to start tracking maintenance
</p>
</CardContent>
</Card>
)}
</div>
{/* Settings modal */}
{showSettings && types && (
<SettingsModal
onClose={() => setShowSettings(false)}
types={types}
onAddType={(data) => addTypeMutation.mutate(data)}
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
/>
)}
</div>
);
}
+65 -2
View File
@@ -16,7 +16,9 @@ import {
Power,
PowerOff,
Zap,
Wrench,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { Printer, PrinterCreate } from '../api/client';
import { Card, CardContent } from '../components/Card';
@@ -82,8 +84,22 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
);
}
function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIfDisconnected?: boolean }) {
interface PrinterMaintenanceInfo {
due_count: number;
warning_count: number;
}
function PrinterCard({
printer,
hideIfDisconnected,
maintenanceInfo
}: {
printer: Printer;
hideIfDisconnected?: boolean;
maintenanceInfo?: PrinterMaintenanceInfo;
}) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const [showMenu, setShowMenu] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showFileManager, setShowFileManager] = useState(false);
@@ -195,6 +211,29 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
: 'OK'}
</button>
)}
{/* Maintenance Status Indicator - always show */}
{maintenanceInfo && (
<button
onClick={() => navigate('/maintenance')}
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${
maintenanceInfo.due_count > 0
? 'bg-red-500/20 text-red-400'
: maintenanceInfo.warning_count > 0
? 'bg-orange-500/20 text-orange-400'
: 'bg-bambu-green/20 text-bambu-green'
}`}
title={
maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0
? `${maintenanceInfo.due_count > 0 ? `${maintenanceInfo.due_count} maintenance due` : ''}${maintenanceInfo.due_count > 0 && maintenanceInfo.warning_count > 0 ? ', ' : ''}${maintenanceInfo.warning_count > 0 ? `${maintenanceInfo.warning_count} due soon` : ''} - Click to view`
: 'All maintenance up to date - Click to view'
}
>
<Wrench className="w-3 h-3" />
{maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0
? maintenanceInfo.due_count + maintenanceInfo.warning_count
: 'OK'}
</button>
)}
<div className="relative">
<Button
variant="ghost"
@@ -674,6 +713,25 @@ export function PrintersPage() {
queryFn: api.getPrinters,
});
// Fetch maintenance overview for all printers to show badges
const { data: maintenanceOverview } = useQuery({
queryKey: ['maintenanceOverview'],
queryFn: api.getMaintenanceOverview,
staleTime: 60 * 1000, // 1 minute
});
// Create a map of printer_id -> maintenance info for quick lookup
const maintenanceByPrinter = maintenanceOverview?.reduce(
(acc, overview) => {
acc[overview.printer_id] = {
due_count: overview.due_count,
warning_count: overview.warning_count,
};
return acc;
},
{} as Record<number, PrinterMaintenanceInfo>
) || {};
const addMutation = useMutation({
mutationFn: api.createPrinter,
onSuccess: () => {
@@ -727,7 +785,12 @@ export function PrintersPage() {
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{printers?.map((printer) => (
<PrinterCard key={printer.id} printer={printer} hideIfDisconnected={hideDisconnected} />
<PrinterCard
key={printer.id}
printer={printer}
hideIfDisconnected={hideDisconnected}
maintenanceInfo={maintenanceByPrinter[printer.id]}
/>
))}
</div>
)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DFEXODPu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CLNqfrMK.css">
<script type="module" crossorigin src="/assets/index-BbYJHXvN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-H_ymON9v.css">
</head>
<body>
<div id="root"></div>