mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Added auto app update; Added maintenance module with notifications
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
|
||||
|
||||
async def init_db():
|
||||
# Import models to register them with SQLAlchemy
|
||||
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification # noqa: F401
|
||||
from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -91,3 +91,12 @@ async def run_migrations(conn):
|
||||
except Exception:
|
||||
# Column already exists
|
||||
pass
|
||||
|
||||
# Migration: Add on_maintenance_due column to notification_providers
|
||||
try:
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"
|
||||
))
|
||||
except Exception:
|
||||
# Column already exists
|
||||
pass
|
||||
|
||||
+43
-1
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -22,12 +22,14 @@ class PrinterUpdate(BaseModel):
|
||||
model: str | None = None
|
||||
is_active: bool | None = None
|
||||
auto_archive: bool | None = None
|
||||
print_hours_offset: float | None = None
|
||||
|
||||
|
||||
class PrinterResponse(PrinterBase):
|
||||
id: int
|
||||
is_active: bool
|
||||
nozzle_count: int = 1 # 1 or 2, auto-detected from MQTT
|
||||
print_hours_offset: float = 0.0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -163,6 +163,18 @@ class NotificationService:
|
||||
message = f"{printer_name}: Slot {slot} at {remaining_percent}%"
|
||||
return title, message
|
||||
|
||||
def _build_maintenance_due_message(
|
||||
self, printer_name: str, maintenance_items: list[dict]
|
||||
) -> tuple[str, str]:
|
||||
"""Build notification message for maintenance due event."""
|
||||
title = "Maintenance Due"
|
||||
lines = [f"{printer_name}:"]
|
||||
for item in maintenance_items:
|
||||
status = "OVERDUE" if item.get("is_due") else "Soon"
|
||||
lines.append(f"• {item['name']} ({status})")
|
||||
message = "\n".join(lines)
|
||||
return title, message
|
||||
|
||||
async def send_test_notification(
|
||||
self, provider_type: str, config: dict[str, Any]
|
||||
) -> tuple[bool, str]:
|
||||
@@ -529,6 +541,26 @@ class NotificationService:
|
||||
title, message = self._build_filament_low_message(printer_name, slot, remaining_percent)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
|
||||
async def on_maintenance_due(
|
||||
self,
|
||||
printer_id: int,
|
||||
printer_name: str,
|
||||
maintenance_items: list[dict],
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""Handle maintenance due event - sends notification when maintenance is due or warning."""
|
||||
if not maintenance_items:
|
||||
return
|
||||
|
||||
providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
|
||||
if not providers:
|
||||
logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
|
||||
title, message = self._build_maintenance_due_message(printer_name, maintenance_items)
|
||||
await self._send_to_providers(providers, title, message, db)
|
||||
|
||||
|
||||
# Global instance
|
||||
notification_service = NotificationService()
|
||||
|
||||
Generated
+160
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { QueuePage } from './pages/QueuePage';
|
||||
import { StatsPage } from './pages/StatsPage';
|
||||
import { SettingsPage } from './pages/SettingsPage';
|
||||
import { ProfilesPage } from './pages/ProfilesPage';
|
||||
import { MaintenancePage } from './pages/MaintenancePage';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
@@ -39,6 +40,7 @@ function App() {
|
||||
<Route path="queue" element={<QueuePage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="maintenance" element={<MaintenancePage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -376,6 +376,7 @@ export interface NotificationProvider {
|
||||
on_printer_offline: boolean;
|
||||
on_printer_error: boolean;
|
||||
on_filament_low: boolean;
|
||||
on_maintenance_due: boolean;
|
||||
// Quiet hours
|
||||
quiet_hours_enabled: boolean;
|
||||
quiet_hours_start: string | null;
|
||||
@@ -406,6 +407,7 @@ export interface NotificationProviderCreate {
|
||||
on_printer_offline?: boolean;
|
||||
on_printer_error?: boolean;
|
||||
on_filament_low?: boolean;
|
||||
on_maintenance_due?: boolean;
|
||||
// Quiet hours
|
||||
quiet_hours_enabled?: boolean;
|
||||
quiet_hours_start?: string | null;
|
||||
@@ -429,6 +431,7 @@ export interface NotificationProviderUpdate {
|
||||
on_printer_offline?: boolean;
|
||||
on_printer_error?: boolean;
|
||||
on_filament_low?: boolean;
|
||||
on_maintenance_due?: boolean;
|
||||
// Quiet hours
|
||||
quiet_hours_enabled?: boolean;
|
||||
quiet_hours_start?: string | null;
|
||||
@@ -518,6 +521,69 @@ export interface UpdateStatus {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Maintenance types
|
||||
export interface MaintenanceType {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
default_interval_hours: number;
|
||||
icon: string | null;
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceTypeCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
default_interval_hours?: number;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface MaintenanceStatus {
|
||||
id: number;
|
||||
printer_id: number;
|
||||
printer_name: string;
|
||||
maintenance_type_id: number;
|
||||
maintenance_type_name: string;
|
||||
maintenance_type_icon: string | null;
|
||||
enabled: boolean;
|
||||
interval_hours: number;
|
||||
current_hours: number;
|
||||
hours_since_maintenance: number;
|
||||
hours_until_due: number;
|
||||
is_due: boolean;
|
||||
is_warning: boolean;
|
||||
last_performed_at: string | null;
|
||||
}
|
||||
|
||||
export interface PrinterMaintenanceOverview {
|
||||
printer_id: number;
|
||||
printer_name: string;
|
||||
total_print_hours: number;
|
||||
maintenance_items: MaintenanceStatus[];
|
||||
due_count: number;
|
||||
warning_count: number;
|
||||
}
|
||||
|
||||
export interface MaintenanceHistory {
|
||||
id: number;
|
||||
printer_maintenance_id: number;
|
||||
performed_at: string;
|
||||
hours_at_maintenance: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface MaintenanceSummary {
|
||||
total_due: number;
|
||||
total_warning: number;
|
||||
printers_with_issues: Array<{
|
||||
printer_id: number;
|
||||
printer_name: string;
|
||||
due_count: number;
|
||||
warning_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// API functions
|
||||
export const api = {
|
||||
// Printers
|
||||
@@ -941,4 +1007,40 @@ export const api = {
|
||||
method: 'POST',
|
||||
}),
|
||||
getUpdateStatus: () => request<UpdateStatus>('/updates/status'),
|
||||
|
||||
// Maintenance
|
||||
getMaintenanceTypes: () => request<MaintenanceType[]>('/maintenance/types'),
|
||||
createMaintenanceType: (data: MaintenanceTypeCreate) =>
|
||||
request<MaintenanceType>('/maintenance/types', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
updateMaintenanceType: (id: number, data: Partial<MaintenanceTypeCreate>) =>
|
||||
request<MaintenanceType>(`/maintenance/types/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
deleteMaintenanceType: (id: number) =>
|
||||
request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }),
|
||||
getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),
|
||||
getPrinterMaintenance: (printerId: number) =>
|
||||
request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
|
||||
updateMaintenanceItem: (itemId: number, data: { custom_interval_hours?: number | null; enabled?: boolean }) =>
|
||||
request<MaintenanceStatus>(`/maintenance/items/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
performMaintenance: (itemId: number, notes?: string) =>
|
||||
request<MaintenanceStatus>(`/maintenance/items/${itemId}/perform`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notes }),
|
||||
}),
|
||||
getMaintenanceHistory: (itemId: number) =>
|
||||
request<MaintenanceHistory[]>(`/maintenance/items/${itemId}/history`),
|
||||
getMaintenanceSummary: () => request<MaintenanceSummary>('/maintenance/summary'),
|
||||
setPrinterHours: (printerId: number, totalHours: number) =>
|
||||
request<{ printer_id: number; total_hours: number; archive_hours: number; offset_hours: number }>(
|
||||
`/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,
|
||||
{ method: 'PATCH' }
|
||||
),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { api } from '../api/client';
|
||||
import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
interface AddNotificationModalProps {
|
||||
provider?: NotificationProvider | null;
|
||||
@@ -350,15 +351,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-white">Quiet Hours (Do Not Disturb)</label>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={quietHoursEnabled}
|
||||
onChange={(e) => setQuietHoursEnabled(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={quietHoursEnabled}
|
||||
onChange={setQuietHoursEnabled}
|
||||
/>
|
||||
</div>
|
||||
{quietHoursEnabled && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, type LucideIcon } from 'lucide-react';
|
||||
import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -19,6 +19,7 @@ export const defaultNavItems: NavItem[] = [
|
||||
{ id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' },
|
||||
{ id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' },
|
||||
{ id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' },
|
||||
{ id: 'maintenance', to: '/maintenance', icon: Wrench, label: 'Maintenance' },
|
||||
{ id: 'settings', to: '/settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { NotificationProvider, NotificationProviderUpdate } from '../api/cl
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
interface NotificationProviderCardProps {
|
||||
provider: NotificationProvider;
|
||||
@@ -134,6 +135,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
{provider.on_filament_low && (
|
||||
<span className="px-2 py-0.5 bg-amber-500/20 text-amber-400 text-xs rounded">Low Filament</span>
|
||||
)}
|
||||
{provider.on_maintenance_due && (
|
||||
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">Maintenance</span>
|
||||
)}
|
||||
{provider.quiet_hours_enabled && (
|
||||
<span className="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded flex items-center gap-1">
|
||||
<Moon className="w-3 h-3" />
|
||||
@@ -204,15 +208,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
<p className="text-sm text-white">Enabled</p>
|
||||
<p className="text-xs text-bambu-gray">Send notifications from this provider</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.enabled}
|
||||
onChange={(e) => updateMutation.mutate({ enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.enabled}
|
||||
onChange={(checked) => updateMutation.mutate({ enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Print Lifecycle Events */}
|
||||
@@ -221,54 +220,34 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Print Started</p>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_print_start}
|
||||
onChange={(e) => updateMutation.mutate({ on_print_start: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_print_start}
|
||||
onChange={(checked) => updateMutation.mutate({ on_print_start: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Print Completed</p>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_print_complete}
|
||||
onChange={(e) => updateMutation.mutate({ on_print_complete: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_print_complete}
|
||||
onChange={(checked) => updateMutation.mutate({ on_print_complete: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Print Failed</p>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_print_failed}
|
||||
onChange={(e) => updateMutation.mutate({ on_print_failed: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_print_failed}
|
||||
onChange={(checked) => updateMutation.mutate({ on_print_failed: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Print Stopped</p>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_print_stopped}
|
||||
onChange={(e) => updateMutation.mutate({ on_print_stopped: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_print_stopped}
|
||||
onChange={(checked) => updateMutation.mutate({ on_print_stopped: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -276,15 +255,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
<p className="text-sm text-white">Progress Milestones</p>
|
||||
<p className="text-xs text-bambu-gray">Notify at 25%, 50%, 75%</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_print_progress}
|
||||
onChange={(e) => updateMutation.mutate({ on_print_progress: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_print_progress}
|
||||
onChange={(checked) => updateMutation.mutate({ on_print_progress: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -294,41 +268,37 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Printer Offline</p>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_printer_offline}
|
||||
onChange={(e) => updateMutation.mutate({ on_printer_offline: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_printer_offline}
|
||||
onChange={(checked) => updateMutation.mutate({ on_printer_offline: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Printer Error</p>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_printer_error}
|
||||
onChange={(e) => updateMutation.mutate({ on_printer_error: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_printer_error}
|
||||
onChange={(checked) => updateMutation.mutate({ on_printer_error: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Low Filament</p>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.on_filament_low}
|
||||
onChange={(e) => updateMutation.mutate({ on_filament_low: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.on_filament_low}
|
||||
onChange={(checked) => updateMutation.mutate({ on_filament_low: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-white">Maintenance Due</p>
|
||||
<p className="text-xs text-bambu-gray">Notify when maintenance is needed</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={provider.on_maintenance_due ?? false}
|
||||
onChange={(checked) => updateMutation.mutate({ on_maintenance_due: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,15 +309,10 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
|
||||
<Moon className="w-4 h-4 text-purple-400" />
|
||||
<p className="text-sm text-white">Quiet Hours</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.quiet_hours_enabled}
|
||||
onChange={(e) => updateMutation.mutate({ quiet_hours_enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
|
||||
</label>
|
||||
<Toggle
|
||||
checked={provider.quiet_hours_enabled}
|
||||
onChange={(checked) => updateMutation.mutate({ quiet_hours_enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider.quiet_hours_enabled && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
Zap,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { Printer, PrinterCreate } from '../api/client';
|
||||
import { Card, CardContent } from '../components/Card';
|
||||
@@ -82,8 +84,22 @@ function CoverImage({ url, printName }: { url: string | null; printName?: string
|
||||
);
|
||||
}
|
||||
|
||||
function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIfDisconnected?: boolean }) {
|
||||
interface PrinterMaintenanceInfo {
|
||||
due_count: number;
|
||||
warning_count: number;
|
||||
}
|
||||
|
||||
function PrinterCard({
|
||||
printer,
|
||||
hideIfDisconnected,
|
||||
maintenanceInfo
|
||||
}: {
|
||||
printer: Printer;
|
||||
hideIfDisconnected?: boolean;
|
||||
maintenanceInfo?: PrinterMaintenanceInfo;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
@@ -195,6 +211,29 @@ function PrinterCard({ printer, hideIfDisconnected }: { printer: Printer; hideIf
|
||||
: 'OK'}
|
||||
</button>
|
||||
)}
|
||||
{/* Maintenance Status Indicator - always show */}
|
||||
{maintenanceInfo && (
|
||||
<button
|
||||
onClick={() => navigate('/maintenance')}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
maintenanceInfo.due_count > 0
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: maintenanceInfo.warning_count > 0
|
||||
? 'bg-orange-500/20 text-orange-400'
|
||||
: 'bg-bambu-green/20 text-bambu-green'
|
||||
}`}
|
||||
title={
|
||||
maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0
|
||||
? `${maintenanceInfo.due_count > 0 ? `${maintenanceInfo.due_count} maintenance due` : ''}${maintenanceInfo.due_count > 0 && maintenanceInfo.warning_count > 0 ? ', ' : ''}${maintenanceInfo.warning_count > 0 ? `${maintenanceInfo.warning_count} due soon` : ''} - Click to view`
|
||||
: 'All maintenance up to date - Click to view'
|
||||
}
|
||||
>
|
||||
<Wrench className="w-3 h-3" />
|
||||
{maintenanceInfo.due_count > 0 || maintenanceInfo.warning_count > 0
|
||||
? maintenanceInfo.due_count + maintenanceInfo.warning_count
|
||||
: 'OK'}
|
||||
</button>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -674,6 +713,25 @@ export function PrintersPage() {
|
||||
queryFn: api.getPrinters,
|
||||
});
|
||||
|
||||
// Fetch maintenance overview for all printers to show badges
|
||||
const { data: maintenanceOverview } = useQuery({
|
||||
queryKey: ['maintenanceOverview'],
|
||||
queryFn: api.getMaintenanceOverview,
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
|
||||
// Create a map of printer_id -> maintenance info for quick lookup
|
||||
const maintenanceByPrinter = maintenanceOverview?.reduce(
|
||||
(acc, overview) => {
|
||||
acc[overview.printer_id] = {
|
||||
due_count: overview.due_count,
|
||||
warning_count: overview.warning_count,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, PrinterMaintenanceInfo>
|
||||
) || {};
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: api.createPrinter,
|
||||
onSuccess: () => {
|
||||
@@ -727,7 +785,12 @@ export function PrintersPage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{printers?.map((printer) => (
|
||||
<PrinterCard key={printer.id} printer={printer} hideIfDisconnected={hideDisconnected} />
|
||||
<PrinterCard
|
||||
key={printer.id}
|
||||
printer={printer}
|
||||
hideIfDisconnected={hideDisconnected}
|
||||
maintenanceInfo={maintenanceByPrinter[printer.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-DFEXODPu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CLNqfrMK.css">
|
||||
<script type="module" crossorigin src="/assets/index-BbYJHXvN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-H_ymON9v.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user