/** * Background slice-job tracker. * * SliceModal calls `trackJob(id, kind)` after enqueuing and closes * immediately. This context keeps the job-id list, polls each one, and * shows toasts on terminal state. Lives at app level so polling continues * across navigation — slice can run in the background while the user does * other things. * * Each tracked job also gets a persistent toast (`slice-job-{id}`) with a * spinner + elapsed-time counter that updates every second so the user has * a continuous visual indicator while a long slice is running. The toast * is replaced by a transient success/error toast on terminal state. */ import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; import { api, type SliceJobProgress, type SliceJobState, type SliceJobStatus } from '../api/client'; import { useToast } from './ToastContext'; import { AlertModal } from '../components/AlertModal'; interface TrackedJob { id: number; kind: 'libraryFile' | 'archive'; sourceName: string; } interface SliceJobTrackerContextValue { trackJob: (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => void; activeJobs: TrackedJob[]; } const SliceJobTrackerContext = createContext(null); const POLL_INTERVAL_MS = 1500; const TICK_INTERVAL_MS = 1000; const toastIdFor = (jobId: number) => `slice-job-${jobId}`; /** Decode percent-encoded characters in a filename so the toast doesn't * show `stormtrooper-helmet%20h2d.3mf` for files that came from a source * with URL-encoded names (MakerWorld API, S3 path tails, etc.). The * MakerWorld import path now decodes at persist time, but already-imported * rows still carry the encoded form — this is a belt-and-suspenders * decode at display time so old rows look right too. Wrapped in try/catch * because malformed encodings (`%XY` where XY isn't hex) throw URIError. */ function prettifyFilename(name: string): string { try { return decodeURIComponent(name); } catch { return name; } } function formatElapsed(seconds: number): string { const s = Math.max(0, Math.floor(seconds)); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); const remS = s % 60; if (m < 60) return `${m}m ${remS}s`; const h = Math.floor(m / 60); const remM = m % 60; return `${h}h ${remM}m`; } export function SliceJobTrackerProvider({ children }: { children: ReactNode }) { const { t } = useTranslation(); const { showToast, showPersistentToast, dismissToast } = useToast(); const queryClient = useQueryClient(); const [activeJobs, setActiveJobs] = useState([]); // A failed slice surfaces as an acknowledge-only modal, not a toast: the // slicer's reason (e.g. "objects over the bed boundary") is actionable and // a 3s toast hides it before it can be read. const [sliceError, setSliceError] = useState<{ name: string; detail: string } | null>(null); // Stable mutable ref so the polling effect can read the current list // without re-subscribing every time it changes. const activeJobsRef = useRef([]); activeJobsRef.current = activeJobs; // Per-job start time, latest phase, and latest progress snapshot, // kept in refs so the 1s tick doesn't need to re-render on every // update. Keyed by job id. const startedAtRef = useRef>(new Map()); const phaseRef = useRef>(new Map()); const progressRef = useRef>(new Map()); // Job ids whose terminal state has already been handled. `completeJob` // shows a toast and invalidates two query keys, so it has to be exactly // once per job no matter how many callers reach it — see the poll loop // below for how more than one used to. const finishedRef = useRef>(new Set()); const renderProgressToast = useCallback( (job: TrackedJob) => { const startedAt = startedAtRef.current.get(job.id); if (startedAt == null) return; const elapsedSecs = (Date.now() - startedAt) / 1000; const phase = phaseRef.current.get(job.id) ?? 'pending'; const elapsedStr = formatElapsed(elapsedSecs); const progress = progressRef.current.get(job.id) ?? null; // When the sidecar has emitted at least one progress frame, weave // the stage label + percent into the toast — that's what makes the // wait feel professional ("Generating G-code 75%" beats "Slicing X // — 47s"). Falls back to the elapsed-time-only message in three // cases: queued/pending phase before the slicer has started, // missing or zero progress (Initializing), or sidecar without // --pipe support. const hasUseful = progress && progress.stage && progress.total_percent > 0; if (phase === 'running' && hasUseful) { const name = prettifyFilename(job.sourceName); const stage = progress.stage; const percent = Math.min(100, Math.max(0, Math.round(progress.total_percent))); // Cross-class slice-all (#1493) feeds the same toast through N // sequential per-plate slices; the augmented snapshot tells us // which plate is currently running so the user sees the loop // progress, not just a single repeating bar. const isMultiPlateLoop = typeof progress.multi_plate_index === 'number' && typeof progress.multi_plate_count === 'number' && progress.multi_plate_count > 1; const message = isMultiPlateLoop ? t( 'slice.runningWithProgressMultiPlate', 'Plate {{plateIndex}} of {{plateCount}} • {{name}} — {{stage}} ({{percent}}%) — {{elapsed}}', { plateIndex: progress.multi_plate_index, plateCount: progress.multi_plate_count, name, stage, percent, elapsed: elapsedStr, }, ) : t( 'slice.runningWithProgress', '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}', { name, stage, percent, elapsed: elapsedStr }, ); showPersistentToast(toastIdFor(job.id), message, 'loading'); return; } const messageKey = phase === 'pending' ? 'slice.queuedToast' : 'slice.runningToast'; const fallback = phase === 'pending' ? 'Queued: {{name}} — {{elapsed}}' : 'Slicing {{name}} — {{elapsed}}'; showPersistentToast( toastIdFor(job.id), t(messageKey, fallback, { name: prettifyFilename(job.sourceName), elapsed: elapsedStr }), 'loading', ); }, [showPersistentToast, t], ); const trackJob = useCallback( (id: number, kind: 'libraryFile' | 'archive', sourceName: string) => { setActiveJobs((prev) => (prev.some((j) => j.id === id) ? prev : [...prev, { id, kind, sourceName }])); // Re-tracking an id re-arms it. Ids come from a database sequence so // this can't collide in practice; clearing here is what keeps the set // from being a permanent record of every job the session ever saw. finishedRef.current.delete(id); startedAtRef.current.set(id, Date.now()); phaseRef.current.set(id, 'pending'); progressRef.current.set(id, null); // Render the initial frame immediately so the user sees the toast // before the first tick lands (~1s delay otherwise). renderProgressToast({ id, kind, sourceName }); }, [renderProgressToast], ); const completeJob = useCallback( (job: TrackedJob, state: SliceJobState) => { // Guard, not an optimisation: everything below is a side effect the // user sees, and a second call would repeat all of it. if (finishedRef.current.has(job.id)) return; finishedRef.current.add(job.id); setActiveJobs((prev) => prev.filter((j) => j.id !== job.id)); startedAtRef.current.delete(job.id); phaseRef.current.delete(job.id); progressRef.current.delete(job.id); // Replace the persistent progress toast with a transient // success/error toast (auto-dismisses after 3s, same as showToast). dismissToast(toastIdFor(job.id)); if (state.status === 'completed') { // `used_embedded_settings` still comes back on the result for tests // and observability, but the warning toast that surfaced it was // firing on essentially every slice (3MF inputs trigger the // embedded-settings fallback as a normal path) and just added // noise — see the trailing yellow toast complaint, removed. showToast( t('slice.completedToast', 'Sliced {{name}}', { name: prettifyFilename(job.sourceName) }), 'success', ); // The result normally lands next to its source, including on an // external mount. When the mount can't take it the file is still // kept — in managed storage — but the user has to be told, or they // go looking on the share and find nothing (#2810). const fallback = state.result && 'external_write_fallback' in state.result ? state.result.external_write_fallback : null; if (fallback) { showToast( t( 'slice.externalWriteFallbackToast', 'Saved to the internal library: the external folder could not be written to', ), 'warning', ); } } else if (state.status === 'failed') { setSliceError({ name: prettifyFilename(job.sourceName), detail: state.error_detail || t('slice.failed'), }); } // Refresh whichever list owns the result. Both are cheap to invalidate. queryClient.invalidateQueries({ queryKey: ['library-files'] }); queryClient.invalidateQueries({ queryKey: ['archives'] }); }, [dismissToast, queryClient, showToast, t], ); // Status polling. Updates phase on each successful poll and triggers // completeJob on terminal states. useEffect(() => { if (activeJobs.length === 0) return; let cancelled = false; // setInterval does not await an async callback, so a tick fires whether // or not the previous one came back. Slicing a large project blocks the // backend for seconds at a time (zip parsing and output assembly are // synchronous), and every tick that piled up during the stall had // already captured a snapshot naming the job as active. They all // resolved `completed` together and each called completeJob, which is // how one slice produced a stream of a dozen "Sliced X" toasts. Letting // only one poll round be in flight fixes that at the source, and stops // queueing requests against a backend that is already saturated. let polling = false; const interval = setInterval(async () => { if (cancelled || polling) return; polling = true; try { const snapshot = [...activeJobsRef.current]; for (const job of snapshot) { try { const state = await api.getSliceJob(job.id); // The tracker may have been torn down while this was in flight. if (cancelled) return; phaseRef.current.set(job.id, state.status); // Capture the latest progress snapshot if the sidecar fed // one through. The 1s tick re-renders the toast off this ref. if (state.progress) { progressRef.current.set(job.id, state.progress); } if (state.status === 'completed' || state.status === 'failed') { completeJob(job, state); } } catch { // Transient poll failure — stay tracked, retry next tick. } } } finally { polling = false; } }, POLL_INTERVAL_MS); return () => { cancelled = true; clearInterval(interval); }; }, [activeJobs.length, completeJob]); // 1Hz tick that re-renders each persistent progress toast with the // current elapsed time. Independent of the status poll so the counter // stays smooth even while the backend is slow to respond. useEffect(() => { if (activeJobs.length === 0) return; const tick = setInterval(() => { for (const job of activeJobsRef.current) { renderProgressToast(job); } }, TICK_INTERVAL_MS); return () => clearInterval(tick); }, [activeJobs.length, renderProgressToast]); return ( {children} {sliceError && ( setSliceError(null)} /> )} ); } export function useSliceJobTracker(): SliceJobTrackerContextValue { const ctx = useContext(SliceJobTrackerContext); if (!ctx) { throw new Error('useSliceJobTracker must be used inside SliceJobTrackerProvider'); } return ctx; }