mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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,
|
||||
|
||||
363
src/app/(dashboard)/dashboard/batch/BatchDetailModal.tsx
Normal file
363
src/app/(dashboard)/dashboard/batch/BatchDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
src/app/(dashboard)/dashboard/batch/BatchListTab.tsx
Normal file
273
src/app/(dashboard)/dashboard/batch/BatchListTab.tsx
Normal 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
193
src/app/(dashboard)/dashboard/batch/FilesListTab.tsx
Normal file
193
src/app/(dashboard)/dashboard/batch/FilesListTab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
34
src/app/api/files/[id]/content/route.ts
Normal file
34
src/app/api/files/[id]/content/route.ts
Normal 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}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
233
tests/unit/batch-file-download.test.ts
Normal file
233
tests/unit/batch-file-download.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
293
tests/unit/batch-status-logic.test.mjs
Normal file
293
tests/unit/batch-status-logic.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user