mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(jobs)!: SnapOtter 2.0 phase 2 job spine: async queues, worker pools, object storage, admin dashboard (#217)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user