| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233 |
- /**
- * 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';
- 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<SliceJobTrackerContextValue | null>(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<TrackedJob[]>([]);
- // Stable mutable ref so the polling effect can read the current list
- // without re-subscribing every time it changes.
- const activeJobsRef = useRef<TrackedJob[]>([]);
- 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<Map<number, number>>(new Map());
- const phaseRef = useRef<Map<number, SliceJobStatus>>(new Map());
- const progressRef = useRef<Map<number, SliceJobProgress | null>>(new Map());
- 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) {
- showPersistentToast(
- toastIdFor(job.id),
- t(
- 'slice.runningWithProgress',
- '{{name}} — {{stage}} ({{percent}}%) — {{elapsed}}',
- {
- name: prettifyFilename(job.sourceName),
- stage: progress.stage,
- percent: Math.min(100, Math.max(0, Math.round(progress.total_percent))),
- elapsed: elapsedStr,
- },
- ),
- '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 }]));
- 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) => {
- 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',
- );
- } else if (state.status === 'failed') {
- const detail = state.error_detail || t('slice.failed');
- showToast(t('slice.failedToast', 'Slicing {{name}} failed: {{detail}}', { name: prettifyFilename(job.sourceName), detail }), 'error');
- }
- // 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;
- const interval = setInterval(async () => {
- if (cancelled) return;
- const snapshot = [...activeJobsRef.current];
- for (const job of snapshot) {
- try {
- const state = await api.getSliceJob(job.id);
- 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.
- }
- }
- }, 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 (
- <SliceJobTrackerContext.Provider value={{ trackJob, activeJobs }}>
- {children}
- </SliceJobTrackerContext.Provider>
- );
- }
- export function useSliceJobTracker(): SliceJobTrackerContextValue {
- const ctx = useContext(SliceJobTrackerContext);
- if (!ctx) {
- throw new Error('useSliceJobTracker must be used inside SliceJobTrackerProvider');
- }
- return ctx;
- }
|