124 lines
3.7 KiB
TypeScript
124 lines
3.7 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
import api from '../api';
|
|
|
|
export type BackgroundJobStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
|
|
|
|
export interface BackgroundJob {
|
|
id: string;
|
|
jobType: string;
|
|
mandateId?: string | null;
|
|
featureInstanceId?: string | null;
|
|
triggeredBy?: string | null;
|
|
status: BackgroundJobStatus;
|
|
progress: number;
|
|
progressMessage?: string | null;
|
|
payload?: Record<string, any>;
|
|
result?: Record<string, any> | null;
|
|
errorMessage?: string | null;
|
|
createdAt?: string;
|
|
startedAt?: string | null;
|
|
finishedAt?: string | null;
|
|
}
|
|
|
|
const TERMINAL_STATUSES: BackgroundJobStatus[] = ['SUCCESS', 'ERROR', 'CANCELLED'];
|
|
|
|
export interface UseBackgroundJobOptions {
|
|
pollMs?: number;
|
|
enabled?: boolean;
|
|
onSuccess?: (job: BackgroundJob) => void;
|
|
onError?: (job: BackgroundJob) => void;
|
|
}
|
|
|
|
export interface UseBackgroundJobResult {
|
|
job: BackgroundJob | null;
|
|
isFinal: boolean;
|
|
isError: boolean;
|
|
isLoading: boolean;
|
|
refetch: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Polls /api/jobs/{jobId} until the job reaches a terminal status.
|
|
*
|
|
* Use after submitting a long-running task to the generic background job
|
|
* service. Handles polling, cleanup on unmount, and exposes the job record
|
|
* directly so callers can read `job.progress`, `job.result`, etc.
|
|
*/
|
|
export function useBackgroundJob(
|
|
jobId: string | null | undefined,
|
|
opts: UseBackgroundJobOptions = {},
|
|
): UseBackgroundJobResult {
|
|
const { pollMs = 2000, enabled = true, onSuccess, onError } = opts;
|
|
const [job, setJob] = useState<BackgroundJob | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const mountedRef = useRef(true);
|
|
const onSuccessRef = useRef(onSuccess);
|
|
const onErrorRef = useRef(onError);
|
|
|
|
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
|
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
return () => { mountedRef.current = false; };
|
|
}, []);
|
|
|
|
const fetchOnce = useCallback(async (): Promise<BackgroundJob | null> => {
|
|
if (!jobId) return null;
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await api.get(`/api/jobs/${jobId}`);
|
|
const next = res.data as BackgroundJob;
|
|
if (mountedRef.current) setJob(next);
|
|
return next;
|
|
} catch (err: any) {
|
|
if (mountedRef.current) {
|
|
setJob(prev => prev ?? {
|
|
id: jobId,
|
|
jobType: '',
|
|
status: 'ERROR',
|
|
progress: 0,
|
|
errorMessage: err?.response?.data?.detail || err?.message || 'Job nicht abrufbar',
|
|
});
|
|
}
|
|
return null;
|
|
} finally {
|
|
if (mountedRef.current) setIsLoading(false);
|
|
}
|
|
}, [jobId]);
|
|
|
|
useEffect(() => {
|
|
if (!enabled || !jobId) return;
|
|
let cancelled = false;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
let firedTerminal = false;
|
|
|
|
const tick = async () => {
|
|
if (cancelled) return;
|
|
const next = await fetchOnce();
|
|
if (cancelled) return;
|
|
const status = next?.status;
|
|
if (status && TERMINAL_STATUSES.includes(status)) {
|
|
if (!firedTerminal) {
|
|
firedTerminal = true;
|
|
if (status === 'SUCCESS') onSuccessRef.current?.(next!);
|
|
else onErrorRef.current?.(next!);
|
|
}
|
|
return;
|
|
}
|
|
timer = setTimeout(tick, pollMs);
|
|
};
|
|
|
|
tick();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (timer) clearTimeout(timer);
|
|
};
|
|
}, [jobId, enabled, pollMs, fetchOnce]);
|
|
|
|
const isFinal = !!job && TERMINAL_STATUSES.includes(job.status);
|
|
const isError = job?.status === 'ERROR' || job?.status === 'CANCELLED';
|
|
|
|
return { job, isFinal, isError, isLoading, refetch: async () => { await fetchOnce(); } };
|
|
}
|