Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)

Expand Down
5 changes: 5 additions & 0 deletions web-app/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
53 changes: 34 additions & 19 deletions web-app/app/instruments/[instrumentId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,19 +60,23 @@ 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,
}),
]);

if (!instrument) notFound();

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 !== "" ||
Expand All @@ -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 (
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-6">
<InstrumentHeader instrument={instrument} />
<InstrumentRunsToolbar />
<InstrumentRunsTable
data={runResult.data}
instrumentId={instrumentId}
instrumentType={instrument.instrumentType}
hasFilters={hasFilters}
filterOptions={filterOptions}
gelDocFilterOptions={gelDocFilterOptions}
/>
<PaginationNav
page={runResult.pagination.page}
totalPages={runResult.pagination.total_pages}
pageParam="page"
/>
<TablePendingProvider>
<InstrumentRunsToolbar />
<TablePendingBoundary>
<InstrumentRunsTable
data={runResult.data}
instrumentId={instrumentId}
instrumentType={instrument.instrumentType}
hasFilters={hasFilters}
filterOptions={filterOptions}
gelDocFilterOptions={gelDocFilterOptions}
qpcrFilterOptions={qpcrFilterOptions}
/>
</TablePendingBoundary>
<PaginationNav
page={runResult.pagination.page}
totalPages={runResult.pagination.total_pages}
pageParam="page"
/>
</TablePendingProvider>
</div>
);
}
22 changes: 15 additions & 7 deletions web-app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,14 +69,18 @@ export default async function DashboardPage({
<InstrumentCards />
</Suspense>

<RunsToolbar instruments={instruments} />
<TablePendingProvider>
<RunsToolbar instruments={instruments} />

<RunsTable data={runResult.data} hasFilters={hasFilters} />
<PaginationNav
page={runResult.pagination.page}
totalPages={runResult.pagination.total_pages}
pageParam="page"
/>
<TablePendingBoundary>
<RunsTable data={runResult.data} hasFilters={hasFilters} />
</TablePendingBoundary>
<PaginationNav
page={runResult.pagination.page}
totalPages={runResult.pagination.total_pages}
pageParam="page"
/>
</TablePendingProvider>
</div>
);
}
3 changes: 3 additions & 0 deletions web-app/components/dashboard/runs-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions web-app/components/instruments/edit-instrument-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const TYPE_LABELS: Record<string, string> = {
generic: "Generic",
plate_reader: "Plate Reader",
gel_doc: "Gel Doc",
qpcr: "qPCR",
tape_station: "TapeStation",
};

const INSTRUMENT_TYPE_OPTIONS = VALID_INSTRUMENT_TYPES.map((value) => ({
Expand Down
4 changes: 4 additions & 0 deletions web-app/components/instruments/instrument-runs-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useTablePending } from "@/components/table-pending";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
Expand All @@ -21,7 +22,8 @@ type FilterParamKey =
| "capture_type"
| "imaging_mode"
| "gel_wavelength"
| "gel_color";
| "gel_color"
| "dye_channel";

export function FilterableColumnHeader({
label,
Expand All @@ -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];
Expand Down
12 changes: 12 additions & 0 deletions web-app/components/instruments/runs-table/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type {
GelDocFilterOptions,
PlateReaderFilterOptions,
QpcrFilterOptions,
} from "@/lib/api/instrument-runs";
import type { InstrumentType } from "@/lib/db/schema";
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;
Expand Down Expand Up @@ -39,13 +41,15 @@ export function InstrumentRunsTable({
hasFilters,
filterOptions,
gelDocFilterOptions,
qpcrFilterOptions,
}: {
data: RunRow[];
instrumentId: string;
instrumentType: InstrumentType;
hasFilters: boolean;
filterOptions?: PlateReaderFilterOptions;
gelDocFilterOptions?: GelDocFilterOptions;
qpcrFilterOptions?: QpcrFilterOptions;
}) {
if (data.length === 0) {
return (
Expand Down Expand Up @@ -77,6 +81,14 @@ export function InstrumentRunsTable({
filterOptions={gelDocFilterOptions!}
/>
);
case "qpcr":
return (
<QpcrRunsTable
data={data}
instrumentId={instrumentId}
filterOptions={qpcrFilterOptions!}
/>
);
default:
return <DefaultRunsTable data={data} instrumentId={instrumentId} />;
}
Expand Down
101 changes: 101 additions & 0 deletions web-app/components/instruments/runs-table/qpcr-runs-table.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Run ID</TableHead>
<TableHead>Files</TableHead>
<TableHead className="text-right">Total Size</TableHead>
<TableHead>
<FilterableColumnHeader
label="Dye Channels"
paramKey="dye_channel"
options={filterOptions.dyeChannels}
/>
</TableHead>
<TableHead className="text-right">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<ClickableRow
key={row.id}
href={`/instruments/${instrumentId}/runs/${encodeURIComponent(row.run_id)}`}
className={cn(isDeleted && "opacity-50")}
>
<TableCell>
<div className="flex items-center gap-2.5">
<RunStatusIcon
filesFailed={row.files_failed}
errorMessages={row.error_messages}
/>
<span
className={cn("font-mono", isDeleted && "line-through")}
>
{row.run_id}
</span>
{isDeleted && (
<Badge variant="outline" className="ml-1.5 font-normal">
deleted
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-sm tabular-nums">
{row.file_count}
</TableCell>
<TableCell className="text-right text-sm tabular-nums">
{formatBytes(row.total_size_bytes)}
</TableCell>
<TableCell>
<MetadataArrayBadges
values={dyeChannels}
colorMap={dyeChannelColors}
/>
</TableCell>
<TableCell className="text-right">
<RelativeTime date={row.created_at.toISOString()} />
</TableCell>
</ClickableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
Loading
Loading