Feature/OpenAI batching gui (#1500)

* Added GUI for OpenAI batching
* Support downloading files via API
* Set stream: false for chat completions in batchProcessor
This commit is contained in:
diegosouzapw
2026-04-22 02:01:17 -03:00
parent 66bf8054f9
commit 128f242808
8 changed files with 1443 additions and 180 deletions

View File

@@ -299,7 +299,19 @@ async function processBatchItems(batch: BatchRecord, items: BatchRequestItem[])
try {
// BATCH-SPECIFIC: Force stream: false — batches don't support SSE responses
const batchItemBody = { ...item.body, stream: false };
const isChatEndpoint = ![
"/v1/embeddings",
"/v1/moderations",
"/v1/images/generations",
"/v1/images/edits",
"/v1/videos",
"/v1/videos/generations",
].includes(item.url);
const batchItemBody = {
...item.body,
...(isChatEndpoint ? { stream: false } : {}),
};
const response = await dispatchBatchApiRequest({
endpoint: item.url,
body: batchItemBody,

View File

@@ -0,0 +1,363 @@
"use client";
import { useEffect } from "react";
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts * 1000;
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.round(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.round(diffHr / 24)}d ago`;
}
interface BatchRecord {
id: string;
endpoint: string;
completionWindow: string;
status: string;
inputFileId: string;
outputFileId?: string | null;
errorFileId?: string | null;
createdAt: number;
inProgressAt?: number | null;
expiresAt?: number | null;
finalizingAt?: number | null;
completedAt?: number | null;
failedAt?: number | null;
expiredAt?: number | null;
cancellingAt?: number | null;
cancelledAt?: number | null;
requestCountsTotal: number;
requestCountsCompleted: number;
requestCountsFailed: number;
metadata?: Record<string, unknown> | null;
errors?: unknown | null;
model?: string | null;
usage?: unknown | null;
}
interface FileRecord {
id: string;
filename: string;
bytes: number;
purpose: string;
status?: string | null;
createdAt: number;
}
interface BatchDetailModalProps {
batch: BatchRecord;
files: FileRecord[];
onClose: () => void;
}
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",
failed: "bg-red-500/15 text-red-400 border-red-500/25",
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/25",
in_progress_with_failures: "bg-orange-500/15 text-orange-400 border-orange-500/25",
finalizing: "bg-violet-500/15 text-violet-400 border-violet-500/25",
finalizing_with_failures: "bg-orange-500/15 text-orange-400 border-orange-500/25",
validating: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
cancelling: "bg-orange-500/15 text-orange-400 border-orange-500/25",
cancelled: "bg-gray-500/15 text-gray-400 border-gray-500/25",
cancelled_with_failures: "bg-red-500/15 text-red-400 border-red-500/25",
expired: "bg-gray-500/15 text-gray-400 border-gray-500/25",
};
const STATUS_LABELS: Record<string, string> = {
completed_with_failures: "completed with failures",
in_progress_with_failures: "in progress (with failures)",
finalizing_with_failures: "finalizing (with failures)",
cancelled_with_failures: "cancelled with failures",
};
function effectiveStatus(batch: BatchRecord): string {
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
if (!hasFailed) return batch.status;
const map: Record<string, string> = {
completed: "completed_with_failures",
in_progress: "in_progress_with_failures",
finalizing: "finalizing_with_failures",
cancelled: "cancelled_with_failures",
};
return map[batch.status] ?? batch.status;
}
function StatusBadge({ batch }: { batch: BatchRecord }) {
const key = effectiveStatus(batch);
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
return (
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
{label}
</span>
);
}
function Field({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
{label}
</span>
<span className="text-sm text-[var(--color-text-main)] font-mono break-all">{value}</span>
</div>
);
}
function formatTs(ts: number | null | undefined): string {
if (!ts) return "—";
const d = new Date(ts * 1000);
return d.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
export default function BatchDetailModal({ batch, files, onClose }: BatchDetailModalProps) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
const total = batch.requestCountsTotal || 0;
const completed = batch.requestCountsCompleted || 0;
const failed = batch.requestCountsFailed || 0;
const donePct = total > 0 ? (completed / total) * 100 : 0;
const failedPct = total > 0 ? (failed / total) * 100 : 0;
const pct = Math.round(donePct + failedPct);
const inputFile = files.find((f) => f.id === batch.inputFileId);
const outputFile = batch.outputFileId ? files.find((f) => f.id === batch.outputFileId) : null;
const errorFile = batch.errorFileId ? files.find((f) => f.id === batch.errorFileId) : null;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div className="relative w-full sm:max-w-2xl bg-[var(--color-surface)] border border-[var(--color-border)] rounded-t-2xl sm:rounded-xl shadow-2xl animate-in fade-in slide-in-from-bottom-4 duration-200 max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border)] flex-shrink-0">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-muted)]">
pending_actions
</span>
<div>
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
Batch Details
</h2>
<p className="text-xs text-[var(--color-text-muted)] font-mono mt-0.5">{batch.id}</p>
</div>
</div>
<button
onClick={onClose}
aria-label="Close"
className="p-1.5 rounded-lg text-[var(--color-text-muted)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<span className="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 p-6 space-y-6">
{/* Status + meta */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Status
</span>
<StatusBadge batch={batch} />
</div>
<Field label="Endpoint" value={batch.endpoint} />
{batch.model && <Field label="Model" value={batch.model} />}
<Field label="Window" value={batch.completionWindow} />
<Field
label="Created"
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
/>
</div>
{/* Progress */}
{total > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--color-text-muted)] uppercase tracking-wider font-medium">
Progress
</span>
<span className="text-[var(--color-text-muted)]">
{completed} / {total} ({pct}%)
</span>
</div>
<div className="h-2 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 className="flex gap-4 text-xs text-[var(--color-text-muted)]">
<span>
<span className="text-emerald-400 font-medium">{completed}</span> completed
</span>
{failed > 0 && (
<span>
<span className="text-red-400 font-medium">{failed}</span> failed
</span>
)}
<span>
<span className="font-medium">{total - completed - failed}</span> pending
</span>
</div>
</div>
)}
{/* Timestamps */}
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
Timeline
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
{[
{ label: "Created", ts: batch.createdAt },
{ label: "In Progress", ts: batch.inProgressAt },
{ label: "Finalizing", ts: batch.finalizingAt },
{ label: "Completed", ts: batch.completedAt },
{ label: "Failed", ts: batch.failedAt },
{ label: "Expires", ts: batch.expiresAt },
{ label: "Expired", ts: batch.expiredAt },
{ label: "Cancelling", ts: batch.cancellingAt },
{ label: "Cancelled", ts: batch.cancelledAt },
]
.filter((t) => t.ts)
.map(({ label, ts }) => (
<div key={label} className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider text-[var(--color-text-muted)]">
{label}
</span>
<span className="text-xs font-mono text-[var(--color-text-main)]">
{formatTs(ts)}
</span>
</div>
))}
</div>
</div>
{/* Files */}
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
Files
</h3>
<div className="space-y-2">
{[
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
]
.filter((f) => f.fileId)
.map(({ role, fileId, record }) => (
<div
key={role}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)]"
>
<div className="flex items-center gap-3 min-w-0">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-muted)] flex-shrink-0">
insert_drive_file
</span>
<div className="min-w-0">
<p className="text-xs font-medium text-[var(--color-text-muted)]">{role}</p>
<p className="text-xs font-mono text-[var(--color-text-main)] truncate">
{record?.filename ?? fileId}
</p>
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0 ml-4">
{record && (
<span className="text-xs text-[var(--color-text-muted)]">
{(record.bytes / 1024).toFixed(1)} KB
</span>
)}
<a
href={`/api/files/${fileId}/content`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
>
<span className="material-symbols-outlined text-[13px]">download</span>
Download
</a>
</div>
</div>
))}
</div>
</div>
{/* Usage */}
{batch.usage && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
Token Usage
</h3>
<pre className="p-3 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)] text-xs font-mono text-[var(--color-text-main)] overflow-x-auto">
{JSON.stringify(batch.usage, null, 2)}
</pre>
</div>
)}
{/* Errors */}
{batch.errors && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
Errors
</h3>
<pre className="p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-xs font-mono text-red-300 overflow-x-auto">
{JSON.stringify(batch.errors, null, 2)}
</pre>
</div>
)}
{/* Metadata */}
{batch.metadata && Object.keys(batch.metadata).length > 0 && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
Metadata
</h3>
<div className="space-y-1">
{Object.entries(batch.metadata).map(([k, v]) => (
<div
key={k}
className="flex items-center gap-2 text-xs font-mono px-3 py-1.5 rounded bg-[var(--color-bg-alt)] border border-[var(--color-border)]"
>
<span className="text-[var(--color-text-muted)]">{k}</span>
<span className="text-[var(--color-text-muted)]">=</span>
<span className="text-[var(--color-text-main)]">{String(v)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,273 @@
"use client";
import { useState } from "react";
import BatchDetailModal from "./BatchDetailModal";
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts * 1000;
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.round(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.round(diffHr / 24)}d ago`;
}
interface BatchRecord {
id: string;
endpoint: string;
completionWindow: string;
status: string;
inputFileId: string;
outputFileId?: string | null;
errorFileId?: string | null;
createdAt: number;
inProgressAt?: number | null;
expiresAt?: number | null;
finalizingAt?: number | null;
completedAt?: number | null;
failedAt?: number | null;
expiredAt?: number | null;
cancellingAt?: number | null;
cancelledAt?: number | null;
requestCountsTotal: number;
requestCountsCompleted: number;
requestCountsFailed: number;
metadata?: Record<string, unknown> | null;
errors?: unknown | null;
model?: string | null;
usage?: unknown | null;
}
interface FileRecord {
id: string;
filename: string;
bytes: number;
purpose: string;
status?: string | null;
createdAt: number;
}
interface BatchListTabProps {
batches: BatchRecord[];
files: FileRecord[];
loading: boolean;
}
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",
failed: "bg-red-500/15 text-red-400 border-red-500/25",
in_progress: "bg-blue-500/15 text-blue-400 border-blue-500/25",
in_progress_with_failures: "bg-orange-500/15 text-orange-400 border-orange-500/25",
finalizing: "bg-violet-500/15 text-violet-400 border-violet-500/25",
finalizing_with_failures: "bg-orange-500/15 text-orange-400 border-orange-500/25",
validating: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
cancelling: "bg-orange-500/15 text-orange-400 border-orange-500/25",
cancelled: "bg-gray-500/15 text-gray-400 border-gray-500/25",
cancelled_with_failures: "bg-red-500/15 text-red-400 border-red-500/25",
expired: "bg-gray-500/15 text-gray-400 border-gray-500/25",
};
const STATUS_LABELS: Record<string, string> = {
completed_with_failures: "completed with failures",
in_progress_with_failures: "in progress (with failures)",
finalizing_with_failures: "finalizing (with failures)",
cancelled_with_failures: "cancelled with failures",
};
/** Returns a composite key that reflects whether partial failures occurred. */
function effectiveStatus(batch: BatchRecord): string {
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
if (!hasFailed) return batch.status;
const map: Record<string, string> = {
completed: "completed_with_failures",
in_progress: "in_progress_with_failures",
finalizing: "finalizing_with_failures",
cancelled: "cancelled_with_failures",
};
return map[batch.status] ?? batch.status;
}
function StatusBadge({ batch }: { batch: BatchRecord }) {
const key = effectiveStatus(batch);
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
return (
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
{label}
</span>
);
}
const ALL_STATUSES = [
"all",
"in_progress",
"validating",
"finalizing",
"completed",
"failed",
"cancelled",
"cancelling",
"expired",
];
export default function BatchListTab({ batches, files, loading }: BatchListTabProps) {
const [selectedBatch, setSelectedBatch] = useState<BatchRecord | null>(null);
const [statusFilter, setStatusFilter] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const filtered = batches.filter((b) => {
if (statusFilter !== "all" && b.status !== statusFilter) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return (
b.id.toLowerCase().includes(q) ||
b.endpoint.toLowerCase().includes(q) ||
(b.model ?? "").toLowerCase().includes(q)
);
}
return true;
});
return (
<>
{/* Filters */}
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
<input
type="text"
placeholder="Search by ID, endpoint, model…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]"
>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>
{s === "all" ? "All statuses" : s}
</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Batches">
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Status
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
ID
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Endpoint
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Model
</th>
<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">
Created
</th>
</tr>
</thead>
<tbody>
{loading && filtered.length === 0 ? (
<tr>
<td colSpan={6} 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
</div>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
No batches found
</td>
</tr>
) : (
filtered.map((batch) => {
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;
return (
<tr
key={batch.id}
onClick={() => setSelectedBatch(batch)}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors cursor-pointer"
>
<td className="px-4 py-3">
<StatusBadge batch={batch} />
</td>
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-muted)] max-w-[180px]">
<span className="truncate block" title={batch.id}>
{batch.id}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)] text-xs">
{batch.endpoint}
</td>
<td className="px-4 py-3 text-[var(--color-text-muted)] text-xs">
{batch.model ?? "—"}
</td>
<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>
) : (
<span className="text-xs text-[var(--color-text-muted)]"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{relativeTime(batch.createdAt)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Detail modal */}
{selectedBatch && (
<BatchDetailModal
batch={selectedBatch}
files={files}
onClose={() => setSelectedBatch(null)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,193 @@
"use client";
import { useState } from "react";
function relativeTime(ts: number): string {
const diffMs = Date.now() - ts * 1000;
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.round(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.round(diffHr / 24)}d ago`;
}
interface FileRecord {
id: string;
filename: string;
bytes: number;
purpose: string;
status?: string | null;
createdAt: number;
}
interface FilesListTabProps {
files: FileRecord[];
loading: boolean;
}
const PURPOSE_STYLES: Record<string, string> = {
batch: "bg-blue-500/15 text-blue-400 border-blue-500/25",
"batch-output": "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
"fine-tune": "bg-violet-500/15 text-violet-400 border-violet-500/25",
assistants: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
};
const STATUS_STYLES: Record<string, string> = {
uploaded: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
processed: "bg-emerald-500/15 text-emerald-400 border-emerald-500/25",
validating: "bg-yellow-500/15 text-yellow-400 border-yellow-500/25",
error: "bg-red-500/15 text-red-400 border-red-500/25",
deleting: "bg-gray-500/15 text-gray-400 border-gray-500/25",
};
function Badge({ value, styles }: { value: string; styles: Record<string, string> }) {
const cls = styles[value] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
return (
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
{value}
</span>
);
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
export default function FilesListTab({ files, loading }: FilesListTabProps) {
const [searchQuery, setSearchQuery] = useState("");
const [purposeFilter, setPurposeFilter] = useState("all");
const purposes = ["all", ...Array.from(new Set(files.map((f) => f.purpose)))];
const filtered = files.filter((f) => {
if (purposeFilter !== "all" && f.purpose !== purposeFilter) return false;
if (searchQuery) {
const q = searchQuery.toLowerCase();
return f.id.toLowerCase().includes(q) || f.filename.toLowerCase().includes(q);
}
return true;
});
return (
<>
{/* Filters */}
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
<input
type="text"
placeholder="Search by ID or filename…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 min-w-[200px] px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] placeholder:text-[var(--color-text-muted)] focus:outline-2 focus:outline-[var(--color-accent)]"
/>
<select
value={purposeFilter}
onChange={(e) => setPurposeFilter(e.target.value)}
className="px-3 py-2 rounded-lg text-sm bg-[var(--color-bg)] border border-[var(--color-border)] text-[var(--color-text-main)] focus:outline-2 focus:outline-[var(--color-accent)]"
>
{purposes.map((p) => (
<option key={p} value={p}>
{p === "all" ? "All purposes" : p}
</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)]">
<table className="w-full text-sm" role="table" aria-label="Files">
<thead>
<tr className="bg-[var(--color-bg-alt)] border-b border-[var(--color-border)]">
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Status
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
ID
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Filename
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Purpose
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Size
</th>
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
Created
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{loading && filtered.length === 0 ? (
<tr>
<td colSpan={7} 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
</div>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
No files found
</td>
</tr>
) : (
filtered.map((file) => (
<tr
key={file.id}
className="border-b border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] transition-colors"
>
<td className="px-4 py-3">
{file.status ? (
<Badge value={file.status} styles={STATUS_STYLES} />
) : (
<span className="text-xs text-[var(--color-text-muted)]"></span>
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-muted)] max-w-[160px]">
<span className="truncate block" title={file.id}>
{file.id}
</span>
</td>
<td className="px-4 py-3 text-[var(--color-text-main)] text-xs max-w-[200px]">
<span className="truncate block" title={file.filename}>
{file.filename}
</span>
</td>
<td className="px-4 py-3">
<Badge value={file.purpose} styles={PURPOSE_STYLES} />
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{formatBytes(file.bytes)}
</td>
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
{relativeTime(file.createdAt)}
</td>
<td className="px-4 py-3">
<a
href={`/api/files/${file.id}/content`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors whitespace-nowrap"
>
<span className="material-symbols-outlined text-[13px]">download</span>
Download
</a>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
);
}

View File

@@ -1,30 +1,17 @@
"use client";
import { useState, useEffect } from "react";
import { Card, EmptyState } from "@/shared/components";
function formatDistanceToNow(timestamp: number) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
import { useState, useEffect, useCallback } from "react";
import { SegmentedControl } from "@/shared/components";
import BatchListTab from "./BatchListTab";
import FilesListTab from "./FilesListTab";
export default function BatchPage() {
const [batches, setBatches] = useState([]);
const [files, setFiles] = useState([]);
const [batches, setBatches] = useState<unknown[]>([]);
const [files, setFiles] = useState<unknown[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"batches" | "files">("batches");
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [batchesRes, filesRes] = await Promise.all([
@@ -44,170 +31,45 @@ export default function BatchPage() {
} finally {
setLoading(false);
}
};
}, []);
const renderBatches = () => {
if (batches.length === 0) {
return (
<EmptyState
icon="view_list"
title="No batches found"
description="There are no OpenAI-compatible batches processing right now."
/>
);
}
return (
<div className="grid grid-cols-1 gap-4">
{batches.map((batch: any) => (
<Card key={batch.id} className="p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold">{batch.id}</span>
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
batch.status === "completed"
? "bg-emerald-500/10 text-emerald-500"
: batch.status === "failed"
? "bg-red-500/10 text-red-500"
: batch.status === "in_progress"
? "bg-blue-500/10 text-blue-500"
: "bg-surface text-text-muted"
}`}
>
{batch.status}
</span>
</div>
<span className="text-xs text-text-muted">
{formatDistanceToNow(batch.createdAt)}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="flex flex-col">
<span className="text-text-muted text-xs">Endpoint</span>
<span>{batch.endpoint}</span>
</div>
<div className="flex flex-col">
<span className="text-text-muted text-xs">Input File</span>
<span className="font-mono text-xs truncate" title={batch.inputFileId}>
{batch.inputFileId}
</span>
</div>
<div className="flex flex-col">
<span className="text-text-muted text-xs">Output File</span>
<span className="font-mono text-xs truncate">{batch.outputFileId || "—"}</span>
</div>
<div className="flex flex-col">
<span className="text-text-muted text-xs">Progress</span>
<span>
{batch.requestCountsCompleted || 0} / {batch.requestCountsTotal || 0} reqs
</span>
</div>
</div>
</Card>
))}
</div>
);
};
const renderFiles = () => {
if (files.length === 0) {
return (
<EmptyState
icon="insert_drive_file"
title="No files found"
description="Files uploaded for batch processing will appear here."
/>
);
}
return (
<div className="grid grid-cols-1 gap-4">
{files.map((file: any) => (
<Card key={file.id} className="p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold">{file.id}</span>
</div>
<span className="text-xs text-text-muted">{formatDistanceToNow(file.createdAt)}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="flex flex-col">
<span className="text-text-muted text-xs">Filename</span>
<span className="truncate">{file.filename}</span>
</div>
<div className="flex flex-col">
<span className="text-text-muted text-xs">Purpose</span>
<span>{file.purpose}</span>
</div>
<div className="flex flex-col">
<span className="text-text-muted text-xs">Size</span>
<span>{(file.bytes / 1024).toFixed(2)} KB</span>
</div>
<div className="flex flex-col">
<span className="text-text-muted text-xs">Status</span>
<span className="capitalize">{file.status}</span>
</div>
</div>
</Card>
))}
</div>
);
};
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<div className="flex flex-col h-full overflow-y-auto custom-scrollbar">
<div className="flex flex-col px-6 py-6 md:py-8 max-w-6xl mx-auto w-full gap-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-text-main mb-1">Batch Processing</h1>
<p className="text-text-muted">Monitor asynchronous batch requests and files</p>
</div>
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface/50 border border-border/50 text-sm hover:bg-surface disabled:opacity-50"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
Refresh
</button>
</div>
<div className="flex flex-col gap-6">
{/* Toolbar */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<SegmentedControl
options={[
{ value: "batches", label: `Batches${batches.length ? ` (${batches.length})` : ""}` },
{ value: "files", label: `Files${files.length ? ` (${files.length})` : ""}` },
]}
value={activeTab}
onChange={(v) => setActiveTab(v as "batches" | "files")}
/>
<div className="flex items-center gap-2 border-b border-border/50 pb-px">
<button
onClick={() => setActiveTab("batches")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "batches"
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
Batches ({batches.length})
</button>
<button
onClick={() => setActiveTab("files")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "files"
? "border-primary text-primary"
: "border-transparent text-text-muted hover:text-text-main"
}`}
>
Files ({files.length})
</button>
</div>
{loading && batches.length === 0 && files.length === 0 ? (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : activeTab === "batches" ? (
renderBatches()
) : (
renderFiles()
)}
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)]
text-[var(--text-secondary,#aaa)] hover:text-[var(--text-primary,#fff)]
hover:border-[var(--accent,#7c3aed)] transition-all duration-200
disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
{loading ? "Refreshing…" : "Refresh"}
</button>
</div>
{/* Tab content */}
{activeTab === "batches" ? (
<BatchListTab batches={batches} files={files} loading={loading} />
) : (
<FilesListTab files={files} loading={loading} />
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getFile, getFileContent } from "@/lib/localDb";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const { id } = await params;
const file = getFile(id);
if (!file) {
return NextResponse.json(
{ error: { message: "File not found", type: "invalid_request_error" } },
{ status: 404 }
);
}
const content = getFileContent(id);
if (!content) {
return NextResponse.json(
{ error: { message: "File content not found", type: "invalid_request_error" } },
{ status: 404 }
);
}
const filename = file.filename || id;
return new Response(content as unknown as BodyInit, {
headers: {
"Content-Type": file.mimeType || "application/octet-stream",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}

View File

@@ -0,0 +1,233 @@
/**
* Tests for the management-auth file download endpoint:
* GET /api/files/{id}/content
*
* This route is used by the batch dashboard UI to let admins download
* batch input/output/error files without needing an OpenAI-compatible API key.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-file-download-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-secret-file-dl";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const fileContentRoute = await import("../../src/app/api/files/[id]/content/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Helper: create a real file in the DB ───────────────────────────────────
function makeFileContent(text: string) {
return Buffer.from(text);
}
function createTestFile(opts: {
filename?: string;
content?: Buffer | null;
mimeType?: string;
} = {}) {
const { filename = "test.jsonl", content = makeFileContent("line1\nline2"), mimeType } = opts;
return localDb.createFile({
bytes: content ? content.length : 0,
filename,
purpose: "batch",
content: content ?? undefined,
mimeType,
});
}
// ── Auth tests ─────────────────────────────────────────────────────────────
test("GET /api/files/{id}/content — auth not required (default) → file content returned", async () => {
// By default (no INITIAL_PASSWORD, no password set) auth is skipped,
// so a plain unauthenticated request should still work.
const content = makeFileContent("hello batch");
const file = createTestFile({ filename: "hello.jsonl", content });
const res = await fileContentRoute.GET(
new Request(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 200);
const buf = Buffer.from(await res.arrayBuffer());
assert.equal(buf.toString(), "hello batch");
});
test("GET /api/files/{id}/content — with management session → 200 and file content", async () => {
const content = makeFileContent('{"custom_id":"req-1","response":{"status_code":200}}');
const file = createTestFile({ filename: "output.jsonl", content, mimeType: "application/jsonl" });
const res = await fileContentRoute.GET(
await makeManagementSessionRequest(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 200);
const buf = Buffer.from(await res.arrayBuffer());
assert.equal(buf.toString(), '{"custom_id":"req-1","response":{"status_code":200}}');
});
test("GET /api/files/{id}/content — with management session → content-type header set", async () => {
const content = makeFileContent("data");
const file = createTestFile({ filename: "data.jsonl", content, mimeType: "application/jsonl" });
const res = await fileContentRoute.GET(
await makeManagementSessionRequest(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 200);
assert.ok(
res.headers.get("content-type")?.includes("application/jsonl"),
"content-type should be application/jsonl"
);
});
test("GET /api/files/{id}/content — content-disposition includes filename", async () => {
const content = makeFileContent("payload");
const file = createTestFile({ filename: "my_batch_output.jsonl", content });
const res = await fileContentRoute.GET(
await makeManagementSessionRequest(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 200);
const disposition = res.headers.get("content-disposition") ?? "";
assert.ok(
disposition.includes("my_batch_output.jsonl"),
`content-disposition should include filename, got: ${disposition}`
);
assert.ok(
disposition.includes("attachment"),
`content-disposition should be attachment, got: ${disposition}`
);
});
test("GET /api/files/{id}/content — fallbacks to octet-stream when no mimeType", async () => {
const content = makeFileContent("raw");
const file = createTestFile({ filename: "raw.bin", content, mimeType: undefined });
const res = await fileContentRoute.GET(
await makeManagementSessionRequest(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 200);
assert.ok(
res.headers.get("content-type")?.includes("application/octet-stream"),
"Should fall back to octet-stream"
);
});
// ── Not-found tests ────────────────────────────────────────────────────────
test("GET /api/files/{id}/content — unknown file ID returns 404", async () => {
const res = await fileContentRoute.GET(
await makeManagementSessionRequest("http://localhost/api/files/file-does-not-exist/content"),
{ params: Promise.resolve({ id: "file-does-not-exist" }) }
);
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.error.message, "File not found");
assert.equal(body.error.type, "invalid_request_error");
});
test("GET /api/files/{id}/content — deleted file returns 404", async () => {
const content = makeFileContent("will be deleted");
const file = createTestFile({ filename: "deleted.jsonl", content });
// Soft-delete the file
localDb.deleteFile(file.id);
const res = await fileContentRoute.GET(
await makeManagementSessionRequest(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.error.message, "File not found");
});
test("GET /api/files/{id}/content — file with null content returns 404", async () => {
// createFile with content=null simulates a file record with no stored bytes
// (e.g., an expired file where content was cleared)
const file = localDb.createFile({
bytes: 0,
filename: "no-content.jsonl",
purpose: "batch",
});
const res = await fileContentRoute.GET(
await makeManagementSessionRequest(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 404);
const body = await res.json();
assert.equal(body.error.message, "File content not found");
});
// ── Auth enforcement tests (when INITIAL_PASSWORD is set) ─────────────────
test("GET /api/files/{id}/content — unauthenticated request is rejected when auth is required", async () => {
// Enable auth
process.env.INITIAL_PASSWORD = "test-password";
const content = makeFileContent("secret");
const file = createTestFile({ filename: "secret.jsonl", content });
try {
const res = await fileContentRoute.GET(
new Request(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.notEqual(res.status, 200, "Unauthenticated request should not return 200");
assert.ok(res.status === 401 || res.status === 403, `Expected 401/403, got ${res.status}`);
} finally {
delete process.env.INITIAL_PASSWORD;
}
});
test("GET /api/files/{id}/content — management session works when auth is required", async () => {
process.env.INITIAL_PASSWORD = "test-password";
const content = makeFileContent("authed content");
const file = createTestFile({ filename: "authed.jsonl", content });
try {
const res = await fileContentRoute.GET(
await makeManagementSessionRequest(`http://localhost/api/files/${file.id}/content`),
{ params: Promise.resolve({ id: file.id }) }
);
assert.equal(res.status, 200);
const buf = Buffer.from(await res.arrayBuffer());
assert.equal(buf.toString(), "authed content");
} finally {
delete process.env.INITIAL_PASSWORD;
}
});

View File

@@ -0,0 +1,293 @@
/**
* Unit tests for batch dashboard display logic.
*
* Tests the effectiveStatus() and progress calculation functions that
* determine how batch status badges and progress bars are rendered in
* BatchListTab.tsx and BatchDetailModal.tsx.
*
* These functions are pure and depend on no external modules, so they
* are duplicated here to allow isolated testing without React/jsdom.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// ── Pure functions mirroring BatchListTab.tsx / BatchDetailModal.tsx ──────
/**
* Returns a composite status key that reflects whether partial failures
* occurred within a batch. Mirrors effectiveStatus() in the UI components.
*/
function effectiveStatus(batch) {
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
if (!hasFailed) return batch.status;
const map = {
completed: "completed_with_failures",
in_progress: "in_progress_with_failures",
finalizing: "finalizing_with_failures",
cancelled: "cancelled_with_failures",
};
return map[batch.status] ?? batch.status;
}
/**
* Computes progress bar segment percentages (green = done, red = failed).
* Mirrors the inline calculations in BatchListTab and BatchDetailModal.
*/
function progressPcts(batch) {
const total = batch.requestCountsTotal || 0;
const completed = batch.requestCountsCompleted || 0;
const failed = batch.requestCountsFailed || 0;
const donePct = total > 0 ? (completed / total) * 100 : 0;
const failedPct = total > 0 ? (failed / total) * 100 : 0;
return { donePct, failedPct, total, completed, failed };
}
// ── effectiveStatus tests ─────────────────────────────────────────────────
describe("effectiveStatus — no failures", () => {
it("returns 'completed' when requestCountsFailed is 0", () => {
assert.equal(
effectiveStatus({ status: "completed", requestCountsFailed: 0 }),
"completed"
);
});
it("returns 'in_progress' when requestCountsFailed is 0", () => {
assert.equal(
effectiveStatus({ status: "in_progress", requestCountsFailed: 0 }),
"in_progress"
);
});
it("returns 'finalizing' when requestCountsFailed is 0", () => {
assert.equal(
effectiveStatus({ status: "finalizing", requestCountsFailed: 0 }),
"finalizing"
);
});
it("returns 'cancelled' when requestCountsFailed is 0", () => {
assert.equal(
effectiveStatus({ status: "cancelled", requestCountsFailed: 0 }),
"cancelled"
);
});
it("returns 'failed' unchanged (already a failure state)", () => {
assert.equal(
effectiveStatus({ status: "failed", requestCountsFailed: 0 }),
"failed"
);
});
it("returns 'validating' unchanged", () => {
assert.equal(
effectiveStatus({ status: "validating", requestCountsFailed: 0 }),
"validating"
);
});
it("returns 'expired' unchanged", () => {
assert.equal(
effectiveStatus({ status: "expired", requestCountsFailed: 0 }),
"expired"
);
});
it("returns 'cancelling' unchanged", () => {
assert.equal(
effectiveStatus({ status: "cancelling", requestCountsFailed: 0 }),
"cancelling"
);
});
});
describe("effectiveStatus — with failures", () => {
it("completed + failures → completed_with_failures", () => {
assert.equal(
effectiveStatus({ status: "completed", requestCountsFailed: 3 }),
"completed_with_failures"
);
});
it("in_progress + failures → in_progress_with_failures", () => {
assert.equal(
effectiveStatus({ status: "in_progress", requestCountsFailed: 1 }),
"in_progress_with_failures"
);
});
it("finalizing + failures → finalizing_with_failures", () => {
assert.equal(
effectiveStatus({ status: "finalizing", requestCountsFailed: 2 }),
"finalizing_with_failures"
);
});
it("cancelled + failures → cancelled_with_failures", () => {
assert.equal(
effectiveStatus({ status: "cancelled", requestCountsFailed: 5 }),
"cancelled_with_failures"
);
});
it("failed + failures → failed (already a terminal error, no extra suffix)", () => {
// 'failed' means the whole batch failed, not partial — no mapping needed
assert.equal(
effectiveStatus({ status: "failed", requestCountsFailed: 10 }),
"failed"
);
});
it("expired + failures → expired (no mapping defined)", () => {
assert.equal(
effectiveStatus({ status: "expired", requestCountsFailed: 1 }),
"expired"
);
});
it("validating + failures → validating (no mapping defined)", () => {
assert.equal(
effectiveStatus({ status: "validating", requestCountsFailed: 1 }),
"validating"
);
});
it("cancelling + failures → cancelling (no mapping defined)", () => {
assert.equal(
effectiveStatus({ status: "cancelling", requestCountsFailed: 2 }),
"cancelling"
);
});
});
describe("effectiveStatus — edge cases", () => {
it("treats null requestCountsFailed as 0", () => {
assert.equal(
effectiveStatus({ status: "completed", requestCountsFailed: null }),
"completed"
);
});
it("treats undefined requestCountsFailed as 0", () => {
assert.equal(
effectiveStatus({ status: "completed" }),
"completed"
);
});
it("treats negative failed count as 0 (> 0 check is false for negatives)", () => {
// Negative counts should not happen in practice.
// The check is `> 0`, so -1 does not trigger the failure mapping.
assert.equal(
effectiveStatus({ status: "completed", requestCountsFailed: -1 }),
"completed"
);
});
});
// ── Progress percentage tests ─────────────────────────────────────────────
describe("progressPcts — zero total", () => {
it("returns 0 for both pcts when total is 0", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 0,
requestCountsCompleted: 0,
requestCountsFailed: 0,
});
assert.equal(donePct, 0);
assert.equal(failedPct, 0);
});
it("returns 0 for both pcts when total is missing", () => {
const { donePct, failedPct } = progressPcts({});
assert.equal(donePct, 0);
assert.equal(failedPct, 0);
});
});
describe("progressPcts — normal cases", () => {
it("all completed, no failures → donePct=100, failedPct=0", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 10,
requestCountsCompleted: 10,
requestCountsFailed: 0,
});
assert.equal(donePct, 100);
assert.equal(failedPct, 0);
});
it("all failed, none completed → donePct=0, failedPct=100", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 4,
requestCountsCompleted: 0,
requestCountsFailed: 4,
});
assert.equal(donePct, 0);
assert.equal(failedPct, 100);
});
it("mixed: 6 done, 2 failed of 10 total", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 10,
requestCountsCompleted: 6,
requestCountsFailed: 2,
});
assert.equal(donePct, 60);
assert.equal(failedPct, 20);
});
it("done + failed < total (still in progress)", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 100,
requestCountsCompleted: 30,
requestCountsFailed: 10,
});
assert.equal(donePct, 30);
assert.equal(failedPct, 10);
});
it("single item done", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 1,
requestCountsCompleted: 1,
requestCountsFailed: 0,
});
assert.equal(donePct, 100);
assert.equal(failedPct, 0);
});
it("single item failed", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 1,
requestCountsCompleted: 0,
requestCountsFailed: 1,
});
assert.equal(donePct, 0);
assert.equal(failedPct, 100);
});
});
describe("progressPcts — combined progress bar width", () => {
it("combined pct (done + failed) equals overall processed fraction", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 10,
requestCountsCompleted: 3,
requestCountsFailed: 4,
});
const combinedPct = Math.round(donePct + failedPct);
assert.equal(combinedPct, 70); // (3+4)/10 = 70%
});
it("combined pct stays ≤ 100 when all processed", () => {
const { donePct, failedPct } = progressPcts({
requestCountsTotal: 5,
requestCountsCompleted: 3,
requestCountsFailed: 2,
});
const combinedPct = donePct + failedPct;
assert.ok(combinedPct <= 100, `Combined pct ${combinedPct} should not exceed 100`);
assert.equal(combinedPct, 100);
});
});