Browse Source

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
maziggy 4 months ago
parent
commit
4f2a45c190

+ 15 - 1
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,

+ 6 - 0
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."""

+ 1 - 0
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())
 

+ 5 - 0
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

+ 5 - 0
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;

+ 127 - 2
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({
                 <Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
               </span>
             )}
+            {/* 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 ? (
+                <a
+                  href={wikiUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="text-bambu-gray hover:text-bambu-green transition-colors shrink-0"
+                  title="View documentation"
+                  onClick={(e) => e.stopPropagation()}
+                >
+                  <ExternalLink className="w-3.5 h-3.5" />
+                </a>
+              ) : null;
+            })()}
           </div>
 
           {/* 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<Set<number>>(new Set());
   const [expandedType, setExpandedType] = useState<number | null>(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({
                     </div>
                   </div>
                 </div>
+                {/* Wiki URL */}
+                <div className="mt-4">
+                  <label className="block text-xs text-bambu-gray mb-1.5">Documentation Link (optional)</label>
+                  <input
+                    type="url"
+                    value={newTypeWikiUrl}
+                    onChange={(e) => 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/..."
+                  />
+                </div>
                 {/* Printer selection */}
                 <div className="mt-4">
                   <label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
@@ -739,6 +857,13 @@ function SettingsSection({
                         );
                       })}
                     </div>
+                    <input
+                      type="url"
+                      value={editTypeWikiUrl}
+                      onChange={(e) => 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)"
+                    />
                     <div className="flex gap-2">
                       <Button size="sm" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>
                         Save

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CLcf9MNg.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-3dBx4a1x.js"></script>
+    <script type="module" crossorigin src="/assets/index-CLcf9MNg.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CrUIX1oi.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff