feat(batch): add concept card, New batch button, cost/expiration columns, row actions, 30s polling on /batch (F6)

This commit is contained in:
diegosouzapw
2026-05-27 22:05:30 -03:00
parent c5183ed55a
commit e404649d43
5 changed files with 1001 additions and 90 deletions

View File

@@ -3,6 +3,11 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import BatchDetailModal from "./BatchDetailModal";
import ExpirationBadge from "./components/ExpirationBadge";
import ProgressBarBicolor from "./components/ProgressBarBicolor";
import { useBatchActions } from "./components/useBatchActions";
// ── Helpers ───────────────────────────────────────────────────────────────────
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts * 1000;
@@ -26,6 +31,8 @@ function relativeTime(ts: number): string {
return `${res} ago`;
}
// ── Types ─────────────────────────────────────────────────────────────────────
interface BatchRecord {
id: string;
endpoint: string;
@@ -69,6 +76,8 @@ interface BatchListTabProps {
onRefresh?: () => void;
}
// ── Status helpers ────────────────────────────────────────────────────────────
const STATUS_STYLES: Record<string, string> = {
completed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
completed_with_failures: "bg-red-500/15 text-red-400 border-red-500/25",
@@ -91,7 +100,7 @@ const STATUS_LABELS: Record<string, string> = {
cancelled_with_failures: "cancelled with failures",
};
/** Returns a composite key that reflects whether partial failures occurred. */
/** Returns a composite status key that reflects whether partial failures occurred. */
function effectiveStatus(batch: BatchRecord): string {
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
if (!hasFailed) return batch.status;
@@ -127,6 +136,139 @@ const ALL_STATUSES = [
"expired",
];
// ── BatchRowActions (internal component) ──────────────────────────────────────
/** Per-row action buttons: cancel, download output/errors, retry, delete. */
function BatchRowActions({
batch,
onRefresh,
deletingId,
setDeletingId,
}: Readonly<{
batch: BatchRecord;
onRefresh?: () => void;
deletingId: string | null;
setDeletingId: (id: string | null) => void;
}>) {
const t = useTranslations("common");
const actions = useBatchActions({ onRefresh, t });
const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status);
const canCancel = ["validating", "in_progress", "finalizing"].includes(batch.status);
const canRetry =
isTerminal && !!batch.errorFileId && (batch.requestCountsFailed ?? 0) > 0;
return (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
{/* Cancel — only for non-terminal statuses */}
{canCancel && (
<button
onClick={async () => {
if (window.confirm(t("batchActionCancel") + "?")) {
await actions.cancel(batch.id);
}
}}
disabled={actions.cancelling}
title={t("batchActionCancel")}
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-orange-400 hover:bg-orange-500/10 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[13px]">
{actions.cancelling ? "hourglass_empty" : "block"}
</span>
</button>
)}
{/* Download output */}
{batch.outputFileId && (
<a
href={actions.downloadHrefOutput(batch.outputFileId) ?? "#"}
download={`batch-${batch.id}-output.jsonl`}
onClick={(e) => e.stopPropagation()}
title={t("batchActionDownloadOutput")}
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-emerald-400 hover:bg-emerald-500/10 transition-colors"
>
<span className="material-symbols-outlined text-[13px]">download</span>
</a>
)}
{/* Download errors */}
{batch.errorFileId && (
<a
href={actions.downloadHrefErrors(batch.errorFileId) ?? "#"}
download={`batch-${batch.id}-errors.jsonl`}
onClick={(e) => e.stopPropagation()}
title={t("batchActionDownloadErrors")}
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
<span className="material-symbols-outlined text-[13px]">file_download</span>
</a>
)}
{/* Retry failed — only when terminal + has error file + has failures */}
{canRetry && (
<button
onClick={async () => {
if (
window.confirm(
t("batchActionRetryConfirm", { n: batch.requestCountsFailed, cost: "TBD" }),
)
) {
await actions.retry({
id: batch.id,
inputFileId: batch.inputFileId,
errorFileId: batch.errorFileId,
endpoint: batch.endpoint,
});
}
}}
disabled={actions.retrying}
title={t("batchActionRetry")}
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[13px]">
{actions.retrying ? "hourglass_empty" : "replay"}
</span>
</button>
)}
{/* Delete — only for terminal statuses */}
{isTerminal && (
<button
onClick={async (e) => {
e.stopPropagation();
setDeletingId(batch.id);
try {
const res = await fetch(`/api/v1/batches/${batch.id}`, { method: "DELETE" });
if (res.ok) {
onRefresh?.();
} else {
console.error(
"[BatchRowActions] DELETE returned non-ok status",
batch.id,
res.status,
);
}
} catch (err) {
console.error("[BatchRowActions] DELETE threw", batch.id, err);
} finally {
setDeletingId(null);
}
}}
disabled={deletingId === batch.id}
title={t("batchListDeleteBatchTitle")}
className="flex items-center justify-center p-1 rounded text-[var(--color-text-muted)] hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[13px]">
{deletingId === batch.id ? "hourglass_empty" : "delete"}
</span>
</button>
)}
</div>
);
}
// ── BatchListTab ──────────────────────────────────────────────────────────────
export default function BatchListTab({
batches,
files,
@@ -143,26 +285,6 @@ export default function BatchListTab({
const completedBatches = batches.filter((b) => b.status === "completed");
const handleDeleteBatch = async (e: React.MouseEvent, batch: BatchRecord) => {
e.stopPropagation();
setDeletingId(batch.id);
try {
const res = await fetch(`/api/v1/batches/${batch.id}`, { method: "DELETE" });
if (res.ok) {
onRefresh?.();
} else {
console.error(
`[DeleteBatch] DELETE ${batch.id} returned ${res.status}`,
await res.text().catch(() => "")
);
}
} catch (err) {
console.error(`[DeleteBatch] DELETE ${batch.id} threw`, err);
} finally {
setDeletingId(null);
}
};
const handleRemoveCompleted = async () => {
if (completedBatches.length === 0) return;
setRemovingCompleted(true);
@@ -172,13 +294,13 @@ export default function BatchListTab({
onRefresh?.();
} else {
console.error(
"[RemoveCompleted] DELETE /batches/delete-completed returned",
"[BatchListTab] DELETE /batches/delete-completed returned",
res.status,
await res.text().catch(() => "")
await res.text().catch(() => ""),
);
}
} catch (err) {
console.error("[RemoveCompleted] DELETE /batches/delete-completed threw", err);
console.error("[BatchListTab] DELETE /batches/delete-completed threw", err);
} finally {
setRemovingCompleted(false);
}
@@ -235,7 +357,7 @@ export default function BatchListTab({
</button>
</div>
{/* Table */}
{/* Table — 9 columns: Status | ID | Endpoint | Model | Progress | Cost | Created | Expires | Actions */}
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label={t("batchListBatchesTable")}>
<thead>
@@ -255,6 +377,9 @@ export default function BatchListTab({
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Progress
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
{t("batchListCostColumn")}
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Created
</th>
@@ -267,7 +392,7 @@ export default function BatchListTab({
<tbody>
{loading && filtered.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<td colSpan={9} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<div className="flex items-center justify-center gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-[var(--color-accent)]" />
Loading
@@ -276,7 +401,7 @@ export default function BatchListTab({
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
<td colSpan={9} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
No batches found
</td>
</tr>
@@ -285,8 +410,26 @@ export default function BatchListTab({
const total = batch.requestCountsTotal || 0;
const done = batch.requestCountsCompleted || 0;
const failed = batch.requestCountsFailed || 0;
const donePct = total > 0 ? (done / total) * 100 : 0;
const failedPct = total > 0 ? (failed / total) * 100 : 0;
// Cost estimate — lightweight heuristic (D8).
// Full per-request estimate (using JSONL input) is shown in BatchDetailModal.
const estimatedCost = (() => {
if (!batch.model || total === 0) return "—";
// Prefer real usage data when available (completed batches)
const usage = batch.usage as
| { input_tokens?: number; output_tokens?: number }
| null
| undefined;
if (usage?.input_tokens != null && usage?.output_tokens != null) {
// batch rate ≈ $0.005/1K tokens (blended, already -50%)
const cost = ((usage.input_tokens + usage.output_tokens) * 0.005) / 1000;
return `~$${cost.toFixed(2)}`;
}
// Fallback heuristic: 500 avg tokens/request × batch rate
const estCost = (total * 500 * 0.005) / 1000;
return `~$${estCost.toFixed(2)}`;
})();
return (
<tr
key={batch.id}
@@ -307,52 +450,45 @@ export default function BatchListTab({
<td className="px-4 py-3 text-[var(--color-text-muted)] text-xs">
{batch.model ?? "—"}
</td>
{/* Progress — ProgressBarBicolor from F3 */}
<td className="px-4 py-3 min-w-[140px]">
{total > 0 ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-[var(--color-text-muted)]">
<span>
<span className="text-emerald-400">{done}</span>
{failed > 0 && <span className="text-red-400"> / {failed} err</span>}
<span> / {total}</span>
</span>
<span>{Math.round(donePct + failedPct)}%</span>
</div>
<div className="h-1.5 rounded-full bg-[var(--color-bg-alt)] overflow-hidden flex">
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${donePct}%` }}
/>
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${failedPct}%` }}
/>
</div>
</div>
<ProgressBarBicolor
total={total}
completed={done}
failed={failed}
showLabels
/>
) : (
<span className="text-xs text-[var(--color-text-muted)]"></span>
)}
</td>
{/* Cost column — heuristic estimate (D8) */}
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{estimatedCost}
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{relativeTime(batch.createdAt)}
</td>
{/* Expiration — countdown badge for active batches (D11) */}
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{batch.expiresAt ? relativeTime(batch.expiresAt) : "—"}
</td>
<td className="px-4 py-3">
{["completed", "failed", "cancelled", "expired"].includes(batch.status) && (
<button
onClick={(e) => handleDeleteBatch(e, batch)}
disabled={deletingId === batch.id}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-red-500/10 border border-red-500/25 text-red-400 hover:text-red-300 transition-colors whitespace-nowrap disabled:opacity-50"
title={t("batchListDeleteBatchTitle")}
>
<span className="material-symbols-outlined text-[13px]">
{deletingId === batch.id ? "hourglass_empty" : "delete"}
</span>
</button>
{["in_progress", "validating", "finalizing"].includes(batch.status) ? (
<ExpirationBadge expiresAt={batch.expiresAt ?? null} variant="compact" />
) : batch.expiresAt ? (
relativeTime(batch.expiresAt)
) : (
"—"
)}
</td>
{/* Actions — cancel / download / retry / delete */}
<td className="px-4 py-3">
<BatchRowActions
batch={batch}
onRefresh={onRefresh}
deletingId={deletingId}
setDeletingId={setDeletingId}
/>
</td>
</tr>
);
})

View File

@@ -0,0 +1,61 @@
"use client";
/**
* NewBatchWizard — Stub placeholder.
*
* F4 (feat/batch-files-20-F4-wizard) delivers the full 4-step implementation.
* This file exists so F6 can import and render it without blocking on F4.
* F4 MUST replace this file with the full wizard when it lands.
*
* Props contract (canonical — matches §3.4 of master-plan-20):
* onClose: () => void
* onCreated: (batchId: string) => void
* availableProviders: Array<{ id: string; name: string; models: string[] }>
*/
import { useTranslations } from "next-intl";
interface NewBatchWizardProps {
onClose: () => void;
onCreated: (batchId: string) => void;
availableProviders: Array<{ id: string; name: string; models: string[] }>;
}
export default function NewBatchWizard({ onClose }: Readonly<NewBatchWizardProps>) {
const t = useTranslations("common");
return (
<div
role="dialog"
aria-modal="true"
aria-label={t("wizardTitle")}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<div className="relative w-full sm:max-w-3xl bg-[var(--color-surface)] border border-[var(--color-border)] rounded-xl shadow-xl flex flex-col gap-6 p-6">
<div className="flex items-center justify-between">
<span className="font-semibold text-base text-[var(--color-text-main)]">
{t("wizardTitle")}
</span>
<button
onClick={onClose}
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
aria-label={t("wizardClose")}
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
<p className="text-sm text-[var(--color-text-muted)]">
Wizard coming soon F4 delivers the full implementation.
</p>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-main)] transition-colors"
>
{t("wizardCancel")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useState, useCallback } from "react";
import { buildRetryPlan } from "@/lib/batches/retryFailed";
// ── Types ─────────────────────────────────────────────────────────────────────
export interface BatchActionsState {
cancelling: boolean;
retrying: boolean;
error: string | null;
}
export interface UseBatchActionsResult extends BatchActionsState {
cancel: (batchId: string) => Promise<void>;
retry: (batch: {
id: string;
inputFileId: string;
errorFileId?: string | null;
endpoint: string;
}) => Promise<{ newBatchId: string } | null>;
downloadHrefOutput: (outputFileId: string | null | undefined) => string | null;
downloadHrefErrors: (errorFileId: string | null | undefined) => string | null;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
/**
* Shared hook for batch row actions: cancel, retry-failed, download output/errors.
* Reused by BatchListTab (F6) and BatchDetailModal (F7).
*
* All error messages use i18n keys via the `t()` passed in opts — never raw err.message/stack.
* Technical errors are only logged via console.error for diagnostics (D14 compliance).
*
* @param opts.onRefresh Optional callback to call after a successful mutating action.
* @param opts.t Translation function (key: string) => string from useTranslations("common").
*/
export function useBatchActions(opts: {
onRefresh?: () => void;
t: (key: string, params?: Record<string, unknown>) => string;
}): UseBatchActionsResult {
const [cancelling, setCancelling] = useState(false);
const [retrying, setRetrying] = useState(false);
const [error, setError] = useState<string | null>(null);
// ── cancel ────────────────────────────────────────────────────────────────
const cancel = useCallback(
async (batchId: string): Promise<void> => {
setCancelling(true);
setError(null);
try {
const res = await fetch(`/api/v1/batches/${batchId}/cancel`, { method: "POST" });
if (!res.ok) {
// Log technical context; surface sanitized i18n key to user (D14)
console.error("[useBatchActions] cancel", batchId, "status", res.status);
setError(opts.t("batchActionCancel"));
return;
}
opts.onRefresh?.();
} catch (e) {
// Never expose e.message/stack in UI — diagnostics only (D14)
console.error("[useBatchActions] cancel threw", e);
setError(opts.t("batchActionCancel"));
} finally {
setCancelling(false);
}
},
[opts],
);
// ── retry ─────────────────────────────────────────────────────────────────
const retry = useCallback(
async (batch: {
id: string;
inputFileId: string;
errorFileId?: string | null;
endpoint: string;
}): Promise<{ newBatchId: string } | null> => {
// Guard: no error file means nothing to retry
if (!batch.errorFileId) {
return null;
}
setRetrying(true);
setError(null);
try {
// 1. Download both input and error files in parallel
const [inputRes, errorRes] = await Promise.all([
fetch(`/api/v1/files/${batch.inputFileId}/content`),
fetch(`/api/v1/files/${batch.errorFileId}/content`),
]);
if (!inputRes.ok || !errorRes.ok) {
console.error(
"[useBatchActions] retry file download failed",
"input",
inputRes.status,
"error",
errorRes.status,
);
setError(opts.t("batchActionRetry"));
return null;
}
const inputJsonl = await inputRes.text();
const errorJsonl = await errorRes.text();
// 2. Build retry plan — pure helper, no side effects (D9)
const plan = buildRetryPlan({ inputJsonl, errorJsonl });
if (plan.retriableLines === 0) {
// i18n key surfaced to user; technical context stays in console
console.error("[useBatchActions] retry: no retriable lines found for", batch.id);
setError(opts.t("batchActionRetry"));
return null;
}
// 3. Upload new JSONL file (purpose=batch)
const formData = new FormData();
formData.append("purpose", "batch");
formData.append(
"file",
new Blob([plan.newJsonl], { type: "application/jsonl" }),
`retry-${batch.id}.jsonl`,
);
const fileRes = await fetch("/api/v1/files", { method: "POST", body: formData });
if (!fileRes.ok) {
console.error("[useBatchActions] retry file upload failed", fileRes.status);
setError(opts.t("batchActionRetry"));
return null;
}
const file = (await fileRes.json()) as { id: string };
// 4. Create new batch with same endpoint + 24h window (D9)
const batchRes = await fetch("/api/v1/batches", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
input_file_id: file.id,
endpoint: batch.endpoint,
completion_window: "24h",
}),
});
if (!batchRes.ok) {
console.error("[useBatchActions] retry batch create failed", batchRes.status);
setError(opts.t("batchActionRetry"));
return null;
}
const newBatch = (await batchRes.json()) as { id: string };
opts.onRefresh?.();
return { newBatchId: newBatch.id };
} catch (e) {
// Never surface e.message/stack to UI (D14)
console.error("[useBatchActions] retry threw", e);
setError(opts.t("batchActionRetry"));
return null;
} finally {
setRetrying(false);
}
},
[opts],
);
// ── download hrefs (pure — no side effects) ───────────────────────────────
const downloadHrefOutput = useCallback(
(outputFileId: string | null | undefined): string | null => {
if (!outputFileId) return null;
return `/api/v1/files/${outputFileId}/content`;
},
[],
);
const downloadHrefErrors = useCallback(
(errorFileId: string | null | undefined): string | null => {
if (!errorFileId) return null;
return `/api/v1/files/${errorFileId}/content`;
},
[],
);
return {
cancelling,
retrying,
error,
cancel,
retry,
downloadHrefOutput,
downloadHrefErrors,
};
}

View File

@@ -6,6 +6,24 @@ import BatchListTab from "./BatchListTab";
import { FileRecord } from "@/lib/db/files";
import { BatchRecord } from "@/lib/db/batches";
import { mapBatchApiToRecord, mapFileApiToRecord } from "./batch-utils";
import BatchConceptCard from "./components/BatchConceptCard";
import NewBatchWizard from "./components/NewBatchWizard";
// ── Batch-capable providers (D16) ─────────────────────────────────────────────
const BATCH_SUPPORTED = ["openai", "anthropic", "gemini"];
const MODEL_DEFAULTS: Record<string, string[]> = {
openai: ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo"],
anthropic: ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022"],
gemini: ["gemini-1.5-flash", "gemini-1.5-pro"],
};
const PROVIDER_NAMES: Record<string, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
gemini: "Gemini",
};
// ── Component ─────────────────────────────────────────────────────────────────
export default function BatchPage() {
const t = useTranslations("common");
@@ -14,6 +32,10 @@ export default function BatchPage() {
const [batchesTotal, setBatchesTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [showWizard, setShowWizard] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; name: string; models: string[] }>>(
[],
);
const [batchesHasMore, setBatchesHasMore] = useState(false);
const [batchesLastId, setBatchesLastId] = useState<string | null>(null);
@@ -46,17 +68,16 @@ export default function BatchPage() {
setBatchesHasMore(Boolean(data.has_more));
setBatchesLastId(data.last_id || null);
} else if (isBackground) {
// Background refresh: merge new items with existing ones, preserve pagination state
// Background refresh: merge new items, preserve pagination state
setBatches((prev) => {
const batchMap = new Map(prev.map((b) => [b.id, b]));
for (const m of mapped) {
batchMap.set(m.id, m);
}
return Array.from(batchMap.values()).sort(
(a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)
(a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id),
);
});
// Don't reset batchesLastId or batchesHasMore on background refresh
} else {
setBatches(mapped);
setBatchesHasMore(Boolean(data.has_more));
@@ -75,7 +96,7 @@ export default function BatchPage() {
fileMap.set(m.id, m);
}
return Array.from(fileMap.values()).sort(
(a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)
(a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id),
);
});
} else {
@@ -83,14 +104,14 @@ export default function BatchPage() {
}
}
} catch (error) {
console.error("Failed to fetch batches/files", error);
console.error("[BatchPage] fetchData threw", error);
} finally {
isFetchingRef.current = false;
if (!isBackground) setLoading(false);
if (opts.appendBatches) setLoadingMore(false);
}
},
[batchesLastId]
[batchesLastId],
);
// Keep fetchData ref in sync
@@ -98,22 +119,66 @@ export default function BatchPage() {
fetchDataRef.current = fetchData;
}, [fetchData]);
// Track loadingMore in a ref for use in observer callback (avoids re-creating observer)
// Track loadingMore in a ref for use in observer callback
const loadingMoreRef = useRef(loadingMore);
useEffect(() => {
loadingMoreRef.current = loadingMore;
}, [loadingMore]);
// Initial fetch and background refresh timer (runs once on mount)
// Fetch available batch-capable providers on mount (D16).
// Intersects /api/providers with BATCH_SUPPORTED list.
// Falls back to hardcoded list if route errors or returns empty.
useEffect(() => {
const load = async () => {
try {
const res = await fetch("/api/providers");
if (res.ok) {
const data = (await res.json()) as {
connections: Array<{ provider: string; is_active?: boolean }>;
};
const connected = new Set(
(data.connections ?? [])
.filter((c) => BATCH_SUPPORTED.includes(c.provider))
.map((c) => c.provider),
);
if (connected.size > 0) {
setProviders(
Array.from(connected).map((id) => ({
id,
name: PROVIDER_NAMES[id] ?? id,
models: MODEL_DEFAULTS[id] ?? [],
})),
);
return;
}
}
} catch (e) {
console.error("[BatchPage] providers fetch error", e);
}
// Fallback: hardcoded list per D16
setProviders(
BATCH_SUPPORTED.map((id) => ({
id,
name: PROVIDER_NAMES[id] ?? id,
models: MODEL_DEFAULTS[id] ?? [],
})),
);
};
void load();
}, []);
// Initial fetch + 30s polling (D10). Pauses when tab is hidden.
useEffect(() => {
const scheduleRefresh = () => {
refreshTimeoutRef.current = setTimeout(async () => {
await fetchDataRef.current?.(true);
if (!document.hidden) {
await fetchDataRef.current?.(true);
}
scheduleRefresh();
}, 10_000);
}, 30_000);
};
// Initial fetch (with loading)
// Initial fetch (with loading indicator)
fetchDataRef.current?.();
// Schedule background refreshes
scheduleRefresh();
@@ -124,7 +189,32 @@ export default function BatchPage() {
refreshTimeoutRef.current = null;
}
};
}, []); // Empty deps - only run once, uses ref for latest fetchData
}, []);
// Pause/resume polling on visibility change (D10)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
} else if (!refreshTimeoutRef.current) {
// Resume polling
const scheduleRefresh = () => {
refreshTimeoutRef.current = setTimeout(async () => {
if (!document.hidden) {
await fetchDataRef.current?.(true);
}
scheduleRefresh();
}, 30_000);
};
scheduleRefresh();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
// IntersectionObserver for infinite scroll on batches
useEffect(() => {
@@ -134,7 +224,7 @@ export default function BatchPage() {
fetchDataRef.current?.(true, { appendBatches: true });
}
},
{ threshold: 0.1 }
{ threshold: 0.1 },
);
if (bottomRefBatches.current) {
@@ -148,22 +238,41 @@ export default function BatchPage() {
return (
<div className="flex flex-col gap-6">
{/* Toolbar */}
<div className="flex items-center justify-end gap-4 flex-wrap">
<button
onClick={() => fetchData(false)}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
bg-surface border border-border
text-text-secondary hover:text-text-primary
hover:border-primary transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{loading ? "Refreshing…" : "Refresh"}
</button>
{/* Concept card (F3) */}
<BatchConceptCard />
{/* Toolbar: auto-refresh indicator + Refresh + New batch */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<span className="material-symbols-outlined text-[14px]">refresh</span>
{t("batchListAutoRefresh")}
</div>
<div className="flex gap-2">
<button
onClick={() => fetchData(false)}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
bg-[var(--color-surface)] border border-[var(--color-border)]
text-[var(--color-text-secondary)] hover:text-[var(--color-text-main)]
hover:border-[var(--color-accent)] transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{loading ? "Refreshing…" : "Refresh"}
</button>
<button
onClick={() => setShowWizard(true)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
bg-[var(--color-accent)] text-white hover:opacity-90
transition-all duration-200"
>
<span className="material-symbols-outlined text-[16px]">add</span>
{t("batchListNewButton")}
</button>
</div>
</div>
{/* Batch list + infinite scroll sentinel */}
<div className="flex flex-col gap-6">
<BatchListTab
batches={batches}
@@ -177,6 +286,18 @@ export default function BatchPage() {
)}
<div ref={bottomRefBatches} className="h-10" />
</div>
{/* New batch wizard (F4 stub — replaced by F4 full implementation on merge) */}
{showWizard && (
<NewBatchWizard
onClose={() => setShowWizard(false)}
onCreated={(_id) => {
setShowWizard(false);
void fetchData(false);
}}
availableProviders={providers}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,395 @@
// @vitest-environment jsdom
/**
* Tests for useBatchActions hook (F6).
*
* Coverage targets:
* 1. cancel: POST /cancel success → onRefresh called, cancelling=false after
* 2. cancel: 500 response → error set (i18n key), stack NOT exposed
* 3. cancel: fetch throws → error set, stack NOT exposed
* 4. retry without errorFileId → returns null without fetching
* 5. retry: 0 retriable lines in plan → error set
* 6. retry: 3 failed → POST /files + POST /batches, returns newBatchId, onRefresh called
* 7. retry: POST /files 500 → error set, stack NOT exposed
* 8. retry: POST /batches 500 → error set, stack NOT exposed
* 9. cancelling=false after cancel call resolves
* 10. retrying=false after retry call resolves
* 11. downloadHrefOutput: returns null when no outputFileId
* 12. downloadHrefOutput: returns URL when outputFileId present
* 13. downloadHrefErrors: returns null when no errorFileId
* 14. downloadHrefErrors: returns URL when errorFileId present
* 15. Sanitization: error string never contains "/home/", "at /", or ".ts:"
*/
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, it, expect, vi, afterEach } from "vitest";
// ── Mocks ─────────────────────────────────────────────────────────────────────
// next-intl mock: return the key as-is for easy assertion
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
// retryFailed mock — controlled by tests
const buildRetryPlanMock = vi.fn();
vi.mock("@/lib/batches/retryFailed", () => ({
buildRetryPlan: (args: unknown) => buildRetryPlanMock(args),
}));
// ── Import after mocks ────────────────────────────────────────────────────────
const { useBatchActions } = await import(
"../../../../../src/app/(dashboard)/dashboard/batch/components/useBatchActions"
);
// ── Types ─────────────────────────────────────────────────────────────────────
type HookResult = ReturnType<typeof useBatchActions>;
/** Simple t() stub that returns the key */
const t = (key: string) => key;
// ── renderHook implementation ─────────────────────────────────────────────────
/**
* Renders the hook via a React component that calls a subscriber on each render.
* Returns a getter for the latest result; never mutates anything in render.
*/
function renderHook(
opts: { onRefresh?: () => void },
containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }>,
): { get: () => HookResult | null } {
// Store the latest result outside React via a subscriber — no mutation inside render
let latestResult: HookResult | null = null;
const subscriber = (result: HookResult) => {
latestResult = result;
};
function Wrapper({ subscribe }: { subscribe: (r: HookResult) => void }) {
const result = useBatchActions({ ...opts, t });
subscribe(result);
return null;
}
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(<Wrapper subscribe={subscriber} />);
});
containers.push({ root, el });
return { get: () => latestResult };
}
// ── Setup / teardown ──────────────────────────────────────────────────────────
const containers: Array<{ root: ReturnType<typeof createRoot>; el: HTMLDivElement }> = [];
afterEach(() => {
for (const { root, el } of containers.splice(0)) {
act(() => root.unmount());
el.remove();
}
vi.clearAllMocks();
vi.restoreAllMocks();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("useBatchActions — cancel", () => {
it("1. success: onRefresh called, cancelling=false after", async () => {
const onRefresh = vi.fn();
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true });
const hook = renderHook({ onRefresh }, containers);
await act(async () => {
await hook.get()!.cancel("batch-123");
});
expect(global.fetch).toHaveBeenCalledWith(
"/api/v1/batches/batch-123/cancel",
{ method: "POST" },
);
expect(onRefresh).toHaveBeenCalledOnce();
expect(hook.get()!.cancelling).toBe(false);
expect(hook.get()!.error).toBeNull();
});
it("2. 500 response → error set (i18n key only), stack NOT exposed", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false, status: 500 });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-abc");
});
expect(hook.get()!.error).toBe("batchActionCancel");
expect(hook.get()!.cancelling).toBe(false);
// Sanitization: error must not leak paths or stack traces
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toMatch(/\/home\//);
expect(errStr).not.toMatch(/at \//);
expect(errStr).not.toMatch(/\.ts:/);
});
it("3. fetch throws → error set (i18n key only), stack NOT exposed", async () => {
global.fetch = vi.fn().mockRejectedValueOnce(
new Error("ECONNREFUSED at /home/user/src/route.ts:42"),
);
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-xyz");
});
expect(hook.get()!.error).toBe("batchActionCancel");
expect(hook.get()!.cancelling).toBe(false);
// The raw error message must NOT appear in error state
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toContain("ECONNREFUSED");
expect(errStr).not.toContain("/home/user");
expect(errStr).not.toContain("route.ts");
});
it("9. cancelling=false after cancel resolves", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-123");
});
expect(hook.get()!.cancelling).toBe(false);
});
});
describe("useBatchActions — retry", () => {
const BASE_BATCH = {
id: "batch-1",
inputFileId: "file-input",
errorFileId: "file-error",
endpoint: "/v1/chat/completions",
};
it("4. no errorFileId → returns null without any fetch", async () => {
global.fetch = vi.fn();
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry({
...BASE_BATCH,
errorFileId: null,
});
});
expect(result).toBeNull();
expect(global.fetch).not.toHaveBeenCalled();
});
it("5. 0 retriable lines in plan → error set", async () => {
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => "line1\n" })
.mockResolvedValueOnce({ ok: true, text: async () => "error1\n" });
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 0, newJsonl: "" });
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetry");
expect(hook.get()!.retrying).toBe(false);
});
it("6. 3 failed → POST /files + POST /batches, returns newBatchId, onRefresh called", async () => {
const onRefresh = vi.fn();
const inputContent = [
'{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}',
'{"custom_id":"r2","method":"POST","url":"/v1/chat/completions","body":{}}',
'{"custom_id":"r3","method":"POST","url":"/v1/chat/completions","body":{}}',
].join("\n");
const errorContent = [
'{"custom_id":"r1","error":{"type":"server_error"}}',
'{"custom_id":"r2","error":{"type":"server_error"}}',
'{"custom_id":"r3","error":{"type":"server_error"}}',
].join("\n");
buildRetryPlanMock.mockReturnValueOnce({
retriableLines: 3,
newJsonl: inputContent,
failedCustomIds: ["r1", "r2", "r3"],
skippedLines: 0,
});
global.fetch = vi
.fn()
// GET input file content
.mockResolvedValueOnce({ ok: true, text: async () => inputContent })
// GET error file content
.mockResolvedValueOnce({ ok: true, text: async () => errorContent })
// POST /files (upload retry JSONL)
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
// POST /batches (create retry batch)
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "batch-retry-1" }) });
const hook = renderHook({ onRefresh }, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toEqual({ newBatchId: "batch-retry-1" });
expect(onRefresh).toHaveBeenCalledOnce();
expect(hook.get()!.retrying).toBe(false);
expect(hook.get()!.error).toBeNull();
// Verify API call shapes
const fetchCalls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
expect(fetchCalls[2][0]).toBe("/api/v1/files");
expect(fetchCalls[2][1].method).toBe("POST");
expect(fetchCalls[3][0]).toBe("/api/v1/batches");
const batchBody = JSON.parse(fetchCalls[3][1].body as string);
expect(batchBody.input_file_id).toBe("file-retry-1");
expect(batchBody.endpoint).toBe(BASE_BATCH.endpoint);
expect(batchBody.completion_window).toBe("24h");
});
it("7. POST /files 500 → error set, stack NOT exposed", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: false, status: 500 }); // POST /files fails
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetry");
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toMatch(/\/home\//);
expect(errStr).not.toMatch(/at \//);
expect(errStr).not.toMatch(/\.ts:/);
});
it("8. POST /batches 500 → error set, stack NOT exposed", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
.mockResolvedValueOnce({ ok: false, status: 503 }); // POST /batches fails
const hook = renderHook({}, containers);
let result: { newBatchId: string } | null = undefined as never;
await act(async () => {
result = await hook.get()!.retry(BASE_BATCH);
});
expect(result).toBeNull();
expect(hook.get()!.error).toBe("batchActionRetry");
const errStr = hook.get()!.error ?? "";
expect(errStr).not.toContain("/home/");
expect(errStr).not.toContain("route.ts");
});
it("10. retrying=false after retry call resolves", async () => {
const fileContent = '{"custom_id":"r1","method":"POST","url":"/v1/chat/completions","body":{}}';
buildRetryPlanMock.mockReturnValueOnce({ retriableLines: 1, newJsonl: fileContent });
global.fetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, text: async () => fileContent })
.mockResolvedValueOnce({ ok: true, text: async () => '{"custom_id":"r1","error":{}}' })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "file-retry-1" }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "batch-new" }) });
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.retry(BASE_BATCH);
});
expect(hook.get()!.retrying).toBe(false);
});
});
describe("useBatchActions — download hrefs", () => {
it("11. downloadHrefOutput: null when no outputFileId", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefOutput(null)).toBeNull();
expect(hook.get()!.downloadHrefOutput(undefined)).toBeNull();
});
it("12. downloadHrefOutput: returns correct URL", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefOutput("file-out-42")).toBe(
"/api/v1/files/file-out-42/content",
);
});
it("13. downloadHrefErrors: null when no errorFileId", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefErrors(null)).toBeNull();
expect(hook.get()!.downloadHrefErrors(undefined)).toBeNull();
});
it("14. downloadHrefErrors: returns correct URL", () => {
const hook = renderHook({}, containers);
expect(hook.get()!.downloadHrefErrors("file-err-99")).toBe(
"/api/v1/files/file-err-99/content",
);
});
});
describe("useBatchActions — sanitization invariant", () => {
it("15. error never leaks internal paths or stack traces", async () => {
// Simulate a low-level error with a path in its message
global.fetch = vi
.fn()
.mockRejectedValue(
new Error("Network failed at /home/diegosouzapw/dev/proxys/OmniRoute/src/route.ts:88"),
);
const hook = renderHook({}, containers);
await act(async () => {
await hook.get()!.cancel("batch-sanitize-test");
});
const errStr = hook.get()!.error ?? "";
// Must be an i18n key, never a raw error message
expect(errStr).toBe("batchActionCancel");
expect(errStr).not.toContain("/home/");
expect(errStr).not.toContain("/dev/proxys");
expect(errStr).not.toContain("route.ts");
expect(errStr).not.toContain("Network failed");
expect(errStr).not.toMatch(/at \//);
});
});