Files
picpeak/frontend/src/hooks/useUploadProgress.ts
T
Paul Nothaft 3b827b80d5 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.
2026-05-02 22:56:04 +02:00

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,
};
}