Merge branch '0.2.0b' into main

This commit is contained in:
MartinNYHC
2026-02-13 16:05:18 +01:00
committed by GitHub
16 changed files with 301 additions and 160 deletions
+28 -4
View File
@@ -177,7 +177,11 @@ async def get_maintenance_types(
):
"""Get all maintenance types."""
await ensure_default_types(db)
result = await db.execute(select(MaintenanceType).order_by(MaintenanceType.is_system.desc(), MaintenanceType.name))
result = await db.execute(
select(MaintenanceType)
.where(MaintenanceType.is_deleted.is_(False))
.order_by(MaintenanceType.is_system.desc(), MaintenanceType.name)
)
return result.scalars().all()
@@ -230,20 +234,40 @@ async def delete_maintenance_type(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
):
"""Delete a custom maintenance type."""
"""Delete 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")
if maint_type.is_system:
raise HTTPException(status_code=400, detail="Cannot delete system maintenance type")
maint_type.is_deleted = True
await db.commit()
return {"status": "deleted"}
await db.delete(maint_type)
await db.commit()
return {"status": "deleted"}
@router.post("/types/restore-defaults")
async def restore_default_maintenance_types(
db: AsyncSession = Depends(get_db),
_: User | None = RequirePermissionIfAuthEnabled(Permission.MAINTENANCE_DELETE),
):
"""Restore deleted default maintenance types."""
await ensure_default_types(db)
result = await db.execute(
select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).where(MaintenanceType.is_deleted.is_(True))
)
deleted_types = result.scalars().all()
for maint_type in deleted_types:
maint_type.is_deleted = False
await db.commit()
return {"restored": len(deleted_types)}
# ============== Printer Maintenance ==============
@@ -264,7 +288,7 @@ async def _get_printer_maintenance_internal(
total_hours = await get_printer_total_hours(db, printer_id)
# Get all maintenance types
result = await db.execute(select(MaintenanceType))
result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_deleted.is_(False)))
all_types = result.scalars().all()
# Get printer's maintenance items
+7
View File
@@ -188,6 +188,13 @@ async def run_migrations(conn):
# Column already exists
pass
# Migration: Add is_deleted column to maintenance_types for soft-deletes
try:
await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0"))
except OperationalError:
# Column already exists
pass
# Migration: Add custom_interval_type column to printer_maintenance
try:
await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
+1
View File
@@ -22,6 +22,7 @@ class MaintenanceType(Base):
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
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) # Hidden/removed type
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
# Relationships
+1 -1
View File
@@ -137,7 +137,7 @@ def find_import_shadowing(file_path: Path) -> list[tuple[str, int, str]]:
Returns list of (name, line_number, function_name) tuples.
"""
try:
with open(file_path) as f:
with open(file_path, encoding="utf-8") as f:
source = f.read()
tree = ast.parse(source)
visitor = DangerousImportVisitor()
+2
View File
@@ -3462,6 +3462,8 @@ export const api = {
}),
deleteMaintenanceType: (id: number) =>
request<{ status: string }>(`/maintenance/types/${id}`, { method: 'DELETE' }),
restoreDefaultMaintenanceTypes: () =>
request<{ restored: number }>(`/maintenance/types/restore-defaults`, { method: 'POST' }),
getMaintenanceOverview: () => request<PrinterMaintenanceOverview[]>('/maintenance/overview'),
getPrinterMaintenance: (printerId: number) =>
request<PrinterMaintenanceOverview>(`/maintenance/printers/${printerId}`),
+14 -2
View File
@@ -9,6 +9,8 @@ interface ConfirmModalProps {
message: string;
confirmText?: string;
cancelText?: string;
cancelVariant?: 'primary' | 'secondary' | 'danger' | 'ghost';
cardClassName?: string;
variant?: 'danger' | 'warning' | 'default';
isLoading?: boolean;
loadingText?: string;
@@ -21,6 +23,8 @@ export function ConfirmModal({
message,
confirmText,
cancelText,
cancelVariant,
cardClassName,
variant = 'default',
isLoading = false,
loadingText,
@@ -62,7 +66,10 @@ export function ConfirmModal({
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={isLoading ? undefined : onCancel}
>
<Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Card
className={`w-full max-w-md ${cardClassName ?? ''}`}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className={`p-2 rounded-full bg-bambu-dark ${styles.icon}`}>
@@ -74,7 +81,12 @@ export function ConfirmModal({
</div>
</div>
<div className="flex gap-3 mt-6">
<Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
<Button
variant={cancelVariant ?? 'secondary'}
onClick={onCancel}
className="flex-1"
disabled={isLoading}
>
{resolvedCancelText}
</Button>
<Button
+4
View File
@@ -958,6 +958,7 @@ export default {
maintenanceTypes: 'Wartungstypen',
maintenanceTypesDescription: 'Systemtypen und Ihre benutzerdefinierten Wartungsaufgaben',
addCustomType: 'Benutzerdefinierten Typ hinzufügen',
restoreDefaults: 'Standardaufgaben wiederherstellen',
intervalType: 'Intervalltyp',
intervalValue: 'Intervall ({{type}})',
icon: 'Symbol',
@@ -1001,11 +1002,14 @@ export default {
maintenanceComplete: 'Wartung als abgeschlossen markiert',
typeUpdated: 'Wartungstyp aktualisiert',
typeDeleted: 'Wartungstyp gelöscht',
defaultsRestored: '{{count}} Standardaufgabe(n) wiederhergestellt',
printHoursUpdated: 'Druckstunden aktualisiert',
printerAssigned: 'Drucker zugewiesen',
printerRemoved: 'Drucker entfernt',
// Confirmation
deleteTypeConfirm: '"{{name}}" löschen?',
deleteSystemTypeTitle: 'Standard-Wartungsaufgabe löschen?',
deleteSystemTypeMessage: 'Möchten Sie die Standard-Wartungsaufgabe "{{name}}" wirklich löschen?',
// Permissions
noPermissionUpdate: 'Sie haben keine Berechtigung, Wartungselemente zu aktualisieren',
noPermissionPerform: 'Sie haben keine Berechtigung, Wartungen durchzuführen',
+4
View File
@@ -958,6 +958,7 @@ export default {
maintenanceTypes: 'Maintenance Types',
maintenanceTypesDescription: 'System types and your custom maintenance tasks',
addCustomType: 'Add Custom Type',
restoreDefaults: 'Restore Default Tasks',
intervalType: 'Interval Type',
intervalValue: 'Interval ({{type}})',
icon: 'Icon',
@@ -1001,11 +1002,14 @@ export default {
maintenanceComplete: 'Maintenance marked as complete',
typeUpdated: 'Maintenance type updated',
typeDeleted: 'Maintenance type deleted',
defaultsRestored: 'Restored {{count}} default task(s)',
printHoursUpdated: 'Print hours updated',
printerAssigned: 'Printer assigned',
printerRemoved: 'Printer removed',
// Confirmation
deleteTypeConfirm: 'Delete "{{name}}"?',
deleteSystemTypeTitle: 'Delete default maintenance task?',
deleteSystemTypeMessage: 'Are you sure you want to delete the default maintenance task "{{name}}"?',
// Permissions
noPermissionUpdate: 'You do not have permission to update maintenance items',
noPermissionPerform: 'You do not have permission to perform maintenance',
+4
View File
@@ -945,6 +945,7 @@ export default {
maintenanceTypes: 'Tipi di manutenzione',
maintenanceTypesDescription: 'Tipi di sistema e tue attivita personalizzate',
addCustomType: 'Aggiungi tipo personalizzato',
restoreDefaults: 'Ripristina attivita predefinite',
intervalType: 'Tipo intervallo',
intervalValue: 'Intervallo ({{type}})',
icon: 'Icona',
@@ -988,11 +989,14 @@ export default {
maintenanceComplete: 'Manutenzione segnata come completata',
typeUpdated: 'Tipo manutenzione aggiornato',
typeDeleted: 'Tipo manutenzione eliminato',
defaultsRestored: 'Ripristinate {{count}} attivita predefinite',
printHoursUpdated: 'Ore di stampa aggiornate',
printerAssigned: 'Stampante assegnata',
printerRemoved: 'Stampante rimossa',
// Confirmation
deleteTypeConfirm: 'Eliminare "{{name}}"?',
deleteSystemTypeTitle: 'Eliminare attività di manutenzione predefinita?',
deleteSystemTypeMessage: 'Sei sicuro di voler eliminare l\'attività di manutenzione predefinita "{{name}}"?',
// Permissions
noPermissionUpdate: 'Non hai il permesso di aggiornare elementi manutenzione',
noPermissionPerform: 'Non hai il permesso di eseguire manutenzione',
+4
View File
@@ -1001,6 +1001,7 @@ export default {
months: '{{count}}ヶ月',
maintenanceTypes: 'メンテナンスタイプ',
addCustomType: 'カスタムタイプを追加',
restoreDefaults: 'デフォルトタスクを復元',
intervalType: 'インターバルタイプ',
icon: 'アイコン',
documentationLink: 'ドキュメントリンク(任意)',
@@ -1034,9 +1035,12 @@ export default {
},
typeUpdated: 'メンテナンスタイプを更新しました',
typeDeleted: 'メンテナンスタイプを削除しました',
defaultsRestored: 'デフォルトタスクを{{count}}件復元しました',
printerAssigned: 'プリンターを割り当てました',
printerRemoved: 'プリンターを削除しました',
deleteTypeConfirm: '「{{name}}」を削除しますか?',
deleteSystemTypeTitle: 'デフォルトのメンテナンスタスクを削除しますか?',
deleteSystemTypeMessage: 'デフォルトのメンテナンスタスク「{{name}}」を削除してもよろしいですか?',
noPermissionUpdate: 'メンテナンス記録を更新する権限がありません',
noPermissionPerform: 'メンテナンスを実行する権限がありません',
noPermissionEditTypes: 'メンテナンスタイプを編集する権限がありません',
+66 -8
View File
@@ -41,6 +41,7 @@ import type { MaintenanceStatus, PrinterMaintenanceOverview, MaintenanceType, Pe
import { Card, CardContent } from '../components/Card';
import { Button } from '../components/Button';
import { Toggle } from '../components/Toggle';
import { ConfirmModal } from '../components/ConfirmModal';
import { useToast } from '../contexts/ToastContext';
import { useAuth } from '../contexts/AuthContext';
@@ -552,6 +553,8 @@ function SettingsSection({
onAddType,
onUpdateType,
onDeleteType,
onRestoreDefaults,
isRestoringDefaults,
onAssignType,
onRemoveItem,
hasPermission,
@@ -563,6 +566,8 @@ function SettingsSection({
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;
onRestoreDefaults: () => void;
isRestoringDefaults: boolean;
onAssignType: (printerId: number, typeId: number) => void;
onRemoveItem: (itemId: number) => void;
hasPermission: (permission: Permission) => boolean;
@@ -579,6 +584,7 @@ function SettingsSection({
const [newTypeWikiUrl, setNewTypeWikiUrl] = useState('');
const [selectedPrinters, setSelectedPrinters] = useState<Set<number>>(new Set());
const [expandedType, setExpandedType] = useState<number | null>(null);
const [pendingSystemDelete, setPendingSystemDelete] = useState<MaintenanceType | null>(null);
// Get unique printers from overview
const printers = useMemo(() => {
@@ -697,14 +703,24 @@ function SettingsSection({
<h2 className="text-lg font-semibold text-white">{t('maintenance.maintenanceTypes')}</h2>
<p className="text-sm text-bambu-gray mt-1">{t('maintenance.maintenanceTypesDescription')}</p>
</div>
<Button
onClick={() => setShowAddType(!showAddType)}
disabled={!hasPermission('maintenance:create')}
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}
>
<Plus className="w-4 h-4" />
{t('maintenance.addCustomType')}
</Button>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={onRestoreDefaults}
disabled={!hasPermission('maintenance:delete') || isRestoringDefaults}
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}
>
{t('maintenance.restoreDefaults')}
</Button>
<Button
onClick={() => setShowAddType(!showAddType)}
disabled={!hasPermission('maintenance:create')}
title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}
>
<Plus className="w-4 h-4" />
{t('maintenance.addCustomType')}
</Button>
</div>
</div>
{/* Add custom type form */}
@@ -846,6 +862,17 @@ function SettingsSection({
{formatIntervalLabel(type.default_interval_hours, intervalType, t)}
</div>
</div>
<button
onClick={() => {
if (!hasPermission('maintenance:delete')) return;
setPendingSystemDelete(type);
}}
disabled={!hasPermission('maintenance:delete')}
title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}
className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
@@ -1124,6 +1151,23 @@ function SettingsSection({
</CardContent>
</Card>
)}
{pendingSystemDelete && (
<ConfirmModal
title={t('maintenance.deleteSystemTypeTitle')}
message={t('maintenance.deleteSystemTypeMessage', { name: pendingSystemDelete.name })}
confirmText={t('common.delete')}
cancelText={t('common.cancel')}
variant="danger"
cancelVariant="primary"
cardClassName="bg-red-950/70 border border-red-800/70"
onConfirm={() => {
onDeleteType(pendingSystemDelete.id);
setPendingSystemDelete(null);
}}
onCancel={() => setPendingSystemDelete(null)}
/>
)}
</div>
);
}
@@ -1199,6 +1243,18 @@ export function MaintenancePage() {
},
});
const restoreDefaultsMutation = useMutation({
mutationFn: api.restoreDefaultMaintenanceTypes,
onSuccess: (data: { restored: number }) => {
queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
showToast(t('maintenance.defaultsRestored', { count: data.restored }));
},
onError: (error: Error) => {
showToast(error.message, 'error');
},
});
const setHoursMutation = useMutation({
mutationFn: ({ printerId, hours }: { printerId: number; hours: number }) =>
api.setPrinterHours(printerId, hours),
@@ -1352,6 +1408,8 @@ export function MaintenancePage() {
}}
onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
onDeleteType={(id) => deleteTypeMutation.mutate(id)}
onRestoreDefaults={() => restoreDefaultsMutation.mutate()}
isRestoringDefaults={restoreDefaultsMutation.isPending}
onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
hasPermission={hasPermission}
+23 -2
View File
@@ -636,10 +636,11 @@ function NozzleSlotHoverCard({ slot, index, activeStatus, filamentName, children
}
// Dual-nozzle hover card showing L and R nozzle details side by side
function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, children }: {
function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, filamentInfo, children }: {
leftSlot?: import('../api/client').NozzleRackSlot;
rightSlot?: import('../api/client').NozzleRackSlot;
activeNozzle: 'L' | 'R';
filamentInfo?: Record<string, { name: string; k: number | null }>;
children: React.ReactNode;
}) {
const { t } = useTranslation();
@@ -684,6 +685,8 @@ function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, children }: {
const isActive = activeNozzle === side;
const typeFull = nozzleTypeName(slot.nozzle_type, t);
const flowFull = nozzleFlowName(slot.nozzle_type, t);
const filamentCss = parseFilamentColor(slot.filament_color);
const filamentName = slot.filament_id ? filamentInfo?.[slot.filament_id]?.name : undefined;
return (
<div className="flex-1 space-y-1.5">
<div className={`text-[10px] font-bold pb-1 border-b border-bambu-dark-tertiary/50 ${isActive ? 'text-amber-400' : 'text-bambu-gray'}`}>
@@ -736,6 +739,19 @@ function DualNozzleHoverCard({ leftSlot, rightSlot, activeNozzle, children }: {
<span className="text-[10px] text-white font-mono">{slot.serial_number}</span>
</div>
)}
{(filamentCss || slot.filament_type || slot.filament_id) && (
<div className="flex items-center justify-between">
<span className="text-[10px] text-bambu-gray">{t('printers.nozzleFilament')}</span>
<div className="flex items-center gap-1">
{filamentCss && (
<div className="w-3 h-3 rounded-sm border border-white/20" style={{ backgroundColor: filamentCss }} />
)}
<span className="text-[10px] text-white font-semibold truncate max-w-[100px]">
{filamentName || slot.filament_type || slot.filament_id || ''}
</span>
</div>
</div>
)}
</div>
);
};
@@ -2508,7 +2524,12 @@ function PrinterCard({
)}
{/* Active nozzle indicator for dual-nozzle printers */}
{isDualNozzle && (
<DualNozzleHoverCard leftSlot={leftNozzleSlot} rightSlot={rightNozzleSlot} activeNozzle={activeNozzle}>
<DualNozzleHoverCard
leftSlot={leftNozzleSlot}
rightSlot={rightNozzleSlot}
activeNozzle={activeNozzle}
filamentInfo={filamentInfo}
>
<div className="text-center px-3 py-1.5 bg-bambu-dark rounded-lg h-full flex flex-col justify-center items-center cursor-default" title={t('printers.activeNozzle', { nozzle: activeNozzle === 'L' ? t('common.left') : t('common.right') })}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-[11px] font-bold ${activeNozzle === 'L' ? 'text-amber-400' : 'text-gray-500'}`}>
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
View File
@@ -23,8 +23,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DIy2OgxD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DMk3iz3Q.css">
<script type="module" crossorigin src="/assets/index-IFV5nOKo.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DZOdnZuT.css">
</head>
<body>
<div id="root"></div>