From f126b0a0759fdecfddc1f093568c648173f71074 Mon Sep 17 00:00:00 2001 From: Martin Ziegler Date: Mon, 1 Dec 2025 08:39:07 +0100 Subject: [PATCH] Added auto app update; Added maintenance module with notifications --- backend/app/api/routes/maintenance.py | 520 ++++++++++++ backend/app/api/routes/notifications.py | 2 + backend/app/core/database.py | 11 +- backend/app/main.py | 44 +- backend/app/models/__init__.py | 12 +- backend/app/models/maintenance.py | 78 ++ backend/app/models/notification.py | 1 + backend/app/models/printer.py | 7 +- backend/app/schemas/maintenance.py | 118 +++ backend/app/schemas/notification.py | 2 + backend/app/schemas/printer.py | 2 + backend/app/services/notification_service.py | 32 + frontend/package-lock.json | 161 +++- frontend/package.json | 4 + frontend/src/App.tsx | 2 + frontend/src/api/client.ts | 102 +++ .../src/components/AddNotificationModal.tsx | 14 +- frontend/src/components/Layout.tsx | 3 +- .../components/NotificationProviderCard.tsx | 145 ++-- frontend/src/components/Toggle.tsx | 38 + frontend/src/pages/MaintenancePage.tsx | 616 ++++++++++++++ frontend/src/pages/PrintersPage.tsx | 67 +- .../{index-DFEXODPu.js => index-BbYJHXvN.js} | 802 +++++++++--------- static/assets/index-CLNqfrMK.css | 1 - static/assets/index-H_ymON9v.css | 1 + static/index.html | 4 +- 26 files changed, 2278 insertions(+), 511 deletions(-) create mode 100644 backend/app/api/routes/maintenance.py create mode 100644 backend/app/models/maintenance.py create mode 100644 backend/app/schemas/maintenance.py create mode 100644 frontend/src/components/Toggle.tsx create mode 100644 frontend/src/pages/MaintenancePage.tsx rename static/assets/{index-DFEXODPu.js => index-BbYJHXvN.js} (62%) delete mode 100644 static/assets/index-CLNqfrMK.css create mode 100644 static/assets/index-H_ymON9v.css diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py new file mode 100644 index 000000000..e461b22ee --- /dev/null +++ b/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, + } diff --git a/backend/app/api/routes/notifications.py b/backend/app/api/routes/notifications.py index bb141ce77..4b2576782 100644 --- a/backend/app/api/routes/notifications.py +++ b/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_complete": provider.on_print_complete, "on_print_failed": provider.on_print_failed, + "on_print_stopped": provider.on_print_stopped, "on_print_progress": provider.on_print_progress, # Printer status events "on_printer_offline": provider.on_printer_offline, "on_printer_error": provider.on_printer_error, "on_filament_low": provider.on_filament_low, + "on_maintenance_due": provider.on_maintenance_due, # Quiet hours "quiet_hours_enabled": provider.quiet_hours_enabled, "quiet_hours_start": provider.quiet_hours_start, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index bfe69661c..756c8586a 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -34,7 +34,7 @@ async def get_db() -> AsyncSession: async def init_db(): # Import models to register them with SQLAlchemy - from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification # noqa: F401 + from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -91,3 +91,12 @@ async def run_migrations(conn): except Exception: # Column already exists pass + + # Migration: Add on_maintenance_due column to notification_providers + try: + await conn.execute(text( + "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0" + )) + except Exception: + # Column already exists + pass diff --git a/backend/app/main.py b/backend/app/main.py index ea8dc5f37..b42ca2d5b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -54,7 +54,7 @@ from fastapi.responses import FileResponse from backend.app.core.database import init_db, async_session from sqlalchemy import select, or_ from backend.app.core.websocket import ws_manager -from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates +from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, spoolman, updates, maintenance from backend.app.api.routes import settings as settings_routes from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import ( @@ -70,6 +70,7 @@ from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.services.tasmota import tasmota_service from backend.app.models.smart_plug import SmartPlug from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client, close_spoolman_client +from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types # Track active prints: {(printer_id, filename): archive_id} @@ -864,6 +865,46 @@ async def on_print_complete(printer_id: int, data: dict): import logging logging.getLogger(__name__).warning(f"Notification on_print_complete failed: {e}") + # Check for maintenance due and send notifications (only for completed prints) + if data.get("status") == "completed": + try: + async with async_session() as db: + from backend.app.models.printer import Printer + + # Get printer name + result = await db.execute( + select(Printer).where(Printer.id == printer_id) + ) + printer = result.scalar_one_or_none() + printer_name = printer.name if printer else f"Printer {printer_id}" + + # Get maintenance overview for this printer + await ensure_default_types(db) + overview = await _get_printer_maintenance_internal(printer_id, db, commit=True) + + # Check for any items that are due or have warnings + items_needing_attention = [ + { + "name": item.maintenance_type_name, + "is_due": item.is_due, + "is_warning": item.is_warning, + } + for item in overview.maintenance_items + if item.enabled and (item.is_due or item.is_warning) + ] + + if items_needing_attention: + await notification_service.on_maintenance_due( + printer_id, printer_name, items_needing_attention, db + ) + logger.info( + f"Sent maintenance notification for printer {printer_id}: " + f"{len(items_needing_attention)} items need attention" + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"Maintenance notification check failed: {e}") + # Update queue item if this was a scheduled print try: async with async_session() as db: @@ -979,6 +1020,7 @@ app.include_router(kprofiles.router, prefix=app_settings.api_prefix) app.include_router(notifications.router, prefix=app_settings.api_prefix) app.include_router(spoolman.router, prefix=app_settings.api_prefix) app.include_router(updates.router, prefix=app_settings.api_prefix) +app.include_router(maintenance.router, prefix=app_settings.api_prefix) app.include_router(websocket.router, prefix=app_settings.api_prefix) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a95f9c28d..2e0545489 100644 --- a/backend/app/models/__init__.py +++ b/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.settings import Settings from backend.app.models.smart_plug import SmartPlug +from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance, MaintenanceHistory -__all__ = ["Printer", "PrintArchive", "Filament", "Settings", "SmartPlug"] +__all__ = [ + "Printer", + "PrintArchive", + "Filament", + "Settings", + "SmartPlug", + "MaintenanceType", + "PrinterMaintenance", + "MaintenanceHistory", +] diff --git a/backend/app/models/maintenance.py b/backend/app/models/maintenance.py new file mode 100644 index 000000000..4e1fc45d2 --- /dev/null +++ b/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 diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 80723d388..206dea520 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -32,6 +32,7 @@ class NotificationProvider(Base): on_printer_offline = Column(Boolean, default=False) on_printer_error = Column(Boolean, default=False) # AMS issues, etc. on_filament_low = Column(Boolean, default=False) + on_maintenance_due = Column(Boolean, default=False) # Maintenance reminder # Quiet hours (do not disturb) quiet_hours_enabled = Column(Boolean, default=False) diff --git a/backend/app/models/printer.py b/backend/app/models/printer.py index f6359c8b2..eddd0dc09 100644 --- a/backend/app/models/printer.py +++ b/backend/app/models/printer.py @@ -1,5 +1,5 @@ from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, func +from sqlalchemy import String, Boolean, DateTime, Float, func from sqlalchemy.orm import Mapped, mapped_column, relationship from backend.app.core.database import Base @@ -17,6 +17,7 @@ class Printer(Base): nozzle_count: Mapped[int] = mapped_column(default=1) # 1 or 2, auto-detected from MQTT is_active: Mapped[bool] = mapped_column(Boolean, default=True) auto_archive: Mapped[bool] = mapped_column(Boolean, default=True) + print_hours_offset: Mapped[float] = mapped_column(Float, default=0.0) # Baseline hours to add created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now() ) @@ -34,8 +35,12 @@ class Printer(Base): notification_providers: Mapped[list["NotificationProvider"]] = relationship( back_populates="printer" ) + maintenance_items: Mapped[list["PrinterMaintenance"]] = relationship( + back_populates="printer", cascade="all, delete-orphan" + ) from backend.app.models.archive import PrintArchive # noqa: E402 from backend.app.models.smart_plug import SmartPlug # noqa: E402 from backend.app.models.notification import NotificationProvider # noqa: E402 +from backend.app.models.maintenance import PrinterMaintenance # noqa: E402 diff --git a/backend/app/schemas/maintenance.py b/backend/app/schemas/maintenance.py new file mode 100644 index 000000000..e76790e39 --- /dev/null +++ b/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 diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index c0f812343..d5ad7c808 100644 --- a/backend/app/schemas/notification.py +++ b/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_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)") on_filament_low: bool = Field(default=False, description="Notify when filament is running low") + on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due") # Quiet hours quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours") @@ -87,6 +88,7 @@ class NotificationProviderUpdate(BaseModel): on_printer_offline: bool | None = None on_printer_error: bool | None = None on_filament_low: bool | None = None + on_maintenance_due: bool | None = None # Quiet hours quiet_hours_enabled: bool | None = None diff --git a/backend/app/schemas/printer.py b/backend/app/schemas/printer.py index 11eae4317..559d3a2c6 100644 --- a/backend/app/schemas/printer.py +++ b/backend/app/schemas/printer.py @@ -22,12 +22,14 @@ class PrinterUpdate(BaseModel): model: str | None = None is_active: bool | None = None auto_archive: bool | None = None + print_hours_offset: float | None = None class PrinterResponse(PrinterBase): id: int is_active: bool nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT + print_hours_offset: float = 0.0 created_at: datetime updated_at: datetime diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index b82e23939..4b08430d5 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -163,6 +163,18 @@ class NotificationService: message = f"{printer_name}: Slot {slot} at {remaining_percent}%" return title, message + def _build_maintenance_due_message( + self, printer_name: str, maintenance_items: list[dict] + ) -> tuple[str, str]: + """Build notification message for maintenance due event.""" + title = "Maintenance Due" + lines = [f"{printer_name}:"] + for item in maintenance_items: + status = "OVERDUE" if item.get("is_due") else "Soon" + lines.append(f"• {item['name']} ({status})") + message = "\n".join(lines) + return title, message + async def send_test_notification( self, provider_type: str, config: dict[str, Any] ) -> tuple[bool, str]: @@ -529,6 +541,26 @@ class NotificationService: title, message = self._build_filament_low_message(printer_name, slot, remaining_percent) await self._send_to_providers(providers, title, message, db) + async def on_maintenance_due( + self, + printer_id: int, + printer_name: str, + maintenance_items: list[dict], + db: AsyncSession, + ): + """Handle maintenance due event - sends notification when maintenance is due or warning.""" + if not maintenance_items: + return + + providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id) + if not providers: + logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}") + return + + logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}") + title, message = self._build_maintenance_due_message(printer_name, maintenance_items) + await self._send_to_providers(providers, title, message, db) + # Global instance notification_service = NotificationService() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 383cbdd22..71b53582c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,10 +22,14 @@ "@tiptap/starter-kit": "^3.11.1", "@types/three": "^0.181.0", "gcode-preview": "^2.18.0", + "i18next": "^25.6.3", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.3.5", "react-router-dom": "^7.9.6", "recharts": "^3.5.1", "three": "^0.181.2" @@ -297,6 +301,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -3072,6 +3085,15 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3770,6 +3792,65 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "25.6.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz", + "integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4405,6 +4486,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -4831,6 +4932,33 @@ "react": "^19.2.0" } }, + "node_modules/react-i18next": { + "version": "16.3.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz", + "integrity": "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", @@ -5187,6 +5315,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -5223,7 +5357,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -5425,12 +5559,37 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index e61669b6a..2b5039b2b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,10 +24,14 @@ "@tiptap/starter-kit": "^3.11.1", "@types/three": "^0.181.0", "gcode-preview": "^2.18.0", + "i18next": "^25.6.3", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "jszip": "^3.10.1", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.3.5", "react-router-dom": "^7.9.6", "recharts": "^3.5.1", "three": "^0.181.2" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 25222547a..2e8f89331 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import { QueuePage } from './pages/QueuePage'; import { StatsPage } from './pages/StatsPage'; import { SettingsPage } from './pages/SettingsPage'; import { ProfilesPage } from './pages/ProfilesPage'; +import { MaintenancePage } from './pages/MaintenancePage'; import { useWebSocket } from './hooks/useWebSocket'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; @@ -39,6 +40,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f3db4d439..4dd67e519 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -376,6 +376,7 @@ export interface NotificationProvider { on_printer_offline: boolean; on_printer_error: boolean; on_filament_low: boolean; + on_maintenance_due: boolean; // Quiet hours quiet_hours_enabled: boolean; quiet_hours_start: string | null; @@ -406,6 +407,7 @@ export interface NotificationProviderCreate { on_printer_offline?: boolean; on_printer_error?: boolean; on_filament_low?: boolean; + on_maintenance_due?: boolean; // Quiet hours quiet_hours_enabled?: boolean; quiet_hours_start?: string | null; @@ -429,6 +431,7 @@ export interface NotificationProviderUpdate { on_printer_offline?: boolean; on_printer_error?: boolean; on_filament_low?: boolean; + on_maintenance_due?: boolean; // Quiet hours quiet_hours_enabled?: boolean; quiet_hours_start?: string | null; @@ -518,6 +521,69 @@ export interface UpdateStatus { error: string | null; } +// Maintenance types +export interface MaintenanceType { + id: number; + name: string; + description: string | null; + default_interval_hours: number; + icon: string | null; + is_system: boolean; + created_at: string; +} + +export interface MaintenanceTypeCreate { + name: string; + description?: string | null; + default_interval_hours?: number; + icon?: string | null; +} + +export interface MaintenanceStatus { + id: number; + printer_id: number; + printer_name: string; + maintenance_type_id: number; + maintenance_type_name: string; + maintenance_type_icon: string | null; + enabled: boolean; + interval_hours: number; + current_hours: number; + hours_since_maintenance: number; + hours_until_due: number; + is_due: boolean; + is_warning: boolean; + last_performed_at: string | null; +} + +export interface PrinterMaintenanceOverview { + printer_id: number; + printer_name: string; + total_print_hours: number; + maintenance_items: MaintenanceStatus[]; + due_count: number; + warning_count: number; +} + +export interface MaintenanceHistory { + id: number; + printer_maintenance_id: number; + performed_at: string; + hours_at_maintenance: number; + notes: string | null; +} + +export interface MaintenanceSummary { + total_due: number; + total_warning: number; + printers_with_issues: Array<{ + printer_id: number; + printer_name: string; + due_count: number; + warning_count: number; + }>; +} + // API functions export const api = { // Printers @@ -941,4 +1007,40 @@ export const api = { method: 'POST', }), getUpdateStatus: () => request('/updates/status'), + + // Maintenance + getMaintenanceTypes: () => request('/maintenance/types'), + createMaintenanceType: (data: MaintenanceTypeCreate) => + request('/maintenance/types', { + method: 'POST', + body: JSON.stringify(data), + }), + updateMaintenanceType: (id: number, data: Partial) => + request(`/maintenance/types/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + deleteMaintenanceType: (id: number) => + request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }), + getMaintenanceOverview: () => request('/maintenance/overview'), + getPrinterMaintenance: (printerId: number) => + request(`/maintenance/printers/${printerId}`), + updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; enabled?: boolean }) => + request(`/maintenance/items/${itemId}`, { + method: 'PATCH', + body: JSON.stringify(data), + }), + performMaintenance: (itemId: number, notes?: string) => + request(`/maintenance/items/${itemId}/perform`, { + method: 'POST', + body: JSON.stringify({ notes }), + }), + getMaintenanceHistory: (itemId: number) => + request(`/maintenance/items/${itemId}/history`), + getMaintenanceSummary: () => request('/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' } + ), }; diff --git a/frontend/src/components/AddNotificationModal.tsx b/frontend/src/components/AddNotificationModal.tsx index 28e825ef9..cb360acfd 100644 --- a/frontend/src/components/AddNotificationModal.tsx +++ b/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 type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client'; import { Button } from './Button'; +import { Toggle } from './Toggle'; interface AddNotificationModalProps { provider?: NotificationProvider | null; @@ -350,15 +351,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
- +
{quietHoursEnabled && (
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 7dee7ee6e..54abec1e4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; -import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, type LucideIcon } from 'lucide-react'; +import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import { useQuery } from '@tanstack/react-query'; @@ -19,6 +19,7 @@ export const defaultNavItems: NavItem[] = [ { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' }, { id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' }, { id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' }, + { id: 'maintenance', to: '/maintenance', icon: Wrench, label: 'Maintenance' }, { id: 'settings', to: '/settings', icon: Settings, label: 'Settings' }, ]; diff --git a/frontend/src/components/NotificationProviderCard.tsx b/frontend/src/components/NotificationProviderCard.tsx index 0e39efb60..fee0d5774 100644 --- a/frontend/src/components/NotificationProviderCard.tsx +++ b/frontend/src/components/NotificationProviderCard.tsx @@ -6,6 +6,7 @@ import type { NotificationProvider, NotificationProviderUpdate } from '../api/cl import { Card, CardContent } from './Card'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; +import { Toggle } from './Toggle'; interface NotificationProviderCardProps { provider: NotificationProvider; @@ -134,6 +135,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi {provider.on_filament_low && ( Low Filament )} + {provider.on_maintenance_due && ( + Maintenance + )} {provider.quiet_hours_enabled && ( @@ -204,15 +208,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi

Enabled

Send notifications from this provider

- + updateMutation.mutate({ enabled: checked })} + />
{/* Print Lifecycle Events */} @@ -221,54 +220,34 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi

Print Started

- + updateMutation.mutate({ on_print_start: checked })} + />

Print Completed

- + updateMutation.mutate({ on_print_complete: checked })} + />

Print Failed

- + updateMutation.mutate({ on_print_failed: checked })} + />

Print Stopped

- + updateMutation.mutate({ on_print_stopped: checked })} + />
@@ -276,15 +255,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi

Progress Milestones

Notify at 25%, 50%, 75%

- + updateMutation.mutate({ on_print_progress: checked })} + /> @@ -294,41 +268,37 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi

