diff --git a/web/components/runs/colony-data-table.tsx b/web/components/runs/colony-data-table.tsx new file mode 100644 index 00000000..2a0d72ab --- /dev/null +++ b/web/components/runs/colony-data-table.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { RunFile } from "@/lib/api/instrument-runs"; +import { cn } from "@/lib/utils"; +import { parse } from "csv-parse/browser/esm/sync"; +import { + AlertTriangle, + ChevronLeft, + ChevronRight, + ExternalLink, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +const PAGE_SIZE = 10; + +type CsvRow = Record; + +async function fetchCsvRows(fileId: number): Promise { + // The download endpoint 302-redirects to a short-lived presigned S3 URL; + // the browser follows transparently, so CSV bytes flow directly from S3 + // with zero Vercel Fast Origin Transfer. + const res = await fetch(`/api/v1/files/${fileId}/download`); + if (!res.ok) { + throw new Error(`Failed to load CSV (HTTP ${res.status})`); + } + const text = await res.text(); + return parse(text, { + columns: true, + skip_empty_lines: true, + trim: true, + }) as CsvRow[]; +} + +type AsyncResult = + | { fileId: number; status: "ready"; rows: CsvRow[] } + | { fileId: number; status: "error"; message: string }; + +type LoadState = + | { status: "loading" } + | { status: "ready"; rows: CsvRow[] } + | { status: "error"; message: string }; + +export function ColonyDataTable({ file }: { file: RunFile }) { + const downloadUrl = `/api/v1/files/${file.id}/download`; + const fileId = file.id; + + const [asyncResult, setAsyncResult] = useState(null); + // Bumped on retry to invalidate any cached error and re-run the load effect. + const [retryNonce, setRetryNonce] = useState(0); + const [page, setPage] = useState(0); + + // Per-mount cache so re-mounting (e.g. drawer toggles) doesn't refetch the + // same CSV from S3. Keyed by fileId so multiple tables share nothing. + const cacheRef = useRef>(new Map()); + + const state: LoadState = useMemo(() => { + const cached = cacheRef.current.get(fileId); + if (cached) return { status: "ready", rows: cached }; + if (asyncResult && asyncResult.fileId === fileId) { + return asyncResult.status === "ready" + ? { status: "ready", rows: asyncResult.rows } + : { status: "error", message: asyncResult.message }; + } + return { status: "loading" }; + // retryNonce participates so a retry that clears the cache entry forces + // a fresh derivation back to "loading" before the next fetch resolves. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fileId, asyncResult, retryNonce]); + + useEffect(() => { + if (cacheRef.current.has(fileId)) return; + let cancelled = false; + fetchCsvRows(fileId) + .then((rows) => { + cacheRef.current.set(fileId, rows); + if (cancelled) return; + setAsyncResult({ fileId, status: "ready", rows }); + }) + .catch((err: unknown) => { + if (cancelled) return; + const message = + err instanceof Error ? err.message : "Failed to load CSV"; + setAsyncResult({ fileId, status: "error", message }); + }); + return () => { + cancelled = true; + }; + }, [fileId, retryNonce]); + + function handleRetry() { + cacheRef.current.delete(fileId); + setAsyncResult(null); + setRetryNonce((n) => n + 1); + } + + return ( +
+
+

{file.filename}

+ +
+ {state.status === "loading" && ( + + )} + {state.status === "error" && ( +
+ +

{state.message}

+ +
+ )} + {state.status === "ready" && ( + + )} +
+ ); +} + +function ColonyDataTableView({ + rows, + page, + onPageChange, +}: { + rows: CsvRow[]; + page: number; + onPageChange: (next: number) => void; +}) { + const columns = useMemo( + () => (rows.length === 0 ? [] : Object.keys(rows[0])), + [rows] + ); + + // Right-align columns whose first non-empty value parses as a finite number. + // Computed once per row set so per-cell rendering stays cheap. + const numericColumns = useMemo>(() => { + const out = new Set(); + if (rows.length === 0) return out; + for (const col of columns) { + for (const row of rows) { + const v = row[col]; + if (v === undefined || v === "") continue; + if (!Number.isNaN(Number(v)) && Number.isFinite(Number(v))) { + out.add(col); + } + break; + } + } + return out; + }, [rows, columns]); + + const total = rows.length; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + // Clamp the current page in case the row set shrinks (e.g. after retry). + const safePage = Math.min(page, totalPages - 1); + const start = safePage * PAGE_SIZE; + const end = Math.min(start + PAGE_SIZE, total); + + const pageRows = useMemo(() => rows.slice(start, end), [rows, start, end]); + + if (total === 0) { + return ( +
+ CSV is empty. +
+ ); + } + + const canGoPrev = safePage > 0; + const canGoNext = safePage < totalPages - 1; + + return ( +
+
+ + + + {columns.map((col) => ( + + {col} + + ))} + + + + {pageRows.map((row, idx) => ( + + {columns.map((col) => ( + + {row[col] ?? ""} + + ))} + + ))} + +
+
+
+ + Showing {start + 1}– + {end} of{" "} + {total} + +
+ + Page {safePage + 1} of {totalPages} + +
+ + +
+
+
+
+ ); +} diff --git a/web/components/runs/run-report-section.tsx b/web/components/runs/run-report-section.tsx index 3861d216..024cc969 100644 --- a/web/components/runs/run-report-section.tsx +++ b/web/components/runs/run-report-section.tsx @@ -1,3 +1,4 @@ +import { ColonyDataTable } from "@/components/runs/colony-data-table"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import type { RunFile } from "@/lib/api/instrument-runs"; @@ -5,6 +6,7 @@ import { ExternalLink } from "lucide-react"; const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|tiff?)$/i; const PDF_EXTENSION = /\.pdf$/i; +const CSV_EXTENSION = /\.csv$/i; function isImageFile(file: RunFile): boolean { return ( @@ -19,6 +21,10 @@ function isPdfFile(file: RunFile): boolean { ); } +function isCsvFile(file: RunFile): boolean { + return file.contentType === "text/csv" || CSV_EXTENSION.test(file.filename); +} + function ProcessedImagePreview({ file }: { file: RunFile }) { const downloadUrl = `/api/v1/files/${file.id}/download`; @@ -67,13 +73,18 @@ function PdfPreview({ file }: { file: RunFile }) { } export function RunReportSection({ files }: { files: RunFile[] }) { + const processedCsvs = files.filter( + (f) => f.category === "processed" && f.deletedAt === null && isCsvFile(f) + ); + const processedImages = files.filter( (f) => f.category === "processed" && f.deletedAt === null && isImageFile(f) ); const pdfFiles = files.filter((f) => f.deletedAt === null && isPdfFile(f)); - const totalCount = processedImages.length + pdfFiles.length; + const totalCount = + processedCsvs.length + processedImages.length + pdfFiles.length; if (totalCount === 0) { return ( @@ -100,6 +111,9 @@ export function RunReportSection({ files }: { files: RunFile[] }) { + {processedCsvs.map((file) => ( + + ))} {processedImages.map((file) => ( ))}