From 6556f065140f5b4493e17e180d1a2ac6efb3cbe0 Mon Sep 17 00:00:00 2001 From: hedhoud <74668966+hedhoud@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:33:14 +0200 Subject: [PATCH 1/6] feat(ui): add filtered exports for admin tables --- ui/src/lib/csv.ts | 26 ++++++++ ui/src/pages/admin/documents/list.test.tsx | 27 ++++++++ ui/src/pages/admin/documents/list.tsx | 76 ++++++++++++++++++++-- ui/src/pages/admin/jobs/list.test.tsx | 49 +++++++++++++- ui/src/pages/admin/jobs/list.tsx | 68 +++++++++++++++++-- 5 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 ui/src/lib/csv.ts diff --git a/ui/src/lib/csv.ts b/ui/src/lib/csv.ts new file mode 100644 index 000000000..00aaf1fe1 --- /dev/null +++ b/ui/src/lib/csv.ts @@ -0,0 +1,26 @@ +type CsvColumn = { + header: string; + value: (row: T) => unknown; +}; + +function csvCell(value: unknown): string { + const text = value == null ? "" : String(value); + return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +export function toCsv(columns: CsvColumn[], rows: T[]): string { + return [ + columns.map((column) => csvCell(column.header)).join(","), + ...rows.map((row) => columns.map((column) => csvCell(column.value(row))).join(",")), + ].join("\n"); +} + +export function downloadCsv(filename: string, columns: CsvColumn[], rows: T[]) { + const blob = new Blob([toCsv(columns, rows)], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} diff --git a/ui/src/pages/admin/documents/list.test.tsx b/ui/src/pages/admin/documents/list.test.tsx index 22e055ce4..b40fdcf14 100644 --- a/ui/src/pages/admin/documents/list.test.tsx +++ b/ui/src/pages/admin/documents/list.test.tsx @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { toast } from "sonner"; import type { Action } from "sonner"; import { deleteFile, uploadFile } from "@/lib/api/indexing"; +import { downloadCsv } from "@/lib/csv"; import DocumentListPage from "./list"; vi.mock("sonner", () => ({ @@ -63,8 +64,13 @@ vi.mock("@/lib/api/indexing", () => ({ newFileId: vi.fn(() => "new-file-id"), })); +vi.mock("@/lib/csv", () => ({ + downloadCsv: vi.fn(), +})); + const deleteFileMock = vi.mocked(deleteFile); const uploadFileMock = vi.mocked(uploadFile); +const downloadCsvMock = vi.mocked(downloadCsv); const toastSuccessMock = vi.mocked(toast.success); function LocationProbe() { @@ -94,6 +100,7 @@ describe("DocumentListPage", () => { beforeEach(() => { deleteFileMock.mockClear(); uploadFileMock.mockReset(); + downloadCsvMock.mockClear(); toastSuccessMock.mockClear(); }); @@ -112,6 +119,26 @@ describe("DocumentListPage", () => { expect(fileLink.className).toContain("truncate"); }); + it("filters documents by file name and indexed date before exporting", async () => { + renderDocuments(); + + expect(await screen.findByText("a.pdf")).not.toBeNull(); + await userEvent.type(screen.getByPlaceholderText("Search files..."), "b"); + await userEvent.type(screen.getByLabelText("Indexed since"), "2026-01-02"); + + expect(screen.queryByText("a.pdf")).toBeNull(); + expect(screen.getByText("b.pdf")).not.toBeNull(); + expect(screen.getByText("1 of 2 file(s)")).not.toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: /export csv/i })); + + expect(downloadCsvMock).toHaveBeenCalledWith( + "openrag-documents-docs.csv", + expect.any(Array), + [expect.objectContaining({ file_id: "file-b" })], + ); + }); + it("selects all documents from the table header and deletes the selected files", async () => { renderDocuments(); diff --git a/ui/src/pages/admin/documents/list.tsx b/ui/src/pages/admin/documents/list.tsx index 418eb57c9..87e63a4c5 100644 --- a/ui/src/pages/admin/documents/list.tsx +++ b/ui/src/pages/admin/documents/list.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { ColumnDef, OnChangeFn, RowSelectionState } from "@tanstack/react-table"; -import { Plus, Eye, Trash2, RefreshCw } from "lucide-react"; +import { Download, Plus, Eye, Trash2, RefreshCw, Search } from "lucide-react"; import { toast } from "sonner"; import { PageHeader } from "@/components/shared/page-header"; @@ -32,11 +32,13 @@ import { listPartitionFiles, type PartitionFile } from "@/lib/api/documents"; import { uploadFile, deleteFile, newFileId } from "@/lib/api/indexing"; import { listPartitions } from "@/lib/api/partitions"; import { usePermissions } from "@/lib/permissions"; +import { downloadCsv } from "@/lib/csv"; import { resolveDocumentsPartition } from "./partition-selection"; const fileHref = (partition: string, fileId: string) => `/documents/${encodeURIComponent(partition)}/${encodeURIComponent(fileId)}`; const fileLabel = (f: PartitionFile) => (f.filename as string) || f.file_id; +const str = (v: unknown) => (v == null ? "" : String(v)); export default function DocumentListPage() { const queryClient = useQueryClient(); @@ -54,6 +56,8 @@ export default function DocumentListPage() { const [uploadOpen, setUploadOpen] = useState(false); const [files, setFiles] = useState([]); const [uploading, setUploading] = useState(false); + const [fileSearch, setFileSearch] = useState(""); + const [indexedSince, setIndexedSince] = useState(""); const [fileSelection, setFileSelection] = useState<{ partition: string; rows: RowSelectionState; @@ -120,6 +124,20 @@ export default function DocumentListPage() { refetchInterval: 5000, }); const fileRows = useMemo(() => filesQuery.data?.files ?? [], [filesQuery.data?.files]); + const filteredFileRows = useMemo(() => { + const q = fileSearch.trim().toLowerCase(); + return fileRows.filter((file) => { + const filename = fileLabel(file); + const fileDate = str(file.indexed_at ?? file.created_at).slice(0, 10); + const matchesSearch = + !q || + [filename, file.file_id, file.mimetype].some((value) => + str(value).toLowerCase().includes(q), + ); + const matchesDate = !indexedSince || (fileDate && fileDate >= indexedSince); + return matchesSearch && matchesDate; + }); + }, [fileRows, fileSearch, indexedSince]); const fileRowSelection = useMemo( () => (fileSelection.partition === selected ? fileSelection.rows : {}), [fileSelection.partition, fileSelection.rows, selected], @@ -135,10 +153,25 @@ export default function DocumentListPage() { [selected], ); const selectedFiles = useMemo( - () => fileRows.filter((file) => fileRowSelection[file.file_id]), - [fileRows, fileRowSelection], + () => filteredFileRows.filter((file) => fileRowSelection[file.file_id]), + [filteredFileRows, fileRowSelection], ); + const exportDocuments = () => { + downloadCsv( + `openrag-documents-${selected || "partition"}.csv`, + [ + { header: "partition", value: () => selected }, + { header: "file_id", value: (file) => file.file_id }, + { header: "filename", value: (file) => fileLabel(file) }, + { header: "mimetype", value: (file) => file.mimetype }, + { header: "indexed_at", value: (file) => file.indexed_at }, + { header: "created_at", value: (file) => file.created_at }, + ], + filteredFileRows, + ); + }; + useEffect(() => { if (!writable && Object.keys(fileRowSelection).length > 0) { // eslint-disable-next-line react-hooks/set-state-in-effect -- Clear stale controlled selection when write access is lost. @@ -335,7 +368,7 @@ export default function DocumentListPage() { } /> -
+
+
+ + setFileSearch(e.target.value)} + className="pl-9" + /> +
+ setIndexedSince(e.target.value)} + className="w-[150px]" + aria-label="Indexed since" + /> {writable && selectedFiles.length > 0 && ( <> )} -
+
{filesQuery.data && ( -

{fileRows.length} file(s)

+

+ {filteredFileRows.length} + {(fileSearch || indexedSince) && ` of ${fileRows.length}`} file(s) +

)} +
{ expect.any(Array), [expect.objectContaining({ task_id: "archive-task" })], ); + + await userEvent.click(screen.getByRole("tab", { name: "FAILED" })); + await waitFor(() => expect(screen.getByText("docs.pdf")).not.toBeNull()); }); }); diff --git a/ui/src/pages/admin/jobs/list.tsx b/ui/src/pages/admin/jobs/list.tsx index 90d2d942d..de0a96041 100644 --- a/ui/src/pages/admin/jobs/list.tsx +++ b/ui/src/pages/admin/jobs/list.tsx @@ -182,6 +182,7 @@ export default function JobListPage() { setStatusTab(value); setSearch(""); setDebouncedSearch(""); + setPartitionFilter("__all__"); }; return ( From 8cbd32af59f9db385059e611c72a8c54d5679433 Mon Sep 17 00:00:00 2001 From: hedhoud <74668966+hedhoud@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:05:40 +0200 Subject: [PATCH 5/6] fix(ui): avoid all-partitions sentinel collision --- ui/src/pages/admin/jobs/list.test.tsx | 16 ++++++++++++++++ ui/src/pages/admin/jobs/list.tsx | 9 +++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/ui/src/pages/admin/jobs/list.test.tsx b/ui/src/pages/admin/jobs/list.test.tsx index d6da9e8f5..5bd916f40 100644 --- a/ui/src/pages/admin/jobs/list.test.tsx +++ b/ui/src/pages/admin/jobs/list.test.tsx @@ -178,6 +178,7 @@ describe("JobListPage filters", () => { tasks: [ task("docs-task", "COMPLETED", "docs.pdf", "docs"), task("archive-task", "FAILED", "archive.pdf", "archive"), + task("all-named-task", "COMPLETED", "all-named.pdf", "__all__"), ], }); @@ -198,6 +199,21 @@ describe("JobListPage filters", () => { [expect.objectContaining({ task_id: "archive-task" })], ); + await userEvent.click(screen.getByRole("combobox", { name: /filter jobs by partition/i })); + await userEvent.click(await screen.findByRole("option", { name: "__all__" })); + + expect(screen.queryByText("docs.pdf")).toBeNull(); + expect(screen.queryByText("archive.pdf")).toBeNull(); + expect(screen.getByText("all-named.pdf")).not.toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: /export csv/i })); + + expect(downloadCsvMock).toHaveBeenLastCalledWith( + "openrag-jobs.csv", + expect.any(Array), + [expect.objectContaining({ task_id: "all-named-task" })], + ); + await userEvent.click(screen.getByRole("tab", { name: "FAILED" })); await waitFor(() => expect(screen.getByText("docs.pdf")).not.toBeNull()); }); diff --git a/ui/src/pages/admin/jobs/list.tsx b/ui/src/pages/admin/jobs/list.tsx index de0a96041..e2a384df6 100644 --- a/ui/src/pages/admin/jobs/list.tsx +++ b/ui/src/pages/admin/jobs/list.tsx @@ -26,6 +26,7 @@ import { downloadCsv } from "@/lib/csv"; const STATUS_TABS = ["ALL", "ACTIVE", "COMPLETED", "FAILED", "CANCELLED"] as const; const JOBS_REFETCH_INTERVAL_MS = 5000; const JOB_SEARCH_DEBOUNCE_MS = 250; +const ALL_PARTITIONS_FILTER = "__openrag/all_partitions__"; const str = (v: unknown) => (v == null ? "" : String(v)); @@ -122,7 +123,7 @@ export default function JobListPage() { const [statusTab, setStatusTab] = useState("ALL"); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); - const [partitionFilter, setPartitionFilter] = useState("__all__"); + const [partitionFilter, setPartitionFilter] = useState(ALL_PARTITIONS_FILTER); const [manualRefreshing, setManualRefreshing] = useState(false); useEffect(() => { @@ -159,7 +160,7 @@ export default function JobListPage() { [task.task_id, task.state, filename, fileId, partition].some((value) => str(value).toLowerCase().includes(q), ); - const matchesPartition = partitionFilter === "__all__" || partition === partitionFilter; + const matchesPartition = partitionFilter === ALL_PARTITIONS_FILTER || partition === partitionFilter; return matchesSearch && matchesPartition; }); }, [tasks, debouncedSearch, partitionFilter]); @@ -182,7 +183,7 @@ export default function JobListPage() { setStatusTab(value); setSearch(""); setDebouncedSearch(""); - setPartitionFilter("__all__"); + setPartitionFilter(ALL_PARTITIONS_FILTER); }; return ( @@ -205,7 +206,7 @@ export default function JobListPage() { - All partitions + All partitions {partitionOptions.map((partition) => ( {partition} From 61b236ea0dbdbffcaf845610cc4ee3a7e445a353 Mon Sep 17 00:00:00 2001 From: hedhoud <74668966+hedhoud@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:12:20 +0200 Subject: [PATCH 6/6] fix(admin): harden filtered CSV exports --- ui/src/lib/csv.test.ts | 43 ++++++++++++++++++++-- ui/src/lib/csv.ts | 28 +++++++++++--- ui/src/pages/admin/documents/list.test.tsx | 14 ++++++- ui/src/pages/admin/documents/list.tsx | 33 ++++++++++------- ui/src/pages/admin/jobs/list.test.tsx | 22 ++++++++++- ui/src/pages/admin/jobs/list.tsx | 28 ++++++++------ 6 files changed, 131 insertions(+), 37 deletions(-) diff --git a/ui/src/lib/csv.test.ts b/ui/src/lib/csv.test.ts index c698ace49..116f11cdc 100644 --- a/ui/src/lib/csv.test.ts +++ b/ui/src/lib/csv.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { toCsv } from "./csv"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { downloadCsv, toCsv, toExcelCsv } from "./csv"; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.replaceChildren(); +}); describe("toCsv", () => { it("sanitizes formula-leading cells", () => { @@ -27,6 +34,36 @@ describe("toCsv", () => { "\"'\r=cmd\"", "\"'\n=cmd\"", "safe.pdf", - ].join("\n")); + ].join("\r\n")); + }); + + it("adds Excel encoding and delimiter hints to downloads", () => { + const csv = toExcelCsv( + [{ header: "filename", value: (row: { filename: string }) => row.filename }], + [{ filename: "Rapport_priv\u00e9.pdf" }], + ); + + expect(csv).toBe("\uFEFFsep=,\r\nfilename\r\nRapport_priv\u00e9.pdf"); + }); + + it("keeps the object URL alive until the browser handles the click", () => { + vi.useFakeTimers(); + const createObjectURL = vi.fn(() => "blob:csv"); + const revokeObjectURL = vi.fn(); + vi.stubGlobal("URL", { createObjectURL, revokeObjectURL }); + const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + downloadCsv("report.csv", [{ header: "name", value: (row: { name: string }) => row.name }], [ + { name: "report" }, + ]); + + expect(document.querySelector('a[download="report.csv"]')).not.toBeNull(); + expect(click).toHaveBeenCalledOnce(); + expect(revokeObjectURL).not.toHaveBeenCalled(); + + vi.runAllTimers(); + + expect(document.querySelector('a[download="report.csv"]')).toBeNull(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv"); }); }); diff --git a/ui/src/lib/csv.ts b/ui/src/lib/csv.ts index 79004f5f9..50cb0024a 100644 --- a/ui/src/lib/csv.ts +++ b/ui/src/lib/csv.ts @@ -4,6 +4,9 @@ type CsvColumn = { }; const FORMULA_PREFIX = /^[=+\-@\t\r\n]/; +const CSV_DELIMITER = ","; +const CSV_LINE_BREAK = "\r\n"; +const UTF8_BOM = "\uFEFF"; function csvCell(value: unknown): string { const raw = value == null ? "" : String(value); @@ -13,17 +16,30 @@ function csvCell(value: unknown): string { export function toCsv(columns: CsvColumn[], rows: T[]): string { return [ - columns.map((column) => csvCell(column.header)).join(","), - ...rows.map((row) => columns.map((column) => csvCell(column.value(row))).join(",")), - ].join("\n"); + columns.map((column) => csvCell(column.header)).join(CSV_DELIMITER), + ...rows.map((row) => columns.map((column) => csvCell(column.value(row))).join(CSV_DELIMITER)), + ].join(CSV_LINE_BREAK); +} + +export function toExcelCsv(columns: CsvColumn[], rows: T[]): string { + return `${UTF8_BOM}sep=${CSV_DELIMITER}${CSV_LINE_BREAK}${toCsv(columns, rows)}`; } export function downloadCsv(filename: string, columns: CsvColumn[], rows: T[]) { - const blob = new Blob([toCsv(columns, rows)], { type: "text/csv;charset=utf-8" }); + const blob = new Blob([toExcelCsv(columns, rows)], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; - link.click(); - URL.revokeObjectURL(url); + link.style.display = "none"; + + try { + document.body.appendChild(link); + link.click(); + } finally { + window.setTimeout(() => { + link.remove(); + URL.revokeObjectURL(url); + }, 0); + } } diff --git a/ui/src/pages/admin/documents/list.test.tsx b/ui/src/pages/admin/documents/list.test.tsx index 8dd24623a..b82e0219d 100644 --- a/ui/src/pages/admin/documents/list.test.tsx +++ b/ui/src/pages/admin/documents/list.test.tsx @@ -55,7 +55,7 @@ vi.mock("@/lib/api/documents", () => ({ partition: "docs", filename: "b.pdf", mimetype: "application/pdf", - indexed_at: "2026-01-02T00:00:00Z", + indexed_at: new Date(2026, 0, 2, 0, 30).toISOString(), }, ], }), @@ -149,6 +149,18 @@ describe("DocumentListPage", () => { ); }); + it("reports CSV download failures", async () => { + downloadCsvMock.mockImplementationOnce(() => { + throw new Error("downloads unavailable"); + }); + renderDocuments(); + + expect(await screen.findByText("a.pdf")).not.toBeNull(); + await userEvent.click(screen.getByRole("button", { name: /export csv/i })); + + expect(toast.error).toHaveBeenCalledWith("CSV export failed: downloads unavailable"); + }); + it("opens the upload dialog for a partition upload link", async () => { renderDocuments(["/documents?partition=docs&upload=1"]); diff --git a/ui/src/pages/admin/documents/list.tsx b/ui/src/pages/admin/documents/list.tsx index dbc48f899..941f56f58 100644 --- a/ui/src/pages/admin/documents/list.tsx +++ b/ui/src/pages/admin/documents/list.tsx @@ -149,15 +149,16 @@ export default function DocumentListPage() { const fileRows = useMemo(() => filesQuery.data?.files ?? [], [filesQuery.data?.files]); const filteredFileRows = useMemo(() => { const q = fileSearch.trim().toLowerCase(); + const indexedSinceTime = indexedSince ? new Date(`${indexedSince}T00:00:00`).getTime() : null; return fileRows.filter((file) => { const filename = fileLabel(file); - const fileDate = str(file.indexed_at ?? file.created_at).slice(0, 10); + const fileTime = Date.parse(str(file.indexed_at ?? file.created_at)); const matchesSearch = !q || [filename, file.file_id, file.mimetype].some((value) => str(value).toLowerCase().includes(q), ); - const matchesDate = !indexedSince || (fileDate && fileDate >= indexedSince); + const matchesDate = indexedSinceTime === null || (Number.isFinite(fileTime) && fileTime >= indexedSinceTime); return matchesSearch && matchesDate; }); }, [fileRows, fileSearch, indexedSince]); @@ -181,18 +182,22 @@ export default function DocumentListPage() { ); const exportDocuments = () => { - downloadCsv( - `openrag-documents-${selected || "partition"}.csv`, - [ - { header: "partition", value: () => selected }, - { header: "file_id", value: (file) => file.file_id }, - { header: "filename", value: (file) => fileLabel(file) }, - { header: "mimetype", value: (file) => file.mimetype }, - { header: "indexed_at", value: (file) => file.indexed_at }, - { header: "created_at", value: (file) => file.created_at }, - ], - filteredFileRows, - ); + try { + downloadCsv( + `openrag-documents-${selected || "partition"}.csv`, + [ + { header: "partition", value: () => selected }, + { header: "file_id", value: (file) => file.file_id }, + { header: "filename", value: (file) => fileLabel(file) }, + { header: "mimetype", value: (file) => file.mimetype }, + { header: "indexed_at", value: (file) => file.indexed_at }, + { header: "created_at", value: (file) => file.created_at }, + ], + filteredFileRows, + ); + } catch (error) { + toast.error(`CSV export failed: ${error instanceof Error ? error.message : "Unknown error"}`); + } }; useEffect(() => { diff --git a/ui/src/pages/admin/jobs/list.test.tsx b/ui/src/pages/admin/jobs/list.test.tsx index 43e7b94ee..b8d3f42bd 100644 --- a/ui/src/pages/admin/jobs/list.test.tsx +++ b/ui/src/pages/admin/jobs/list.test.tsx @@ -202,7 +202,10 @@ describe("JobListPage filters", () => { listTasksMock.mockResolvedValue({ tasks: [ task("docs-task", "COMPLETED", "docs.pdf", "docs"), - task("archive-task", "FAILED", "archive.pdf", "archive"), + task("archive-task", "FAILED", "archive.pdf", "archive", { + created_at: "2026-07-20T08:00:00Z", + duration_ms: 65_000, + }), task("all-named-task", "COMPLETED", "all-named.pdf", "__all__"), ], }); @@ -220,7 +223,10 @@ describe("JobListPage filters", () => { expect(downloadCsvMock).toHaveBeenCalledWith( "openrag-jobs.csv", - expect.any(Array), + expect.arrayContaining([ + expect.objectContaining({ header: "created_at" }), + expect.objectContaining({ header: "duration_ms" }), + ]), [expect.objectContaining({ task_id: "archive-task" })], ); @@ -243,6 +249,18 @@ describe("JobListPage filters", () => { await waitFor(() => expect(screen.getByText("docs.pdf")).not.toBeNull()); }); + it("reports CSV download failures", async () => { + downloadCsvMock.mockImplementationOnce(() => { + throw new Error("downloads unavailable"); + }); + renderJobs(); + + expect(await screen.findByText("completed.pdf")).not.toBeNull(); + await userEvent.click(screen.getByRole("button", { name: /export csv/i })); + + expect(toastErrorMock).toHaveBeenCalledWith("CSV export failed: downloads unavailable"); + }); + it("keeps long job values constrained while exposing full names", async () => { const longTaskId = "task-" + "1234567890".repeat(5); const longFilename = "benchmark-run-with-a-very-long-document-name-that-should-not-stretch-the-table.pdf"; diff --git a/ui/src/pages/admin/jobs/list.tsx b/ui/src/pages/admin/jobs/list.tsx index 9c2935e4a..32643580f 100644 --- a/ui/src/pages/admin/jobs/list.tsx +++ b/ui/src/pages/admin/jobs/list.tsx @@ -228,17 +228,23 @@ export default function JobListPage() { }); const exportJobs = () => { - downloadCsv( - "openrag-jobs.csv", - [ - { header: "task_id", value: (task) => task.task_id }, - { header: "state", value: (task) => task.state }, - { header: "filename", value: (task) => str(task.details?.metadata?.filename) }, - { header: "file_id", value: (task) => str(task.details?.file_id) }, - { header: "partition", value: (task) => str(task.details?.partition) }, - ], - filteredTasks, - ); + try { + downloadCsv( + "openrag-jobs.csv", + [ + { header: "task_id", value: (task) => task.task_id }, + { header: "state", value: (task) => task.state }, + { header: "filename", value: (task) => str(task.details?.metadata?.filename) }, + { header: "file_id", value: (task) => str(task.details?.file_id) }, + { header: "partition", value: (task) => str(task.details?.partition) }, + { header: "created_at", value: (task) => task.created_at }, + { header: "duration_ms", value: (task) => task.duration_ms }, + ], + filteredTasks, + ); + } catch (error) { + toast.error(`CSV export failed: ${error instanceof Error ? error.message : "Unknown error"}`); + } }; const handleStatusTabChange = (value: string) => {