import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, Cog, Loader2, Play, Printer as PrinterIcon, X } from 'lucide-react'; import { api, type PipelineEligibilityReport, type Printer as PrinterType, type SlicerPipeline, } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { useSliceJobTracker } from '../contexts/SliceJobTrackerContext'; // Same source-kind shape SliceModal uses, so the same library-file vs archive // distinction flows through eligibility-check, run dispatch, AND the progress // toast tracker. export type RunPipelineSource = | { kind: 'libraryFile'; id: number; filename: string } | { kind: 'archive'; id: number; filename: string }; export interface RunWithPipelineModalProps { source: RunPipelineSource; onClose: () => void; } // Two-step modal. Step 1: pick a pipeline. Step 2: confirm eligibility // (skipped when ok=true) and run. Lives in two views in the same modal so // the user keeps context — most production runs hit the green path and // never see step 2. export function RunWithPipelineModal({ source, onClose }: RunWithPipelineModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const [picked, setPicked] = useState(null); const [report, setReport] = useState(null); const [copies, setCopies] = useState(1); const { trackJob } = useSliceJobTracker(); const { data: list, isLoading: pipelinesLoading } = useQuery({ queryKey: ['slicer-pipelines'], queryFn: () => api.listSlicerPipelines(), }); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: () => api.getPrinters(), }); // Cap from settings (PR C). Falls back to 50 when the fetch is in-flight or // missing — same default the backend writes. const { data: settings } = useQuery({ queryKey: ['app-settings'], queryFn: () => api.getSettings(), }); const maxCopies = settings?.pipeline_max_copies ?? 50; const sourceRef = { kind: source.kind, id: source.id } as const; const checkMutation = useMutation({ mutationFn: (pipelineId: number) => api.checkPipelineEligibility(pipelineId, sourceRef), }); const runMutation = useMutation({ mutationFn: ({ pipelineId, force }: { pipelineId: number; force: boolean }) => api.runPipeline(pipelineId, sourceRef, force, copies), onSuccess: (run) => { queryClient.invalidateQueries({ queryKey: ['slicer-pipelines'] }); queryClient.invalidateQueries({ queryKey: ['pipeline-runs'] }); // Hand the slice job off to the existing tracker so the same persistent // progress toast renders for pipeline runs as for manual SliceModal // slices — no separate notification surface. if (run.slice_job_id) { trackJob(run.slice_job_id, source.kind, source.filename); } showToast(t('library.runWithPipeline.toast.started', 'Pipeline run started'), 'success'); onClose(); }, onError: (err: Error) => { showToast(err.message || t('library.runWithPipeline.toast.failed', 'Could not start run'), 'error'); }, }); const pipelines = list?.pipelines ?? []; const printerById: Record = (printers ?? []).reduce((acc, p) => { acc[p.id] = p; return acc; }, {} as Record); const handlePick = async (pipeline: SlicerPipeline) => { const hasTarget = pipeline.target_printer_id || (pipeline.target_kind === 'printer_class' && pipeline.target_model_class); if (!hasTarget) { showToast( t('library.runWithPipeline.noTargetMessage', 'This pipeline has no target printer set. Open it in Settings to pick one.'), 'error', ); return; } setPicked(pipeline); try { const result = await checkMutation.mutateAsync(pipeline.id); setReport(result); if (result.ok) { runMutation.mutate({ pipelineId: pipeline.id, force: false }); } } catch { // Network error — keep the user on step 1 so they can retry. setPicked(null); } }; const handleConfirm = () => { if (!picked) return; runMutation.mutate({ pipelineId: picked.id, force: true }); }; const handleBack = () => { setPicked(null); setReport(null); }; return (
e.stopPropagation()} >

{picked && report ? t('library.runWithPipeline.confirmTitle', 'Confirm run') : t('library.runWithPipeline.modalTitle', 'Run with pipeline')}