Printer Offline

- + updateMutation.mutate({ on_printer_offline: checked })} + />

Printer Error

- + updateMutation.mutate({ on_printer_error: checked })} + />

Low Filament

- + updateMutation.mutate({ on_filament_low: checked })} + /> +
+ +
+
+

Maintenance Due

+

Notify when maintenance is needed

+
+ updateMutation.mutate({ on_maintenance_due: checked })} + />
@@ -339,15 +309,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi

Quiet Hours

- + updateMutation.mutate({ quiet_hours_enabled: checked })} + /> {provider.quiet_hours_enabled && ( diff --git a/frontend/src/components/Toggle.tsx b/frontend/src/components/Toggle.tsx new file mode 100644 index 000000000..7a388012e --- /dev/null +++ b/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 ( + + ); +} diff --git a/frontend/src/pages/MaintenancePage.tsx b/frontend/src/pages/MaintenancePage.tsx new file mode 100644 index 000000000..bf3f840b6 --- /dev/null +++ b/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> = { + 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 ( +
+ {/* Icon & Name */} +
+ + + {item.maintenance_type_name} + +
+ + {/* Progress bar */} +
+
+
+
+
+ + {/* Status */} +
+ {item.is_due && } + {item.is_warning && } + {!item.is_due && !item.is_warning && item.enabled && } + {getStatusText()} +
+ + {/* Enable/Disable toggle */} + onToggle(item.id, checked)} + /> + + {/* Reset button */} + +
+ ); +} + +// 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 ( + +
+ {/* Header row with printer name and status */} +
+
+

