import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Check, Edit2, Loader2, Printer as PrinterIcon, Search, Trash2, Workflow, X } from 'lucide-react'; import { api, type PipelineRun, type PresetRef, type PresetSource, type Printer as PrinterType, type SlicerPipeline, type UnifiedPresetsResponse, } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; import { useToast } from '../contexts/ToastContext'; // Resolve a PresetRef back to its pretty name via the unified-presets listing. // Returns null when the ref no longer points at a known preset — render a // "deleted" badge in that case so users can see what to fix. function resolveName(presets: UnifiedPresetsResponse | undefined, slot: 'printer' | 'process' | 'filament', ref: PresetRef): string | null { if (!presets) return null; const list = presets[ref.source]?.[slot] ?? []; const hit = list.find((p) => p.id === ref.id); return hit ? hit.name : null; } const SOURCE_LABEL: Record = { orca_cloud: 'Orca Cloud', cloud: 'Bambu Cloud', local: 'Imported', standard: 'Standard', }; export function SlicerPipelinesPanel() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { data: list, isLoading, error } = useQuery({ queryKey: ['slicer-pipelines'], queryFn: () => api.listSlicerPipelines(), }); // The unified presets endpoint is the source of pretty names for each // PresetRef. Same listing the SliceModal pulls — reused here to avoid a // second round-trip to the slicer registry. const { data: presets } = useQuery({ queryKey: ['slicer-presets'], queryFn: () => api.getSlicerPresets(), }); // Printers list for the target picker (PR B). const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: () => api.getPrinters(), }); const updateMutation = useMutation({ mutationFn: ({ id, name, description, target_printer_id, target_kind, target_model_class, fanout_strategy, }: { id: number; name?: string; description?: string | null; target_printer_id?: number | null; target_kind?: 'specific_printer' | 'printer_class'; target_model_class?: string | null; fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin'; }) => api.updateSlicerPipeline(id, { name, description, target_printer_id, target_kind, target_model_class, fanout_strategy, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] }); showToast(t('settings.pipelines.toast.saved', 'Pipeline saved'), 'success'); }, onError: (err: Error) => { showToast(err.message || t('settings.pipelines.toast.saveFailed', 'Save failed'), 'error'); }, }); const deleteMutation = useMutation({ mutationFn: (id: number) => api.deleteSlicerPipeline(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] }); showToast(t('settings.pipelines.toast.deleted', 'Pipeline deleted'), 'success'); }, onError: (err: Error) => { showToast(err.message || t('settings.pipelines.toast.deleteFailed', 'Delete failed'), 'error'); }, }); // Panel-level search + filter (#1425 PR C polish). Filters by pipeline name // (case-insensitive substring) and by target — the dropdown lists every // distinct target in use across the saved pipelines so operators can jump // straight to "show me everything for X1C #2" or "everything for the H2D // class". State is local — list is small enough that re-rendering on every // keystroke is fine. const [searchTerm, setSearchTerm] = useState(''); // Encoded target filter value: '' = all, 'none' = no target set, // 'p:' = specific printer, 'c:' = printer class. const [targetFilter, setTargetFilter] = useState(''); const allPipelines = useMemo(() => list?.pipelines ?? [], [list?.pipelines]); // Build the dropdown's options from the targets actually in use. Only // printers / classes that at least one pipeline points at appear — keeps // the dropdown short and meaningful for installs with many printers but // few pipelines. const targetOptions = useMemo(() => { const printerIds = new Set(); const classes = new Set(); let anyWithoutTarget = false; for (const p of allPipelines) { if (p.target_kind === 'printer_class' && p.target_model_class) { classes.add(p.target_model_class); } else if (p.target_printer_id) { printerIds.add(p.target_printer_id); } else { anyWithoutTarget = true; } } return { printers: (printers ?? []).filter((pr) => printerIds.has(pr.id)), classes: Array.from(classes).sort(), anyWithoutTarget, }; }, [allPipelines, printers]); const pipelines = useMemo(() => { const term = searchTerm.trim().toLowerCase(); return allPipelines.filter((p) => { if (term && !p.name.toLowerCase().includes(term)) return false; if (targetFilter === 'none') { const hasTarget = p.target_kind === 'printer_class' ? !!p.target_model_class : p.target_printer_id !== null; if (hasTarget) return false; } else if (targetFilter.startsWith('p:')) { const wantId = parseInt(targetFilter.slice(2), 10); if (p.target_kind === 'printer_class' || p.target_printer_id !== wantId) return false; } else if (targetFilter.startsWith('c:')) { const wantClass = targetFilter.slice(2); if (p.target_kind !== 'printer_class' || p.target_model_class !== wantClass) return false; } return true; }); }, [allPipelines, searchTerm, targetFilter]); return (

{t('settings.pipelines.title', 'Slicer Pipelines')}

{t( 'settings.pipelines.subtitle', 'Reusable preset bundles (printer + process + filaments + bed type). Save one from the Slice dialog and apply it with a single click on the next file.', )}

