From 4f2a45c1903283d00957491f166105e6b9a37d6a Mon Sep 17 00:00:00 2001 From: maziggy Date: Wed, 7 Jan 2026 07:53:42 +0100 Subject: [PATCH] Add maintenance documentation links with model-specific wiki URLs (#59) Features: - Add wiki_url field to MaintenanceType for custom type documentation links - Add printer_model to MaintenanceStatus for model-specific URL mapping - Display external link icon next to maintenance item names - Map system maintenance types to Bambu Lab wiki pages by printer model: - Lubricate Linear Rails, Clean Nozzle/Hotend, Check Belt Tension - Clean Carbon Rods (X1/P1 only), Clean Build Plate, Check PTFE Tube - HEPA/Carbon Filter, Left Nozzle Rail (H2 series) - Add Documentation Link input to custom type create/edit forms Bug fix: - Fix 500 error when assigning maintenance type to printer (lazy loading) Database: - Add migration for wiki_url column on maintenance_types table --- backend/app/api/routes/maintenance.py | 16 ++- backend/app/core/database.py | 6 + backend/app/models/maintenance.py | 1 + backend/app/schemas/maintenance.py | 5 + frontend/src/api/client.ts | 5 + frontend/src/pages/MaintenancePage.tsx | 129 +++++++++++++++++- .../{index-3dBx4a1x.js => index-CLcf9MNg.js} | 116 ++++++++-------- static/index.html | 2 +- 8 files changed, 218 insertions(+), 62 deletions(-) rename static/assets/{index-3dBx4a1x.js => index-CLcf9MNg.js} (87%) diff --git a/backend/app/api/routes/maintenance.py b/backend/app/api/routes/maintenance.py index 10a9dc9e8..782521247 100644 --- a/backend/app/api/routes/maintenance.py +++ b/backend/app/api/routes/maintenance.py @@ -294,9 +294,11 @@ async def _get_printer_maintenance_internal( id=item_id, printer_id=printer_id, printer_name=printer.name, + printer_model=printer.model, maintenance_type_id=maint_type.id, maintenance_type_name=maint_type.name, maintenance_type_icon=maint_type.icon, + maintenance_type_wiki_url=getattr(maint_type, "wiki_url", None), enabled=enabled, interval_hours=interval, interval_type=interval_type, @@ -317,6 +319,7 @@ async def _get_printer_maintenance_internal( return PrinterMaintenanceOverview( printer_id=printer_id, printer_name=printer.name, + printer_model=printer.model, total_print_hours=total_hours, maintenance_items=maintenance_items, due_count=due_count, @@ -417,7 +420,16 @@ async def assign_maintenance_type( ) db.add(item) await db.commit() - await db.refresh(item) + + # Re-fetch with relationship loaded for response serialization + from sqlalchemy.orm import selectinload + + result = await db.execute( + select(PrinterMaintenance) + .options(selectinload(PrinterMaintenance.maintenance_type)) + .where(PrinterMaintenance.id == item.id) + ) + item = result.scalar_one() return item @@ -494,9 +506,11 @@ async def perform_maintenance( id=item.id, printer_id=item.printer_id, printer_name=printer.name, + printer_model=printer.model, maintenance_type_id=item.maintenance_type_id, maintenance_type_name=item.maintenance_type.name, maintenance_type_icon=item.maintenance_type.icon, + maintenance_type_wiki_url=getattr(item.maintenance_type, "wiki_url", None), enabled=item.enabled, interval_hours=interval, interval_type=interval_type, diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 91b65cb36..89434f9d2 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -387,6 +387,12 @@ async def run_migrations(conn): except Exception: pass + # Migration: Add wiki_url column to maintenance_types for documentation links + try: + await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/backend/app/models/maintenance.py b/backend/app/models/maintenance.py index c62bcdd4f..2bd772829 100644 --- a/backend/app/models/maintenance.py +++ b/backend/app/models/maintenance.py @@ -20,6 +20,7 @@ class MaintenanceType(Base): # Interval type: "hours" (print hours) or "days" (calendar days) interval_type: Mapped[str] = mapped_column(String(20), default="hours") icon: Mapped[str | None] = mapped_column(String(50)) # Icon name for UI + wiki_url: Mapped[str | None] = mapped_column(String(500)) # Documentation link is_system: Mapped[bool] = mapped_column(Boolean, default=False) # Pre-defined vs custom created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) diff --git a/backend/app/schemas/maintenance.py b/backend/app/schemas/maintenance.py index bf1c48a38..fa045f6cd 100644 --- a/backend/app/schemas/maintenance.py +++ b/backend/app/schemas/maintenance.py @@ -13,6 +13,7 @@ class MaintenanceTypeBase(BaseModel): # "hours" = print hours, "days" = calendar days interval_type: str = Field(default="hours", pattern="^(hours|days)$") icon: str | None = None + wiki_url: str | None = None # Documentation link for custom types class MaintenanceTypeCreate(MaintenanceTypeBase): @@ -25,6 +26,7 @@ class MaintenanceTypeUpdate(BaseModel): default_interval_hours: float | None = Field(default=None, ge=1.0) interval_type: str | None = Field(default=None, pattern="^(hours|days)$") icon: str | None = None + wiki_url: str | None = None class MaintenanceTypeResponse(MaintenanceTypeBase): @@ -96,9 +98,11 @@ class MaintenanceStatus(BaseModel): id: int printer_id: int printer_name: str + printer_model: str | None # For model-specific documentation links maintenance_type_id: int maintenance_type_name: str maintenance_type_icon: str | None + maintenance_type_wiki_url: str | None # Custom wiki URL for the type enabled: bool # Interval configuration interval_hours: float # custom or default (hours for print-based, days for time-based) @@ -121,6 +125,7 @@ class PrinterMaintenanceOverview(BaseModel): printer_id: int printer_name: str + printer_model: str | None # For model-specific documentation links total_print_hours: float maintenance_items: list[MaintenanceStatus] due_count: int diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f064b1acd..d5d58684b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1189,6 +1189,7 @@ export interface MaintenanceType { default_interval_hours: number; interval_type: 'hours' | 'days'; // "hours" = print hours, "days" = calendar days icon: string | null; + wiki_url: string | null; // Documentation link is_system: boolean; created_at: string; } @@ -1199,15 +1200,18 @@ export interface MaintenanceTypeCreate { default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string | null; + wiki_url?: string | null; } export interface MaintenanceStatus { id: number; printer_id: number; printer_name: string; + printer_model: string | null; maintenance_type_id: number; maintenance_type_name: string; maintenance_type_icon: string | null; + maintenance_type_wiki_url: string | null; // Custom wiki URL from type enabled: boolean; interval_hours: number; // For hours type: print hours; for days type: number of days interval_type: 'hours' | 'days'; @@ -1224,6 +1228,7 @@ export interface MaintenanceStatus { export interface PrinterMaintenanceOverview { printer_id: number; printer_name: string; + printer_model: string | null; total_print_hours: number; maintenance_items: MaintenanceStatus[]; due_count: number; diff --git a/frontend/src/pages/MaintenancePage.tsx b/frontend/src/pages/MaintenancePage.tsx index 4dfce21ec..33419eafb 100644 --- a/frontend/src/pages/MaintenancePage.tsx +++ b/frontend/src/pages/MaintenancePage.tsx @@ -33,6 +33,7 @@ import { Filter, CircleDot, Printer, + ExternalLink, } from 'lucide-react'; import { api } from '../api/client'; import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType } from '../api/client'; @@ -110,6 +111,89 @@ function formatIntervalLabel(value: number, type: 'hours' | 'days'): string { return `${value}h`; } +// Get Bambu Lab wiki URL for a maintenance task based on printer model +function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): string | null { + const model = (printerModel || '').toUpperCase().replace(/[- ]/g, ''); + + // Helper to match model families + const isX1 = model.includes('X1'); + const isP1 = model.includes('P1'); + const isA1Mini = model.includes('A1MINI'); + const isA1 = model.includes('A1') && !isA1Mini; + const isH2D = model.includes('H2D'); + const isH2C = model.includes('H2C'); + const isH2S = model.includes('H2S'); + const isH2 = isH2D || isH2C || isH2S; + const isP2S = model.includes('P2S'); + + switch (typeName) { + case 'Lubricate Linear Rails': + if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/basic-maintenance'; + if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance'; + if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis'; + if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis'; + if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication'; + if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension'; // P2S maintenance page + return 'https://wiki.bambulab.com/en/general/lead-screws-lubrication'; + + case 'Clean Nozzle/Hotend': + if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog'; + if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/troubleshooting/nozzle-clog'; + if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/nozzl-cold-pull-maintenance-and-cleaning'; + if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/cold-pull-maintenance-hotend'; + return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog'; + + case 'Check Belt Tension': + if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension'; + if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance'; + if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/belt_tension'; + if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/belt_tension'; + if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/belt-tension'; + if (isH2C) return 'https://wiki.bambulab.com/en/h2c/maintenance/belt-tension'; + if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/belt-tension'; + if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension'; + return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension'; + + case 'Clean Carbon Rods': + // Only X1 and P1 series have carbon rods + if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance'; + // A1, H2, P2S don't have carbon rods - return null + if (isA1Mini || isA1 || isH2 || isP2S) return null; + return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance'; + + case 'Clean Build Plate': + // Same for all printers + return 'https://wiki.bambulab.com/en/filament-acc/acc/pei-plate-clean-guide'; + + case 'Check PTFE Tube': + if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube'; + if (isA1Mini || isA1) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/ptfe-tube'; + if (isH2D) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer'; + if (isH2S) return 'https://wiki.bambulab.com/en/h2s/maintenance/replace-ptfe-tube-on-h2s-printer'; + if (isH2C) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-ptfe-tube-on-h2d-printer'; // H2C uses H2D guide + if (isP2S) return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube'; // P2S uses similar PTFE + return 'https://wiki.bambulab.com/en/x1/maintenance/replace-ptfe-tube'; + + case 'Replace HEPA Filter': + case 'HEPA Filter': + case 'Replace Carbon Filter': + case 'Carbon Filter': + if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/replace-smoke-purifier-air-filte'; + // X1/P1 use the activated carbon filter + return 'https://wiki.bambulab.com/en/x1/maintenance/replace-carbon-filter'; + + case 'Lubricate Left Nozzle Rail': + case 'Left Nozzle Rail': + // H2 series specific - dual nozzle system + if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication'; + return null; + + default: + // Custom maintenance types don't have wiki URLs + return null; + } +} + // Maintenance item card - cleaner, more visual design function MaintenanceCard({ item, @@ -200,6 +284,23 @@ function MaintenanceCard({ )} + {/* Wiki link - next to name */} + {(() => { + // Use custom wiki_url from type if available, otherwise use computed URL + const wikiUrl = item.maintenance_type_wiki_url || getMaintenanceWikiUrl(item.maintenance_type_name, item.printer_model); + return wikiUrl ? ( + e.stopPropagation()} + > + + + ) : null; + })()} {/* Progress bar */} @@ -418,8 +519,8 @@ function SettingsSection({ overview: PrinterMaintenanceOverview[] | undefined; types: MaintenanceType[]; onUpdateInterval: (id: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null }) => void; - onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string }, printerIds: number[]) => void; - onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string }) => void; + onAddType: (data: { name: string; description?: string; default_interval_hours: number; interval_type: 'hours' | 'days'; icon?: string; wiki_url?: string | null }, printerIds: number[]) => void; + onUpdateType: (id: number, data: { name?: string; default_interval_hours?: number; interval_type?: 'hours' | 'days'; icon?: string; wiki_url?: string | null }) => void; onDeleteType: (id: number) => void; onAssignType: (printerId: number, typeId: number) => void; onRemoveItem: (itemId: number) => void; @@ -432,6 +533,7 @@ function SettingsSection({ const [newTypeInterval, setNewTypeInterval] = useState('100'); const [newTypeIntervalType, setNewTypeIntervalType] = useState<'hours' | 'days'>('hours'); const [newTypeIcon, setNewTypeIcon] = useState('Wrench'); + const [newTypeWikiUrl, setNewTypeWikiUrl] = useState(''); const [selectedPrinters, setSelectedPrinters] = useState>(new Set()); const [expandedType, setExpandedType] = useState(null); @@ -466,6 +568,7 @@ function SettingsSection({ const [editTypeInterval, setEditTypeInterval] = useState(''); const [editTypeIntervalType, setEditTypeIntervalType] = useState<'hours' | 'days'>('hours'); const [editTypeIcon, setEditTypeIcon] = useState('Wrench'); + const [editTypeWikiUrl, setEditTypeWikiUrl] = useState(''); const startEditType = (type: MaintenanceType) => { setEditingType(type); @@ -473,6 +576,7 @@ function SettingsSection({ setEditTypeInterval(type.default_interval_hours.toString()); setEditTypeIntervalType(type.interval_type || 'hours'); setEditTypeIcon(type.icon || 'Wrench'); + setEditTypeWikiUrl(type.wiki_url || ''); }; const handleSaveEditType = () => { @@ -482,6 +586,7 @@ function SettingsSection({ default_interval_hours: parseFloat(editTypeInterval), interval_type: editTypeIntervalType, icon: editTypeIcon, + wiki_url: editTypeWikiUrl.trim() || null, }); setEditingType(null); } @@ -508,10 +613,12 @@ function SettingsSection({ default_interval_hours: parseFloat(newTypeInterval), interval_type: newTypeIntervalType, icon: newTypeIcon, + wiki_url: newTypeWikiUrl.trim() || null, }, Array.from(selectedPrinters)); setNewTypeName(''); setNewTypeInterval('100'); setNewTypeIntervalType('hours'); + setNewTypeWikiUrl(''); setSelectedPrinters(new Set()); setShowAddType(false); } @@ -626,6 +733,17 @@ function SettingsSection({ + {/* Wiki URL */} +
+ + setNewTypeWikiUrl(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" + placeholder="https://wiki.bambulab.com/..." + /> +
{/* Printer selection */}
@@ -739,6 +857,13 @@ function SettingsSection({ ); })}
+ setEditTypeWikiUrl(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none" + placeholder="Documentation link (optional)" + />