{overview.printer_name}

+ {overview.due_count > 0 && ( + + + {overview.due_count} overdue + + )} + {overview.warning_count > 0 && ( + + + {overview.warning_count} due soon + + )} + {overview.due_count === 0 && overview.warning_count === 0 && ( + + + All good + + )} +
+ +
+ + {/* Info cards row */} +
+ {/* Print Hours Card */} +
+
Total Print Time
+ {editingHours ? ( +
+ 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 + /> + hours +
+ + +
+
+ ) : ( + + )} +
+ + {/* Next Maintenance Card */} +
+
Next Maintenance
+ {nextTask ? ( +
+
+ {nextTask.maintenance_type_name} +
+
+ {nextTask.is_due ? ( + <>Overdue by {formatHours(Math.abs(nextTask.hours_until_due))} + ) : ( + <>Due in {formatHours(nextTask.hours_until_due)} + )} +
+
+ ) : ( +
No tasks enabled
+ )} +
+
+
+ + {expanded && ( + +
+ {sortedItems.map((item) => ( + + ))} +
+
+ )} +
+ ); +} + +// 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 ( +
+
e.stopPropagation()}> +

Maintenance Settings

+ + {/* Existing custom types */} + {customTypes.length > 0 && ( +
+

Custom Maintenance Types

+
+ {customTypes.map((type) => { + const Icon = getIcon(type.icon); + return ( +
+
+ + {type.name} + ({type.default_interval_hours}h) +
+ +
+ ); + })} +
+
+ )} + + {/* Add new type */} +
+

