feat(jobs)!: SnapOtter 2.0 phase 2 job spine: async queues, worker pools, object storage, admin dashboard (#217)

This commit is contained in:
SnapOtter
2026-06-13 10:17:13 +08:00
parent 1c724d5d21
commit c451b939c7
130 changed files with 10438 additions and 3592 deletions
+56 -16
View File
@@ -1,5 +1,6 @@
import { PYTHON_SIDECAR_TOOLS, TOOLS } from "@snapotter/shared";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders, parseApiError } from "@/lib/api";
import { generateId } from "@/lib/utils";
import { useFileStore } from "@/stores/file-store";
@@ -37,8 +38,17 @@ const UPLOAD_WEIGHT = 15;
const SSE_STALL_TIMEOUT_MS = 300_000;
export function useToolProcessor(toolId: string) {
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
useFileStore();
const { t } = useTranslation();
const {
processing,
error,
processedUrl,
originalSize,
processedSize,
setProcessing,
setError,
setActiveJob,
} = useFileStore();
const [progress, setProgress] = useState<ToolProgress>(IDLE_PROGRESS);
const [warning, setWarning] = useState<string | null>(null);
@@ -53,6 +63,24 @@ export function useToolProcessor(toolId: string) {
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
const clearActiveJob = useCallback(() => {
activeJobIdRef.current = null;
setActiveJob(null, null);
}, [setActiveJob]);
const cancelCurrentJob = useCallback(async () => {
const jobId = activeJobIdRef.current;
if (!jobId) return;
try {
await fetch(`/api/v1/jobs/${jobId}/cancel`, {
method: "POST",
headers: formatHeaders(),
});
} catch {
// Cancel request failed; SSE handler or stall timeout will clean up
}
}, []);
const reconnectSSE = useCallback(() => {
const jobId = activeJobIdRef.current;
if (!jobId) return;
@@ -82,7 +110,7 @@ export function useToolProcessor(toolId: string) {
eventSourceRef.current = null;
}
if (elapsedRef.current) clearInterval(elapsedRef.current);
activeJobIdRef.current = null;
clearActiveJob();
setError(
"Processing timed out with no progress for 5 minutes. Try again or use a smaller image.",
);
@@ -96,7 +124,7 @@ export function useToolProcessor(toolId: string) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
activeJobIdRef.current = null;
clearActiveJob();
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
@@ -120,7 +148,7 @@ export function useToolProcessor(toolId: string) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
activeJobIdRef.current = null;
clearActiveJob();
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -150,7 +178,7 @@ export function useToolProcessor(toolId: string) {
} catch {
// EventSource creation failed
}
}, [setError, setProcessing]);
}, [clearActiveJob, setError, setProcessing]);
// Reconnect SSE when tab becomes visible again (mobile tab recovery)
useEffect(() => {
@@ -224,6 +252,7 @@ export function useToolProcessor(toolId: string) {
eventSourceRef.current = null;
}
if (elapsedRef.current) clearInterval(elapsedRef.current);
clearActiveJob();
useFileStore.getState().updateEntry(capturedIndex, {
status: "failed",
error: "Processing timed out",
@@ -254,7 +283,7 @@ export function useToolProcessor(toolId: string) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
activeJobIdRef.current = null;
clearActiveJob();
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
@@ -277,7 +306,7 @@ export function useToolProcessor(toolId: string) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
activeJobIdRef.current = null;
clearActiveJob();
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -354,6 +383,7 @@ export function useToolProcessor(toolId: string) {
if (xhr.status === 202) {
asyncMode = true;
asyncModeRef.current = true;
setActiveJob(clientJobId, cancelCurrentJob);
resetStallTimer();
return;
}
@@ -398,7 +428,7 @@ export function useToolProcessor(toolId: string) {
setProcessing(false);
setProgress(IDLE_PROGRESS);
activeJobIdRef.current = null;
clearActiveJob();
};
xhr.onerror = () => {
@@ -411,7 +441,7 @@ export function useToolProcessor(toolId: string) {
setError("Processing was interrupted. Retry when reconnected.");
setProcessing(false);
setProgress(IDLE_PROGRESS);
activeJobIdRef.current = null;
clearActiveJob();
};
xhr.ontimeout = () => {
@@ -424,7 +454,7 @@ export function useToolProcessor(toolId: string) {
setError("Request timed out - the server may be overloaded. Try again.");
setProcessing(false);
setProgress(IDLE_PROGRESS);
activeJobIdRef.current = null;
clearActiveJob();
};
xhr.open("POST", `/api/v1/tools/${toolId}`);
@@ -433,7 +463,16 @@ export function useToolProcessor(toolId: string) {
});
xhr.send(formData);
},
[toolId, isAiTool, setProcessing, setError, toolName],
[
toolId,
isAiTool,
setProcessing,
setError,
setActiveJob,
clearActiveJob,
cancelCurrentJob,
toolName,
],
);
const processAllFiles = useCallback(
@@ -572,7 +611,7 @@ export function useToolProcessor(toolId: string) {
setProcessing(false);
setProgress(IDLE_PROGRESS);
activeJobIdRef.current = null;
clearActiveJob();
} catch (err) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (eventSourceRef.current) {
@@ -582,17 +621,18 @@ export function useToolProcessor(toolId: string) {
setError(err instanceof Error ? err.message : "Batch processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
activeJobIdRef.current = null;
clearActiveJob();
}
},
[toolId, processFiles, setProcessing, setError, toolName],
[toolId, processFiles, setProcessing, setError, clearActiveJob, toolName],
);
return {
processFiles,
processAllFiles,
cancelCurrentJob,
processing,
error,
error: error === "Canceled" ? t.tools.processing.canceled : error,
warning,
downloadUrl: processedUrl,
originalSize,