Procházet zdrojové kódy

Added auto app update; Added maintenance module with notifications

Martin Ziegler před 5 měsíci
rodič
revize
f126b0a075

+ 520 - 0
backend/app/api/routes/maintenance.py

@@ -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 - 0
backend/app/api/routes/notifications.py

@@ -35,11 +35,13 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_print_start": provider.on_print_start,
         "on_print_start": provider.on_print_start,
         "on_print_complete": provider.on_print_complete,
         "on_print_complete": provider.on_print_complete,
         "on_print_failed": provider.on_print_failed,
         "on_print_failed": provider.on_print_failed,
+        "on_print_stopped": provider.on_print_stopped,
         "on_print_progress": provider.on_print_progress,
         "on_print_progress": provider.on_print_progress,
         # Printer status events
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
         "on_printer_error": provider.on_printer_error,
         "on_filament_low": provider.on_filament_low,
         "on_filament_low": provider.on_filament_low,
+        "on_maintenance_due": provider.on_maintenance_due,
         # Quiet hours
         # Quiet hours
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
         "quiet_hours_start": provider.quiet_hours_start,

+ 10 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 
 async def init_db():
 async def init_db():
     # Import models to register them with SQLAlchemy
     # 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:
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
         await conn.run_sync(Base.metadata.create_all)
@@ -91,3 +91,12 @@ async def run_migrations(conn):
     except Exception:
     except Exception:
         # Column already exists
         # Column already exists
         pass
         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
backend/app/main.py

@@ -54,7 +54,7 @@ from fastapi.responses import FileResponse
 from backend.app.core.database import init_db, async_session
 from backend.app.core.database import init_db, async_session
 from sqlalchemy import select, or_
 from sqlalchemy import select, or_
 from backend.app.core.websocket import ws_manager
 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.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
 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.services.tasmota import tasmota_service
 from backend.app.models.smart_plug import SmartPlug
 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.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}
 # Track active prints: {(printer_id, filename): archive_id}
