3b827b80d5
Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.
services/uploads.service.ts (new)
- getStatus(uploadId) — JSON snapshot from /admin/uploads/:id/status
- retryPhoto(photoId) — POST /admin/photos/:id/retry
- streamUrl(uploadId) — SSE upgrade URL
hooks/useUploadProgress.ts (new)
- Tracks N concurrent upload IDs (one per chunk POST) and merges
counters into a single aggregate.
- Always polls every 1.5s; opportunistic SSE upgrade on top of that.
SSE failure (proxy buffering, etc.) silently downgrades to polling
only — no reconnect storms.
- Auto-stops both channels when every tracked group is in a terminal
(complete/failed) state.
components/admin/PhotoUpload.tsx
- Captures upload_id from each chunk's 202 response, feeds them into
useUploadProgress.
- Phase machine extended: stays in 'processing' until the worker
drains the queue (not just until bytes-on-wire). Progress UI shows
real "X of N done" with a determinate bar fed by the aggregate.
- "You can leave this page" hint kept — closing the modal is now
actually safe, work continues server-side.
- Side-effect refactor: invokes onUploadComplete twice — once early
so the user sees photos appearing immediately, once on terminal
so the parent grid sees final state.
components/admin/AdminPhotoGrid.tsx
- Photos with processing_status pending/processing render an amber
placeholder card with a spinning Cog instead of the missing
thumbnail.
- Photos with status='failed' render a red card with the error message
and a "Retry" button that POSTs /admin/photos/:id/retry.
pages/admin/EventDetailsPage.tsx
- Photo list query gains refetchInterval that polls every 2s while
any photo is non-terminal, then stops. Keeps the grid auto-fresh
during ongoing processing.
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
uploadsService,
|
|
type UploadStatusSnapshot,
|
|
} from '../services/uploads.service';
|
|
|
|
interface UseUploadProgressOptions {
|
|
/**
|
|
* If false, the hook does nothing (used to "pause" tracking when no
|
|
* upload is in progress). Default: true.
|
|
*/
|
|
enabled?: boolean;
|
|
/**
|
|
* Polling interval in ms (used always as a fallback, and as the
|
|
* primary channel when SSE is unavailable). Default: 1500.
|
|
*/
|
|
pollIntervalMs?: number;
|
|
/**
|
|
* If true, attempts an SSE upgrade for low-latency updates and
|
|
* falls back to polling when the stream errors. Default: true.
|
|
*/
|
|
preferStream?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Tracks an upload group's processing state. Returns a merged snapshot
|
|
* across all upload IDs the caller passes in (admin upload modal sends
|
|
* each chunk as its own upload_id; this hook merges their counters).
|
|
*
|
|
* The hook is resilient: it always polls in the background and uses
|
|
* SSE (when available and not disabled) as a faster supplementary
|
|
* channel. Either source landing on a terminal state stops the hook.
|
|
*/
|
|
export function useUploadProgress(
|
|
uploadIds: string[],
|
|
{ enabled = true, pollIntervalMs = 1500, preferStream = true }: UseUploadProgressOptions = {}
|
|
) {
|
|
const [snapshots, setSnapshots] = useState<Record<string, UploadStatusSnapshot | null>>({});
|
|
const [error, setError] = useState<Error | null>(null);
|
|
const eventSourcesRef = useRef<Record<string, EventSource>>({});
|
|
// Stable string key so we re-trigger the effect only when the actual
|
|
// set of IDs changes (parents may pass a new array each render).
|
|
const idsKey = uploadIds.join('|');
|
|
|
|
useEffect(() => {
|
|
if (!enabled || uploadIds.length === 0) {
|
|
return undefined;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const pollHandles: Record<string, ReturnType<typeof setTimeout>> = {};
|
|
|
|
const closeStream = (uploadId: string) => {
|
|
const es = eventSourcesRef.current[uploadId];
|
|
if (es) {
|
|
es.close();
|
|
delete eventSourcesRef.current[uploadId];
|
|
}
|
|
};
|
|
|
|
const isTerminal = (snap: UploadStatusSnapshot | null) =>
|
|
!!snap && snap.pending === 0 && snap.processing === 0;
|
|
|
|
const merge = (uploadId: string, snap: UploadStatusSnapshot) => {
|
|
if (cancelled) return;
|
|
setSnapshots((prev) => ({ ...prev, [uploadId]: snap }));
|
|
};
|
|
|
|
const pollOnce = async (uploadId: string) => {
|
|
try {
|
|
const snap = await uploadsService.getStatus(uploadId);
|
|
merge(uploadId, snap);
|
|
if (!isTerminal(snap)) {
|
|
pollHandles[uploadId] = setTimeout(() => pollOnce(uploadId), pollIntervalMs);
|
|
} else {
|
|
closeStream(uploadId);
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) setError(e as Error);
|
|
// Retry polling on error after a longer interval — don't drop
|
|
// the group entirely just because one snapshot failed.
|
|
pollHandles[uploadId] = setTimeout(() => pollOnce(uploadId), pollIntervalMs * 4);
|
|
}
|
|
};
|
|
|
|
const tryStream = (uploadId: string) => {
|
|
if (typeof EventSource === 'undefined') return;
|
|
try {
|
|
const es = new EventSource(uploadsService.streamUrl(uploadId), { withCredentials: true });
|
|
eventSourcesRef.current[uploadId] = es;
|
|
|
|
es.onmessage = (event) => {
|
|
try {
|
|
const payload: UploadStatusSnapshot = JSON.parse(event.data);
|
|
merge(uploadId, payload);
|
|
if (isTerminal(payload)) {
|
|
closeStream(uploadId);
|
|
}
|
|
} catch (_) {
|
|
/* ignore malformed event */
|
|
}
|
|
};
|
|
|
|
es.onerror = () => {
|
|
// Treat any error as a fatal stream failure; polling keeps
|
|
// running anyway and will pick up status. Avoids reconnect
|
|
// storms on broken proxies.
|
|
closeStream(uploadId);
|
|
};
|
|
} catch (_) {
|
|
// EventSource construction failed — polling alone covers it.
|
|
}
|
|
};
|
|
|
|
for (const uploadId of uploadIds) {
|
|
pollOnce(uploadId);
|
|
if (preferStream) tryStream(uploadId);
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
for (const handle of Object.values(pollHandles)) clearTimeout(handle);
|
|
for (const uploadId of Object.keys(eventSourcesRef.current)) closeStream(uploadId);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [idsKey, enabled, pollIntervalMs, preferStream]);
|
|
|
|
// Aggregate counters across all tracked upload IDs.
|
|
const aggregate = (() => {
|
|
const totals = { total: 0, pending: 0, processing: 0, complete: 0, failed: 0 };
|
|
const failedPhotos: { id: number; filename: string; error: string | null }[] = [];
|
|
let allReady = true;
|
|
for (const uploadId of uploadIds) {
|
|
const snap = snapshots[uploadId];
|
|
if (!snap) {
|
|
allReady = false;
|
|
continue;
|
|
}
|
|
totals.total += snap.total;
|
|
totals.pending += snap.pending;
|
|
totals.processing += snap.processing;
|
|
totals.complete += snap.complete;
|
|
totals.failed += snap.failed;
|
|
for (const p of snap.photos) {
|
|
if (p.status === 'failed') {
|
|
failedPhotos.push({ id: p.id, filename: p.original_filename, error: p.error });
|
|
}
|
|
}
|
|
}
|
|
const isComplete = allReady && totals.pending === 0 && totals.processing === 0 && totals.total > 0;
|
|
return { ...totals, failedPhotos, isComplete, isReady: allReady };
|
|
})();
|
|
|
|
return {
|
|
snapshots,
|
|
aggregate,
|
|
error,
|
|
};
|
|
}
|