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
This commit is contained in:
maziggy
2026-01-07 07:53:42 +01:00
parent fe1d10b705
commit 4f2a45c190
8 changed files with 218 additions and 62 deletions
+15 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
+1 -1
View File
@@ -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>