Add Custom Type

+
+ 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)" + /> + 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" + /> +
+
+
+ {Object.keys(iconMap).map((iconName) => { + const IconComp = iconMap[iconName]; + return ( + + ); + })} +
+ +
+
+ +
+ +
+
+
+ ); +} + +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 ( +
+ +
+ ); + } + + // 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 ( +
+ {/* Header */} +
+
+

Maintenance

+

+ {totalDue > 0 && {totalDue} tasks overdue} + {totalDue > 0 && totalWarning > 0 && ' · '} + {totalWarning > 0 && {totalWarning} due soon} + {totalDue === 0 && totalWarning === 0 && 'All maintenance up to date'} +

+
+ +
+ + {/* Printers - sorted alphabetically */} +
+ {overview && overview.length > 0 ? ( + [...overview].sort((a, b) => a.printer_name.localeCompare(b.printer_name)).map((printerOverview) => ( + + )) + ) : ( + + + +

No printers configured

+

+ Add printers to start tracking maintenance +

+
+
+ )} +
+ + {/* Settings modal */} + {showSettings && types && ( + setShowSettings(false)} + types={types} + onAddType={(data) => addTypeMutation.mutate(data)} + onDeleteType={(id) => deleteTypeMutation.mutate(id)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 75fc18249..39e0bcefe 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -16,7 +16,9 @@ import { Power, PowerOff, Zap, + Wrench, } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; import { api } from '../api/client'; import type { Printer, PrinterCreate } from '../api/client'; import { Card, CardContent } from '../components/Card'; @@ -82,8 +84,22 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string ); } -function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIfDisconnected?: boolean }) { +interface PrinterMaintenanceInfo { + due_count: number; + warning_count: number; +} + +function PrinterCard({ + printer, + hideIfDisconnected, + maintenanceInfo +}: { + printer: Printer; + hideIfDisconnected?: boolean; + maintenanceInfo?: PrinterMaintenanceInfo; +}) { const queryClient = useQueryClient(); + const navigate = useNavigate(); const [showMenu, setShowMenu] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showFileManager, setShowFileManager] = useState(false); @@ -195,6 +211,29 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf : 'OK'} )} + {/* Maintenance Status Indicator - always show */} + {maintenanceInfo && ( + + )}