diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 684e6817a..5749cdf29 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -66,6 +66,11 @@ const Batches = lazy(() => default: m.Batches, })), ); +const BatchInfo = lazy(() => + import("./components/features/batches/BatchInfo").then((m) => ({ + default: m.BatchInfo, + })), +); const FileRequests = lazy(() => import("./components/features/batches/FileRequests").then((m) => ({ default: m.FileRequests, @@ -121,7 +126,11 @@ const queryClient = new QueryClient({ mutations: { onError: (error) => { // Handle 401s globally for mutations - if (error instanceof Error && "status" in error && error.status === 401) { + if ( + error instanceof Error && + "status" in error && + error.status === 401 + ) { // Clear all queries and redirect to login queryClient.clear(); window.location.href = "/login"; @@ -279,6 +288,18 @@ function AppRoutes() { } /> + + + }> + + + + + } + /> { + const { batchId } = useParams<{ batchId: string }>(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + const fromUrl = searchParams.get("from"); + + const { data: batch, isLoading, error } = useBatch(batchId!); + + if (isLoading) { + return ( +
+
+
+

+ Loading batch details... +

+
+
+ ); + } + + if (error) { + return ( +
+
+
+ +
+

+ Error: {error instanceof Error ? error.message : "Unknown error"} +

+ +
+
+ ); + } + + if (!batch) { + return ( +
+
+

Batch not found

+ +
+
+ ); + } + + const getStatusBadge = (status: BatchStatus) => { + const statusConfig: Record< + BatchStatus, + { + label: string; + variant: "default" | "destructive" | "outline" | "secondary"; + icon: React.ReactNode; + } + > = { + validating: { + label: "Validating", + variant: "secondary", + icon: , + }, + in_progress: { + label: "In Progress", + variant: "default", + icon: , + }, + finalizing: { + label: "Finalizing", + variant: "secondary", + icon: , + }, + completed: { + label: "Completed", + variant: "outline", + icon: , + }, + failed: { + label: "Failed", + variant: "destructive", + icon: , + }, + expired: { + label: "Expired", + variant: "outline", + icon: , + }, + cancelling: { + label: "Cancelling", + variant: "secondary", + icon: , + }, + cancelled: { + label: "Cancelled", + variant: "outline", + icon: , + }, + }; + + const config = statusConfig[status]; + return ( + + {config.icon} + {config.label} + + ); + }; + + const formatTimestamp = (timestamp: number | null | undefined) => { + if (!timestamp) return "N/A"; + return new Date(timestamp * 1000).toLocaleString(); + }; + + const formatDuration = ( + startTimestamp: number | null | undefined, + endTimestamp: number | null | undefined, + ) => { + if (!startTimestamp || !endTimestamp) return "N/A"; + const durationMs = (endTimestamp - startTimestamp) * 1000; + const seconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } else if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } else { + return `${seconds}s`; + } + }; + + const progress = + batch.request_counts.total > 0 + ? Math.round( + (batch.request_counts.completed / batch.request_counts.total) * 100, + ) + : 0; + + const description = batch.metadata?.batch_description; + + return ( +
+ {/* Header */} +
+
+ +
+
+
+

+ Batch Details +

+

+ {batch.id} +

+
+
+ {getStatusBadge(batch.status)} +
+
+
+
+
+ +
+ {/* Main Content */} +
+ {/* Progress Card */} + {batch.status !== "failed" && + batch.status !== "cancelled" && + batch.status !== "expired" && ( + + + Progress + + +
+ {/* Progress Bar */} +
+
+ Overall Progress + {progress}% +
+
+
+
+
+ + {/* Request Counts */} +
+
+

+ {batch.request_counts.total} +

+

+ Total Requests +

+
+
+

+ {batch.request_counts.completed} +

+

Completed

+
+
+

+ {batch.request_counts.failed} +

+

Failed

+
+
+
+
+
+ )} + + {/* Batch Details */} + + + Batch Information + + +
+
+
+

Endpoint

+

+ {batch.endpoint} +

+
+
+

+ Completion Window +

+

{batch.completion_window}

+
+
+ + {description && ( +
+

Description

+

{description}

+
+ )} + + {/* Files */} +
+

+ Associated Files +

+
+
+ +
+

+ Input File +

+

+ {batch.input_file_id} +

+
+ +
+ + {batch.output_file_id && ( +
+ +
+

+ Output File +

+

+ {batch.output_file_id} +

+
+ +
+ )} + + {batch.error_file_id && ( +
+ +
+

+ Error File +

+

+ {batch.error_file_id} +

+
+ +
+ )} +
+
+ + {/* Errors */} + {batch.errors && batch.errors.data.length > 0 && ( +
+

+ Errors +

+
+ {batch.errors.data.map((error, index) => ( +
+
+ +
+

+ {error.code} +

+

+ {error.message} +

+ {error.line && ( +

+ Line {error.line} +

+ )} +
+
+
+ ))} +
+
+ )} +
+
+
+
+ + {/* Sidebar */} +
+ {/* Timeline Card */} + + + Timeline + + +
+
+

Created

+

+ {formatTimestamp(batch.created_at)} +

+
+ + {batch.in_progress_at && ( +
+

Started

+

+ {formatTimestamp(batch.in_progress_at)} +

+
+ )} + + {batch.finalizing_at && ( +
+

Finalizing

+

+ {formatTimestamp(batch.finalizing_at)} +

+
+ )} + + {batch.completed_at && ( +
+

Completed

+

+ {formatTimestamp(batch.completed_at)} +

+
+ )} + + {batch.failed_at && ( +
+

Failed

+

+ {formatTimestamp(batch.failed_at)} +

+
+ )} + + {batch.cancelled_at && ( +
+

Cancelled

+

+ {formatTimestamp(batch.cancelled_at)} +

+
+ )} + + {batch.expired_at && ( +
+

Expired

+

+ {formatTimestamp(batch.expired_at)} +

+
+ )} + + {batch.expires_at && !batch.expired_at && ( +
+

Expires

+

+ {formatTimestamp(batch.expires_at)} +

+
+ )} + + {/* Duration */} + {batch.in_progress_at && batch.completed_at && ( +
+

Duration

+

+ {formatDuration(batch.in_progress_at, batch.completed_at)} +

+
+ )} +
+
+
+ + {/* Metadata Card */} + {batch.metadata && Object.keys(batch.metadata).length > 0 && ( + + + Metadata + + +
+ {Object.entries(batch.metadata).map(([key, value]) => ( +
+

{key}

+

+ {value} +

+
+ ))} +
+
+
+ )} +
+
+
+ ); +}; + +export default BatchInfo; diff --git a/dashboard/src/components/features/batches/BatchInfo/index.ts b/dashboard/src/components/features/batches/BatchInfo/index.ts new file mode 100644 index 000000000..c1d6f3515 --- /dev/null +++ b/dashboard/src/components/features/batches/BatchInfo/index.ts @@ -0,0 +1 @@ +export { default as BatchInfo } from "./BatchInfo"; diff --git a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx index bc4b16f3f..825a508b2 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.pagination.test.tsx @@ -178,6 +178,9 @@ describe("Batches - Pagination", () => { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + // Wait for initial render await waitFor(() => { expect( @@ -278,6 +281,9 @@ describe("Batches - Pagination", () => { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + // Wait for page 1 await waitFor(() => { expect( @@ -389,6 +395,9 @@ describe("Batches - Pagination", () => { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + await waitFor(() => { expect( within(container).getByText("file_page1_0.jsonl"), @@ -523,6 +532,9 @@ describe("Batches - Pagination", () => { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + await waitFor(() => { expect( within(container).getByText("file_page1_0.jsonl"), @@ -636,6 +648,9 @@ describe("Batches - Pagination", () => { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + await waitFor(() => { expect( within(container).getByText("file_page1_0.jsonl"), diff --git a/dashboard/src/components/features/batches/Batches/Batches.test.tsx b/dashboard/src/components/features/batches/Batches/Batches.test.tsx index 833bcda72..804e14939 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.test.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.test.tsx @@ -226,13 +226,14 @@ describe("Batches", () => { ).toBeInTheDocument(); }); - it("should render upload file button", () => { + it("should render create batch button on batches tab", () => { const { container } = render(, { wrapper: createWrapper(), }); + // On batches tab by default, should show "Create Batch" button expect( - within(container).getByRole("button", { name: /upload file/i }), + within(container).getByRole("button", { name: /create batch/i }), ).toBeInTheDocument(); }); @@ -242,12 +243,45 @@ describe("Batches", () => { }); expect( - within(container).getByRole("tab", { name: /files \(2\)/i }), + within(container).getByRole("tab", { name: /files/i }), ).toBeInTheDocument(); expect( - within(container).getByRole("tab", { name: /batches \(2\)/i }), + within(container).getByRole("tab", { name: /batches/i }), ).toBeInTheDocument(); }); + + it("should fetch both files and batches on initial render", () => { + const useFilesSpy = vi.mocked(hooks.useFiles); + const useBatchesSpy = vi.mocked(hooks.useBatches); + + render(, { + wrapper: createWrapper(), + }); + + // Verify both queries were called (not disabled) + expect(useFilesSpy).toHaveBeenCalled(); + expect(useBatchesSpy).toHaveBeenCalled(); + }); + + it("should show correct tab when starting on files tab", () => { + const { container } = render(, { + wrapper: createWrapper(), + }); + + const filesTab = within(container).getByRole("tab", { + name: /files/i, + }); + const batchesTab = within(container).getByRole("tab", { + name: /batches/i, + }); + + expect(filesTab).toBeInTheDocument(); + expect(batchesTab).toBeInTheDocument(); + + // Verify batches tab is active by default + expect(batchesTab).toHaveAttribute("data-state", "active"); + expect(filesTab).toHaveAttribute("data-state", "inactive"); + }); }); describe("Loading State", () => { @@ -303,7 +337,8 @@ describe("Batches", () => { }); describe("Empty States", () => { - it("should show empty state when no files exist", () => { + it("should show empty state when no files exist", async () => { + const user = userEvent.setup(); vi.mocked(hooks.useFiles).mockReturnValue({ data: { data: [] }, isLoading: false, @@ -315,6 +350,9 @@ describe("Batches", () => { wrapper: createWrapper(), }); + // Switch to files tab + await user.click(within(container).getByRole("tab", { name: /files/i })); + expect( within(container).getByText("No files uploaded"), ).toBeInTheDocument(); @@ -362,11 +400,15 @@ describe("Batches", () => { }); describe("Files Tab", () => { - it("should display files in the table", () => { + it("should display files in the table", async () => { + const user = userEvent.setup(); const { container } = render(, { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + expect( within(container).getByText("test_file.jsonl"), ).toBeInTheDocument(); @@ -383,6 +425,9 @@ describe("Batches", () => { { wrapper: createWrapper() }, ); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + await user.click( within(container).getByRole("button", { name: /upload file/i }), ); @@ -396,6 +441,9 @@ describe("Batches", () => { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + const searchInput = within(container).getByPlaceholderText(/search files/i); await user.type(searchInput, "test"); @@ -409,6 +457,9 @@ describe("Batches", () => { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + const searchInput = within(container).getByPlaceholderText(/search files/i); await user.type(searchInput, "test_file"); @@ -465,26 +516,25 @@ describe("Batches", () => { }); describe("Tab Switching", () => { - it("should maintain search when switching tabs", async () => { + it("should have independent search when switching tabs", async () => { const user = userEvent.setup(); const { container } = render(, { wrapper: createWrapper(), }); - // Search in files tab - const fileSearch = - within(container).getByPlaceholderText(/search files/i); - await user.type(fileSearch, "test"); - - // Switch to batches - await user.click( - within(container).getByRole("tab", { name: /batches/i }), - ); - - // Search should be cleared or independent + // Start on batches tab - search batches const batchSearch = within(container).getByPlaceholderText(/search batches/i); - expect(batchSearch).toHaveValue(""); + await user.type(batchSearch, "batch-1"); + expect(batchSearch).toHaveValue("batch-1"); + + // Switch to files tab + await user.click(within(container).getByRole("tab", { name: /files/i })); + + // Files search should be empty (independent from batches search) + const fileSearch = + within(container).getByPlaceholderText(/search files/i); + expect(fileSearch).toHaveValue(""); }); }); @@ -497,6 +547,9 @@ describe("Batches", () => { { wrapper: createWrapper() }, ); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + await user.click( within(container).getByRole("button", { name: /upload file/i }), ); @@ -511,22 +564,30 @@ describe("Batches", () => { }); describe("File Size Display", () => { - it("should display file sizes correctly", () => { + it("should display file sizes correctly", async () => { + const user = userEvent.setup(); const { container } = render(, { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + // File sizes should be formatted (e.g., "142.19 KB", "87.11 KB") within(container).getByText("test_file.jsonl").closest("table"); }); }); describe("Date Formatting", () => { - it("should display created dates for files", () => { + it("should display created dates for files", async () => { + const user = userEvent.setup(); const { container } = render(, { wrapper: createWrapper(), }); + // Switch to files tab first + await user.click(within(container).getByRole("tab", { name: /files/i })); + // Dates should be formatted and displayed within(container).getByText("test_file.jsonl").closest("table"); }); @@ -544,7 +605,7 @@ describe("Batches", () => { }); expect(filesTab).toHaveAttribute("aria-selected"); - expect(batchesTab).not.toHaveAttribute("aria-selected", "true"); + expect(batchesTab).toHaveAttribute("aria-selected", "true"); }); it("should have accessible action buttons", () => { @@ -552,8 +613,9 @@ describe("Batches", () => { wrapper: createWrapper(), }); + // On batches tab by default, should have "Create Batch" button expect( - within(container).getByRole("button", { name: /upload file/i }), + within(container).getByRole("button", { name: /create batch/i }), ).toBeEnabled(); }); }); diff --git a/dashboard/src/components/features/batches/Batches/Batches.tsx b/dashboard/src/components/features/batches/Batches/Batches.tsx index d665e957f..fa95646df 100644 --- a/dashboard/src/components/features/batches/Batches/Batches.tsx +++ b/dashboard/src/components/features/batches/Batches/Batches.tsx @@ -94,48 +94,29 @@ export function Batches({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [onBatchCreatedCallback]); - // Read state from URL - const activeTab = (searchParams.get("tab") as "files" | "batches") || "files"; + // Read state from URL - default to batches tab + const activeTab = + (searchParams.get("tab") as "files" | "batches") || "batches"; const batchFileFilter = searchParams.get("fileFilter"); const fileTypeFilter = (searchParams.get("fileType") as "input" | "output" | "error") || "input"; // Pagination state from URL - const filesPage = parseInt(searchParams.get("filesPage") || "0", 10); + const filesPage = parseInt(searchParams.get("filesPage") || "1", 10); const filesPageSize = parseInt(searchParams.get("filesPageSize") || "10", 10); - const [filesAfterCursor, setFilesAfterCursor] = useState( - undefined, - ); + const filesAfterCursor = searchParams.get("filesAfter") || undefined; - const batchesPage = parseInt(searchParams.get("batchesPage") || "0", 10); + const batchesPage = parseInt(searchParams.get("batchesPage") || "1", 10); const batchesPageSize = parseInt( searchParams.get("batchesPageSize") || "10", 10, ); - const [batchesAfterCursor, setBatchesAfterCursor] = useState< - string | undefined - >(undefined); + const batchesAfterCursor = searchParams.get("batchesAfter") || undefined; // Cursor history for backwards pagination const filesCursorHistory = React.useRef<(string | undefined)[]>([]); const batchesCursorHistory = React.useRef<(string | undefined)[]>([]); - // Update files pagination in URL - const updateFilesPagination = (newPage: number, newPageSize: number) => { - const params = new URLSearchParams(searchParams); - params.set("filesPage", newPage.toString()); - params.set("filesPageSize", newPageSize.toString()); - setSearchParams(params, { replace: true }); - }; - - // Update batches pagination in URL - const updateBatchesPagination = (newPage: number, newPageSize: number) => { - const params = new URLSearchParams(searchParams); - params.set("batchesPage", newPage.toString()); - params.set("batchesPageSize", newPageSize.toString()); - setSearchParams(params, { replace: true }); - }; - // API queries // Paginated files query for display in Files tab // Map fileType to purpose filter @@ -150,14 +131,14 @@ export function Batches({ purpose: filePurpose, limit: filesPageSize + 1, // Fetch one extra to detect if there are more after: filesAfterCursor, - enabled: activeTab === "files", // Only fetch when on files tab + // Always fetch to populate tab counts, but refetch interval is lower when not active }); // Paginated batches query const { data: batchesResponse, isLoading: batchesLoading } = useBatches({ limit: batchesPageSize + 1, // Fetch one extra to detect if there are more after: batchesAfterCursor, - enabled: activeTab === "batches", // Only fetch when on batches tab + // Always fetch to populate tab counts, but refetch interval is lower when not active }); // Process batches response - remove extra item used for hasMore detection @@ -320,8 +301,11 @@ export function Batches({ if (lastFile && filesHasMore) { // Save current cursor to history before moving forward filesCursorHistory.current[filesPage] = filesAfterCursor; - setFilesAfterCursor(lastFile.id); - updateFilesPagination(filesPage + 1, filesPageSize); + const params = new URLSearchParams(searchParams); + params.set("filesPage", (filesPage + 1).toString()); + params.set("filesPageSize", filesPageSize.toString()); + params.set("filesAfter", lastFile.id); + setSearchParams(params, { replace: true }); } }; @@ -329,15 +313,25 @@ export function Batches({ if (filesPage > 0) { // Use cursor history to go back one page const previousCursor = filesCursorHistory.current[filesPage - 1]; - setFilesAfterCursor(previousCursor); - updateFilesPagination(filesPage - 1, filesPageSize); + const params = new URLSearchParams(searchParams); + params.set("filesPage", (filesPage - 1).toString()); + params.set("filesPageSize", filesPageSize.toString()); + if (previousCursor) { + params.set("filesAfter", previousCursor); + } else { + params.delete("filesAfter"); + } + setSearchParams(params, { replace: true }); } }; const handleFilesPageSizeChange = (newSize: number) => { - setFilesAfterCursor(undefined); filesCursorHistory.current = []; // Clear history when changing page size - updateFilesPagination(0, newSize); + const params = new URLSearchParams(searchParams); + params.set("filesPage", "1"); + params.set("filesPageSize", newSize.toString()); + params.delete("filesAfter"); + setSearchParams(params, { replace: true }); }; const handleBatchesNextPage = () => { @@ -345,8 +339,11 @@ export function Batches({ if (lastBatch && batchesHasMore) { // Save current cursor to history before moving forward batchesCursorHistory.current[batchesPage] = batchesAfterCursor; - setBatchesAfterCursor(lastBatch.id); - updateBatchesPagination(batchesPage + 1, batchesPageSize); + const params = new URLSearchParams(searchParams); + params.set("batchesPage", (batchesPage + 1).toString()); + params.set("batchesPageSize", batchesPageSize.toString()); + params.set("batchesAfter", lastBatch.id); + setSearchParams(params, { replace: true }); } }; @@ -354,15 +351,25 @@ export function Batches({ if (batchesPage > 0) { // Use cursor history to go back one page const previousCursor = batchesCursorHistory.current[batchesPage - 1]; - setBatchesAfterCursor(previousCursor); - updateBatchesPagination(batchesPage - 1, batchesPageSize); + const params = new URLSearchParams(searchParams); + params.set("batchesPage", (batchesPage - 1).toString()); + params.set("batchesPageSize", batchesPageSize.toString()); + if (previousCursor) { + params.set("batchesAfter", previousCursor); + } else { + params.delete("batchesAfter"); + } + setSearchParams(params, { replace: true }); } }; const handleBatchesPageSizeChange = (newSize: number) => { - setBatchesAfterCursor(undefined); batchesCursorHistory.current = []; // Clear history when changing page size - updateBatchesPagination(0, newSize); + const params = new URLSearchParams(searchParams); + params.set("batchesPage", "1"); + params.set("batchesPageSize", newSize.toString()); + params.delete("batchesAfter"); + setSearchParams(params, { replace: true }); }; // Check if a file's associated batch is still in progress @@ -401,11 +408,17 @@ export function Batches({ isFileInProgress, }); + const handleBatchClick = (batch: Batch) => { + if ((batch as any)._isEmpty) return; + navigate(`/batches/${batch.id}?from=/batches`); + }; + const batchColumns = createBatchColumns({ onCancel: handleCancelBatch, getBatchFiles, onViewFile: handleViewFileRequests, getInputFile, + onRowClick: handleBatchClick, }); // Loading state @@ -458,39 +471,156 @@ export function Batches({ {/* Right: Buttons + Tabs */}
- {/* Action Button */} - + {/* Action Button - changes based on active tab */} + {activeTab === "batches" ? ( + + ) : ( + + )} {/* Tabs Selector */} - - Files ({files.length}) + + Batches - - Batches ({batches.length}) + + Files
{/* Content */} + + {/* Show filter indicator if active */} + {batchFileFilter && ( +
+ + + Showing batches for file:{" "} + + {files.find((f) => f.id === batchFileFilter)?.filename || + batchFileFilter} + + + +
+ )} + {filteredBatches.length === 0 ? ( +
+
+ +
+

+ No batches created +

+

+ Create a batch from an uploaded file to start processing + requests +

+ +
+ ) : ( + <> + + Rows: + + + } + /> + { + batchesCursorHistory.current = []; + const params = new URLSearchParams(searchParams); + params.set("batchesPage", "1"); + params.set("batchesPageSize", batchesPageSize.toString()); + params.delete("batchesAfter"); + setSearchParams(params, { replace: true }); + }} + hasNextPage={batchesHasMore} + hasPrevPage={batchesPage > 1} + currentPageItemCount={filteredBatches.length} + itemName="batches" + /> + + )} +
+ {files.length === 0 ? (
@@ -555,14 +685,14 @@ export function Batches({ params.delete("fileType"); } // Reset pagination - params.set("filesPage", "0"); + params.set("filesPage", "1"); params.set( "filesPageSize", filesPageSize.toString(), ); + params.delete("filesAfter"); setSearchParams(params, { replace: false }); - // Reset cursor and history - setFilesAfterCursor(undefined); + // Reset cursor history filesCursorHistory.current = []; }} className={`inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${ @@ -599,125 +729,26 @@ export function Batches({ } /> { - setFilesAfterCursor(undefined); filesCursorHistory.current = []; - updateFilesPagination(0, filesPageSize); + const params = new URLSearchParams(searchParams); + params.set("filesPage", "1"); + params.set("filesPageSize", filesPageSize.toString()); + params.delete("filesAfter"); + setSearchParams(params, { replace: true }); }} hasNextPage={filesHasMore} - hasPrevPage={filesPage > 0} + hasPrevPage={filesPage > 1} currentPageItemCount={files.length} itemName="files" /> )} - - - {/* Show filter indicator if active */} - {batchFileFilter && ( -
- - - Showing batches for file:{" "} - - {files.find((f) => f.id === batchFileFilter)?.filename || - batchFileFilter} - - - -
- )} - {filteredBatches.length === 0 ? ( -
-
- -
-

- No batches created -

-

- Create a batch from an uploaded file to start processing - requests -

- -
- ) : ( - <> - - Rows: - -
- } - /> - { - setBatchesAfterCursor(undefined); - batchesCursorHistory.current = []; - updateBatchesPagination(0, batchesPageSize); - }} - hasNextPage={batchesHasMore} - hasPrevPage={batchesPage > 0} - currentPageItemCount={filteredBatches.length} - itemName="batches" - /> - - )} -
); diff --git a/dashboard/src/components/features/batches/BatchesTable/columns.tsx b/dashboard/src/components/features/batches/BatchesTable/columns.tsx index 37331b8b2..ad181ae69 100644 --- a/dashboard/src/components/features/batches/BatchesTable/columns.tsx +++ b/dashboard/src/components/features/batches/BatchesTable/columns.tsx @@ -25,6 +25,7 @@ interface ColumnActions { getBatchFiles: (batch: Batch) => any[]; onViewFile: (file: any) => void; getInputFile: (batch: Batch) => any | undefined; + onRowClick?: (batch: Batch) => void; } const getStatusIcon = (status: BatchStatus) => { @@ -266,7 +267,7 @@ export const createBatchColumns = ( }} > - + {formatNumber(outputCount)} @@ -291,7 +292,7 @@ export const createBatchColumns = ( }} > - + {formatNumber(errorCount)} diff --git a/dashboard/src/components/features/batches/FilesTable/columns.tsx b/dashboard/src/components/features/batches/FilesTable/columns.tsx index 97b0f7d5d..e25e8deab 100644 --- a/dashboard/src/components/features/batches/FilesTable/columns.tsx +++ b/dashboard/src/components/features/batches/FilesTable/columns.tsx @@ -150,13 +150,14 @@ export const createFileColumns = ( }, { id: "actions", + header: "Actions", cell: ({ row }) => { const file = row.original; const isExpired = file.expires_at && new Date(file.expires_at * 1000) < new Date(); return ( -
+
{!isExpired && file.purpose === "batch" && ( diff --git a/dashboard/src/components/modals/CreateBatchModal/CreateBatchModal.test.tsx b/dashboard/src/components/modals/CreateBatchModal/CreateBatchModal.test.tsx new file mode 100644 index 000000000..54304ef1d --- /dev/null +++ b/dashboard/src/components/modals/CreateBatchModal/CreateBatchModal.test.tsx @@ -0,0 +1,489 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CreateBatchModal } from "./CreateBatchModal"; +import * as hooks from "../../../api/control-layer/hooks"; + +// Mock the hooks +vi.mock("../../../api/control-layer/hooks", () => ({ + useCreateBatch: vi.fn(), + useUploadFile: vi.fn(), + useFiles: vi.fn(), +})); + +// Mock sonner toast +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const mockFile = { + id: "file-123", + object: "file" as const, + bytes: 1024000, + created_at: 1730995200, + expires_at: 1765065600, + filename: "test-batch.jsonl", + purpose: "batch" as const, +}; + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: 0 }, + mutations: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +}; + +describe("CreateBatchModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mock for useUploadFile + vi.mocked(hooks.useUploadFile).mockReturnValue({ + mutateAsync: vi.fn(), + isPending: false, + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "idle", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: true, + isPaused: false, + submittedAt: 0, + variables: undefined, + } as any); + + // Default mock for useFiles + vi.mocked(hooks.useFiles).mockReturnValue({ + data: { data: [] }, + isLoading: false, + error: null, + refetch: vi.fn(), + } as any); + }); + + describe("Basic interactions", () => { + it("should close modal when Cancel button is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: false, + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "idle", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: true, + isPaused: false, + submittedAt: 0, + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Find and click the Cancel button + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + await user.click(cancelButton); + + // Verify onClose was called + expect(onClose).toHaveBeenCalled(); + // Verify mutation was NOT called + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("should submit when Create Batch button is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: false, + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "idle", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: true, + isPaused: false, + submittedAt: 0, + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Add a description + const descriptionInput = screen.getByPlaceholderText( + /daily evaluation batch/i, + ); + await user.type(descriptionInput, "Test batch"); + + // Find and click the Create Batch button + const createButton = screen.getByRole("button", { + name: /create batch/i, + }); + await user.click(createButton); + + // Verify the mutation was called + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + input_file_id: "file-123", + endpoint: "/v1/chat/completions", + completion_window: "24h", + metadata: { + batch_description: "Test batch", + }, + }); + }); + + // Verify callbacks were called + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("should disable Create Batch button when no file is selected", async () => { + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: false, + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "idle", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: true, + isPaused: false, + submittedAt: 0, + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Find the Create Batch button + const createButton = screen.getByRole("button", { + name: /create batch/i, + }); + + // Verify it's disabled + expect(createButton).toBeDisabled(); + }); + + it("should disable buttons when mutation is pending", async () => { + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: true, // Mutation in progress + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "pending", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: false, + isPaused: false, + submittedAt: Date.now(), + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Find buttons + const cancelButton = screen.getByRole("button", { name: /cancel/i }); + const createButton = screen.getByRole("button", { name: /creating/i }); + + // Verify they're disabled + expect(cancelButton).toBeDisabled(); + expect(createButton).toBeDisabled(); + }); + }); + + describe("Enter key submission", () => { + it("should submit the form when Enter is pressed in description field", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: false, + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "idle", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: true, + isPaused: false, + submittedAt: 0, + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Find and focus the description input - use screen since Dialog renders in a portal + const descriptionInput = screen.getByPlaceholderText( + /daily evaluation batch/i, + ); + await user.click(descriptionInput); + await user.type(descriptionInput, "Test batch description"); + + // Press Enter + await user.keyboard("{Enter}"); + + // Verify the mutation was called + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + input_file_id: "file-123", + endpoint: "/v1/chat/completions", + completion_window: "24h", + metadata: { + batch_description: "Test batch description", + }, + }); + }); + + // Verify callbacks were called + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it("should not submit when Enter is pressed if no file is selected", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: false, + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "idle", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: true, + isPaused: false, + submittedAt: 0, + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Find and focus the description input - use screen since Dialog renders in a portal + const descriptionInput = screen.getByPlaceholderText( + /daily evaluation batch/i, + ); + await user.click(descriptionInput); + await user.type(descriptionInput, "Test description"); + + // Press Enter + await user.keyboard("{Enter}"); + + // Verify the mutation was NOT called + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("should not submit when Enter is pressed if mutation is pending", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: true, // Mutation in progress + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "pending", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: false, + isPaused: false, + submittedAt: Date.now(), + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Find and focus the description input - use screen since Dialog renders in a portal + const descriptionInput = screen.getByPlaceholderText( + /daily evaluation batch/i, + ); + await user.click(descriptionInput); + await user.type(descriptionInput, "Test description"); + + // Press Enter + await user.keyboard("{Enter}"); + + // Verify the mutation was NOT called again + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it("should submit with empty description when Enter is pressed", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const onSuccess = vi.fn(); + const mutateAsync = vi.fn().mockResolvedValue({}); + + vi.mocked(hooks.useCreateBatch).mockReturnValue({ + mutateAsync, + isPending: false, + isError: false, + error: null, + isSuccess: false, + data: undefined, + mutate: vi.fn(), + reset: vi.fn(), + status: "idle", + context: undefined, + failureCount: 0, + failureReason: null, + isIdle: true, + isPaused: false, + submittedAt: 0, + variables: undefined, + } as any); + + render( + , + { wrapper: createWrapper() }, + ); + + // Find and focus the description input (don't type anything) - use screen since Dialog renders in a portal + const descriptionInput = screen.getByPlaceholderText( + /daily evaluation batch/i, + ); + await user.click(descriptionInput); + + // Press Enter without typing + await user.keyboard("{Enter}"); + + // Verify the mutation was called without metadata + await waitFor(() => { + expect(mutateAsync).toHaveBeenCalledWith({ + input_file_id: "file-123", + endpoint: "/v1/chat/completions", + completion_window: "24h", + metadata: undefined, + }); + }); + + // Verify callbacks were called + await waitFor(() => { + expect(onSuccess).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/dashboard/src/components/modals/CreateBatchModal/CreateBatchModal.tsx b/dashboard/src/components/modals/CreateBatchModal/CreateBatchModal.tsx index 65cd6f9ab..9e488ee6f 100644 --- a/dashboard/src/components/modals/CreateBatchModal/CreateBatchModal.tsx +++ b/dashboard/src/components/modals/CreateBatchModal/CreateBatchModal.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { Play, AlertCircle } from "lucide-react"; +import { useState, useEffect } from "react"; +import { Play, AlertCircle, X, Upload } from "lucide-react"; import { Dialog, DialogContent, @@ -11,7 +11,12 @@ import { import { Button } from "../../ui/button"; import { Label } from "../../ui/label"; import { Input } from "../../ui/input"; -import { useCreateBatch } from "../../../api/control-layer/hooks"; +import { Combobox } from "../../ui/combobox"; +import { + useCreateBatch, + useFiles, + useUploadFile, +} from "../../../api/control-layer/hooks"; import { toast } from "sonner"; import type { FileObject } from "../../features/batches/types"; import { AlertBox } from "@/components/ui/alert-box"; @@ -29,15 +34,114 @@ export function CreateBatchModal({ onSuccess, preselectedFile, }: CreateBatchModalProps) { + const [selectedFileId, setSelectedFileId] = useState( + preselectedFile?.id || null, + ); + const [fileToUpload, setFileToUpload] = useState(null); + const [expirationSeconds, setExpirationSeconds] = useState(2592000); // 30 days default const [endpoint, setEndpoint] = useState("/v1/chat/completions"); const [description, setDescription] = useState(""); const [error, setError] = useState(null); + const [dragActive, setDragActive] = useState(false); + const [isUploading, setIsUploading] = useState(false); const createBatchMutation = useCreateBatch(); + const uploadMutation = useUploadFile(); + + // Fetch available files for combobox (only input files with purpose "batch") + const { data: filesResponse } = useFiles({ + purpose: "batch", + limit: 1000, // Fetch plenty for the dropdown + }); + + const availableFiles = filesResponse?.data || []; + + // Update selected file when preselected file changes + useEffect(() => { + if (preselectedFile) { + setSelectedFileId(preselectedFile.id); + setFileToUpload(null); // Clear any file to upload + } + }, [preselectedFile]); + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true); + } else if (e.type === "dragleave") { + setDragActive(false); + } + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile.name.endsWith(".jsonl")) { + setFileToUpload(droppedFile); + setSelectedFileId(null); // Clear combobox selection + setError(null); + } else { + setError("Please upload a .jsonl file"); + } + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + const file = e.target.files[0]; + if (file.name.endsWith(".jsonl")) { + setFileToUpload(file); + setSelectedFileId(null); // Clear combobox selection + setError(null); + } else { + setError("Please upload a .jsonl file"); + } + } + }; + + const handleRemoveFile = () => { + setFileToUpload(null); + setSelectedFileId(null); + }; const handleSubmit = async () => { - if (!preselectedFile) { - setError("No file selected"); + let finalFileId = selectedFileId; + + // If a file needs to be uploaded, upload it first + if (fileToUpload) { + setIsUploading(true); + try { + const uploadedFile = await uploadMutation.mutateAsync({ + file: fileToUpload, + purpose: "batch", + expires_after: { + anchor: "created_at", + seconds: expirationSeconds, + }, + }); + finalFileId = uploadedFile.id; + toast.success(`File "${fileToUpload.name}" uploaded successfully`); + } catch (error) { + console.error("Failed to upload file:", error); + setError( + error instanceof Error + ? error.message + : "Failed to upload file. Please try again.", + ); + setIsUploading(false); + return; + } finally { + setIsUploading(false); + } + } + + if (!finalFileId) { + setError("Please select or upload a file"); return; } @@ -48,18 +152,25 @@ export function CreateBatchModal({ } await createBatchMutation.mutateAsync({ - input_file_id: preselectedFile.id, + input_file_id: finalFileId, endpoint, completion_window: "24h", metadata: Object.keys(metadata).length > 0 ? metadata : undefined, }); - toast.success( - `Batch created successfully from "${preselectedFile.filename}"`, - ); + const fileName = + fileToUpload?.name || + availableFiles.find((f) => f.id === finalFileId)?.filename || + "file"; + toast.success(`Batch created successfully from "${fileName}"`); + // Reset form + setSelectedFileId(null); + setFileToUpload(null); setEndpoint("/v1/chat/completions"); setDescription(""); + setExpirationSeconds(2592000); + setError(null); onSuccess?.(); onClose(); } catch (error) { @@ -69,18 +180,33 @@ export function CreateBatchModal({ }; const handleClose = () => { + setSelectedFileId(preselectedFile?.id || null); + setFileToUpload(null); setEndpoint("/v1/chat/completions"); setDescription(""); + setExpirationSeconds(2592000); + setError(null); onClose(); }; + const selectedFile = selectedFileId + ? availableFiles.find((f) => f.id === selectedFileId) + : null; + + const fileOptions = availableFiles.map((file) => ({ + value: file.id, + label: file.filename, + })); + + const isPending = createBatchMutation.isPending || isUploading; + return ( Create New Batch - Enter a description for the batch. + Select an existing file or upload a new one to create a batch. @@ -89,23 +215,124 @@ export function CreateBatchModal({
- {/* File Info */} - {preselectedFile && ( -
- -
-

- {preselectedFile.filename} -

-
- - Size: {(preselectedFile.bytes / 1024).toFixed(1)} KB - - ID: {preselectedFile.id} + {/* File Selection/Upload */} +
+ + + {/* Show selected file or file to upload */} + {selectedFile || fileToUpload ? ( +
+
+
+

+ {fileToUpload?.name || selectedFile?.filename} +

+
+ {fileToUpload ? ( + <> + + Size: {(fileToUpload.size / 1024).toFixed(1)} KB + + Ready to upload + + ) : ( + selectedFile && ( + <> + + Size: {(selectedFile.bytes / 1024).toFixed(1)} KB + + ID: {selectedFile.id} + + ) + )} +
+
+
-
- )} + ) : ( + <> + {/* Combobox for selecting existing file */} + {availableFiles.length > 0 && ( +
+ { + setSelectedFileId(value); + setFileToUpload(null); // Clear file to upload + setError(null); + }} + placeholder="Select an existing file..." + searchPlaceholder="Search files..." + emptyMessage="No files found." + className="w-full" + /> +

+ Choose from your uploaded batch files +

+
+ )} + + {/* Separator */} + {availableFiles.length > 0 && ( +
+
+ +
+
+ + Or + +
+
+ )} + + {/* Drop zone for new file */} +
+ + +
+ +
+

+ Drop a .jsonl file here +

+

+ or click to browse +

+
+
+
+ + )} +
{/* Description (Optional) */}
@@ -117,7 +344,18 @@ export function CreateBatchModal({ placeholder="e.g., Daily evaluation batch" value={description} onChange={(e) => setDescription(e.target.value)} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + !isPending && + (selectedFileId || fileToUpload) + ) { + e.preventDefault(); + handleSubmit(); + } + }} maxLength={512} + disabled={isPending} />

Add a description to help identify this batch later @@ -131,8 +369,10 @@ export function CreateBatchModal({

Batch Processing

- The batch will process all requests in the selected file. You - can track progress and download results once completed. + {fileToUpload + ? "The file will be uploaded and the batch will process all requests. " + : "The batch will process all requests in the selected file. "} + You can track progress and download results once completed.

@@ -144,7 +384,7 @@ export function CreateBatchModal({ type="button" variant="outline" onClick={handleClose} - disabled={createBatchMutation.isPending} + disabled={isPending} > Cancel @@ -152,13 +392,13 @@ export function CreateBatchModal({ type="button" variant="outline" onClick={handleSubmit} - disabled={!preselectedFile || createBatchMutation.isPending} + disabled={(!selectedFileId && !fileToUpload) || isPending} className="group" > - {createBatchMutation.isPending ? ( + {isPending ? ( <>
- Creating... + {isUploading ? "Uploading..." : "Creating..."} ) : ( <> diff --git a/dashboard/src/components/ui/data-table.tsx b/dashboard/src/components/ui/data-table.tsx index 06d6829bb..7e83ec04b 100644 --- a/dashboard/src/components/ui/data-table.tsx +++ b/dashboard/src/components/ui/data-table.tsx @@ -47,6 +47,7 @@ interface DataTableProps { actionBar?: React.ReactNode; headerActions?: React.ReactNode; initialColumnVisibility?: VisibilityState; + onRowClick?: (row: TData) => void; } export function DataTable({ @@ -63,6 +64,7 @@ export function DataTable({ actionBar, headerActions, initialColumnVisibility = {}, + onRowClick, }: DataTableProps) { const [sorting, setSorting] = React.useState([]); const [columnFilters, setColumnFilters] = React.useState( @@ -184,7 +186,7 @@ export function DataTable({ )}
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -245,10 +247,11 @@ export function DataTable({ onRowClick?.(row.original)} > - {row.getVisibleCells().map((cell) => ( + {row.getVisibleCells().map((cell, index, cells) => ( ({ ? "pl-6 w-[50px]" : cell.column.getIndex() === 0 ? "pl-6" - : cell.column.id === "actions" - ? "pr-6" + : index === cells.length - 1 + ? "pr-0" : "" } >