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
@@ -1,4 +1,7 @@
import { Loader2, Upload } from "lucide-react";
import { Loader2, Upload, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { useFileStore } from "@/stores/file-store";
interface ProgressCardProps {
active: boolean;
@@ -10,6 +13,11 @@ interface ProgressCardProps {
}
export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) {
const { t } = useTranslation();
const activeJobId = useFileStore((s) => s.activeJobId);
const cancelCurrentJob = useFileStore((s) => s.cancelCurrentJob);
const [canceling, setCanceling] = useState(false);
if (!active) return null;
const icon =
@@ -45,6 +53,24 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
style={{ width: `${Math.min(100, percent)}%` }}
/>
</div>
{activeJobId && cancelCurrentJob && (
<button
type="button"
disabled={canceling}
onClick={async () => {
setCanceling(true);
try {
await cancelCurrentJob();
} finally {
setCanceling(false);
}
}}
className="w-full py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:text-foreground hover:bg-muted flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<X className="h-3 w-3" />
{t.common.cancel}
</button>
)}
</div>
);
}
@@ -1,18 +1,11 @@
import type { FeatureBundleState } from "@snapotter/shared";
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { Clock, Download, Loader2, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { apiGet } from "@/lib/api";
import { format } from "@/lib/format";
import { apiGet, formatHeaders } from "@/lib/api";
import { format, formatFileSize } from "@/lib/format";
import { useFeaturesStore } from "@/stores/features-store";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatTimeRemaining(ms: number): string {
if (ms < 60000) return "Less than a minute left";
const mins = Math.ceil(ms / 60000);
@@ -131,7 +124,101 @@ export function AiFeaturesSection() {
{diskUsage !== null && (
<p className="text-xs text-muted-foreground pt-2 border-t border-border">
{format(t.settings.aiFeatures.diskUsage, { size: formatBytes(diskUsage) })}
{format(t.settings.aiFeatures.diskUsage, { size: formatFileSize(diskUsage) })}
</p>
)}
<ImportBundleSection
onImported={() => {
fetch();
loadDiskUsage();
}}
/>
</div>
);
}
function ImportBundleSection({ onImported }: { onImported: () => void }) {
const { t } = useTranslation();
const [importing, setImporting] = useState(false);
const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(
null,
);
const fileRef = useRef<HTMLInputElement>(null);
const handleImport = async (file: File) => {
setImporting(true);
setFeedback(null);
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/v1/admin/features/import", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
throw new Error(body.error || `Import failed: ${res.status}`);
}
setFeedback({ type: "success", message: t.settings.aiFeatures.importSuccess });
onImported();
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown error";
setFeedback({
type: "error",
message: format(t.settings.aiFeatures.importError, { error: msg }),
});
} finally {
setImporting(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<div className="pt-4 border-t border-border space-y-2">
<div>
<h4 className="text-sm font-medium text-foreground">
{t.settings.aiFeatures.importBundle}
</h4>
<p className="text-xs text-muted-foreground mt-0.5">
{t.settings.aiFeatures.importDescription}
</p>
</div>
<div className="flex items-center gap-3">
<input
ref={fileRef}
type="file"
accept=".tar.gz,.tgz"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleImport(file);
}}
/>
<button
type="button"
disabled={importing}
onClick={() => fileRef.current?.click()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-muted transition-colors disabled:opacity-50"
>
{importing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{importing ? t.settings.aiFeatures.importing : t.settings.aiFeatures.importButton}
</button>
</div>
{feedback && (
<p
className={`text-xs ${feedback.type === "success" ? "text-green-600 dark:text-green-400" : "text-destructive"}`}
>
{feedback.message}
</p>
)}
</div>
@@ -1,5 +1,6 @@
import { APP_VERSION, CATEGORIES, SUPPORTED_LOCALES, TOOLS } from "@snapotter/shared";
import {
BarChart3,
Check,
Copy,
Eye,
@@ -40,6 +41,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useThemeStore } from "@/stores/theme-store";
import { OtterLogo } from "../common/otter-logo";
import { AiFeaturesSection } from "./ai-features-section";
import { UsageSection } from "./usage-section";
interface SettingsDialogProps {
open: boolean;
@@ -54,6 +56,7 @@ type Section =
| "teams"
| "roles"
| "audit-log"
| "usage"
| "api-keys"
| "ai-features"
| "tools"
@@ -107,6 +110,12 @@ function useNavItems() {
icon: FileText,
requiredPermission: "audit:read",
},
{
id: "usage",
label: t.settings.nav.usage,
icon: BarChart3,
requiredPermission: "audit:read",
},
{ id: "api-keys", label: t.settings.nav.apiKeys, icon: Key },
{
id: "ai-features",
@@ -204,6 +213,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{section === "teams" && <TeamsSection />}
{section === "roles" && <RolesSection />}
{section === "audit-log" && <AuditLogSection />}
{section === "usage" && <UsageSection />}
{section === "api-keys" && <ApiKeysSection />}
{section === "ai-features" && <AiFeaturesSection />}
{section === "tools" && <ToolsSection />}
@@ -274,6 +284,7 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
{section === "teams" && <TeamsSection />}
{section === "roles" && <RolesSection />}
{section === "audit-log" && <AuditLogSection />}
{section === "usage" && <UsageSection />}
{section === "api-keys" && <ApiKeysSection />}
{section === "ai-features" && <AiFeaturesSection />}
{section === "tools" && <ToolsSection />}
@@ -513,6 +524,8 @@ function SystemSection() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
const [bundleLoading, setBundleLoading] = useState(false);
const [bundleError, setBundleError] = useState<string | null>(null);
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
@@ -696,6 +709,46 @@ function SystemSection() {
</span>
)}
</div>
<div className="pt-4 border-t border-border">
<SettingRow
label={t.settings.system.supportBundleButton}
description={t.settings.system.supportBundleDescription}
>
<button
type="button"
disabled={bundleLoading}
onClick={async () => {
setBundleLoading(true);
setBundleError(null);
try {
const res = await fetch("/api/v1/admin/support-bundle", {
headers: formatHeaders(),
});
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const cd = res.headers.get("Content-Disposition") || "";
const filenameMatch = cd.match(/filename=([^\s;]+)/);
a.download = filenameMatch ? filenameMatch[1] : "snapotter-support.zip";
a.click();
URL.revokeObjectURL(url);
} catch {
setBundleError(t.settings.system.supportBundleFailed);
} finally {
setBundleLoading(false);
}
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{bundleLoading && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.settings.system.supportBundleButton}
</button>
</SettingRow>
{bundleError && <p className="text-sm text-destructive mt-2">{bundleError}</p>}
</div>
</div>
);
}
@@ -0,0 +1,267 @@
import { Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { apiGet } from "@/lib/api";
import { format, formatFileSize } from "@/lib/format";
import { cn } from "@/lib/utils";
interface UsageData {
days: number;
jobsPerDay: Array<{ day: string; total: number; completed: number; failed: number }>;
topTools: Array<{ toolId: string; runs: number }>;
perUser: Array<{ username: string | null; runs: number; bytesIn: string }>;
durations: Array<{ pool: string; p50Ms: number | null; p95Ms: number | null }>;
storage: { libraryBytes: string; libraryFiles: number };
}
export function UsageSection() {
const { t } = useTranslation();
const [data, setData] = useState<UsageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [days, setDays] = useState(30);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await apiGet<UsageData>(`/v1/admin/usage?days=${days}`);
setData(result);
} catch {
setError("Failed to load usage data.");
setData(null);
} finally {
setLoading(false);
}
}, [days]);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-foreground">{t.settings.usage.heading}</h3>
<p className="text-sm text-muted-foreground mt-1">{t.settings.usage.description}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t.settings.usage.periodLabel}</span>
{[7, 30, 90].map((d) => (
<button
key={d}
type="button"
onClick={() => setDays(d)}
className={cn(
"px-3 py-1 rounded-lg text-xs font-medium transition-colors",
days === d
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80",
)}
>
{d === 7
? t.settings.usage.days7
: d === 30
? t.settings.usage.days30
: t.settings.usage.days90}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<p className="text-sm text-destructive text-center py-8">{error}</p>
) : data ? (
<div className="space-y-6">
{/* Jobs per day */}
<div className="rounded-lg border border-border p-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t.settings.usage.jobsPerDayHeading}
</h4>
{data.jobsPerDay.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.settings.usage.noData}</p>
) : (
<div className="space-y-1.5">
{(() => {
const maxJobsTotal = Math.max(...data.jobsPerDay.map((r) => r.total), 1);
return data.jobsPerDay.map((row) => {
const pct = (row.total / maxJobsTotal) * 100;
return (
<div key={row.day} className="flex items-center gap-3 text-xs">
<span className="w-20 shrink-0 text-muted-foreground font-mono">
{row.day}
</span>
<div className="flex-1 h-5 bg-muted/30 rounded overflow-hidden relative">
<div
className="absolute inset-y-0 start-0 bg-primary/70 rounded"
style={{ width: `${pct}%` }}
/>
<span className="absolute inset-y-0 start-0 flex items-center ps-2 text-foreground font-medium z-10">
{row.total}
</span>
</div>
<span className="w-14 shrink-0 text-end text-green-600 dark:text-green-400">
{row.completed}
</span>
<span className="w-10 shrink-0 text-end text-destructive">
{row.failed}
</span>
</div>
);
});
})()}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pt-1">
<span className="w-20 shrink-0" />
<span className="flex-1" />
<span className="w-14 shrink-0 text-end">{t.settings.usage.completedColumn}</span>
<span className="w-10 shrink-0 text-end">{t.settings.usage.failedColumn}</span>
</div>
</div>
)}
</div>
{/* Top tools */}
<div className="rounded-lg border border-border p-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t.settings.usage.topToolsHeading}
</h4>
{data.topTools.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.settings.usage.noData}</p>
) : (
<div className="space-y-1.5">
{(() => {
const maxToolRuns = Math.max(...data.topTools.map((r) => r.runs), 1);
return data.topTools.map((row) => {
const pct = (row.runs / maxToolRuns) * 100;
return (
<div key={row.toolId} className="flex items-center gap-3 text-xs">
<span className="w-32 shrink-0 text-foreground font-medium truncate">
{row.toolId}
</span>
<div className="flex-1 h-5 bg-muted/30 rounded overflow-hidden relative">
<div
className="absolute inset-y-0 start-0 bg-primary/50 rounded"
style={{ width: `${pct}%` }}
/>
</div>
<span className="w-12 shrink-0 text-end text-muted-foreground font-mono">
{row.runs}
</span>
</div>
);
});
})()}
</div>
)}
</div>
{/* Per-user volume */}
<div className="rounded-lg border border-border p-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t.settings.usage.perUserHeading}
</h4>
{data.perUser.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.settings.usage.noData}</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-start py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.userColumn}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.runsColumn}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.bytesInColumn}
</th>
</tr>
</thead>
<tbody>
{data.perUser.map((row, i) => (
<tr
key={row.username ?? `anon-${i}`}
className="border-b border-border last:border-0"
>
<td className="py-1.5 text-foreground">
{row.username ?? t.settings.usage.unknownUser}
</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{row.runs}
</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{formatFileSize(Number(row.bytesIn))}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Durations + Storage */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Duration percentiles */}
<div className="rounded-lg border border-border p-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t.settings.usage.durationsHeading}
</h4>
{data.durations.length === 0 ? (
<p className="text-sm text-muted-foreground">{t.settings.usage.noData}</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-start py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.poolColumn}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.p50Column}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.p95Column}
</th>
</tr>
</thead>
<tbody>
{data.durations.map((row) => (
<tr key={row.pool} className="border-b border-border last:border-0">
<td className="py-1.5 text-foreground">{row.pool}</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{row.p50Ms != null ? `${row.p50Ms}ms` : "-"}
</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{row.p95Ms != null ? `${row.p95Ms}ms` : "-"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Storage */}
<div className="rounded-lg border border-border p-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t.settings.usage.storageHeading}
</h4>
<div className="space-y-2">
<p className="text-2xl font-bold text-foreground">
{formatFileSize(Number(data.storage.libraryBytes))}
</p>
<p className="text-sm text-muted-foreground">
{format(t.settings.usage.storageFiles, { count: data.storage.libraryFiles })}
</p>
</div>
</div>
</div>
</div>
) : null}
</div>
);
}
+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,
+11
View File
@@ -93,6 +93,8 @@ interface FileState {
batchZipFilename: string | null;
processing: boolean;
error: string | null;
activeJobId: string | null;
cancelCurrentJob: (() => Promise<void>) | null;
// Derived from entries (selected entry fields)
readonly files: File[];
@@ -116,6 +118,7 @@ interface FileState {
setBatchZip: (blob: Blob, filename: string) => void;
setProcessing: (v: boolean) => void;
setError: (e: string | null) => void;
setActiveJob: (id: string | null, cancelFn: (() => Promise<void>) | null) => void;
setJobId: (id: string) => void;
setProcessedUrl: (url: string | null, previewUrl?: string | null) => void;
setSizes: (original: number, processed: number) => void;
@@ -130,6 +133,8 @@ export const useFileStore = create<FileState>((set, get) => ({
batchZipFilename: null,
processing: false,
error: null,
activeJobId: null,
cancelCurrentJob: null,
// Initial derived values (empty state)
files: [],
@@ -271,6 +276,8 @@ export const useFileStore = create<FileState>((set, get) => ({
setError: (e) => set(e ? { error: e, processing: false } : { error: null }),
setActiveJob: (id, cancelFn) => set({ activeJobId: id, cancelCurrentJob: cancelFn }),
setJobId: (_id) => {
// no-op for backward compat
},
@@ -332,6 +339,8 @@ export const useFileStore = create<FileState>((set, get) => ({
batchZipFilename: null,
processing: false,
error: null,
activeJobId: null,
cancelCurrentJob: null,
files: deriveFiles(resetEntries),
...deriveSelected(resetEntries, selectedIndex),
});
@@ -347,6 +356,8 @@ export const useFileStore = create<FileState>((set, get) => ({
batchZipFilename: null,
processing: false,
error: null,
activeJobId: null,
cancelCurrentJob: null,
files: [],
...deriveSelected([], 0),
});