feat(upload): async photo processing — frontend (PR-B part 2)
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.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user