{picked && report ? ( ) : ( )}
); } function PickStep({ source, pipelines, printerById, loading, onPick, copies, maxCopies, onCopiesChange, }: { source: { filename: string }; pipelines: SlicerPipeline[]; printerById: Record; loading: boolean; onPick: (p: SlicerPipeline) => void; copies: number; maxCopies: number; onCopiesChange: (n: number) => void; }) { const { t } = useTranslation(); return ( <>

{t('library.runWithPipeline.sourceHint', 'Source')}:{' '} {source.filename}

{ const n = parseInt(e.target.value, 10); if (Number.isNaN(n)) return; onCopiesChange(Math.max(1, Math.min(maxCopies, n))); }} aria-label={t('library.runWithPipeline.copies', 'Copies')} className="w-20 px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary rounded text-white" /> {t('library.runWithPipeline.copiesHint', 'max {{n}}', { n: maxCopies })}
{loading && (
{t('library.runWithPipeline.loading', 'Loading…')}
)} {!loading && pipelines.length === 0 && (

{t( 'library.runWithPipeline.empty', 'No pipelines saved yet. Open the Slice dialog and click "Save as pipeline" to create one.', )}

)} {!loading && pipelines.length > 0 && (
    {pipelines.map((p) => { const isClass = p.target_kind === 'printer_class'; const targetName = p.target_printer_id ? printerById[p.target_printer_id]?.name : null; const classLabel = isClass && p.target_model_class ? t('library.runWithPipeline.classTarget', 'Any {{model}}', { model: p.target_model_class }) : null; const hasTarget = !!(p.target_printer_id || (isClass && p.target_model_class)); return (
  • ); })}
)} ); } function ConfirmStep({ pipeline, report, source, onBack, onConfirm, running, }: { pipeline: SlicerPipeline; report: PipelineEligibilityReport; source: { filename: string }; onBack: () => void; onConfirm: () => void; running: boolean; }) { const { t } = useTranslation(); return ( <>

{t('library.runWithPipeline.confirmIntro', 'Pre-flight found issues with this run')}:

{t('library.runWithPipeline.sourceHint', 'Source')}: {source.filename}

{t('library.runWithPipeline.pipelineHint', 'Pipeline')}: {pipeline.name}

{report.target_printer_name && (

{t('library.runWithPipeline.targetHint', 'Target')}: {report.target_printer_name}

)}
    {report.issues.map((issue, idx) => (
  • ))}
); } function IssueText({ issue }: { issue: PipelineEligibilityReport['issues'][number] }) { const { t } = useTranslation(); switch (issue.kind) { case 'printer_not_set': return <>{t('library.runWithPipeline.issue.printerNotSet', 'No target printer set on this pipeline.')}; case 'printer_not_found': return <>{t('library.runWithPipeline.issue.printerNotFound', 'Target printer no longer exists.')}; case 'printer_disabled': return <>{t('library.runWithPipeline.issue.printerDisabled', 'Target printer is disabled.')}; case 'printer_offline': return <>{t('library.runWithPipeline.issue.printerOffline', 'Target printer is offline.')}; case 'filament_type_mismatch': return ( <> {t('library.runWithPipeline.issue.filamentType', 'Filament slot {{slot}}: expected {{expected}}, AMS has {{actual}}', { slot: (issue.slot_index ?? 0) + 1, expected: issue.expected ?? '?', actual: issue.actual ?? '?', })} ); case 'filament_color_mismatch': return ( <> {t('library.runWithPipeline.issue.filamentColor', 'Filament slot {{slot}}: colour differs (expected {{expected}}, AMS has {{actual}})', { slot: (issue.slot_index ?? 0) + 1, expected: issue.expected ?? '?', actual: issue.actual ?? '?', })} ); case 'ams_slot_missing': return ( <> {t('library.runWithPipeline.issue.amsSlotMissing', 'AMS slot {{slot}} not available on this printer', { slot: (issue.slot_index ?? 0) + 1, })} ); case 'filament_unverified': return ( <> {t('library.runWithPipeline.issue.filamentUnverified', 'Filament slot {{slot}} comes from a cloud / standard preset and could not be statically verified.', { slot: (issue.slot_index ?? 0) + 1, })} ); default: return <>{issue.kind}; } }