{isLoading && (
{t('settings.pipelines.loading', 'Loading pipelines…')}
)} {error && (
{t('settings.pipelines.loadError', 'Could not load pipelines.')}
)} {/* Search + target-type filter. Only render when there are pipelines to filter; the empty-state hint reads better without controls. */} {!isLoading && !error && allPipelines.length > 0 && (
setSearchTerm(e.target.value)} placeholder={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')} aria-label={t('settings.pipelines.searchPlaceholder', 'Search pipelines…')} className="w-full pl-7 pr-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white" />
{(searchTerm || targetFilter) && ( {t('settings.pipelines.filter.count', '{{shown}} / {{total}}', { shown: pipelines.length, total: allPipelines.length, })} )}
)} {!isLoading && !error && allPipelines.length === 0 && (

{t('settings.pipelines.empty.title', 'No pipelines yet.')}

{t( 'settings.pipelines.empty.howto', 'Open the Slice dialog for any file, pick your printer / process / filaments / bed type, then click "Save as pipeline". Your saved pipelines will appear here.', )}

)} {!isLoading && !error && allPipelines.length > 0 && pipelines.length === 0 && (

{t('settings.pipelines.filter.noMatches', 'No pipelines match the current filters.')}

)} {!isLoading && !error && pipelines.length > 0 && (
{pipelines.map((p) => ( updateMutation.mutate({ id: p.id, ...payload })} onDelete={() => { if (confirm(t('settings.pipelines.confirmDelete', 'Delete this pipeline? This cannot be undone.'))) { deleteMutation.mutate(p.id); } }} saving={updateMutation.isPending} deleting={deleteMutation.isPending} /> ))}
)}
); } function PipelineRow({ pipeline, presets, printers, onSave, onDelete, saving, deleting, }: { pipeline: SlicerPipeline; presets: UnifiedPresetsResponse | undefined; printers: PrinterType[]; onSave: (payload: { name?: string; description?: string | null; target_printer_id?: number | null; target_kind?: 'specific_printer' | 'printer_class'; target_model_class?: string | null; fanout_strategy?: 'max_parallel' | 'fill_one_first' | 'round_robin'; }) => void; onDelete: () => void; saving: boolean; deleting: boolean; }) { const { t } = useTranslation(); const [editing, setEditing] = useState(false); const [draftName, setDraftName] = useState(pipeline.name); const [draftDescription, setDraftDescription] = useState(pipeline.description ?? ''); const [draftTargetPrinterId, setDraftTargetPrinterId] = useState( pipeline.target_printer_id, ); // PR C: target kind, model class, and fanout strategy. const [draftTargetKind, setDraftTargetKind] = useState<'specific_printer' | 'printer_class'>( pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer', ); const [draftTargetModelClass, setDraftTargetModelClass] = useState( pipeline.target_model_class ?? '', ); const [draftFanout, setDraftFanout] = useState<'max_parallel' | 'fill_one_first' | 'round_robin'>( pipeline.fanout_strategy ?? 'max_parallel', ); // Installed model classes — derived from the loaded printers list so the // dropdown only offers models the user actually has. Same data the row // header uses, no second fetch. const installedModels = Array.from( new Set(printers.map((p) => p.model).filter((m): m is string => !!m)), ).sort(); // Recent runs for the inline last-run summary. ``enabled: editing === false`` // avoids re-querying every keystroke while the editor is open. const { data: runsList } = useQuery({ queryKey: ['pipeline-runs', pipeline.id], queryFn: () => api.listPipelineRuns(pipeline.id, 1), enabled: !editing, refetchInterval: 15_000, }); const lastRun: PipelineRun | undefined = runsList?.runs?.[0]; const printerName = resolveName(presets, 'printer', pipeline.printer_preset); const processName = resolveName(presets, 'process', pipeline.process_preset); const filamentResolutions = pipeline.filament_presets.map((f) => resolveName(presets, 'filament', f)); // Collapse identical filaments into a single "All N slots" line — most // production pipelines load the same filament into every AMS slot, and // listing the same line three times is just noise. Compares raw preset // refs (source + id) rather than resolved names so the dedup is correct // even when ``presets`` hasn't loaded yet. const filamentsAllIdentical = pipeline.filament_presets.length > 1 && pipeline.filament_presets.every( (f) => f.source === pipeline.filament_presets[0].source && f.id === pipeline.filament_presets[0].id, ); const hasStaleRef = presets !== undefined && (printerName === null || processName === null || filamentResolutions.some((n) => n === null)); const targetPrinter = pipeline.target_printer_id ? printers.find((p) => p.id === pipeline.target_printer_id) : undefined; const isClassTargeting = pipeline.target_kind === 'printer_class'; const needsTarget = isClassTargeting ? !pipeline.target_model_class : pipeline.target_printer_id === null; const handleSave = () => { const trimmedName = draftName.trim(); if (!trimmedName) return; onSave({ name: trimmedName, description: draftDescription.trim() || null, target_kind: draftTargetKind, // Backend treats 0 as "clear"; null in TS maps to that intent. target_printer_id: draftTargetKind === 'specific_printer' ? (draftTargetPrinterId ?? 0) : 0, target_model_class: draftTargetKind === 'printer_class' ? (draftTargetModelClass || null) : null, fanout_strategy: draftFanout, }); setEditing(false); }; const handleCancel = () => { setDraftName(pipeline.name); setDraftDescription(pipeline.description ?? ''); setDraftTargetPrinterId(pipeline.target_printer_id); setDraftTargetKind(pipeline.target_kind === 'printer_class' ? 'printer_class' : 'specific_printer'); setDraftTargetModelClass(pipeline.target_model_class ?? ''); setDraftFanout(pipeline.fanout_strategy ?? 'max_parallel'); setEditing(false); }; return (
{editing ? (
setDraftName(e.target.value)} aria-label={t('settings.pipelines.field.name', 'Pipeline name')} placeholder={t('settings.pipelines.field.name', 'Pipeline name')} className="w-full px-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white" />