@@ -864,6 +865,46 @@ async def on_print_complete(printer_id: int, data: dict):
         import logging
         import logging
         logging.getLogger(__name__).warning(f"Notification on_print_complete failed: {e}")
         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
     # Update queue item if this was a scheduled print
     try:
     try:
         async with async_session() as db:
         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(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.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(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)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 
 
 

+ 11 - 1
backend/app/models/__init__.py

@@ -3,5 +3,15 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.filament import Filament
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 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 - 0
backend/app/models/maintenance.py

@@ -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 - 0
backend/app/models/notification.py

@@ -32,6 +32,7 @@ class NotificationProvider(Base):
     on_printer_offline = Column(Boolean, default=False)
     on_printer_offline = Column(Boolean, default=False)
     on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
     on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
     on_filament_low = Column(Boolean, default=False)
     on_filament_low = Column(Boolean, default=False)
+    on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
 
 
     # Quiet hours (do not disturb)
     # Quiet hours (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)
     quiet_hours_enabled = Column(Boolean, default=False)

+ 6 - 1
backend/app/models/printer.py

@@ -1,5 +1,5 @@
 from datetime import datetime
 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 sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 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
     nozzle_count: Mapped[int] = mapped_column(default=1)  # 1 or 2, auto-detected from MQTT
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     is_active: Mapped[bool] = mapped_column(Boolean, default=True)
     auto_archive: 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(
     created_at: Mapped[datetime] = mapped_column(
         DateTime, server_default=func.now()
         DateTime, server_default=func.now()
     )
     )
@@ -34,8 +35,12 @@ class Printer(Base):
     notification_providers: Mapped[list["NotificationProvider"]] = relationship(
     notification_providers: Mapped[list["NotificationProvider"]] = relationship(
         back_populates="printer"
         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.archive import PrintArchive  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # 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.notification import NotificationProvider  # noqa: E402
+from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402

+ 118 - 0
backend/app/schemas/maintenance.py

@@ -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 - 0
backend/app/schemas/notification.py

@@ -36,6 +36,7 @@ class NotificationProviderBase(BaseModel):
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     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_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_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
     quiet_hours_enabled: bool = Field(default=False, description="Enable 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_offline: bool | None = None
     on_printer_error: bool | None = None
     on_printer_error: bool | None = None
     on_filament_low: bool | None = None
     on_filament_low: bool | None = None
+    on_maintenance_due: bool | None = None
 
 
     # Quiet hours
     # Quiet hours
     quiet_hours_enabled: bool | None = None
     quiet_hours_enabled: bool | None = None

+ 2 - 0
backend/app/schemas/printer.py

@@ -22,12 +22,14 @@ class PrinterUpdate(BaseModel):
     model: str | None = None
     model: str | None = None
     is_active: bool | None = None
     is_active: bool | None = None
     auto_archive: bool | None = None
     auto_archive: bool | None = None
+    print_hours_offset: float | None = None
 
 
 
 
 class PrinterResponse(PrinterBase):
 class PrinterResponse(PrinterBase):
     id: int
     id: int
     is_active: bool
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
+    print_hours_offset: float = 0.0
     created_at: datetime
     created_at: datetime
     updated_at: datetime
     updated_at: datetime
 
 

+ 32 - 0
backend/app/services/notification_service.py

@@ -163,6 +163,18 @@ class NotificationService:
         message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
         message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
         return title, message
         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(
     async def send_test_notification(
         self, provider_type: str, config: dict[str, Any]
         self, provider_type: str, config: dict[str, Any]
     ) -> tuple[bool, str]:
     ) -> tuple[bool, str]:
@@ -529,6 +541,26 @@ class NotificationService:
         title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
         title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
         await self._send_to_providers(providers, title, message, db)
         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
 # Global instance
 notification_service = NotificationService()
 notification_service = NotificationService()

+ 160 - 1
frontend/package-lock.json

@@ -22,10 +22,14 @@
         "@tiptap/starter-kit": "^3.11.1",
         "@tiptap/starter-kit": "^3.11.1",
         "@types/three": "^0.181.0",
         "@types/three": "^0.181.0",
         "gcode-preview": "^2.18.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",
         "jszip": "^3.10.1",
         "lucide-react": "^0.555.0",
         "lucide-react": "^0.555.0",
         "react": "^19.2.0",
         "react": "^19.2.0",
         "react-dom": "^19.2.0",
         "react-dom": "^19.2.0",
+        "react-i18next": "^16.3.5",
         "react-router-dom": "^7.9.6",
         "react-router-dom": "^7.9.6",
         "recharts": "^3.5.1",
         "recharts": "^3.5.1",
         "three": "^0.181.2"
         "three": "^0.181.2"
@@ -297,6 +301,15 @@
         "@babel/core": "^7.0.0-0"
         "@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": {
     "node_modules/@babel/template": {
       "version": "7.27.2",
       "version": "7.27.2",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -3072,6 +3085,15 @@
       "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
       "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
       "license": "MIT"
       "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": {
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3770,6 +3792,65 @@
         "hermes-estree": "0.25.1"
         "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": {
     "node_modules/ignore": {
       "version": "5.3.2",
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
       "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4405,6 +4486,26 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "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": {
     "node_modules/node-releases": {
       "version": "2.0.27",
       "version": "2.0.27",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
       "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -4831,6 +4932,33 @@
         "react": "^19.2.0"
         "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": {
     "node_modules/react-is": {
       "version": "19.2.0",
       "version": "19.2.0",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz",
@@ -5187,6 +5315,12 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
         "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": {
     "node_modules/ts-api-utils": {
       "version": "2.1.0",
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
       "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -5223,7 +5357,7 @@
       "version": "5.9.3",
       "version": "5.9.3",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
       "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
-      "dev": true,
+      "devOptional": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "peer": true,
       "peer": true,
       "bin": {
       "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": {
     "node_modules/w3c-keyname": {
       "version": "2.2.8",
       "version": "2.2.8",
       "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
       "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
       "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
       "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
       "license": "MIT"
       "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": {
     "node_modules/which": {
       "version": "2.0.2",
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

+ 4 - 0
frontend/package.json

@@ -24,10 +24,14 @@
     "@tiptap/starter-kit": "^3.11.1",
     "@tiptap/starter-kit": "^3.11.1",
     "@types/three": "^0.181.0",
     "@types/three": "^0.181.0",
     "gcode-preview": "^2.18.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",
     "jszip": "^3.10.1",
     "lucide-react": "^0.555.0",
     "lucide-react": "^0.555.0",
     "react": "^19.2.0",
     "react": "^19.2.0",
     "react-dom": "^19.2.0",
     "react-dom": "^19.2.0",
+    "react-i18next": "^16.3.5",
     "react-router-dom": "^7.9.6",
     "react-router-dom": "^7.9.6",
     "recharts": "^3.5.1",
     "recharts": "^3.5.1",
     "three": "^0.181.2"
     "three": "^0.181.2"

+ 2 - 0
frontend/src/App.tsx

@@ -7,6 +7,7 @@ import { QueuePage } from './pages/QueuePage';
 import { StatsPage } from './pages/StatsPage';
 import { StatsPage } from './pages/StatsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { SettingsPage } from './pages/SettingsPage';
 import { ProfilesPage } from './pages/ProfilesPage';
 import { ProfilesPage } from './pages/ProfilesPage';
+import { MaintenancePage } from './pages/MaintenancePage';
 import { useWebSocket } from './hooks/useWebSocket';
 import { useWebSocket } from './hooks/useWebSocket';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ThemeProvider } from './contexts/ThemeContext';
 import { ToastProvider } from './contexts/ToastContext';
 import { ToastProvider } from './contexts/ToastContext';
@@ -39,6 +40,7 @@ function App() {
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="queue" element={<QueuePage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="stats" element={<StatsPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
                   <Route path="profiles" element={<ProfilesPage />} />
+                  <Route path="maintenance" element={<MaintenancePage />} />
                   <Route path="settings" element={<SettingsPage />} />
                   <Route path="settings" element={<SettingsPage />} />
                 </Route>
                 </Route>
               </Routes>
               </Routes>

+ 102 - 0
frontend/src/api/client.ts

@@ -376,6 +376,7 @@ export interface NotificationProvider {
   on_printer_offline: boolean;
   on_printer_offline: boolean;
   on_printer_error: boolean;
   on_printer_error: boolean;
   on_filament_low: boolean;
   on_filament_low: boolean;
+  on_maintenance_due: boolean;
   // Quiet hours
   // Quiet hours
   quiet_hours_enabled: boolean;
   quiet_hours_enabled: boolean;
   quiet_hours_start: string | null;
   quiet_hours_start: string | null;
@@ -406,6 +407,7 @@ export interface NotificationProviderCreate {
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
   on_filament_low?: boolean;
   on_filament_low?: boolean;
+  on_maintenance_due?: boolean;
   // Quiet hours
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;
@@ -429,6 +431,7 @@ export interface NotificationProviderUpdate {
   on_printer_offline?: boolean;
   on_printer_offline?: boolean;
   on_printer_error?: boolean;
   on_printer_error?: boolean;
   on_filament_low?: boolean;
   on_filament_low?: boolean;
+  on_maintenance_due?: boolean;
   // Quiet hours
   // Quiet hours
   quiet_hours_enabled?: boolean;
   quiet_hours_enabled?: boolean;
   quiet_hours_start?: string | null;
   quiet_hours_start?: string | null;
@@ -518,6 +521,69 @@ export interface UpdateStatus {
   error: string | null;
   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
 // API functions
 export const api = {
 export const api = {
   // Printers
   // Printers
@@ -941,4 +1007,40 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
   getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
   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' }
+    ),
 };
 };

+ 5 - 9
frontend/src/components/AddNotificationModal.tsx

@@ -4,6 +4,7 @@ import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
 import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
+import { Toggle } from './Toggle';
 
 
 interface AddNotificationModalProps {
 interface AddNotificationModalProps {
   provider?: NotificationProvider | null;
   provider?: NotificationProvider | null;
@@ -350,15 +351,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           <div className="space-y-2">
           <div className="space-y-2">
             <div className="flex items-center justify-between">
             <div className="flex items-center justify-between">
               <label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
               <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>
             </div>
             {quietHoursEnabled && (
             {quietHoursEnabled && (
               <div className="grid grid-cols-2 gap-3">
               <div className="grid grid-cols-2 gap-3">

+ 2 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { useState, useEffect, useCallback, useRef } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 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 { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
 import { useQuery } from '@tanstack/react-query';
 import { useQuery } from '@tanstack/react-query';
@@ -19,6 +19,7 @@ export const defaultNavItems: NavItem[] = [
   { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
   { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
   { id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
   { id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
   { id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
   { id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
+  { id: 'maintenance', to: '/maintenance', icon: Wrench, label: 'Maintenance' },
   { id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
   { id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
 ];
 ];
 
 

+ 55 - 90
frontend/src/components/NotificationProviderCard.tsx

@@ -6,6 +6,7 @@ import type { NotificationProvider, NotificationProviderUpdate } from '../api/cl
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
+import { Toggle } from './Toggle';
 
 
 interface NotificationProviderCardProps {
 interface NotificationProviderCardProps {
   provider: NotificationProvider;
   provider: NotificationProvider;
@@ -134,6 +135,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_filament_low && (
             {provider.on_filament_low && (
               <span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
               <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 && (
             {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">
               <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" />
                 <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-sm text-white">Enabled</p>
                   <p className="text-xs text-bambu-gray">Send notifications from this provider</p>
                   <p className="text-xs text-bambu-gray">Send notifications from this provider</p>
                 </div>
                 </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>
               </div>
 
 
               {/* Print Lifecycle Events */}
               {/* Print Lifecycle Events */}
@@ -221,54 +220,34 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Started</p>
                   <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>
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Completed</p>
                   <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>
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Failed</p>
                   <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>
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Stopped</p>
                   <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>
 
 
                 <div className="flex items-center justify-between">
                 <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-sm text-white">Progress Milestones</p>
                     <p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
                     <p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
                   </div>
                   </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>
               </div>
               </div>
 
 
@@ -294,41 +268,37 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Printer Offline</p>
                   <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>
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Printer Error</p>
                   <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>
 
 
                 <div className="flex items-center justify-between">
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Low Filament</p>
                   <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>
               </div>
               </div>
 
 
@@ -339,15 +309,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                     <Moon className="w-4 h-4 text-purple-400" />
                     <Moon className="w-4 h-4 text-purple-400" />
                     <p className="text-sm text-white">Quiet Hours</p>
                     <p className="text-sm text-white">Quiet Hours</p>
                   </div>
                   </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>
                 </div>
 
 
                 {provider.quiet_hours_enabled && (
                 {provider.quiet_hours_enabled && (

+ 38 - 0
frontend/src/components/Toggle.tsx

@@ -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 - 0
frontend/src/pages/MaintenancePage.tsx

@@ -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
frontend/src/pages/PrintersPage.tsx

@@ -16,7 +16,9 @@ import {
   Power,
   Power,
   PowerOff,
   PowerOff,
   Zap,
   Zap,
+  Wrench,
 } from 'lucide-react';
 } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import type { Printer, PrinterCreate } from '../api/client';
 import type { Printer, PrinterCreate } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 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 queryClient = useQueryClient();
+  const navigate = useNavigate();
   const [showMenu, setShowMenu] = useState(false);
   const [showMenu, setShowMenu] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
   const [showFileManager, setShowFileManager] = useState(false);
@@ -195,6 +211,29 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
                   : 'OK'}
                   : 'OK'}
               </button>
               </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">
             <div className="relative">
               <Button
               <Button
                 variant="ghost"
                 variant="ghost"
@@ -674,6 +713,25 @@ export function PrintersPage() {
     queryFn: api.getPrinters,
     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({
   const addMutation = useMutation({
     mutationFn: api.createPrinter,
     mutationFn: api.createPrinter,
     onSuccess: () => {
     onSuccess: () => {
@@ -727,7 +785,12 @@ export function PrintersPage() {
       ) : (
       ) : (
         <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
         <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
           {printers?.map((printer) => (
           {printers?.map((printer) => (
-            <PrinterCard key={printer.id} printer={printer} hideIfDisconnected={hideDisconnected} />
+            <PrinterCard
+              key={printer.id}
+              printer={printer}
+              hideIfDisconnected={hideDisconnected}
+              maintenanceInfo={maintenanceByPrinter[printer.id]}
+            />
           ))}
           ))}
         </div>
         </div>
       )}
       )}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-BbYJHXvN.js


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-CLNqfrMK.css


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
static/assets/index-H_ymON9v.css


+ 2 - 2
static/index.html

@@ -7,8 +7,8 @@
     <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
     <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="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů