mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-09 17:28:51 +00:00
13 fixes from independent round-2 code review:
- B-1: Fix FileRecord wrong import in batch-utils.ts (TS2305 masked by limited typecheck-core scope); include batch-utils.ts + 5 lib/batches/* in tsconfig.typecheck-core.json so future regressions are caught
- A-2: role="dialog" + aria-modal + aria-labelledby on NewBatchWizard + BatchDetailModal panels (a11y consistency with UploadFileModal)
- A-3: "Janela de conclusão de 24h" line in CostEstimateStep (spec §5 explicit)
- A-7: "campos obrigatórios válidos" appended to JsonlValidationStep success summary (spec §5)
- A-9 + B-7: 18 hardcoded UI strings → i18n keys (Size, Refresh/Refreshing, Remove, Uploading, Reading file, Ready, Large file warning, JSONL generated, etc.)
- B-4: Strip UTF-8 BOM in validateJsonl + csvToJsonl (Windows-saved files no longer fail "invalid JSON" cryptically)
- B-5: Remove dead exports WizardStep + BatchProviderConfig from types.ts
- A-1: Add Provider column with derived heuristic (gpt-/o1-/o3-/text-embedding-/dall-e- → OpenAI; claude- → Anthropic; gemini → Gemini; else "—") in BatchListTab (BatchRecord has no provider field, so derivation is display-only)
- A-5: Enter key advances wizard step via data-wizard-next button trigger (skip when focus in INPUT/TEXTAREA/SELECT)
- A-6: Auto-dismiss "Batch {id} criado" banner on /batch page after wizard completes; consumes onCreated id (was discarded as _id)
- B-2: Race guard in InputStep.processFile (early-return if isReading) + drop zone pointer-events-none while reading
- C-tests: 6 new regression tests covering body=[] Array.isArray guard, BOM stripping, alias-match pricing path, (partial) suffix on expired_with_failures, -50% inline badge on Cost column, Provider column derivation per family
Side fix: narrow Record<string,unknown> child navigation in csvToJsonl.ts:135 to silence TS2322 once file is in typecheck scope.
22 new i18n keys (filesListSizeColumn, batchListProviderColumn/Unknown/BatchCreated/Dismiss/Refreshing/Refresh, uploadFileModalRemove/Uploading, wizardInputReading/Ready/LargeFileLabel/CsvJsonlReady/LargeFileWarning, wizardCostWindow24h, wizardValidationFieldsOk) added in en.json + pt-BR.json and propagated to 40 locales via fill-missing-from-en.mjs.
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import FileDetailModal from "./FileDetailModal";
|
|
import ExpirationBadge from "./components/ExpirationBadge";
|
|
|
|
function relativeTime(ts: number): string {
|
|
// ts is in seconds (Unix timestamp)
|
|
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`;
|
|
const diffDays = Math.round(diffHr / 24);
|
|
return `${diffDays}d ago`;
|
|
}
|
|
|
|
|
|
interface FileRecord {
|
|
id: string;
|
|
filename: string;
|
|
bytes: number;
|
|
purpose: string;
|
|
createdAt: number;
|
|
expiresAt?: number | null;
|
|
}
|
|
|
|
interface BatchRecord {
|
|
id: string;
|
|
endpoint: string;
|
|
status: string;
|
|
inputFileId: string;
|
|
outputFileId?: string | null;
|
|
errorFileId?: string | null;
|
|
model?: string | null;
|
|
}
|
|
|
|
interface FilesListTabProps {
|
|
files: FileRecord[];
|
|
filesTotal?: number;
|
|
loading: boolean;
|
|
onRefresh?: () => void;
|
|
batches?: BatchRecord[];
|
|
}
|
|
|
|
const PURPOSE_STYLES_MAP: 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 TERMINAL_STATUSES = new Set(["completed", "failed", "cancelled", "expired"]);
|
|
|
|
function Badge({ value, styles }: Readonly<{ 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,
|
|
filesTotal,
|
|
loading,
|
|
onRefresh,
|
|
batches,
|
|
}: Readonly<FilesListTabProps>) {
|
|
const t = useTranslations("common");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [purposeFilter, setPurposeFilter] = useState("all");
|
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
|
const [fileContents, setFileContents] = useState<string | null>(null);
|
|
const [contentsLoading, setContentsLoading] = useState(false);
|
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
|
|
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;
|
|
});
|
|
|
|
const selectedFile = selectedFileId ? files.find((f) => f.id === selectedFileId) : null;
|
|
|
|
const handleFileClick = async (file: FileRecord) => {
|
|
setSelectedFileId(file.id);
|
|
setFileContents(null);
|
|
setContentsLoading(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/files/${file.id}/content`);
|
|
if (response.ok) {
|
|
const text = await response.text();
|
|
setFileContents(text);
|
|
} else {
|
|
setFileContents("Failed to load file contents");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch file contents:", error);
|
|
setFileContents("Error loading file contents");
|
|
} finally {
|
|
setContentsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteFile = async (file: FileRecord) => {
|
|
setDeletingId(file.id);
|
|
try {
|
|
const res = await fetch(`/api/v1/files/${file.id}`, { method: "DELETE" });
|
|
if (res.ok) {
|
|
onRefresh?.();
|
|
} else {
|
|
console.error("[FilesListTab] DELETE returned", res.status);
|
|
}
|
|
} catch (err) {
|
|
console.error("[FilesListTab] DELETE threw", err);
|
|
} finally {
|
|
setDeletingId(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap gap-3 p-4 rounded-xl bg-[var(--color-surface)] border border-[var(--color-border)]">
|
|
<span className="text-sm text-[var(--color-text-muted)] self-center">
|
|
{filesTotal ? `${filesTotal} files` : "Files"}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
placeholder={t("batchFilesListSearchPlaceholder")}
|
|
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 overflow-y-hidden rounded-xl border border-[var(--color-border)]">
|
|
<table className="w-full text-sm" role="table" aria-label={t("batchFilesListFilesTable")}>
|
|
<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">
|
|
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">
|
|
{t("filesListSizeColumn")}
|
|
</th>
|
|
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
|
{t("filesListUsedByColumn")}
|
|
</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="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
|
Expires
|
|
</th>
|
|
<th className="text-left px-4 py-3 font-medium text-[var(--color-text-muted)] uppercase text-xs tracking-wider">
|
|
{/* Actions */}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading && filtered.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={8} 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={8} className="px-4 py-10 text-center text-[var(--color-text-muted)]">
|
|
No files found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filtered.map((file) => {
|
|
const fileCreatedAt = file.createdAt;
|
|
const fileExpiresAt = file.expiresAt;
|
|
|
|
// D12 — derive "Used by" from batches prop
|
|
const related = (batches ?? []).filter(
|
|
(b) =>
|
|
b.inputFileId === file.id ||
|
|
b.outputFileId === file.id ||
|
|
b.errorFileId === file.id
|
|
);
|
|
const allTerminal = related.every((b) => TERMINAL_STATUSES.has(b.status));
|
|
const canDelete = related.length === 0 || allTerminal;
|
|
|
|
return (
|
|
<tr
|
|
key={file.id}
|
|
onClick={() => handleFileClick(file)}
|
|
className={`border-b border-[var(--color-border)] cursor-pointer transition-colors ${
|
|
selectedFileId === file.id
|
|
? "bg-[var(--color-accent)]/10"
|
|
: "hover:bg-[var(--color-bg-alt)]"
|
|
}`}
|
|
>
|
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-muted)] max-w-[140px]">
|
|
<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-[180px]">
|
|
<span className="truncate block" title={file.filename}>
|
|
{file.filename}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Badge value={file.purpose} styles={PURPOSE_STYLES_MAP} />
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
|
{formatBytes(file.bytes)}
|
|
</td>
|
|
{/* "Used by" column (D12) — shows batch id + role (input/output/error) per plan §4 */}
|
|
<td className="px-4 py-3 text-xs">
|
|
{related.length === 0 ? (
|
|
<span className="text-[var(--color-text-muted)]">
|
|
{t("filesListUsedByNone")}
|
|
</span>
|
|
) : (
|
|
(() => {
|
|
const roleFor = (b: BatchRecord): string =>
|
|
b.inputFileId === file.id
|
|
? t("filesListUsedByRoleInput")
|
|
: b.outputFileId === file.id
|
|
? t("filesListUsedByRoleOutput")
|
|
: t("filesListUsedByRoleError");
|
|
return (
|
|
<div
|
|
className="flex flex-col gap-0.5"
|
|
title={related.map((b) => `${b.id} (${roleFor(b)})`).join(", ")}
|
|
>
|
|
{related.slice(0, 2).map((b) => (
|
|
<span
|
|
key={b.id}
|
|
className="font-mono text-[10px] text-[var(--color-text-main)]"
|
|
>
|
|
{b.id.slice(0, 12)}…{" "}
|
|
<span className="text-[var(--color-text-muted)]">
|
|
({roleFor(b)})
|
|
</span>
|
|
</span>
|
|
))}
|
|
{related.length > 2 && (
|
|
<span className="text-[10px] text-[var(--color-text-muted)]">
|
|
+{related.length - 2}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})()
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
|
{fileCreatedAt ? relativeTime(fileCreatedAt) : "—"}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-[var(--color-text-muted)] whitespace-nowrap">
|
|
{fileExpiresAt ? (
|
|
<ExpirationBadge expiresAt={fileExpiresAt} variant="compact" />
|
|
) : (
|
|
<span className="text-xs text-[var(--color-text-muted)]">Never</span>
|
|
)}
|
|
</td>
|
|
{/* Actions column */}
|
|
<td
|
|
className="px-4 py-3 whitespace-nowrap"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-1">
|
|
{/* Download button */}
|
|
<a
|
|
href={`/api/v1/files/${file.id}/content`}
|
|
download={file.filename}
|
|
className="p-1.5 rounded text-[var(--color-text-muted)] hover:text-[var(--color-accent)] hover:bg-[var(--color-bg-alt)] transition-colors"
|
|
title={t("filesListDownload")}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">download</span>
|
|
</a>
|
|
{/* Delete button */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void handleDeleteFile(file);
|
|
}}
|
|
disabled={!canDelete || deletingId === file.id}
|
|
title={
|
|
canDelete
|
|
? t("filesListDelete")
|
|
: "File in use by active batch"
|
|
}
|
|
className="p-1.5 rounded text-[var(--color-text-muted)] hover:text-red-400 hover:bg-[var(--color-bg-alt)] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">
|
|
{deletingId === file.id ? "hourglass_empty" : "delete"}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* File Info Modal */}
|
|
{selectedFile && (
|
|
<FileDetailModal
|
|
file={selectedFile}
|
|
contents={fileContents}
|
|
loading={contentsLoading}
|
|
batches={batches}
|
|
onClose={() => setSelectedFileId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|