diff --git a/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py b/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py
index b28cff82..80617975 100644
--- a/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py
+++ b/lambda/src/data_hub_lambda/azure_600_gel_doc/process_file.py
@@ -68,6 +68,7 @@ def process_file(run_id: str, filename: str) -> str:
s3_bucket=processed_bucket or "",
s3_key=png_s3_key,
filename=png_file_path.name,
+ size_bytes=png_file_path.stat().st_size,
category="processed",
)
diff --git a/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py b/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py
index 5a88eaee..95b94557 100644
--- a/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py
+++ b/lambda/src/data_hub_lambda/spectramax_plate_reader/process_file.py
@@ -71,6 +71,7 @@ def process_file(instrument_id: InstrumentType, run_id: str, filename: str) -> s
s3_bucket=processed_bucket or "",
s3_key=csv_s3_key,
filename=csv_filename,
+ size_bytes=csv_path.stat().st_size,
category="processed",
)
diff --git a/web-app/app/globals.css b/web-app/app/globals.css
index a04b0e5f..c0af73a7 100644
--- a/web-app/app/globals.css
+++ b/web-app/app/globals.css
@@ -116,6 +116,11 @@
--sidebar-ring: oklch(0.556 0 0);
}
+@keyframes table-pending-slide {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(400%); }
+}
+
html .shiki,
html .shiki span {
color: var(--shiki-light);
diff --git a/web-app/app/instruments/[instrumentId]/page.tsx b/web-app/app/instruments/[instrumentId]/page.tsx
index 432fa8a8..1381cb47 100644
--- a/web-app/app/instruments/[instrumentId]/page.tsx
+++ b/web-app/app/instruments/[instrumentId]/page.tsx
@@ -2,10 +2,15 @@ import { InstrumentHeader } from "@/components/instruments/instrument-header";
import { InstrumentRunsToolbar } from "@/components/instruments/instrument-runs-toolbar";
import { InstrumentRunsTable } from "@/components/instruments/runs-table";
import { PaginationNav } from "@/components/pagination-nav";
+import {
+ TablePendingBoundary,
+ TablePendingProvider,
+} from "@/components/table-pending";
import {
buildRunListQuery,
getGelDocFilterOptions,
getPlateReaderFilterOptions,
+ getQpcrFilterOptions,
} from "@/lib/api/instrument-runs";
import { getInstrumentById } from "@/lib/api/instruments";
import { auth } from "@/lib/auth";
@@ -55,6 +60,7 @@ export default async function InstrumentDetailPage({
imagingMode: filters.imaging_mode ?? undefined,
gelWavelength: filters.gel_wavelength ?? undefined,
gelColor: filters.gel_color ?? undefined,
+ dyeChannel: filters.dye_channel ?? undefined,
}),
]);
@@ -62,12 +68,15 @@ export default async function InstrumentDetailPage({
const isPlateReader = instrument.instrumentType === "plate_reader";
const isGelDoc = instrument.instrumentType === "gel_doc";
+ const isQpcr = instrument.instrumentType === "qpcr";
// Fetch distinct metadata values for instrument-specific column filter dropdowns.
- const [filterOptions, gelDocFilterOptions] = await Promise.all([
- isPlateReader ? getPlateReaderFilterOptions(instrumentId) : undefined,
- isGelDoc ? getGelDocFilterOptions(instrumentId) : undefined,
- ]);
+ const [filterOptions, gelDocFilterOptions, qpcrFilterOptions] =
+ await Promise.all([
+ isPlateReader ? getPlateReaderFilterOptions(instrumentId) : undefined,
+ isGelDoc ? getGelDocFilterOptions(instrumentId) : undefined,
+ isQpcr ? getQpcrFilterOptions(instrumentId) : undefined,
+ ]);
const hasFilters =
filters.search !== "" ||
@@ -80,25 +89,31 @@ export default async function InstrumentDetailPage({
filters.capture_type !== null ||
filters.imaging_mode !== null ||
filters.gel_wavelength !== null ||
- filters.gel_color !== null;
+ filters.gel_color !== null ||
+ filters.dye_channel !== null;
return (
);
}
diff --git a/web-app/app/page.tsx b/web-app/app/page.tsx
index 9e92529d..5fbb7652 100644
--- a/web-app/app/page.tsx
+++ b/web-app/app/page.tsx
@@ -5,6 +5,10 @@ import {
import { RunsTable } from "@/components/dashboard/runs-table";
import { RunsToolbar } from "@/components/dashboard/runs-toolbar";
import { PaginationNav } from "@/components/pagination-nav";
+import {
+ TablePendingBoundary,
+ TablePendingProvider,
+} from "@/components/table-pending";
import { getInstruments } from "@/lib/api/dashboard";
import { buildRunListQuery } from "@/lib/api/instrument-runs";
import { auth } from "@/lib/auth";
@@ -65,14 +69,18 @@ export default async function DashboardPage({
-
+
+
-
-
+
+
+
+
+
);
}
diff --git a/web-app/components/dashboard/runs-toolbar.tsx b/web-app/components/dashboard/runs-toolbar.tsx
index f813eb48..799c1d41 100644
--- a/web-app/components/dashboard/runs-toolbar.tsx
+++ b/web-app/components/dashboard/runs-toolbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTablePending } from "@/components/table-pending";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -58,9 +59,11 @@ function resolvePreset(dateFrom: string | null): string {
}
export function RunsToolbar({ instruments }: { instruments: Instrument[] }) {
+ const { startTransition } = useTablePending();
const [filters, setFilters] = useQueryStates(dashboardSearchParams, {
shallow: false,
throttleMs: 300,
+ startTransition,
});
const [instrumentOpen, setInstrumentOpen] = useState(false);
diff --git a/web-app/components/instruments/edit-instrument-dialog.tsx b/web-app/components/instruments/edit-instrument-dialog.tsx
index b0fd6acb..75f9f1ef 100644
--- a/web-app/components/instruments/edit-instrument-dialog.tsx
+++ b/web-app/components/instruments/edit-instrument-dialog.tsx
@@ -29,6 +29,8 @@ const TYPE_LABELS: Record = {
generic: "Generic",
plate_reader: "Plate Reader",
gel_doc: "Gel Doc",
+ qpcr: "qPCR",
+ tape_station: "TapeStation",
};
const INSTRUMENT_TYPE_OPTIONS = VALID_INSTRUMENT_TYPES.map((value) => ({
diff --git a/web-app/components/instruments/instrument-runs-toolbar.tsx b/web-app/components/instruments/instrument-runs-toolbar.tsx
index 85f14dd9..f887bc43 100644
--- a/web-app/components/instruments/instrument-runs-toolbar.tsx
+++ b/web-app/components/instruments/instrument-runs-toolbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTablePending } from "@/components/table-pending";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -11,9 +12,12 @@ import { useQueryStates } from "nuqs";
export function InstrumentRunsToolbar() {
// shallow: false triggers a server-side re-fetch on every URL change so the
// table data stays in sync. throttleMs debounces rapid keystrokes in search.
+ // startTransition ties the refetch to the table's pending/stale treatment.
+ const { startTransition } = useTablePending();
const [filters, setFilters] = useQueryStates(instrumentDetailSearchParams, {
shallow: false,
throttleMs: 300,
+ startTransition,
});
const hasFilters =
diff --git a/web-app/components/instruments/runs-table/filterable-column-header.tsx b/web-app/components/instruments/runs-table/filterable-column-header.tsx
index 22e54d56..3cc47709 100644
--- a/web-app/components/instruments/runs-table/filterable-column-header.tsx
+++ b/web-app/components/instruments/runs-table/filterable-column-header.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTablePending } from "@/components/table-pending";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -21,7 +22,8 @@ type FilterParamKey =
| "capture_type"
| "imaging_mode"
| "gel_wavelength"
- | "gel_color";
+ | "gel_color"
+ | "dye_channel";
export function FilterableColumnHeader({
label,
@@ -32,9 +34,11 @@ export function FilterableColumnHeader({
paramKey: FilterParamKey;
options: string[];
}) {
+ const { startTransition } = useTablePending();
const [filters, setFilters] = useQueryStates(instrumentDetailSearchParams, {
shallow: false,
throttleMs: 300,
+ startTransition,
});
const currentValue = filters[paramKey];
diff --git a/web-app/components/instruments/runs-table/index.tsx b/web-app/components/instruments/runs-table/index.tsx
index 4f2465f6..2602bf59 100644
--- a/web-app/components/instruments/runs-table/index.tsx
+++ b/web-app/components/instruments/runs-table/index.tsx
@@ -1,6 +1,7 @@
import type {
GelDocFilterOptions,
PlateReaderFilterOptions,
+ QpcrFilterOptions,
} from "@/lib/api/instrument-runs";
import type { InstrumentType } from "@/lib/db/schema";
import { SearchX } from "lucide-react";
@@ -8,6 +9,7 @@ import { SearchX } from "lucide-react";
import { DefaultRunsTable } from "./default-runs-table";
import { GelDocRunsTable } from "./gel-doc-runs-table";
import { PlateReaderRunsTable } from "./plate-reader-runs-table";
+import { QpcrRunsTable } from "./qpcr-runs-table";
export type RunRow = {
id: string;
@@ -39,6 +41,7 @@ export function InstrumentRunsTable({
hasFilters,
filterOptions,
gelDocFilterOptions,
+ qpcrFilterOptions,
}: {
data: RunRow[];
instrumentId: string;
@@ -46,6 +49,7 @@ export function InstrumentRunsTable({
hasFilters: boolean;
filterOptions?: PlateReaderFilterOptions;
gelDocFilterOptions?: GelDocFilterOptions;
+ qpcrFilterOptions?: QpcrFilterOptions;
}) {
if (data.length === 0) {
return (
@@ -77,6 +81,14 @@ export function InstrumentRunsTable({
filterOptions={gelDocFilterOptions!}
/>
);
+ case "qpcr":
+ return (
+
+ );
default:
return ;
}
diff --git a/web-app/components/instruments/runs-table/qpcr-runs-table.tsx b/web-app/components/instruments/runs-table/qpcr-runs-table.tsx
new file mode 100644
index 00000000..a2a9321c
--- /dev/null
+++ b/web-app/components/instruments/runs-table/qpcr-runs-table.tsx
@@ -0,0 +1,101 @@
+import { RelativeTime } from "@/components/dashboard/relative-time";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { QpcrFilterOptions } from "@/lib/api/instrument-runs";
+import { getDyeChannelColor } from "@/lib/instrument-colors";
+import { cn, formatBytes } from "@/lib/utils";
+
+import type { RunRow } from ".";
+import { ClickableRow } from "./clickable-row";
+import { FilterableColumnHeader } from "./filterable-column-header";
+import { MetadataArrayBadges, getMetadataArray } from "./metadata-utils";
+import { RunStatusIcon } from "./run-status-icon";
+
+export function QpcrRunsTable({
+ data,
+ instrumentId,
+ filterOptions,
+}: {
+ data: RunRow[];
+ instrumentId: string;
+ filterOptions: QpcrFilterOptions;
+}) {
+ return (
+
+
+
+
+ Run ID
+ Files
+ Total Size
+
+
+
+ Created
+
+
+
+ {data.map((row) => {
+ const isDeleted = row.deleted_at !== null;
+ const dyeChannels = getMetadataArray(row.metadata, "dye_channels");
+ const dyeChannelColors = Object.fromEntries(
+ dyeChannels.map((ch) => [ch, getDyeChannelColor(ch)])
+ );
+ return (
+
+
+
+
+
+ {row.run_id}
+
+ {isDeleted && (
+
+ deleted
+
+ )}
+
+
+
+ {row.file_count}
+
+
+ {formatBytes(row.total_size_bytes)}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/web-app/components/pagination-nav.tsx b/web-app/components/pagination-nav.tsx
index 3b3cbcc3..ff6ba3ad 100644
--- a/web-app/components/pagination-nav.tsx
+++ b/web-app/components/pagination-nav.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTablePending } from "@/components/table-pending";
import {
Pagination,
PaginationContent,
@@ -9,6 +10,7 @@ import {
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
+import { cn } from "@/lib/utils";
import { parseAsInteger, useQueryState } from "nuqs";
import type { MouseEvent } from "react";
@@ -43,9 +45,15 @@ export function PaginationNav({
totalPages: number;
pageParam: string;
}) {
+ // When used inside a TablePendingProvider, URL updates are wrapped in a
+ // React transition so the sibling table can render a "stale" treatment
+ // until the new RSC payload streams in.
+ const { isPending, isPendingVisible, startTransition } = useTablePending();
const [, setPage] = useQueryState(
pageParam,
- parseAsInteger.withDefault(1).withOptions({ shallow: false })
+ parseAsInteger
+ .withDefault(1)
+ .withOptions({ shallow: false, startTransition })
);
if (totalPages <= 1) return null;
@@ -60,15 +68,25 @@ export function PaginationNav({
};
}
+ const atPrev = page <= 1;
+ const atNext = page >= totalPages;
+
return (
-
+
@@ -90,10 +108,8 @@ export function PaginationNav({
= totalPages}
- className={
- page >= totalPages ? "pointer-events-none opacity-50" : undefined
- }
+ aria-disabled={atNext}
+ className={atNext ? "pointer-events-none opacity-50" : undefined}
/>
diff --git a/web-app/components/runs/report-data-table.tsx b/web-app/components/runs/report-data-table.tsx
deleted file mode 100644
index 0b53b016..00000000
--- a/web-app/components/runs/report-data-table.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-
-function formatCell(value: unknown): string {
- if (value === null || value === undefined) return "—";
- if (typeof value === "number") {
- return Number.isInteger(value)
- ? String(value)
- : value.toPrecision(6).replace(/\.?0+$/, "");
- }
- if (typeof value === "boolean") return value ? "true" : "false";
- if (typeof value === "object") return JSON.stringify(value);
- return String(value);
-}
-
-export function ReportDataTable({ data }: { data: unknown }) {
- if (!Array.isArray(data) || data.length === 0) return null;
-
- const rows = data as Record[];
- const columns = Object.keys(rows[0]);
-
- return (
-
-
-
-
- {columns.map((col) => (
-
- {col}
-
- ))}
-
-
-
- {rows.map((row, i) => (
-
- {columns.map((col) => (
-
- {formatCell(row[col])}
-
- ))}
-
- ))}
-
-
-
-
- );
-}
diff --git a/web-app/components/runs/run-files-section.tsx b/web-app/components/runs/run-files-section.tsx
index 7e55cfa8..d2c81d76 100644
--- a/web-app/components/runs/run-files-section.tsx
+++ b/web-app/components/runs/run-files-section.tsx
@@ -11,9 +11,7 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
-import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -22,26 +20,12 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
import type { RunFile } from "@/lib/api/instrument-runs";
-import { formatDateTime } from "@/lib/date";
-import { formatBytes } from "@/lib/utils";
-import { Download, Loader2, RotateCw, Search, Upload, X } from "lucide-react";
+import { Download, Loader2, Search, Upload, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
+import { RunFilesTable, statusLabel } from "./run-files-table";
type StatusFilter =
| "all"
@@ -54,91 +38,20 @@ type SortField = "name" | "size" | "date" | "status";
const PENDING_STATUSES = new Set(["detected", "upload_requested"]);
-function statusLabel(file: RunFile): string {
- if (file.deletedAt !== null) return "Dismissed";
- switch (file.status) {
- case "detected":
- case "upload_requested":
- return "Pending";
- case "uploaded":
- return "Uploaded";
- case "processing":
- return "Processing";
- case "completed":
- return "Completed";
- case "failed":
- return "Failed";
- default:
- return file.status;
- }
-}
-
-function StatusBadge({ file }: { file: RunFile }) {
- const label = statusLabel(file);
-
- switch (label) {
- case "Pending":
- return (
-
- {label}
-
- );
- case "Uploaded":
- return {label} ;
- case "Processing":
- return (
-
-
- {label}
-
- );
- case "Completed":
- return (
-
- {label}
-
- );
- case "Failed": {
- const badge = {label} ;
- if (!file.errorMessage) return badge;
- return (
-
-
- {badge}
-
-
- {file.errorMessage}
-
-
- );
- }
- case "Dismissed":
- return (
-
- {label}
-
- );
- default:
- return {label} ;
- }
-}
-
function matchesFilter(file: RunFile, filter: StatusFilter): boolean {
if (filter === "all") return true;
if (filter === "pending") return PENDING_STATUSES.has(file.status);
return file.status === filter;
}
-function compareFiles(a: RunFile, b: RunFile, field: SortField): number {
+// Raw files always come before processed files; the user-selected field
+// breaks ties within each category.
+function compareByCategory(a: RunFile, b: RunFile): number {
+ if (a.category === b.category) return 0;
+ return a.category === "raw" ? -1 : 1;
+}
+
+function compareByField(a: RunFile, b: RunFile, field: SortField): number {
switch (field) {
case "name":
return a.filename.localeCompare(b.filename);
@@ -156,6 +69,10 @@ function compareFiles(a: RunFile, b: RunFile, field: SortField): number {
}
}
+function compareFiles(a: RunFile, b: RunFile, field: SortField): number {
+ return compareByCategory(a, b) || compareByField(a, b, field);
+}
+
export function RunFilesSection({
files,
instrumentId,
@@ -539,196 +456,23 @@ export function RunFilesSection({
No files match your filters.
) : (
-
-
-
- {!isDeleted && visibleSelectableIds.size > 0 && (
-
-
-
- )}
-
- File name
-
-
- Size
-
-
- Created
-
-
- Status
-
-
-
-
-
- {filteredFiles.map((file) => {
- const isDismissed = file.deletedAt !== null;
- const isSelectable =
- !isDeleted && file.status === "detected" && !isDismissed;
- const isSelected = selectedIds.has(file.id);
- const showRowActions =
- !isDeleted &&
- !isDismissed &&
- file.status === "detected" &&
- selectedDetectedIds.length === 0;
-
- return (
-
- {!isDeleted && visibleSelectableIds.size > 0 && (
-
- {isSelectable ? (
- toggleFile(file.id)}
- />
- ) : (
-
- )}
-
- )}
-
-
- {file.filename}
- {[
- "uploaded",
- "processing",
- "completed",
- "failed",
- ].includes(file.status) && (
-
-
-
- )}
-
-
-
- {formatBytes(file.sizeBytes)}
-
-
- {file.createdAt ? formatDateTime(file.createdAt) : "—"}
-
-
-
-
-
- {showRowActions ? (
-
-
handleSingleUpload(file.id)}
- disabled={isPending}
- >
-
- Upload
-
-
-
-
-
- Dismiss
-
-
-
-
-
- Dismiss file?
-
-
-
- {file.filename}
- {" "}
- will be soft-deleted. The watcher will skip it
- on future scans.
-
-
-
- Cancel
- handleSingleDismiss(file.id)}
- >
- Dismiss
-
-
-
-
-
- ) : (
- !isDismissed &&
- file.status === "failed" &&
- file.s3Key !== null && (
-
-
-
- {isPending ? (
-
- ) : (
-
- )}
- Reprocess
-
-
-
-
-
- Reprocess file?
-
-
-
- {file.filename}
- {" "}
- will be sent to the Lambda function for
- reprocessing. Any existing report data for
- this file will be cleared.
-
-
-
- Cancel
- handleReprocess(file.id)}
- >
- Reprocess
-
-
-
-
- )
- )}
-
-
- );
- })}
-
-
+ 0,
+ onToggleFile: toggleFile,
+ onToggleAll: toggleAll,
+ }}
+ onUpload={handleSingleUpload}
+ onDismiss={handleSingleDismiss}
+ onReprocess={handleReprocess}
+ />
)}
{/* Summary footer */}
diff --git a/web-app/components/runs/run-files-table.tsx b/web-app/components/runs/run-files-table.tsx
new file mode 100644
index 00000000..605e5ec1
--- /dev/null
+++ b/web-app/components/runs/run-files-table.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import type { RunFile } from "@/lib/api/instrument-runs";
+import { formatDateTime } from "@/lib/date";
+import { formatBytes } from "@/lib/utils";
+import { Download, Loader2, RotateCw, Upload, X } from "lucide-react";
+
+const DOWNLOADABLE_STATUSES = new Set([
+ "uploaded",
+ "processing",
+ "completed",
+ "failed",
+]);
+
+const REPROCESSABLE_STATUSES = new Set(["completed", "failed"]);
+
+export function statusLabel(file: RunFile): string {
+ if (file.deletedAt !== null) return "Dismissed";
+ switch (file.status) {
+ case "detected":
+ case "upload_requested":
+ return "Pending";
+ case "uploaded":
+ return "Uploaded";
+ case "processing":
+ return "Processing";
+ case "completed":
+ return "Completed";
+ case "failed":
+ return "Failed";
+ default:
+ return file.status;
+ }
+}
+
+function StatusBadge({ file }: { file: RunFile }) {
+ const label = statusLabel(file);
+
+ switch (label) {
+ case "Pending":
+ return (
+
+ {label}
+
+ );
+ case "Uploaded":
+ return {label} ;
+ case "Processing":
+ return (
+
+
+ {label}
+
+ );
+ case "Completed":
+ return (
+
+ {label}
+
+ );
+ case "Failed": {
+ const badge = {label} ;
+ if (!file.errorMessage) return badge;
+ return (
+
+
+ {badge}
+
+
+ {file.errorMessage}
+
+
+ );
+ }
+ case "Dismissed":
+ return (
+
+ {label}
+
+ );
+ default:
+ return {label} ;
+ }
+}
+
+export type RunFilesTableSelection = {
+ selectedIds: Set;
+ visibleSelectableIds: Set;
+ allVisibleSelected: boolean;
+ someVisibleSelected: boolean;
+ hasBulkSelection: boolean;
+ onToggleFile: (id: number) => void;
+ onToggleAll: () => void;
+};
+
+export type RunFilesTableProps = {
+ files: RunFile[];
+ isDeleted: boolean;
+ isPending: boolean;
+ selection: RunFilesTableSelection;
+ onUpload: (id: number) => void;
+ onDismiss: (id: number) => void;
+ onReprocess: (id: number) => void;
+};
+
+export function RunFilesTable({
+ files,
+ isDeleted,
+ isPending,
+ selection,
+ onUpload,
+ onDismiss,
+ onReprocess,
+}: RunFilesTableProps) {
+ const {
+ selectedIds,
+ visibleSelectableIds,
+ allVisibleSelected,
+ someVisibleSelected,
+ hasBulkSelection,
+ onToggleFile,
+ onToggleAll,
+ } = selection;
+
+ const showSelectionColumn = !isDeleted && visibleSelectableIds.size > 0;
+
+ return (
+
+
+
+ {showSelectionColumn && (
+
+
+
+ )}
+
+ File name
+
+
+ Type
+
+
+ Size
+
+
+ Created
+
+
+ Status
+
+
+
+
+
+ {files.map((file) => {
+ const isDismissed = file.deletedAt !== null;
+ const isSelectable =
+ !isDeleted && file.status === "detected" && !isDismissed;
+ const isSelected = selectedIds.has(file.id);
+ const showRowActions =
+ !isDeleted &&
+ !isDismissed &&
+ file.status === "detected" &&
+ !hasBulkSelection;
+
+ return (
+
+ {showSelectionColumn && (
+
+ {isSelectable ? (
+ onToggleFile(file.id)}
+ />
+ ) : (
+
+ )}
+
+ )}
+
+
+ {file.filename}
+ {DOWNLOADABLE_STATUSES.has(file.status) && (
+
+
+
+ )}
+
+
+
+
+ {file.category}
+
+
+
+ {formatBytes(file.sizeBytes)}
+
+
+ {file.createdAt ? formatDateTime(file.createdAt) : "—"}
+
+
+
+
+
+ {showRowActions ? (
+
+
onUpload(file.id)}
+ disabled={isPending}
+ >
+
+ Upload
+
+
+
+
+
+ Dismiss
+
+
+
+
+ Dismiss file?
+
+
+ {file.filename}
+ {" "}
+ will be soft-deleted. The watcher will skip it on
+ future scans.
+
+
+
+ Cancel
+ onDismiss(file.id)}>
+ Dismiss
+
+
+
+
+
+ ) : (
+ !isDismissed &&
+ REPROCESSABLE_STATUSES.has(file.status) &&
+ file.s3Key !== null && (
+
+
+
+
+ {isPending ? (
+
+ ) : (
+
+ )}
+ Reprocess
+
+
+
+
+ Reprocess file?
+
+
+ {file.filename}
+ {" "}
+ will be sent to the Lambda function for
+ reprocessing. Any existing report data for this
+ file will be cleared.
+
+
+
+ Cancel
+ onReprocess(file.id)}
+ >
+ Reprocess
+
+
+
+
+
+ )
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web-app/components/runs/run-metadata-badges.tsx b/web-app/components/runs/run-metadata-badges.tsx
index a0aad6a6..2ab75552 100644
--- a/web-app/components/runs/run-metadata-badges.tsx
+++ b/web-app/components/runs/run-metadata-badges.tsx
@@ -6,6 +6,7 @@ import {
MEASUREMENT_MODE_COLORS,
MEASUREMENT_TYPE_COLORS,
buildWavelengthColorMap,
+ getDyeChannelColor,
} from "@/lib/instrument-colors";
import { cn } from "@/lib/utils";
@@ -51,6 +52,14 @@ function ColorBadge({
// Plate reader
// ---------------------------------------------------------------------------
+export function hasPlateReaderMetadata(metadata: Record) {
+ return Boolean(
+ getMetadataField(metadata, "wavelength") ||
+ getMetadataField(metadata, "measurement_mode") ||
+ getMetadataField(metadata, "measurement_type")
+ );
+}
+
export function PlateReaderRunBadges({
metadata,
}: {
@@ -87,6 +96,15 @@ export function PlateReaderRunBadges({
// Gel doc
// ---------------------------------------------------------------------------
+export function hasGelDocMetadata(metadata: Record) {
+ return Boolean(
+ getMetadataField(metadata, "capture_type") ||
+ getMetadataField(metadata, "imaging_mode") ||
+ getMetadataArray(metadata, "wavelengths").length ||
+ getMetadataArray(metadata, "colors").length
+ );
+}
+
export function GelDocRunBadges({
metadata,
}: {
@@ -142,6 +160,56 @@ export function GelDocRunBadges({
);
}
+// ---------------------------------------------------------------------------
+// qPCR
+// ---------------------------------------------------------------------------
+
+export function hasQpcrMetadata(metadata: Record) {
+ return getMetadataArray(metadata, "dye_channels").length > 0;
+}
+
+export function QpcrRunBadges({
+ metadata,
+}: {
+ metadata: Record;
+}) {
+ const dyeChannels = getMetadataArray(metadata, "dye_channels");
+
+ if (dyeChannels.length === 0) return null;
+
+ return (
+
+ {dyeChannels.map((ch) => (
+
+ ))}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// TapeStation
+// ---------------------------------------------------------------------------
+
+export function hasTapeStationMetadata(metadata: Record) {
+ return Boolean(getMetadataField(metadata, "Tape Type"));
+}
+
+export function TapeStationRunBadges({
+ metadata,
+}: {
+ metadata: Record;
+}) {
+ const tapeType = getMetadataField(metadata, "Tape Type");
+
+ if (!tapeType) return null;
+
+ return (
+
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Default / generic — each key gets a row with outline badge value(s)
// ---------------------------------------------------------------------------
@@ -161,6 +229,10 @@ function formatLabel(key: string): string {
return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
+export function hasDefaultMetadata(metadata: Record) {
+ return Object.values(metadata).some((v) => formatBadgeValue(v) !== null);
+}
+
export function DefaultRunBadges({
metadata,
}: {
diff --git a/web-app/components/runs/run-metadata.tsx b/web-app/components/runs/run-metadata.tsx
index 90558565..2178a91f 100644
--- a/web-app/components/runs/run-metadata.tsx
+++ b/web-app/components/runs/run-metadata.tsx
@@ -2,16 +2,7 @@ import type { ReactNode } from "react";
export function RunMetadata({ children }: { children?: ReactNode }) {
if (!children) {
- return (
-
-
Metadata
-
-
- No metadata recorded yet
-
-
-
- );
+ return null;
}
return (
diff --git a/web-app/components/runs/run-report-section.tsx b/web-app/components/runs/run-report-section.tsx
index 289e3d6a..3861d216 100644
--- a/web-app/components/runs/run-report-section.tsx
+++ b/web-app/components/runs/run-report-section.tsx
@@ -4,6 +4,7 @@ import type { RunFile } from "@/lib/api/instrument-runs";
import { ExternalLink } from "lucide-react";
const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|tiff?)$/i;
+const PDF_EXTENSION = /\.pdf$/i;
function isImageFile(file: RunFile): boolean {
return (
@@ -12,6 +13,12 @@ function isImageFile(file: RunFile): boolean {
);
}
+function isPdfFile(file: RunFile): boolean {
+ return (
+ file.contentType === "application/pdf" || PDF_EXTENSION.test(file.filename)
+ );
+}
+
function ProcessedImagePreview({ file }: { file: RunFile }) {
const downloadUrl = `/api/v1/files/${file.id}/download`;
@@ -34,12 +41,41 @@ function ProcessedImagePreview({ file }: { file: RunFile }) {
);
}
+function PdfPreview({ file }: { file: RunFile }) {
+ const downloadUrl = `/api/v1/files/${file.id}/download`;
+
+ return (
+
+ );
+}
+
export function RunReportSection({ files }: { files: RunFile[] }) {
const processedImages = files.filter(
(f) => f.category === "processed" && f.deletedAt === null && isImageFile(f)
);
- if (processedImages.length === 0) {
+ const pdfFiles = files.filter((f) => f.deletedAt === null && isPdfFile(f));
+
+ const totalCount = processedImages.length + pdfFiles.length;
+
+ if (totalCount === 0) {
return (
Report Data
@@ -59,7 +95,7 @@ export function RunReportSection({ files }: { files: RunFile[] }) {
Report Data{" "}
- {processedImages.length} image(s)
+ {totalCount} file(s)
@@ -67,6 +103,9 @@ export function RunReportSection({ files }: { files: RunFile[] }) {
{processedImages.map((file) => (
))}
+ {pdfFiles.map((file) => (
+
+ ))}
diff --git a/web-app/components/runs/variants/default-run-detail.tsx b/web-app/components/runs/variants/default-run-detail.tsx
index 78ae2616..bc6314b1 100644
--- a/web-app/components/runs/variants/default-run-detail.tsx
+++ b/web-app/components/runs/variants/default-run-detail.tsx
@@ -2,7 +2,10 @@ import { DeleteRunDialog } from "@/components/runs/delete-run-dialog";
import { RestoreRunButton } from "@/components/runs/restore-run-button";
import type { RunDetailProps } from "@/components/runs/run-detail";
import { RunDetail } from "@/components/runs/run-detail";
-import { DefaultRunBadges } from "@/components/runs/run-metadata-badges";
+import {
+ DefaultRunBadges,
+ hasDefaultMetadata,
+} from "@/components/runs/run-metadata-badges";
export function DefaultRunDetail({
run,
@@ -34,11 +37,13 @@ export function DefaultRunDetail({
-
- }
- />
-
+ {hasDefaultMetadata(run.metadata as Record) && (
+
+ }
+ />
+
+ )}
-
- } />
-
+ {hasGelDocMetadata(run.metadata as Record) && (
+
+ }
+ />
+
+ )}
;
case "gel_doc":
return ;
+ case "qpcr":
+ return ;
+ case "tape_station":
+ return ;
default:
return ;
}
diff --git a/web-app/components/runs/variants/plate-reader-run-detail.tsx b/web-app/components/runs/variants/plate-reader-run-detail.tsx
index 8e3d62f1..ef715540 100644
--- a/web-app/components/runs/variants/plate-reader-run-detail.tsx
+++ b/web-app/components/runs/variants/plate-reader-run-detail.tsx
@@ -7,7 +7,10 @@ import {
import { RestoreRunButton } from "@/components/runs/restore-run-button";
import type { RunDetailProps } from "@/components/runs/run-detail";
import { RunDetail } from "@/components/runs/run-detail";
-import { PlateReaderRunBadges } from "@/components/runs/run-metadata-badges";
+import {
+ hasPlateReaderMetadata,
+ PlateReaderRunBadges,
+} from "@/components/runs/run-metadata-badges";
import { Card, CardContent } from "@/components/ui/card";
import type { RawWellRow } from "@/lib/api/instrument-runs";
@@ -297,11 +300,13 @@ export function PlateReaderRunDetail({
-
- }
- />
-
+ {hasPlateReaderMetadata(run.metadata as Record) && (
+
+ }
+ />
+
+ )}
f.deletedAt === null).length;
+ const hasProcessedFiles =
+ files.filter((f) => f.category === "processed" && f.deletedAt === null)
+ .length > 0;
+
+ return (
+ <>
+
+ {!isDeleted && (
+
+ )}
+ {canRestore && (
+
+ )}
+
+
+
+ {hasQpcrMetadata(run.metadata as Record) && (
+
+ } />
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web-app/components/runs/variants/tape-station-run-detail.tsx b/web-app/components/runs/variants/tape-station-run-detail.tsx
new file mode 100644
index 00000000..4dd6e624
--- /dev/null
+++ b/web-app/components/runs/variants/tape-station-run-detail.tsx
@@ -0,0 +1,60 @@
+import { DeleteRunDialog } from "@/components/runs/delete-run-dialog";
+import { RestoreRunButton } from "@/components/runs/restore-run-button";
+import type { RunDetailProps } from "@/components/runs/run-detail";
+import { RunDetail } from "@/components/runs/run-detail";
+import {
+ hasTapeStationMetadata,
+ TapeStationRunBadges,
+} from "@/components/runs/run-metadata-badges";
+
+export function TapeStationRunDetail({
+ run,
+ files,
+ instrumentId,
+ runId,
+}: RunDetailProps) {
+ const isDeleted = run.deletedAt !== null;
+ const canRestore = isDeleted && run.filesPurgedAt === null;
+ const activeFileCount = files.filter((f) => f.deletedAt === null).length;
+ const hasProcessedFiles =
+ files.filter((f) => f.category === "processed" && f.deletedAt === null)
+ .length > 0;
+
+ return (
+ <>
+
+ {!isDeleted && (
+
+ )}
+ {canRestore && (
+
+ )}
+
+
+
+ {hasTapeStationMetadata(run.metadata as Record) && (
+
+ }
+ />
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web-app/components/table-pending.tsx b/web-app/components/table-pending.tsx
new file mode 100644
index 00000000..b986241e
--- /dev/null
+++ b/web-app/components/table-pending.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import {
+ type ReactNode,
+ type TransitionStartFunction,
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useTransition,
+} from "react";
+
+type TablePendingContextValue = {
+ // Instantaneous transition state from React. Use this for logic that should
+ // react immediately (e.g. blocking double-clicks on pagination).
+ isPending: boolean;
+ // Debounced pending state that only flips true after `delayMs`. Use this for
+ // visible UI (opacity, cursor, progress bar) so that fast fetches don't
+ // flicker a loading state on screen.
+ isPendingVisible: boolean;
+ // Pass this into nuqs `withOptions({ startTransition })` so URL updates are
+ // tracked as React transitions.
+ startTransition: TransitionStartFunction;
+};
+
+const noopStartTransition: TransitionStartFunction = (cb) => cb();
+
+const TablePendingContext = createContext({
+ isPending: false,
+ isPendingVisible: false,
+ startTransition: noopStartTransition,
+});
+
+export function TablePendingProvider({
+ children,
+ delayMs = 120,
+}: {
+ children: ReactNode;
+ delayMs?: number;
+}) {
+ const [isPending, startTransition] = useTransition();
+ const [isPendingVisible, setIsPendingVisible] = useState(false);
+
+ // The visible state is only ever flipped inside async callbacks (setTimeout
+ // on entry, cleanup on exit). This keeps the setState out of the effect body
+ // itself, avoiding cascading render warnings.
+ useEffect(() => {
+ if (!isPending) return;
+ const id = setTimeout(() => setIsPendingVisible(true), delayMs);
+ return () => {
+ clearTimeout(id);
+ setIsPendingVisible(false);
+ };
+ }, [isPending, delayMs]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useTablePending(): TablePendingContextValue {
+ return useContext(TablePendingContext);
+}
+
+// Wraps a table (or other server-rendered content) and applies the shared
+// "stale data" treatment while a navigation is in flight: dim the content,
+// block interaction, show a thin indeterminate progress bar on top.
+export function TablePendingBoundary({
+ children,
+ className,
+}: {
+ children: ReactNode;
+ className?: string;
+}) {
+ const { isPending, isPendingVisible } = useTablePending();
+ return (
+
+ {isPendingVisible && (
+
+ )}
+
+ {children}
+
+
+ );
+}
diff --git a/web-app/components/watchers/event-log-toolbar.tsx b/web-app/components/watchers/event-log-toolbar.tsx
index d1f88a55..f85ee217 100644
--- a/web-app/components/watchers/event-log-toolbar.tsx
+++ b/web-app/components/watchers/event-log-toolbar.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useTablePending } from "@/components/table-pending";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -25,9 +26,12 @@ const EVENT_TYPES = [
export function EventLogToolbar() {
// shallow: false pushes filter changes to the URL and triggers a full
// server-side re-render, so the event list refetches with the new filters.
+ // startTransition ties the refetch to the surrounding table's pending UI.
+ const { startTransition } = useTablePending();
const [filters, setFilters] = useQueryStates(watcherDetailSearchParams, {
shallow: false,
throttleMs: 300,
+ startTransition,
});
const hasFilters =
diff --git a/web-app/components/watchers/event-log.tsx b/web-app/components/watchers/event-log.tsx
index 50bb227a..7d40c5f8 100644
--- a/web-app/components/watchers/event-log.tsx
+++ b/web-app/components/watchers/event-log.tsx
@@ -2,6 +2,7 @@
import { CopyButton } from "@/components/copy-button";
import { PaginationNav } from "@/components/pagination-nav";
+import { TablePendingBoundary } from "@/components/table-pending";
import {
Accordion,
AccordionContent,
@@ -87,22 +88,26 @@ export function EventLog({
}) {
if (events.length === 0) {
return (
-
-
-
- No events in this time range.
-
-
+
+
+
+
+ No events in this time range.
+
+
+
);
}
return (
-
- {events.map((event) => (
-
- ))}
-
+
+
+ {events.map((event) => (
+
+ ))}
+
+
-
-
-
Event Log
- {eventsTotal > 0 && (
-
- {eventsTotal} event{eventsTotal !== 1 && "s"}
-
- )}
+
+
+
+
Event Log
+ {eventsTotal > 0 && (
+
+ {eventsTotal} event{eventsTotal !== 1 && "s"}
+
+ )}
+
+
-
-
-
+
+
diff --git a/web-app/drizzle/0006_qpcr_tape_station.sql b/web-app/drizzle/0006_qpcr_tape_station.sql
new file mode 100644
index 00000000..4b630f19
--- /dev/null
+++ b/web-app/drizzle/0006_qpcr_tape_station.sql
@@ -0,0 +1,2 @@
+ALTER TYPE "public"."instrument_type" ADD VALUE 'qpcr';--> statement-breakpoint
+ALTER TYPE "public"."instrument_type" ADD VALUE 'tape_station';
diff --git a/web-app/drizzle/meta/0006_snapshot.json b/web-app/drizzle/meta/0006_snapshot.json
new file mode 100644
index 00000000..a3a4a79a
--- /dev/null
+++ b/web-app/drizzle/meta/0006_snapshot.json
@@ -0,0 +1,1263 @@
+{
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "prevId": "56fce10b-253f-4400-897b-50450318252d",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_accounts_user_id": {
+ "name": "idx_accounts_user_id",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "name": "account_provider_providerAccountId_pk",
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.files": {
+ "name": "files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "instrument_run_id": {
+ "name": "instrument_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "relative_path": {
+ "name": "relative_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "s3_bucket": {
+ "name": "s3_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "s3_key": {
+ "name": "s3_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "filename": {
+ "name": "filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "file_category",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'raw'"
+ },
+ "status": {
+ "name": "status",
+ "type": "file_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'detected'"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "detected_at": {
+ "name": "detected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "upload_requested_at": {
+ "name": "upload_requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uploaded_at": {
+ "name": "uploaded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "processed_at": {
+ "name": "processed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "uq_files_instrument_run_id_relative_path": {
+ "name": "uq_files_instrument_run_id_relative_path",
+ "columns": [
+ {
+ "expression": "instrument_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "relative_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"files\".\"relative_path\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "uq_files_s3_key": {
+ "name": "uq_files_s3_key",
+ "columns": [
+ {
+ "expression": "s3_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"files\".\"s3_key\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_files_instrument_run_id": {
+ "name": "idx_files_instrument_run_id",
+ "columns": [
+ {
+ "expression": "instrument_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_files_status_instrument_run_id": {
+ "name": "idx_files_status_instrument_run_id",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "instrument_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_files_active": {
+ "name": "idx_files_active",
+ "columns": [
+ {
+ "expression": "instrument_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"files\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_files_upload_queue": {
+ "name": "idx_files_upload_queue",
+ "columns": [
+ {
+ "expression": "upload_requested_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"files\".\"upload_requested_at\" is not null and \"files\".\"uploaded_at\" is null and \"files\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_files_metadata_gin": {
+ "name": "idx_files_metadata_gin",
+ "columns": [
+ {
+ "expression": "metadata",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "files_instrument_run_id_instrument_runs_id_fk": {
+ "name": "files_instrument_run_id_instrument_runs_id_fk",
+ "tableFrom": "files",
+ "tableTo": "instrument_runs",
+ "columnsFrom": [
+ "instrument_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.instrument_runs": {
+ "name": "instrument_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "instrument_id": {
+ "name": "instrument_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "instrument_run_source",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'lambda'"
+ },
+ "watcher_id": {
+ "name": "watcher_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "files_purged_at": {
+ "name": "files_purged_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_instrument_runs_instrument_id_created_at": {
+ "name": "idx_instrument_runs_instrument_id_created_at",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_instrument_runs_active": {
+ "name": "idx_instrument_runs_active",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"instrument_runs\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_instrument_runs_metadata_gin": {
+ "name": "idx_instrument_runs_metadata_gin",
+ "columns": [
+ {
+ "expression": "metadata",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "instrument_runs_instrument_id_instruments_id_fk": {
+ "name": "instrument_runs_instrument_id_instruments_id_fk",
+ "tableFrom": "instrument_runs",
+ "tableTo": "instruments",
+ "columnsFrom": [
+ "instrument_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "instrument_runs_watcher_id_watchers_id_fk": {
+ "name": "instrument_runs_watcher_id_watchers_id_fk",
+ "tableFrom": "instrument_runs",
+ "tableTo": "watchers",
+ "columnsFrom": [
+ "watcher_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "uq_instrument_runs_instrument_id_run_id": {
+ "name": "uq_instrument_runs_instrument_id_run_id",
+ "nullsNotDistinct": false,
+ "columns": [
+ "instrument_id",
+ "run_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.instruments": {
+ "name": "instruments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "instrument_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "instrument_type": {
+ "name": "instrument_type",
+ "type": "instrument_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'generic'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.personal_access_tokens": {
+ "name": "personal_access_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_personal_access_tokens_user_id": {
+ "name": "idx_personal_access_tokens_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "personal_access_tokens_user_id_user_id_fk": {
+ "name": "personal_access_tokens_user_id_user_id_fk",
+ "tableFrom": "personal_access_tokens",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "personal_access_tokens_token_hash_unique": {
+ "name": "personal_access_tokens_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "idx_sessions_user_id": {
+ "name": "idx_sessions_user_id",
+ "columns": [
+ {
+ "expression": "userId",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.watcher_events": {
+ "name": "watcher_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "watcher_id": {
+ "name": "watcher_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "watcher_event_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_watcher_events_watcher_id_timestamp": {
+ "name": "idx_watcher_events_watcher_id_timestamp",
+ "columns": [
+ {
+ "expression": "watcher_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "timestamp",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_watcher_events_watcher_id_event_type": {
+ "name": "idx_watcher_events_watcher_id_event_type",
+ "columns": [
+ {
+ "expression": "watcher_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "watcher_events_watcher_id_watchers_id_fk": {
+ "name": "watcher_events_watcher_id_watchers_id_fk",
+ "tableFrom": "watcher_events",
+ "tableTo": "watchers",
+ "columnsFrom": [
+ "watcher_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.watcher_heartbeats": {
+ "name": "watcher_heartbeats",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "watcher_id": {
+ "name": "watcher_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timestamp": {
+ "name": "timestamp",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "upload_mode": {
+ "name": "upload_mode",
+ "type": "upload_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "files_uploaded_since_last": {
+ "name": "files_uploaded_since_last",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "runs_reported_since_last": {
+ "name": "runs_reported_since_last",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "errors_since_last": {
+ "name": "errors_since_last",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "uptime_seconds": {
+ "name": "uptime_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_watcher_heartbeats_watcher_id_timestamp": {
+ "name": "idx_watcher_heartbeats_watcher_id_timestamp",
+ "columns": [
+ {
+ "expression": "watcher_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "timestamp",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "watcher_heartbeats_watcher_id_watchers_id_fk": {
+ "name": "watcher_heartbeats_watcher_id_watchers_id_fk",
+ "tableFrom": "watcher_heartbeats",
+ "tableTo": "watchers",
+ "columnsFrom": [
+ "watcher_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.watchers": {
+ "name": "watchers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "instrument_id": {
+ "name": "instrument_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hostname": {
+ "name": "hostname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "os_info": {
+ "name": "os_info",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config_checksum": {
+ "name": "config_checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config_yaml": {
+ "name": "config_yaml",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_heartbeat_at": {
+ "name": "last_heartbeat_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "watcher_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'registered'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_watchers_instrument_id": {
+ "name": "idx_watchers_instrument_id",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"watchers\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "watchers_instrument_id_instruments_id_fk": {
+ "name": "watchers_instrument_id_instruments_id_fk",
+ "tableFrom": "watchers",
+ "tableTo": "instruments",
+ "columnsFrom": [
+ "instrument_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.file_category": {
+ "name": "file_category",
+ "schema": "public",
+ "values": [
+ "raw",
+ "processed"
+ ]
+ },
+ "public.file_status": {
+ "name": "file_status",
+ "schema": "public",
+ "values": [
+ "detected",
+ "upload_requested",
+ "uploaded",
+ "processing",
+ "completed",
+ "failed"
+ ]
+ },
+ "public.instrument_run_source": {
+ "name": "instrument_run_source",
+ "schema": "public",
+ "values": [
+ "lambda",
+ "watcher"
+ ]
+ },
+ "public.instrument_status": {
+ "name": "instrument_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "active",
+ "inactive"
+ ]
+ },
+ "public.instrument_type": {
+ "name": "instrument_type",
+ "schema": "public",
+ "values": [
+ "generic",
+ "plate_reader",
+ "gel_doc",
+ "qpcr",
+ "tape_station"
+ ]
+ },
+ "public.upload_mode": {
+ "name": "upload_mode",
+ "schema": "public",
+ "values": [
+ "auto",
+ "manual"
+ ]
+ },
+ "public.watcher_event_type": {
+ "name": "watcher_event_type",
+ "schema": "public",
+ "values": [
+ "watcher_started",
+ "watcher_stopped",
+ "file_uploaded",
+ "upload_failed",
+ "run_reported",
+ "config_synced",
+ "error"
+ ]
+ },
+ "public.watcher_status": {
+ "name": "watcher_status",
+ "schema": "public",
+ "values": [
+ "registered",
+ "watching",
+ "stopped"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/web-app/drizzle/meta/_journal.json b/web-app/drizzle/meta/_journal.json
index fd76ef3b..2485996e 100644
--- a/web-app/drizzle/meta/_journal.json
+++ b/web-app/drizzle/meta/_journal.json
@@ -43,6 +43,13 @@
"when": 1776726428193,
"tag": "0005_drop_s3_trigger_suffix",
"breakpoints": true
+ },
+ {
+ "idx": 6,
+ "version": "7",
+ "when": 1776988800000,
+ "tag": "0006_qpcr_tape_station",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/web-app/lib/api/instrument-runs.ts b/web-app/lib/api/instrument-runs.ts
index a280a58b..292a9399 100644
--- a/web-app/lib/api/instrument-runs.ts
+++ b/web-app/lib/api/instrument-runs.ts
@@ -81,6 +81,7 @@ type RunListFilters = {
imagingMode?: string;
gelWavelength?: string;
gelColor?: string;
+ dyeChannel?: string;
};
const MAX_PER_PAGE = 100;
@@ -176,6 +177,13 @@ export async function buildRunListQuery(filters: RunListFilters) {
);
}
+ // qPCR metadata column filters (leverages the GIN index).
+ if (filters.dyeChannel) {
+ conditions.push(
+ sql`${instrumentRuns.metadata}->'dye_channels' @> ${JSON.stringify([filters.dyeChannel])}::jsonb`
+ );
+ }
+
const where = conditions.length > 0 ? and(...conditions) : undefined;
// Single-query aggregation: counts per-run file stats using FILTER (WHERE ...)
@@ -400,7 +408,11 @@ export type GelDocFilterOptions = {
colors: string[];
};
-const ALLOWED_METADATA_ARRAY_KEYS = new Set(["wavelengths", "colors"]);
+const ALLOWED_METADATA_ARRAY_KEYS = new Set([
+ "wavelengths",
+ "colors",
+ "dye_channels",
+]);
async function distinctMetadataArrayValues(
instrumentId: string,
@@ -433,3 +445,21 @@ export async function getGelDocFilterOptions(
]);
return { captureTypes, imagingModes, wavelengths, colors };
}
+
+// ---------------------------------------------------------------------------
+// Distinct metadata values for qPCR column filters.
+// ---------------------------------------------------------------------------
+
+export type QpcrFilterOptions = {
+ dyeChannels: string[];
+};
+
+export async function getQpcrFilterOptions(
+ instrumentId: string
+): Promise {
+ const dyeChannels = await distinctMetadataArrayValues(
+ instrumentId,
+ "dye_channels"
+ );
+ return { dyeChannels };
+}
diff --git a/web-app/lib/db/schema.ts b/web-app/lib/db/schema.ts
index 6ca02b16..ed299473 100644
--- a/web-app/lib/db/schema.ts
+++ b/web-app/lib/db/schema.ts
@@ -49,6 +49,8 @@ export const instrumentTypeEnum = pgEnum("instrument_type", [
"generic",
"plate_reader",
"gel_doc",
+ "qpcr",
+ "tape_station",
]);
export const VALID_INSTRUMENT_TYPES = instrumentTypeEnum.enumValues;
diff --git a/web-app/lib/instrument-colors.ts b/web-app/lib/instrument-colors.ts
index fbad825b..c34d9c9d 100644
--- a/web-app/lib/instrument-colors.ts
+++ b/web-app/lib/instrument-colors.ts
@@ -56,6 +56,20 @@ export const MEASUREMENT_MODE_COLORS: Record = {
"border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-300",
};
+// ---------- qPCR ----------
+
+// Known qPCR dye channels. Unknown values fall through to the hash-based cycle
+// below so the same label always maps to the same color across the table and
+// the run-detail metadata section.
+export const DYE_CHANNEL_COLORS: Record = {
+ HEX: "border-stone-200 bg-stone-50 text-stone-700 dark:border-stone-800 dark:bg-stone-950 dark:text-stone-300",
+ TAMRA:
+ "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-300",
+ ROX: "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300",
+ "ORANGE 560":
+ "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300",
+};
+
// ---------- Shared wavelength cycle ----------
export const WAVELENGTH_COLOR_CYCLE = [
@@ -82,3 +96,28 @@ export function buildWavelengthColorMap(
}
return map;
}
+
+/**
+ * Deterministic djb2-style hash for stable color assignment. Using a hash
+ * (rather than sorted-index) means a label gets the same cycle color no matter
+ * which subset of labels it appears alongside.
+ */
+function hashLabel(s: string): number {
+ let h = 5381;
+ for (let i = 0; i < s.length; i++) {
+ h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
+ }
+ return h;
+}
+
+/**
+ * Stable color for a qPCR dye channel: named entries from
+ * `DYE_CHANNEL_COLORS`, otherwise a deterministic slot from
+ * `WAVELENGTH_COLOR_CYCLE` keyed by the label itself.
+ */
+export function getDyeChannelColor(channel: string): string {
+ return (
+ DYE_CHANNEL_COLORS[channel] ??
+ WAVELENGTH_COLOR_CYCLE[hashLabel(channel) % WAVELENGTH_COLOR_CYCLE.length]
+ );
+}
diff --git a/web-app/lib/search-params.ts b/web-app/lib/search-params.ts
index 310542e2..84060715 100644
--- a/web-app/lib/search-params.ts
+++ b/web-app/lib/search-params.ts
@@ -41,6 +41,8 @@ export const instrumentDetailSearchParams = {
imaging_mode: parseAsString,
gel_wavelength: parseAsString,
gel_color: parseAsString,
+ // qPCR metadata column filters.
+ dye_channel: parseAsString,
};
export const instrumentDetailParamsCache = createSearchParamsCache(