+ {/* Deregistration is signalled by the badge and date below, not by
+ fading the header (which made the text too faint to read). */}
+
@@ -95,11 +89,7 @@ export function WatcherHeader({ watcher }: { watcher: WatcherDetail }) {
v{watcher.watcherVersion}
)}
- {isDeregistered && (
-
- Deregistered
-
- )}
+ {isDeregistered && Deregistered}
{!isDeregistered && (
@@ -134,7 +124,18 @@ export function WatcherHeader({ watcher }: { watcher: WatcherDetail }) {
{isDeregistered && watcher.deletedAt && (
<>
·
-
Deregistered {formatDate(watcher.deletedAt)}
+
+ Deregistered {formatDate(watcher.deletedAt)}
+ {watcher.deregisteredByUser && (
+
+ by
+
+
+ {watcher.deregisteredByUser.displayName}
+
+
+ )}
+
>
)}
diff --git a/web/components/watchers/watcher-status-badge.tsx b/web/components/watchers/watcher-status-badge.tsx
index e738a48c..2f332d4a 100644
--- a/web/components/watchers/watcher-status-badge.tsx
+++ b/web/components/watchers/watcher-status-badge.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Clock, Power, Radio, WifiOff } from "lucide-react";
+import { Clock, Power, Radio, Unplug, WifiOff } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
@@ -24,7 +24,12 @@ import { cn, formatRelativeTime } from "@/lib/utils";
* can't tell why an instrument's watchers are silent, while the per-watcher
* badge can.
*/
-export type WatcherBadgeStatus = WatcherOnlineStatus | EffectiveStatus;
+export type WatcherBadgeStatus =
+ | WatcherOnlineStatus
+ | EffectiveStatus
+ // Active instrument whose only watcher was deregistered. Distinct from
+ // `no_watcher` (never had one), so the header can still link to the watcher.
+ | "deregistered";
const ONLINE_CLASSNAME =
"bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300";
@@ -53,6 +58,11 @@ const STATUS_CONFIG: Record<
Icon: WifiOff,
className: MUTED_NEUTRAL_CLASSNAME,
},
+ deregistered: {
+ label: "Deregistered",
+ Icon: Unplug,
+ className: MUTED_NEUTRAL_CLASSNAME,
+ },
// Per-watcher
watching: { label: "Online", Icon: Radio, className: ONLINE_CLASSNAME },
stale: {
diff --git a/web/drizzle/0030_mature_manta.sql b/web/drizzle/0030_mature_manta.sql
new file mode 100644
index 00000000..faa501df
--- /dev/null
+++ b/web/drizzle/0030_mature_manta.sql
@@ -0,0 +1,5 @@
+ALTER TABLE "instruments" ADD COLUMN "retired_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "instruments" ADD COLUMN "retired_by" text;--> statement-breakpoint
+ALTER TABLE "watchers" ADD COLUMN "deregistered_by" text;--> statement-breakpoint
+ALTER TABLE "instruments" ADD CONSTRAINT "instruments_retired_by_user_id_fk" FOREIGN KEY ("retired_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "watchers" ADD CONSTRAINT "watchers_deregistered_by_user_id_fk" FOREIGN KEY ("deregistered_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
\ No newline at end of file
diff --git a/web/drizzle/meta/0030_snapshot.json b/web/drizzle/meta/0030_snapshot.json
new file mode 100644
index 00000000..143d3c72
--- /dev/null
+++ b/web/drizzle/meta/0030_snapshot.json
@@ -0,0 +1,2225 @@
+{
+ "id": "e00a4a46-d808-4a76-a6a3-46c9a8ab0def",
+ "prevId": "90447fa8-1d75-4bfa-a7b4-ae83c4f14121",
+ "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.archive_jobs": {
+ "name": "archive_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "instrument_run_id": {
+ "name": "instrument_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "archive_bucket": {
+ "name": "archive_bucket",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archive_key": {
+ "name": "archive_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "archive_job_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "uq_archive_jobs_inflight": {
+ "name": "uq_archive_jobs_inflight",
+ "columns": [
+ {
+ "expression": "instrument_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"archive_jobs\".\"status\" in ('pending', 'building')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_archive_jobs_run_fingerprint_status": {
+ "name": "idx_archive_jobs_run_fingerprint_status",
+ "columns": [
+ {
+ "expression": "instrument_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "archive_jobs_instrument_run_id_instrument_runs_id_fk": {
+ "name": "archive_jobs_instrument_run_id_instrument_runs_id_fk",
+ "tableFrom": "archive_jobs",
+ "tableTo": "instrument_runs",
+ "columnsFrom": ["instrument_run_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "archive_jobs_created_by_user_id_fk": {
+ "name": "archive_jobs_created_by_user_id_fk",
+ "tableFrom": "archive_jobs",
+ "tableTo": "user",
+ "columnsFrom": ["created_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "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
+ },
+ "file_created_at": {
+ "name": "file_created_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_active_instrument_run_id_filename": {
+ "name": "uq_files_active_instrument_run_id_filename",
+ "columns": [
+ {
+ "expression": "instrument_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "filename",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"files\".\"deleted_at\" is 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_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": {}
+ },
+ "idx_files_filename_trgm": {
+ "name": "idx_files_filename_trgm",
+ "columns": [
+ {
+ "expression": "\"filename\" gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"files\".\"deleted_at\" is null",
+ "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_notification_subscriptions": {
+ "name": "instrument_notification_subscriptions",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instrument_id": {
+ "name": "instrument_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "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": {
+ "idx_instrument_notification_subscriptions_user_id": {
+ "name": "idx_instrument_notification_subscriptions_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "instrument_notification_subscriptions_user_id_user_id_fk": {
+ "name": "instrument_notification_subscriptions_user_id_user_id_fk",
+ "tableFrom": "instrument_notification_subscriptions",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "instrument_notification_subscriptions_instrument_id_instruments_id_fk": {
+ "name": "instrument_notification_subscriptions_instrument_id_instruments_id_fk",
+ "tableFrom": "instrument_notification_subscriptions",
+ "tableTo": "instruments",
+ "columnsFrom": ["instrument_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "instrument_notification_subscriptions_user_id_instrument_id_pk": {
+ "name": "instrument_notification_subscriptions_user_id_instrument_id_pk",
+ "columns": ["user_id", "instrument_id"]
+ }
+ },
+ "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()"
+ },
+ "acquired_at": {
+ "name": "acquired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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
+ },
+ "deleted_by": {
+ "name": "deleted_by",
+ "type": "text",
+ "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_active_acquired_at": {
+ "name": "idx_instrument_runs_active_acquired_at",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "coalesce(\"acquired_at\", \"created_at\") desc",
+ "asc": true,
+ "isExpression": true,
+ "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": {}
+ },
+ "idx_instrument_runs_run_id_trgm": {
+ "name": "idx_instrument_runs_run_id_trgm",
+ "columns": [
+ {
+ "expression": "\"run_id\" gin_trgm_ops",
+ "asc": true,
+ "isExpression": 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"
+ },
+ "instrument_runs_deleted_by_user_id_fk": {
+ "name": "instrument_runs_deleted_by_user_id_fk",
+ "tableFrom": "instrument_runs",
+ "tableTo": "user",
+ "columnsFrom": ["deleted_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "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()"
+ },
+ "retired_at": {
+ "name": "retired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retired_by": {
+ "name": "retired_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_instruments_display_name_trgm": {
+ "name": "idx_instruments_display_name_trgm",
+ "columns": [
+ {
+ "expression": "\"display_name\" gin_trgm_ops",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "instruments_retired_by_user_id_fk": {
+ "name": "instruments_retired_by_user_id_fk",
+ "tableFrom": "instruments",
+ "tableTo": "user",
+ "columnsFrom": ["retired_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notification_preferences": {
+ "name": "notification_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "runs_all_muted": {
+ "name": "runs_all_muted",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "comments_attributed_enabled": {
+ "name": "comments_attributed_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "comments_participated_enabled": {
+ "name": "comments_participated_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "slack_runs_enabled": {
+ "name": "slack_runs_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "slack_comments_attributed_enabled": {
+ "name": "slack_comments_attributed_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "slack_comments_participated_enabled": {
+ "name": "slack_comments_participated_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "notification_preferences_user_id_user_id_fk": {
+ "name": "notification_preferences_user_id_user_id_fk",
+ "tableFrom": "notification_preferences",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "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
+ },
+ "type": {
+ "name": "type",
+ "type": "notification_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_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_notifications_user_id_created_at": {
+ "name": "idx_notifications_user_id_created_at",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_notifications_user_id_unread": {
+ "name": "idx_notifications_user_id_unread",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"notifications\".\"read_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_user_id_fk": {
+ "name": "notifications_user_id_user_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_run_id_instrument_runs_id_fk": {
+ "name": "notifications_run_id_instrument_runs_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "instrument_runs",
+ "columnsFrom": ["run_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_comment_id_run_comments_id_fk": {
+ "name": "notifications_comment_id_run_comments_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "run_comments",
+ "columnsFrom": ["comment_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_actor_user_id_user_id_fk": {
+ "name": "notifications_actor_user_id_user_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "user",
+ "columnsFrom": ["actor_user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "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
+ },
+ "scopes": {
+ "name": "scopes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "ARRAY['*']::text[]"
+ },
+ "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.run_attributions": {
+ "name": "run_attributions",
+ "schema": "",
+ "columns": {
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "idx_run_attributions_run_id": {
+ "name": "idx_run_attributions_run_id",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_run_attributions_user_id": {
+ "name": "idx_run_attributions_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "run_attributions_run_id_instrument_runs_id_fk": {
+ "name": "run_attributions_run_id_instrument_runs_id_fk",
+ "tableFrom": "run_attributions",
+ "tableTo": "instrument_runs",
+ "columnsFrom": ["run_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "run_attributions_user_id_user_id_fk": {
+ "name": "run_attributions_user_id_user_id_fk",
+ "tableFrom": "run_attributions",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "run_attributions_run_id_user_id_pk": {
+ "name": "run_attributions_run_id_user_id_pk",
+ "columns": ["run_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.run_comments": {
+ "name": "run_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "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()"
+ },
+ "edited_at": {
+ "name": "edited_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "idx_run_comments_run_id_created_at": {
+ "name": "idx_run_comments_run_id_created_at",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "idx_run_comments_user_id": {
+ "name": "idx_run_comments_user_id",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "run_comments_run_id_instrument_runs_id_fk": {
+ "name": "run_comments_run_id_instrument_runs_id_fk",
+ "tableFrom": "run_comments",
+ "tableTo": "instrument_runs",
+ "columnsFrom": ["run_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "run_comments_user_id_user_id_fk": {
+ "name": "run_comments_user_id_user_id_fk",
+ "tableFrom": "run_comments",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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.slack_channel_config": {
+ "name": "slack_channel_config",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "boolean",
+ "primaryKey": true,
+ "notNull": true,
+ "default": true
+ },
+ "webhook_url": {
+ "name": "webhook_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "slack_channel_config_updated_by_user_id_fk": {
+ "name": "slack_channel_config_updated_by_user_id_fk",
+ "tableFrom": "slack_channel_config",
+ "tableTo": "user",
+ "columnsFrom": ["updated_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "slack_channel_config_singleton": {
+ "name": "slack_channel_config_singleton",
+ "value": "\"slack_channel_config\".\"id\" = true"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.slack_connections": {
+ "name": "slack_connections",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slack_user_id": {
+ "name": "slack_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slack_team_id": {
+ "name": "slack_team_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slack_team_name": {
+ "name": "slack_team_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "slack_connections_user_id_user_id_fk": {
+ "name": "slack_connections_user_id_user_id_fk",
+ "tableFrom": "slack_connections",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "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
+ },
+ "is_admin": {
+ "name": "is_admin",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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.watcher_release_config": {
+ "name": "watcher_release_config",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "boolean",
+ "primaryKey": true,
+ "notNull": true,
+ "default": true
+ },
+ "latest_version": {
+ "name": "latest_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "min_supported_version": {
+ "name": "min_supported_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'stable'"
+ },
+ "mandatory": {
+ "name": "mandatory",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "watcher_release_config_updated_by_user_id_fk": {
+ "name": "watcher_release_config_updated_by_user_id_fk",
+ "tableFrom": "watcher_release_config",
+ "tableTo": "user",
+ "columnsFrom": ["updated_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "watcher_release_config_singleton": {
+ "name": "watcher_release_config_singleton",
+ "value": "\"watcher_release_config\".\"id\" = true"
+ }
+ },
+ "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
+ },
+ "watcher_version": {
+ "name": "watcher_version",
+ "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
+ },
+ "deregistered_by": {
+ "name": "deregistered_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "uq_watchers_active_instrument_id": {
+ "name": "uq_watchers_active_instrument_id",
+ "columns": [
+ {
+ "expression": "instrument_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "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"
+ },
+ "watchers_deregistered_by_user_id_fk": {
+ "name": "watchers_deregistered_by_user_id_fk",
+ "tableFrom": "watchers",
+ "tableTo": "user",
+ "columnsFrom": ["deregistered_by"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.archive_job_status": {
+ "name": "archive_job_status",
+ "schema": "public",
+ "values": ["pending", "building", "ready", "failed"]
+ },
+ "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",
+ "hina_microscope",
+ "epson_v700_scanner",
+ "instant_raman"
+ ]
+ },
+ "public.notification_type": {
+ "name": "notification_type",
+ "schema": "public",
+ "values": ["run_created", "comment_attributed", "comment_participated"]
+ },
+ "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",
+ "update_started",
+ "update_succeeded",
+ "update_failed"
+ ]
+ },
+ "public.watcher_status": {
+ "name": "watcher_status",
+ "schema": "public",
+ "values": ["registered", "watching", "stopped"]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json
index e683be17..30dc965d 100644
--- a/web/drizzle/meta/_journal.json
+++ b/web/drizzle/meta/_journal.json
@@ -211,6 +211,13 @@
"when": 1783365397666,
"tag": "0029_add_search_trgm_indexes",
"breakpoints": true
+ },
+ {
+ "idx": 30,
+ "version": "7",
+ "when": 1783557726363,
+ "tag": "0030_mature_manta",
+ "breakpoints": true
}
]
}
diff --git a/web/lib/api/actor.ts b/web/lib/api/actor.ts
new file mode 100644
index 00000000..0131857a
--- /dev/null
+++ b/web/lib/api/actor.ts
@@ -0,0 +1,36 @@
+import type { UserAvatarUser } from "@/components/user-avatar";
+
+// Who performed an audited action (retiring an instrument, deregistering a
+// watcher). Aliases `UserAvatarUser` so it feeds `
` directly.
+export type ActorUser = UserAvatarUser;
+
+export function toInitials(displayName: string): string {
+ const parts = displayName.trim().split(/\s+/).filter(Boolean);
+ if (parts.length === 0) {
+ return "?";
+ }
+ if (parts.length === 1) {
+ return parts[0].slice(0, 2).toUpperCase();
+ }
+ return (parts[0][0] + (parts.at(-1)?.[0] ?? "")).toUpperCase();
+}
+
+// Returns null when no actor was recorded: a NULL FK, or a row that predates
+// the actor column. Falls back to email, then a placeholder, for the label.
+export function resolveActorUser(input: {
+ userId: string | null;
+ name: string | null;
+ email: string | null;
+ image: string | null;
+}): ActorUser | null {
+ if (!input.userId) {
+ return null;
+ }
+ const displayName = input.name ?? input.email ?? "Unknown user";
+ return {
+ userId: input.userId,
+ displayName,
+ initials: toInitials(displayName),
+ avatarUrl: input.image,
+ };
+}
diff --git a/web/lib/api/dashboard.ts b/web/lib/api/dashboard.ts
index e6cf5072..1594991d 100644
--- a/web/lib/api/dashboard.ts
+++ b/web/lib/api/dashboard.ts
@@ -96,7 +96,11 @@ export const getInstrumentSummaries = cache(
}
);
-export const getInstruments = cache(async function getInstruments() {
+// `activeOnly` is a primitive so `cache()` dedupes calls with the same value
+// within a request; an options object would key on identity and miss.
+export const getInstruments = cache(async function getInstruments(
+ activeOnly = false
+) {
return await db
.select({
id: instruments.id,
@@ -104,6 +108,7 @@ export const getInstruments = cache(async function getInstruments() {
status: instruments.status,
})
.from(instruments)
+ .where(activeOnly ? eq(instruments.status, "active") : undefined)
.orderBy(instruments.displayName);
});
@@ -149,8 +154,10 @@ export const getDashboardStats = cache(async function getDashboardStats(
total: sql`cast(count(*) as int)`,
})
.from(instrumentRuns)
+ .innerJoin(instruments, eq(instruments.id, instrumentRuns.instrumentId))
.where(
and(
+ eq(instruments.status, "active"),
isNull(instrumentRuns.deletedAt),
sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '24 hours'`
)
@@ -171,8 +178,10 @@ export const getDashboardStats = cache(async function getDashboardStats(
})
.from(files)
.innerJoin(instrumentRuns, eq(files.instrumentRunId, instrumentRuns.id))
+ .innerJoin(instruments, eq(instruments.id, instrumentRuns.instrumentId))
.where(
and(
+ eq(instruments.status, "active"),
isNull(files.deletedAt),
isNull(instrumentRuns.deletedAt),
sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days'`
@@ -184,8 +193,11 @@ export const getDashboardStats = cache(async function getDashboardStats(
totalBytes: sql`cast(coalesce(sum(${files.sizeBytes}), 0) as bigint)`,
})
.from(files)
+ .innerJoin(instrumentRuns, eq(files.instrumentRunId, instrumentRuns.id))
+ .innerJoin(instruments, eq(instruments.id, instrumentRuns.instrumentId))
.where(
and(
+ eq(instruments.status, "active"),
isNull(files.deletedAt),
sql`${files.status} in ('detected', 'upload_requested')`
)
@@ -201,8 +213,10 @@ export const getDashboardStats = cache(async function getDashboardStats(
unattributed: sql`cast(count(*) filter (where not exists (select 1 from ${runAttributions} where ${runAttributions.runId} = ${instrumentRuns.id})) as int)`,
})
.from(instrumentRuns)
+ .innerJoin(instruments, eq(instruments.id, instrumentRuns.instrumentId))
.where(
and(
+ eq(instruments.status, "active"),
isNull(instrumentRuns.deletedAt),
sql`coalesce(${instrumentRuns.acquiredAt}, ${instrumentRuns.createdAt}) > now() - interval '7 days'`
)
diff --git a/web/lib/api/instrument-runs.ts b/web/lib/api/instrument-runs.ts
index ea375734..15922725 100644
--- a/web/lib/api/instrument-runs.ts
+++ b/web/lib/api/instrument-runs.ts
@@ -1,6 +1,16 @@
import { parse } from "csv-parse/sync";
import type { AnyColumn, SQL } from "drizzle-orm";
-import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ ilike,
+ inArray,
+ isNull,
+ or,
+ sql,
+} from "drizzle-orm";
import { cache } from "react";
import { formatHinaSizes } from "@/components/runs/run-metadata-badges";
import { db } from "@/lib/db";
@@ -12,6 +22,7 @@ import {
runAttributions,
users,
} from "@/lib/db/schema";
+import type { RunStatus } from "@/lib/runs/run-status";
import { getS3ObjectStream } from "@/lib/s3";
// ---------------------------------------------------------------------------
@@ -235,6 +246,8 @@ interface RunListFilters {
search?: string;
sort?: string;
source?: string;
+ // Derived run statuses to match (OR'd together). Undefined/empty = no filter.
+ statuses?: RunStatus[];
wavelength?: string;
}
@@ -254,6 +267,43 @@ const ALLOWED_SORT_FIELDS: Record = {
updated_at: instrumentRuns.updatedAt,
};
+// Mirrors the aggregate LEFT JOIN's scope so the filter matches the shown icon.
+const rawFileScopeSql = sql`${files.instrumentRunId} = ${instrumentRuns.id} and ${files.category} = 'raw' and ${files.deletedAt} is null`;
+
+function existsRawFileWithStatus(statusSql: SQL): SQL {
+ return sql`exists (select 1 from ${files} where ${rawFileScopeSql} and ${statusSql})`;
+}
+
+// Priority-exclusive (NOT) EXISTS predicate per derived status, mirroring
+// `deriveRunStatus`. Correlated subqueries (not a HAVING over the aggregate)
+// keep the pagination COUNT off a `files` join and on the status/run indexes.
+function runStatusCondition(status: RunStatus): SQL {
+ const failed = existsRawFileWithStatus(sql`${files.status} = 'failed'`);
+ const pending = existsRawFileWithStatus(
+ sql`${files.status} in ('detected', 'upload_requested')`
+ );
+ const uploaded = existsRawFileWithStatus(sql`${files.status} = 'uploaded'`);
+ const processing = existsRawFileWithStatus(
+ sql`${files.status} = 'processing'`
+ );
+ const completed = existsRawFileWithStatus(sql`${files.status} = 'completed'`);
+
+ switch (status) {
+ case "failed":
+ return failed;
+ case "pending":
+ return sql`(not ${failed} and ${pending})`;
+ case "uploaded":
+ return sql`(not ${failed} and not ${pending} and ${uploaded})`;
+ case "processing":
+ return sql`(not ${failed} and not ${pending} and not ${uploaded} and ${processing})`;
+ case "completed":
+ return sql`(not ${failed} and not ${pending} and not ${uploaded} and not ${processing} and ${completed})`;
+ default:
+ return sql`not exists (select 1 from ${files} where ${rawFileScopeSql})`;
+ }
+}
+
// ---------------------------------------------------------------------------
// Adjacent-run navigation for the run detail header.
// ---------------------------------------------------------------------------
@@ -458,6 +508,13 @@ export async function buildRunListQuery(filters: RunListFilters) {
);
}
+ if (filters.statuses && filters.statuses.length > 0) {
+ const statusOr = or(...filters.statuses.map(runStatusCondition));
+ if (statusOr) {
+ conditions.push(statusOr);
+ }
+ }
+
const where = conditions.length > 0 ? and(...conditions) : undefined;
// Single-query aggregation: counts per-run file stats using FILTER (WHERE ...)
diff --git a/web/lib/api/instruments.ts b/web/lib/api/instruments.ts
index b22e221a..f9ac6ad8 100644
--- a/web/lib/api/instruments.ts
+++ b/web/lib/api/instruments.ts
@@ -1,11 +1,13 @@
import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { cache } from "react";
import YAML from "yaml";
-import { db } from "@/lib/db";
+import { type ActorUser, resolveActorUser } from "@/lib/api/actor";
+import { type DbExecutor, db } from "@/lib/db";
import {
type InstrumentType,
instrumentRuns,
instruments,
+ users,
watchers,
} from "@/lib/db/schema";
@@ -13,6 +15,11 @@ export interface InstrumentListItem {
createdAt: Date;
displayName: string;
filePatterns: string[];
+ /**
+ * True when the instrument has a deregistered watcher. With
+ * `watcherCount === 0`, distinguishes "Deregistered" from "No Watcher".
+ */
+ hasDeregisteredWatcher: boolean;
id: string;
instrumentType: InstrumentType;
lastRunAt: Date | null;
@@ -38,13 +45,14 @@ const HEARTBEAT_STALE_MINUTES = 5;
* perpetual "Uploading" spinner with no error.
*/
export async function instrumentHasOnlineWatcher(
- instrumentId: string
+ instrumentId: string,
+ executor: DbExecutor = db
): Promise {
const staleThreshold = new Date(
Date.now() - HEARTBEAT_STALE_MINUTES * 60 * 1000
);
- const [row] = await db
+ const [row] = await executor
.select({ value: count() })
.from(watchers)
.where(
@@ -123,20 +131,30 @@ function buildRunCountSubquery() {
}
function buildWatcherCountSubquery() {
+ // No `deleted_at` filter so instruments whose only watcher was deregistered
+ // still appear; the per-column `filter (...)` clauses keep live counts live
+ // while also exposing a deregistered tally.
return db
.select({
instrumentId: watchers.instrumentId,
- count: sql`cast(count(*) as int)`.as("watcher_count"),
+ count:
+ sql`cast(count(*) filter (where ${watchers.deletedAt} is null) as int)`.as(
+ "watcher_count"
+ ),
online:
- sql`cast(count(*) filter (where ${watchers.status} = 'watching' and ${watchers.lastHeartbeatAt} > now() - interval '${sql.raw(String(HEARTBEAT_STALE_MINUTES))} minutes') as int)`.as(
+ sql`cast(count(*) filter (where ${watchers.status} = 'watching' and ${watchers.lastHeartbeatAt} > now() - interval '${sql.raw(String(HEARTBEAT_STALE_MINUTES))} minutes' and ${watchers.deletedAt} is null) as int)`.as(
"online_count"
),
- lastHeartbeatAt: sql`max(${watchers.lastHeartbeatAt})`.as(
- "last_heartbeat_at"
- ),
+ deregistered:
+ sql`cast(count(*) filter (where ${watchers.deletedAt} is not null) as int)`.as(
+ "deregistered_count"
+ ),
+ lastHeartbeatAt:
+ sql`max(${watchers.lastHeartbeatAt}) filter (where ${watchers.deletedAt} is null)`.as(
+ "last_heartbeat_at"
+ ),
})
.from(watchers)
- .where(isNull(watchers.deletedAt))
.groupBy(watchers.instrumentId)
.as("watcher_counts");
}
@@ -155,6 +173,7 @@ interface InstrumentListRow {
runsThisWeek: number;
status: "pending" | "active" | "inactive";
watcherCount: number;
+ watchersDeregistered: number;
watchersOnline: number;
}
@@ -162,13 +181,15 @@ function hydrateInstrumentRow(
row: InstrumentListRow,
configsByInstrument: Map
): InstrumentListItem {
+ const { watchersDeregistered, ...rest } = row;
return {
- ...row,
+ ...rest,
lastRunAt: row.lastRunAt ? new Date(row.lastRunAt) : null,
lastWatcherHeartbeatAt: row.lastWatcherHeartbeatAt
? new Date(row.lastWatcherHeartbeatAt)
: null,
filePatterns: mergeFilePatterns(configsByInstrument.get(row.id) ?? []),
+ hasDeregisteredWatcher: watchersDeregistered > 0,
};
}
@@ -207,6 +228,7 @@ export const getInstrumentListWithCounts = cache(
lastRunAt: runCountSq.lastRunAt,
watcherCount: sql`coalesce(${watcherCountSq.count}, 0)`,
watchersOnline: sql`coalesce(${watcherCountSq.online}, 0)`,
+ watchersDeregistered: sql`coalesce(${watcherCountSq.deregistered}, 0)`,
lastWatcherHeartbeatAt: watcherCountSq.lastHeartbeatAt,
})
.from(instruments)
@@ -265,6 +287,7 @@ export const getRecentActiveInstrumentsForDashboard = cache(
lastRunAt: runCountSq.lastRunAt,
watcherCount: sql`coalesce(${watcherCountSq.count}, 0)`,
watchersOnline: sql`coalesce(${watcherCountSq.online}, 0)`,
+ watchersDeregistered: sql`coalesce(${watcherCountSq.deregistered}, 0)`,
lastWatcherHeartbeatAt: watcherCountSq.lastHeartbeatAt,
})
.from(instruments)
@@ -315,14 +338,19 @@ export const getRecentActiveInstrumentsForDashboard = cache(
);
export interface InstrumentDetail {
- /** Desktop hostname of the canonical active watcher, if any. */
+ /**
+ * True when `activeWatcherId` is a deregistered watcher (no live one
+ * remains). Lets the header distinguish "was deregistered" from "never had
+ * a watcher".
+ */
+ activeWatcherDeregistered: boolean;
+ /** Desktop hostname of the canonical watcher, if any. */
activeWatcherHostname: string | null;
/**
- * The "canonical" watcher for this instrument, used to render watcher
- * affordances in the instrument header. When multiple watchers are
- * attached, the most recently heartbeating one wins; ties (or instruments
- * whose watchers have never heartbeated) fall back to the first watcher
- * by `createdAt`.
+ * The "canonical" watcher for the header: the most recently heartbeating
+ * live watcher, else the earliest by `createdAt`. When no live watcher
+ * remains, falls back to the most recently deregistered one so the header
+ * can still link to it (counts below stay based on live watchers).
*/
activeWatcherId: string | null;
createdAt: Date;
@@ -332,6 +360,10 @@ export interface InstrumentDetail {
instrumentType: InstrumentType;
/** Most recent heartbeat from any watcher attached to this instrument. */
lastWatcherHeartbeatAt: Date | null;
+ /** When the instrument was retired; null unless `status` is `inactive`. */
+ retiredAt: Date | null;
+ /** Who retired the instrument; null when unknown or not retired. */
+ retiredByUser: ActorUser | null;
runCount: number;
status: "pending" | "active" | "inactive";
updatedAt: Date;
@@ -344,8 +376,23 @@ export const getInstrumentById = cache(async function getInstrumentById(
instrumentId: string
): Promise {
const [instrument] = await db
- .select()
+ .select({
+ id: instruments.id,
+ displayName: instruments.displayName,
+ status: instruments.status,
+ instrumentType: instruments.instrumentType,
+ createdAt: instruments.createdAt,
+ updatedAt: instruments.updatedAt,
+ retiredAt: instruments.retiredAt,
+ retiredBy: instruments.retiredBy,
+ retiredByName: users.name,
+ retiredByEmail: users.email,
+ retiredByImage: users.image,
+ })
.from(instruments)
+ // Resolve the retirer for display; all NULL when active or retired before
+ // `retired_by` existed.
+ .leftJoin(users, eq(users.id, instruments.retiredBy))
.where(eq(instruments.id, instrumentId))
.limit(1);
@@ -353,7 +400,7 @@ export const getInstrumentById = cache(async function getInstrumentById(
return null;
}
- const [runCountResult, watcherRows] = await Promise.all([
+ const [runCountResult, allWatcherRows] = await Promise.all([
db
.select({ value: count() })
.from(instrumentRuns)
@@ -371,13 +418,16 @@ export const getInstrumentById = cache(async function getInstrumentById(
lastHeartbeatAt: watchers.lastHeartbeatAt,
createdAt: watchers.createdAt,
configYaml: watchers.configYaml,
+ deletedAt: watchers.deletedAt,
})
.from(watchers)
- .where(
- and(eq(watchers.instrumentId, instrumentId), isNull(watchers.deletedAt))
- ),
+ .where(eq(watchers.instrumentId, instrumentId)),
]);
+ // Counts and roll-ups use live watchers only; deregistered rows are kept
+ // just to resolve the canonical watcher link below.
+ const watcherRows = allWatcherRows.filter((w) => w.deletedAt === null);
+
const staleThreshold = new Date(
Date.now() - HEARTBEAT_STALE_MINUTES * 60 * 1000
);
@@ -422,6 +472,17 @@ export const getInstrumentById = cache(async function getInstrumentById(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
)[0]);
+ // Fall back to the most recently deregistered watcher so the header can
+ // still link to it once no live watcher remains (e.g. after retirement).
+ const canonicalWatcher =
+ activeWatcher ??
+ allWatcherRows
+ .filter((w) => w.deletedAt !== null)
+ .sort(
+ (a, b) => (b.deletedAt?.getTime() ?? 0) - (a.deletedAt?.getTime() ?? 0)
+ )[0] ??
+ null;
+
return {
id: instrument.id,
displayName: instrument.displayName,
@@ -430,12 +491,21 @@ export const getInstrumentById = cache(async function getInstrumentById(
filePatterns: mergeFilePatterns(watcherRows.map((w) => w.configYaml)),
createdAt: instrument.createdAt,
updatedAt: instrument.updatedAt,
+ retiredAt: instrument.retiredAt,
+ retiredByUser: resolveActorUser({
+ userId: instrument.retiredBy,
+ name: instrument.retiredByName,
+ email: instrument.retiredByEmail,
+ image: instrument.retiredByImage,
+ }),
runCount: runCountResult[0].value,
watcherCount: watcherRows.length,
watchersOnline,
watchersOffline,
lastWatcherHeartbeatAt,
- activeWatcherId: activeWatcher?.id ?? null,
- activeWatcherHostname: activeWatcher?.hostname ?? null,
+ activeWatcherId: canonicalWatcher?.id ?? null,
+ activeWatcherHostname: canonicalWatcher?.hostname ?? null,
+ activeWatcherDeregistered:
+ activeWatcher === null && canonicalWatcher !== null,
};
});
diff --git a/web/lib/api/notifications.ts b/web/lib/api/notifications.ts
index 82fe703b..9963a45f 100644
--- a/web/lib/api/notifications.ts
+++ b/web/lib/api/notifications.ts
@@ -136,7 +136,8 @@ export interface InstrumentSubscriptionRow {
}
export async function listInstrumentSubscriptions(
- userId: string
+ userId: string,
+ opts?: { activeOnly?: boolean }
): Promise {
const rows = await db
.select({
@@ -152,6 +153,7 @@ export async function listInstrumentSubscriptions(
eq(instrumentNotificationSubscriptions.userId, userId)
)
)
+ .where(opts?.activeOnly ? eq(instruments.status, "active") : undefined)
.orderBy(asc(instruments.displayName));
return rows.map((r) => ({
diff --git a/web/lib/api/search.ts b/web/lib/api/search.ts
index ee38b874..03738fdd 100644
--- a/web/lib/api/search.ts
+++ b/web/lib/api/search.ts
@@ -69,7 +69,9 @@ export interface SearchInstrumentResult {
runCount: number;
status: "pending" | "active" | "inactive";
type: "instrument";
- watcherStatus: WatcherOnlineStatus;
+ // `deregistered` (active instrument, only watcher deregistered) is distinct
+ // from `no_watcher`. Only meaningful when `status` is `active`.
+ watcherStatus: WatcherOnlineStatus | "deregistered";
}
export interface GlobalSearchResult {
@@ -257,7 +259,10 @@ async function searchInstruments(
id: row.id,
displayName: row.displayName,
status: row.status,
- watcherStatus: getWatcherOnlineStatus(row),
+ watcherStatus:
+ row.watcherCount === 0 && row.hasDeregisteredWatcher
+ ? ("deregistered" as const)
+ : getWatcherOnlineStatus(row),
lastWatcherHeartbeatAt: row.lastWatcherHeartbeatAt
? row.lastWatcherHeartbeatAt.toISOString()
: null,
diff --git a/web/lib/api/validators.ts b/web/lib/api/validators.ts
index 46e41adf..8f7a588a 100644
--- a/web/lib/api/validators.ts
+++ b/web/lib/api/validators.ts
@@ -1,7 +1,11 @@
+import { RUN_STATUS_VALUES, type RunStatus } from "@/lib/runs/run-status";
+
const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+const RUN_STATUS_SET = new Set(RUN_STATUS_VALUES);
+
export function isValidKebabCase(id: string): boolean {
return KEBAB_RE.test(id);
}
@@ -38,3 +42,21 @@ export function parseDateParam(value: string | null): Date | null {
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
+
+// Accepts repeated (`?status=a&status=b`) or comma-separated (`?status=a,b`)
+// values, silently dropping unknown ones. Returns undefined when nothing valid
+// remains so the query treats it as "no status filter".
+export function parseRunStatusParam(
+ searchParams: URLSearchParams
+): RunStatus[] | undefined {
+ const seen = new Set();
+ for (const raw of searchParams.getAll("status")) {
+ for (const part of raw.split(",")) {
+ const value = part.trim();
+ if (RUN_STATUS_SET.has(value)) {
+ seen.add(value as RunStatus);
+ }
+ }
+ }
+ return seen.size > 0 ? [...seen] : undefined;
+}
diff --git a/web/lib/api/watchers.ts b/web/lib/api/watchers.ts
index 4bf843c0..d7258bb4 100644
--- a/web/lib/api/watchers.ts
+++ b/web/lib/api/watchers.ts
@@ -1,12 +1,14 @@
import { and, asc, count, desc, eq, gte, inArray, isNull } from "drizzle-orm";
import { cache } from "react";
import YAML from "yaml";
+import { type ActorUser, resolveActorUser } from "@/lib/api/actor";
import { instrumentHasOnlineWatcher } from "@/lib/api/instruments";
-import { db } from "@/lib/db";
+import { type DbExecutor, db } from "@/lib/db";
import {
files,
instrumentRuns,
instruments,
+ users,
watcherEvents,
watcherEventTypeEnum,
watcherHeartbeats,
@@ -55,9 +57,10 @@ export function extractWatchDirectory(
* (ENG-1397). Returns the reverted file ids (for event reporting).
*/
export async function revertPendingUploadRequests(
- instrumentId: string
+ instrumentId: string,
+ executor: DbExecutor = db
): Promise {
- const reverted = await db
+ const reverted = await executor
.update(files)
.set({ status: "detected", uploadRequestedAt: null })
.where(
@@ -86,7 +89,8 @@ export const UPLOAD_REQUEST_REVERT_GRACE_MS = 15 * 60 * 1000;
type UploadRevertReason =
| "watcher_stopped"
| "watcher_deregistered"
- | "watcher_offline_sweep";
+ | "watcher_offline_sweep"
+ | "instrument_retired";
/**
* Reverts an instrument's pending upload requests to `detected` when no watcher
@@ -95,16 +99,22 @@ type UploadRevertReason =
* but lets the sweep attribute an event to a soft-deleted watcher without
* touching a live one.
*/
-export async function revertUploadQueueIfWatcherOffline(opts: {
- instrumentId: string;
- watcherId: string;
- reason: UploadRevertReason;
-}): Promise {
- if (await instrumentHasOnlineWatcher(opts.instrumentId)) {
+export async function revertUploadQueueIfWatcherOffline(
+ opts: {
+ instrumentId: string;
+ watcherId: string;
+ reason: UploadRevertReason;
+ },
+ executor: DbExecutor = db
+): Promise {
+ if (await instrumentHasOnlineWatcher(opts.instrumentId, executor)) {
return 0;
}
- const revertedIds = await revertPendingUploadRequests(opts.instrumentId);
+ const revertedIds = await revertPendingUploadRequests(
+ opts.instrumentId,
+ executor
+ );
if (revertedIds.length === 0) {
return 0;
}
@@ -115,7 +125,7 @@ export async function revertUploadQueueIfWatcherOffline(opts: {
const eventType: "watcher_stopped" | "error" =
opts.reason === "watcher_offline_sweep" ? "error" : "watcher_stopped";
- await db.insert(watcherEvents).values({
+ await executor.insert(watcherEvents).values({
watcherId: opts.watcherId,
eventType,
message: `Reverted ${revertedIds.length} pending upload request(s) — no online watcher to upload them`,
@@ -130,6 +140,68 @@ export async function revertUploadQueueIfWatcherOffline(opts: {
return revertedIds.length;
}
+/**
+ * Soft-deletes a resolved watcher and reverts its instrument's upload queue.
+ * Shared by the watcher DELETE route and instrument retirement so both
+ * teardown paths behave identically. Returns the `deleted_at` timestamp.
+ */
+export async function deregisterWatcherRow(
+ watcher: { id: string; instrumentId: string },
+ reason: UploadRevertReason,
+ // The acting session/PAT user, recorded on the row for the "Deregistered by"
+ // display. Null only when no caller identity is available.
+ actorId: string | null,
+ executor: DbExecutor = db
+): Promise {
+ const now = new Date();
+ await executor
+ .update(watchers)
+ .set({ deletedAt: now, deregisteredBy: actorId })
+ .where(eq(watchers.id, watcher.id));
+
+ // Must run after the soft-delete so the helper's online check excludes this
+ // watcher; otherwise a deregistered instrument's queue would sit undrained.
+ await revertUploadQueueIfWatcherOffline(
+ {
+ instrumentId: watcher.instrumentId,
+ watcherId: watcher.id,
+ reason,
+ },
+ executor
+ );
+
+ return now;
+}
+
+/**
+ * Deregisters every active watcher attached to an instrument (on retire).
+ * Accepts an `executor` so the caller can run it inside the same transaction
+ * as the status flip, keeping retirement atomic.
+ */
+export async function deregisterInstrumentWatchers(
+ instrumentId: string,
+ actorId: string | null,
+ executor: DbExecutor = db
+): Promise {
+ const active = await executor
+ .select({ id: watchers.id, instrumentId: watchers.instrumentId })
+ .from(watchers)
+ .where(
+ and(eq(watchers.instrumentId, instrumentId), isNull(watchers.deletedAt))
+ );
+
+ for (const watcher of active) {
+ await deregisterWatcherRow(
+ watcher,
+ "instrument_retired",
+ actorId,
+ executor
+ );
+ }
+
+ return active.length;
+}
+
interface WatcherLike {
lastHeartbeatAt: Date | null;
status: string;
@@ -223,6 +295,8 @@ export type WatcherDetail = WatcherListItem & {
configYaml: string | null;
configChecksum: string | null;
updatedAt: Date;
+ /** Who deregistered the watcher; null when live or unknown. */
+ deregisteredByUser: ActorUser | null;
};
// React.cache() deduplicates calls within a single request — used by both
@@ -245,9 +319,16 @@ export const getWatcherById = cache(async function getWatcherById(
createdAt: watchers.createdAt,
updatedAt: watchers.updatedAt,
deletedAt: watchers.deletedAt,
+ deregisteredBy: watchers.deregisteredBy,
+ deregisteredByName: users.name,
+ deregisteredByEmail: users.email,
+ deregisteredByImage: users.image,
})
.from(watchers)
.leftJoin(instruments, eq(instruments.id, watchers.instrumentId))
+ // Resolve the actor who deregistered the watcher for display; all NULL
+ // when live or deregistered before `deregistered_by` existed.
+ .leftJoin(users, eq(users.id, watchers.deregisteredBy))
// No deletedAt filter — the detail page renders deregistered watchers too,
// with muted styling and historical data still visible.
.where(eq(watchers.id, watcherId))
@@ -271,6 +352,12 @@ export const getWatcherById = cache(async function getWatcherById(
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
+ deregisteredByUser: resolveActorUser({
+ userId: row.deregisteredBy,
+ name: row.deregisteredByName,
+ email: row.deregisteredByEmail,
+ image: row.deregisteredByImage,
+ }),
};
});
diff --git a/web/lib/db/index.ts b/web/lib/db/index.ts
index f10116e1..ac5d696b 100644
--- a/web/lib/db/index.ts
+++ b/web/lib/db/index.ts
@@ -43,3 +43,9 @@ if (!globalForDb.dbPool) {
}
export const db = drizzle(globalForDb.dbPool, { schema });
+
+// Either the pooled `db` or a transaction handle. Functions that accept this
+// can run standalone or enlist in a caller's `db.transaction(...)`.
+export type DbExecutor =
+ | typeof db
+ | Parameters[0]>[0];
diff --git a/web/lib/db/schema.ts b/web/lib/db/schema.ts
index 07dd442b..e91acf60 100644
--- a/web/lib/db/schema.ts
+++ b/web/lib/db/schema.ts
@@ -303,6 +303,19 @@ export const instruments = pgTable(
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
+ // When the instrument was retired (status set to `inactive`). NULL while
+ // active/pending; cleared again on reactivation. Distinct from `updatedAt`,
+ // which any edit bumps.
+ retiredAt: timestamp("retired_at", {
+ withTimezone: true,
+ mode: "date",
+ }),
+ // Who retired the instrument, captured from the acting session/PAT user.
+ // NULL while active and for rows retired before this column existed.
+ // `set null` on user deletion so removing a user never blocks on history.
+ retiredBy: text("retired_by").references(() => users.id, {
+ onDelete: "set null",
+ }),
},
(instrument) => [
// Trigram GIN index backing the case-insensitive `ilike '%…%'` display-name
@@ -361,6 +374,13 @@ export const watchers = pgTable(
withTimezone: true,
mode: "date",
}),
+ // Who deregistered this watcher (the soft-delete actor), captured from the
+ // acting session/PAT user. NULL while active and for rows deregistered
+ // before this column existed. `set null` on user deletion so removing a
+ // user never blocks on history.
+ deregisteredBy: text("deregistered_by").references(() => users.id, {
+ onDelete: "set null",
+ }),
},
(watcher) => [
// Partial unique index — at most one active watcher per instrument.
diff --git a/web/lib/docs.ts b/web/lib/docs.ts
index 7042f9d0..2ca8b1b7 100644
--- a/web/lib/docs.ts
+++ b/web/lib/docs.ts
@@ -1,6 +1,12 @@
-export const DOCS_BASE_URL =
- process.env.DOCS_BASE_URL ?? "https://arcadia-data-hub-docs.vercel.app";
+// Must be `NEXT_PUBLIC_` so it inlines into the client bundle: some docs links
+// render in Client Components (e.g. the sidebar user menu), where a bare
+// `process.env` var would be `undefined`.
+const DOCS_ORIGIN =
+ process.env.NEXT_PUBLIC_DOCS_BASE_URL ?? "https://datahub.arcadiascience.com";
-export const QUICKSTART_DOCS_URL = `${DOCS_BASE_URL}/docs/getting-started`;
-export const ADD_INSTRUMENT_DOCS_URL = `${DOCS_BASE_URL}/docs/adding-an-instrument`;
-export const MANAGING_TOKENS_DOCS_URL = `${DOCS_BASE_URL}/docs/managing-tokens`;
+// The docs site is served under `/docs` on the product's domain (Vercel
+// Microfrontends), so every docs link hangs off `${DOCS_ORIGIN}/docs`.
+export const DOCS_URL = `${DOCS_ORIGIN}/docs`;
+export const QUICKSTART_DOCS_URL = `${DOCS_URL}/quickstart`;
+export const ADD_INSTRUMENT_DOCS_URL = `${DOCS_URL}/adding-an-instrument`;
+export const MANAGING_TOKENS_DOCS_URL = `${DOCS_URL}/managing-tokens`;
diff --git a/web/lib/mcp/tools.ts b/web/lib/mcp/tools.ts
index ab638ad4..0bdc7650 100644
--- a/web/lib/mcp/tools.ts
+++ b/web/lib/mcp/tools.ts
@@ -22,6 +22,7 @@ import { hasScope, type Scope } from "@/lib/api/scopes";
import { getWatcherHeartbeats, getWatcherList } from "@/lib/api/watchers";
import { db } from "@/lib/db";
import { runAttributions } from "@/lib/db/schema";
+import { RUN_STATUS_VALUES } from "@/lib/runs/run-status";
import {
getPresignedDownloadUrl,
PRESIGNED_DOWNLOAD_URL_EXPIRY_SECONDS,
@@ -177,7 +178,7 @@ export function registerTools(server: McpServer) {
{
title: "Search Runs",
description:
- "Search instrument runs with filtering, pagination, and sorting. Supports plate reader metadata filters (wavelength, measurement mode/type).",
+ "Search instrument runs with filtering, pagination, and sorting. Supports run status filters and plate reader metadata filters (wavelength, measurement mode/type).",
inputSchema: {
instrumentId: z
.union([z.string(), z.array(z.string())])
@@ -244,6 +245,12 @@ export function registerTools(server: McpServer) {
.describe(
'Filter by attributor. Pass a user id to match runs attributed to that user, or the literal "unattributed" to match runs with no attributions. Use list_run_attributors to discover valid user ids.'
),
+ status: z
+ .array(z.enum(RUN_STATUS_VALUES))
+ .optional()
+ .describe(
+ "Filter by derived run status (OR'd together). Status is derived from a run's raw file states, priority-exclusive: failed (any file failed), pending (files awaiting upload), uploaded (awaiting processing), processing, completed (all done), empty (no files)."
+ ),
},
annotations: { readOnlyHint: true },
},
@@ -267,6 +274,7 @@ export function registerTools(server: McpServer) {
measurementMode: args.measurementMode,
measurementType: args.measurementType,
ranBy: args.ranBy,
+ statuses: args.status,
});
return textResult(result);
}
diff --git a/web/lib/runs/run-status.ts b/web/lib/runs/run-status.ts
new file mode 100644
index 00000000..b69edea7
--- /dev/null
+++ b/web/lib/runs/run-status.ts
@@ -0,0 +1,105 @@
+import {
+ Circle,
+ CircleCheck,
+ CircleDashed,
+ CircleX,
+ Clock,
+ LoaderCircle,
+ type LucideIcon,
+} from "lucide-react";
+
+// Order is the single source of truth for status priority, shared by the icon
+// and the exclusive SQL predicates in `buildRunListQuery`. Highest first.
+export const RUN_STATUS_VALUES = [
+ "failed",
+ "pending",
+ "uploaded",
+ "processing",
+ "completed",
+ "empty",
+] as const;
+
+export type RunStatus = (typeof RUN_STATUS_VALUES)[number];
+
+export interface RunStatusMeta {
+ colorClassName: string;
+ description: string;
+ Icon: LucideIcon;
+ label: string;
+ spin?: boolean;
+}
+
+export const RUN_STATUS_META: Record = {
+ failed: {
+ label: "Failed",
+ description: "One or more files failed processing",
+ Icon: CircleX,
+ colorClassName: "text-destructive",
+ },
+ pending: {
+ label: "Pending upload",
+ description: "Files are waiting on the instrument PC to be uploaded",
+ Icon: Clock,
+ colorClassName: "text-amber-500",
+ },
+ uploaded: {
+ label: "Awaiting processing",
+ description: "Files are uploaded and waiting in the processing queue",
+ Icon: CircleDashed,
+ colorClassName: "text-muted-foreground",
+ },
+ processing: {
+ label: "Processing",
+ description: "Files are actively being processed",
+ Icon: LoaderCircle,
+ colorClassName: "text-sky-500",
+ spin: true,
+ },
+ completed: {
+ label: "Completed",
+ description: "All files processed successfully",
+ Icon: CircleCheck,
+ colorClassName: "text-green-600 dark:text-green-500",
+ },
+ empty: {
+ label: "Empty",
+ description: "Run has no files",
+ Icon: Circle,
+ colorClassName: "text-muted-foreground",
+ },
+};
+
+export const RUN_STATUS_OPTIONS = RUN_STATUS_VALUES.map((value) => ({
+ value,
+ ...RUN_STATUS_META[value],
+}));
+
+export interface RunStatusCounts {
+ filesCompleted: number;
+ filesFailed: number;
+ filesPendingUpload: number;
+ filesProcessing: number;
+ filesUploaded: number;
+}
+
+// Keep the priority order in sync with the SQL predicates in
+// `buildRunListQuery`. Every non-deleted raw file falls into one bucket (the
+// `file_status` enum has no other values), so `empty` needs no `fileCount`.
+export function deriveRunStatus(counts: RunStatusCounts): RunStatus {
+ if (counts.filesFailed > 0) {
+ return "failed";
+ }
+ if (counts.filesPendingUpload > 0) {
+ return "pending";
+ }
+ if (counts.filesUploaded > 0) {
+ return "uploaded";
+ }
+ if (counts.filesProcessing > 0) {
+ return "processing";
+ }
+ if (counts.filesCompleted > 0) {
+ return "completed";
+ }
+ return "empty";
+}
diff --git a/web/lib/search-params.ts b/web/lib/search-params.ts
index 5bc2e013..ebb3155f 100644
--- a/web/lib/search-params.ts
+++ b/web/lib/search-params.ts
@@ -6,6 +6,7 @@ import {
parseAsString,
parseAsStringLiteral,
} from "nuqs/server";
+import { RUN_STATUS_VALUES, type RunStatus } from "@/lib/runs/run-status";
// All dashboard filter/pagination state lives in the URL via nuqs. This makes
// filter combinations shareable via link and keeps the server component in
@@ -16,6 +17,10 @@ export const dashboardSearchParams = {
date_from: parseAsString.withOptions({ clearOnDefault: true }),
date_to: parseAsString.withOptions({ clearOnDefault: true }),
include_deleted: parseAsBoolean.withDefault(false),
+ // Derived run status, multi-select. Empty array = no filter (show all).
+ status: parseAsArrayOf(parseAsStringLiteral(RUN_STATUS_VALUES))
+ .withDefault([])
+ .withOptions({ clearOnDefault: true }),
page: parseAsInteger.withDefault(1),
per_page: parseAsInteger.withDefault(10),
};
@@ -56,6 +61,10 @@ export const instrumentDetailSearchParams = {
color_mode: parseAsString,
// Attribution filter: either a userId or the reserved sentinel "unattributed".
ran_by: parseAsString,
+ // Derived run status, multi-select. Empty array = no filter (show all).
+ status: parseAsArrayOf(parseAsStringLiteral(RUN_STATUS_VALUES))
+ .withDefault([])
+ .withOptions({ clearOnDefault: true }),
};
export const instrumentDetailParamsCache = createSearchParamsCache(
@@ -111,10 +120,12 @@ export function hasActiveFilters(params: {
search: string;
instrument_id: string[];
include_deleted: boolean;
+ status: RunStatus[];
}): boolean {
return (
params.search !== "" ||
params.instrument_id.length > 0 ||
- params.include_deleted
+ params.include_deleted ||
+ params.status.length > 0
);
}
diff --git a/web/package-lock.json b/web/package-lock.json
index 9f73e589..14cbbdb9 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1812,21 +1812,22 @@
}
},
"node_modules/@emnapi/core": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
- "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
+ "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
- "@emnapi/wasi-threads": "1.2.1",
+ "@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
- "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "version": "1.11.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
+ "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1834,12 +1835,13 @@
}
},
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"dev": true,
"license": "MIT",
"optional": true,
+ "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -3487,9 +3489,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3506,9 +3505,6 @@
"cpu": [
"arm64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3525,9 +3521,6 @@
"cpu": [
"x64"
],
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3544,9 +3537,6 @@
"cpu": [
"x64"
],
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5407,9 +5397,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5427,9 +5414,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5447,9 +5431,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5467,9 +5448,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5487,9 +5465,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5507,9 +5482,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -5555,6 +5527,18 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -5566,6 +5550,17 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
diff --git a/web/tests/integration/run-status-filter.test.ts b/web/tests/integration/run-status-filter.test.ts
new file mode 100644
index 00000000..57b47fcc
--- /dev/null
+++ b/web/tests/integration/run-status-filter.test.ts
@@ -0,0 +1,134 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { files, instrumentRuns, instruments } from "@/lib/db/schema";
+import {
+ api,
+ closeTestDb,
+ getTestDb,
+ resetDb,
+ seedTestUser,
+} from "@/tests/integration/helpers";
+
+// Run status is derived (never stored) from a run's raw-file states. These
+// tests seed files directly and assert the `?status=` filter and its
+// `pagination.total` honor the same priority-exclusive ordering as
+// `deriveRunStatus`. Every request is scoped by instrument_id so totals stay
+// deterministic regardless of other seeded data.
+
+type FileStatus =
+ | "detected"
+ | "upload_requested"
+ | "uploaded"
+ | "processing"
+ | "completed"
+ | "failed";
+
+describe("Run status filter", () => {
+ let token: string;
+ const instrumentId = "run-status-filter-instrument";
+
+ async function seedRun(runId: string, fileStatuses: FileStatus[]) {
+ const db = getTestDb();
+ const [run] = await db
+ .insert(instrumentRuns)
+ .values({ instrumentId, runId, source: "watcher" })
+ .returning({ id: instrumentRuns.id });
+ if (fileStatuses.length > 0) {
+ await db.insert(files).values(
+ fileStatuses.map((status, i) => ({
+ instrumentRunId: run.id,
+ filename: `${runId}-${i}.csv`,
+ category: "raw" as const,
+ status,
+ }))
+ );
+ }
+ }
+
+ async function fetchRuns(query: string) {
+ const res = await api(
+ `/api/v1/instrument-runs?instrument_id=${instrumentId}&per_page=100&${query}`,
+ { token }
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ return {
+ ids: body.data.map((r: { run_id: string }) => r.run_id) as string[],
+ total: body.pagination.total as number,
+ };
+ }
+
+ beforeAll(async () => {
+ await resetDb();
+ ({ token } = await seedTestUser());
+
+ const db = getTestDb();
+ await db.insert(instruments).values({
+ id: instrumentId,
+ displayName: "Run Status Filter Instrument",
+ status: "active",
+ });
+
+ // One run per terminal bucket. The mixed runs each pair a higher-priority
+ // file with a completed file, proving priority-exclusivity: they must be
+ // hidden from the lower-priority `completed` filter.
+ await seedRun("rs-empty", []);
+ await seedRun("rs-completed", ["completed", "completed"]);
+ await seedRun("rs-failed", ["failed", "completed"]);
+ await seedRun("rs-pending", ["detected", "completed"]);
+ await seedRun("rs-uploaded", ["uploaded", "completed"]);
+ await seedRun("rs-processing", ["processing", "completed"]);
+ });
+
+ afterAll(async () => {
+ await closeTestDb();
+ });
+
+ it("filters to a single status and reports a matching total", async () => {
+ const { ids, total } = await fetchRuns("status=failed");
+ expect(ids).toEqual(["rs-failed"]);
+ expect(total).toBe(1);
+ });
+
+ // The core parity guarantee: `completed` must exclude runs that merely
+ // contain a completed file but rank higher (failed/pending/uploaded/processing).
+ it("'completed' matches only all-completed runs", async () => {
+ const { ids, total } = await fetchRuns("status=completed");
+ expect(ids).toEqual(["rs-completed"]);
+ expect(total).toBe(1);
+ });
+
+ it("'empty' matches only runs with no files", async () => {
+ const { ids, total } = await fetchRuns("status=empty");
+ expect(ids).toEqual(["rs-empty"]);
+ expect(total).toBe(1);
+ });
+
+ it("OR's repeated status params together", async () => {
+ const { ids, total } = await fetchRuns("status=failed&status=empty");
+ expect(new Set(ids)).toEqual(new Set(["rs-failed", "rs-empty"]));
+ expect(total).toBe(2);
+ });
+
+ it("accepts the comma-separated form", async () => {
+ const { ids, total } = await fetchRuns("status=failed,empty");
+ expect(new Set(ids)).toEqual(new Set(["rs-failed", "rs-empty"]));
+ expect(total).toBe(2);
+ });
+
+ it("ignores unknown status values instead of erroring or over-filtering", async () => {
+ const { total } = await fetchRuns("status=bogus");
+ expect(total).toBe(6);
+ });
+
+ it("applies the same filter on the instrument-scoped endpoint", async () => {
+ const res = await api(
+ `/api/v1/instruments/${instrumentId}/runs?status=pending&per_page=100`,
+ { token }
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ const ids = body.data.map((r: { run_id: string }) => r.run_id);
+ expect(ids).toEqual(["rs-pending"]);
+ expect(body.pagination.total).toBe(1);
+ });
+});
diff --git a/web/tests/unit/run-status.test.ts b/web/tests/unit/run-status.test.ts
new file mode 100644
index 00000000..bb295699
--- /dev/null
+++ b/web/tests/unit/run-status.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from "vitest";
+import { parseRunStatusParam } from "@/lib/api/validators";
+import {
+ deriveRunStatus,
+ RUN_STATUS_VALUES,
+ type RunStatusCounts,
+} from "@/lib/runs/run-status";
+
+// Pure-function coverage for the two halves of the derived-status contract:
+// `deriveRunStatus` (used by the icon) and `parseRunStatusParam` (used by both
+// REST GET routes and the MCP tool). The SQL predicates that mirror this
+// priority live in `buildRunListQuery` and are exercised by the integration
+// suite (`tests/integration/run-status-filter.test.ts`).
+
+const ZERO: RunStatusCounts = {
+ filesCompleted: 0,
+ filesFailed: 0,
+ filesPendingUpload: 0,
+ filesProcessing: 0,
+ filesUploaded: 0,
+};
+
+function counts(overrides: Partial): RunStatusCounts {
+ return { ...ZERO, ...overrides };
+}
+
+describe("deriveRunStatus", () => {
+ it("returns 'empty' when every bucket is zero", () => {
+ expect(deriveRunStatus(ZERO)).toBe("empty");
+ });
+
+ it("maps each bucket to its status in isolation", () => {
+ expect(deriveRunStatus(counts({ filesFailed: 1 }))).toBe("failed");
+ expect(deriveRunStatus(counts({ filesPendingUpload: 1 }))).toBe("pending");
+ expect(deriveRunStatus(counts({ filesUploaded: 1 }))).toBe("uploaded");
+ expect(deriveRunStatus(counts({ filesProcessing: 1 }))).toBe("processing");
+ expect(deriveRunStatus(counts({ filesCompleted: 1 }))).toBe("completed");
+ });
+
+ it("prefers 'failed' over every lower-priority bucket", () => {
+ expect(
+ deriveRunStatus(
+ counts({
+ filesFailed: 1,
+ filesPendingUpload: 1,
+ filesUploaded: 1,
+ filesProcessing: 1,
+ filesCompleted: 1,
+ })
+ )
+ ).toBe("failed");
+ });
+
+ // The intentional behavior change: pending outranks completed, so a run with
+ // both still-pending and already-completed files reads as "pending".
+ it("prefers 'pending' over 'completed'", () => {
+ expect(
+ deriveRunStatus(counts({ filesPendingUpload: 1, filesCompleted: 5 }))
+ ).toBe("pending");
+ });
+
+ it("prefers 'uploaded' over 'processing' and 'completed'", () => {
+ expect(
+ deriveRunStatus(
+ counts({ filesUploaded: 1, filesProcessing: 1, filesCompleted: 1 })
+ )
+ ).toBe("uploaded");
+ });
+
+ it("prefers 'processing' over 'completed'", () => {
+ expect(
+ deriveRunStatus(counts({ filesProcessing: 1, filesCompleted: 1 }))
+ ).toBe("processing");
+ });
+});
+
+describe("parseRunStatusParam", () => {
+ it("returns undefined when the param is absent", () => {
+ expect(parseRunStatusParam(new URLSearchParams())).toBeUndefined();
+ });
+
+ it("parses a single value", () => {
+ expect(parseRunStatusParam(new URLSearchParams("status=failed"))).toEqual([
+ "failed",
+ ]);
+ });
+
+ it("parses repeated params", () => {
+ expect(
+ parseRunStatusParam(new URLSearchParams("status=failed&status=empty"))
+ ).toEqual(["failed", "empty"]);
+ });
+
+ it("parses a comma-separated list", () => {
+ expect(
+ parseRunStatusParam(new URLSearchParams("status=failed,empty"))
+ ).toEqual(["failed", "empty"]);
+ });
+
+ it("trims whitespace around comma-separated values", () => {
+ expect(
+ parseRunStatusParam(new URLSearchParams("status=failed , empty"))
+ ).toEqual(["failed", "empty"]);
+ });
+
+ it("drops unknown values but keeps the valid ones", () => {
+ expect(
+ parseRunStatusParam(new URLSearchParams("status=failed,bogus,empty"))
+ ).toEqual(["failed", "empty"]);
+ });
+
+ it("returns undefined when every value is unknown", () => {
+ expect(
+ parseRunStatusParam(new URLSearchParams("status=bogus&status=nope"))
+ ).toBeUndefined();
+ });
+
+ it("de-duplicates repeated values", () => {
+ expect(
+ parseRunStatusParam(
+ new URLSearchParams("status=failed&status=failed,failed")
+ )
+ ).toEqual(["failed"]);
+ });
+
+ it("accepts every documented status value", () => {
+ const query = RUN_STATUS_VALUES.join(",");
+ expect(parseRunStatusParam(new URLSearchParams(`status=${query}`))).toEqual(
+ [...RUN_STATUS_VALUES]
+ );
+ });
+});