From ee03ebd31b6f1b72084f1edc0c02e98c325e5fd7 Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:37:05 -0700 Subject: [PATCH 1/3] Point docs links at datahub.arcadiascience.com/docs (#130) The docs site now serves under /docs on the product's domain via Vercel Microfrontends, so move all references off the old arcadia-data-hub-docs.vercel.app deployment. - Swap the docs-site host in the READMEs, AGENTS.md, developer-docs, and the watcher package metadata to https://datahub.arcadiascience.com/docs. - Source the docs origin from NEXT_PUBLIC_DOCS_BASE_URL so it inlines into the client bundle: the Docs link in the sidebar user menu is a Client Component, where the old non-public DOCS_BASE_URL resolved to undefined and silently fell back to the hardcoded URL. - Add DOCS_URL (/docs) for the bare "Docs" links, which otherwise pointed at the product root after the domain move, and fix the login page's Quickstart link to /docs/quickstart (getting-started doesn't exist). Co-authored-by: Cursor --- AGENTS.md | 2 +- README.md | 2 +- developer-docs/README.md | 2 +- developer-docs/architecture.md | 2 +- developer-docs/ci-and-deployment.md | 2 +- developer-docs/lambda.md | 2 +- developer-docs/local-development.md | 4 ++-- developer-docs/run-archives.md | 2 +- developer-docs/watcher.md | 6 +++--- watcher/README.md | 2 +- watcher/pyproject.toml | 2 +- web/.env.example | 7 ++++--- web/components/app-sidebar/user-menu-footer.tsx | 4 ++-- web/components/auth/auth-screen.tsx | 4 ++-- web/lib/docs.ts | 16 +++++++++++----- 15 files changed, 33 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 443e781a..c3be17ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Documentation -User-, operator-, and admin-facing documentation — installing a watcher, adding an instrument, managing tokens, deploying the web app and AWS infrastructure, security/permissions — lives on the docs site at https://arcadia-data-hub-docs.vercel.app/, **not in this repository**. Search there first for "how do I use/deploy Data Hub" questions; don't rely on training data or guess at UI flows, since the site's `/llms.txt` and `/llms-full.txt` routes (and a `.md` suffix on any page URL) serve clean Markdown that's cheap to fetch. +User-, operator-, and admin-facing documentation — installing a watcher, adding an instrument, managing tokens, deploying the web app and AWS infrastructure, security/permissions — lives on the docs site at https://datahub.arcadiascience.com/docs, **not in this repository**. Search there first for "how do I use/deploy Data Hub" questions; don't rely on training data or guess at UI flows, since the site's `/docs/llms.txt` and `/docs/llms-full.txt` routes (and a `.md` suffix on any page URL) serve clean Markdown that's cheap to fetch. This repo's `developer-docs/` only covers contributing to Data Hub itself: architecture internals, local dev setup (`getting-started.md`, `local-development.md`), conventions, CI/deployment, and per-package references (`lambda.md`, `watcher.md`, `shared-library.md`). See `developer-docs/README.md` for the full index. diff --git a/README.md b/README.md index 4e276393..885c2f48 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ make dev See the full [Getting started guide](developer-docs/getting-started.md) for prerequisites and details. Don't have AWS/Google credentials? [Local development](developer-docs/local-development.md) covers a zero-credential setup for the web app + API + database alone (no watcher or Lambda needed). -Developer docs live in [developer-docs/](developer-docs/README.md). You can find user documentation (self-hosted deployment, watcher installation, adding an instrument, managing tokens) on the [docs site](https://arcadia-data-hub-docs.vercel.app/). +Developer docs live in [developer-docs/](developer-docs/README.md). You can find user documentation (self-hosted deployment, watcher installation, adding an instrument, managing tokens) on the [docs site](https://datahub.arcadiascience.com/docs). ## Checks and tests diff --git a/developer-docs/README.md b/developer-docs/README.md index b9aebcb3..01a3ae98 100644 --- a/developer-docs/README.md +++ b/developer-docs/README.md @@ -2,7 +2,7 @@ Documentation for developing Data Hub itself. User, operator, and admin documentation (installing a watcher, adding an instrument, managing tokens, -deployment) lives on the [docs site](https://arcadia-data-hub-docs.vercel.app/) +deployment) lives on the [docs site](https://datahub.arcadiascience.com/docs) instead — see the root [README](../README.md#getting-started) for that split. - [Getting started](getting-started.md) — development setup, environment variables, running locally diff --git a/developer-docs/architecture.md b/developer-docs/architecture.md index 657e8952..f2731a3f 100644 --- a/developer-docs/architecture.md +++ b/developer-docs/architecture.md @@ -58,6 +58,6 @@ Steps 1–3 are the same, but the watcher does not upload immediately. Instead: - **Lambda-built run archives.** The "Download all" actions on a run delegate to the Lambda, which streams files from the raw + processed buckets directly into a zip in a separate `arcadia-data-hub-archives-{env}` bucket via S3 multipart upload. The web app then 302s the browser to a short-lived presigned URL on that archive — bytes never traverse Vercel, eliminating Fast Origin Transfer for run downloads. Builds are cached at `runs/{instrument_id}/{run_id}/{fingerprint}.zip` and expire after 7 days. Every cache miss is dispatched asynchronously: the route inserts an `archive_jobs` row, schedules the Lambda invocation via `after()`, returns `202 { job_id }`, and the UI polls the same `/download-archive` URL (which HEADs S3 on each call) until the artifact appears — making the S3 object, not the row's `status`, the source of truth for "ready". See [Run archives](run-archives.md) for the full flow and runbook. - **Public page metadata, gated bodies.** Routes commonly shared into Slack/Notion (dashboard, instruments, instrument and run detail, settings) are reachable without a session so link unfurlers can read `` metadata; the page or layout renders a `SignInRequired` CTA in place of the real body when there's no session. `/watchers/*` stays redirected to `/login` by `web/proxy.ts`. Three independent layers prevent search indexing: a `robots` field on the root layout, an `app/robots.ts` that disallows all generic crawlers (with an allow-list for unfurl bots), and an `X-Robots-Tag: noindex, nofollow` header on every response. - **Shared library for contracts.** Instrument IDs, S3 utilities, and environment config live in `data-hub-shared` so they stay consistent across Lambda and the watcher without duplicating code. -- **Wildcard PAT scope for the legacy backfill.** Migration `0022_pat_scopes` backfilled every pre-existing token with `["*"]` so deployed watchers and the Lambda kept working once scope enforcement shipped. `POST /api/v1/tokens` rejects `*` from API callers, so every token minted since carries explicit least-privilege scopes; see [Security & permissions](https://arcadia-data-hub-docs.vercel.app/docs/security#token-scopes) for the scope vocabulary. +- **Wildcard PAT scope for the legacy backfill.** Migration `0022_pat_scopes` backfilled every pre-existing token with `["*"]` so deployed watchers and the Lambda kept working once scope enforcement shipped. `POST /api/v1/tokens` rejects `*` from API callers, so every token minted since carries explicit least-privilege scopes; see [Security & permissions](https://datahub.arcadiascience.com/docs/security#token-scopes) for the scope vocabulary. - **MCP for AI access.** The web app includes a [Model Context Protocol](https://modelcontextprotocol.io/) server at `/api/v1/mcp` that exposes read-only tools, resources, and prompts. AI clients (e.g. Claude Desktop, Cursor) can query instruments, runs, and system status using a personal access token. - **Integration tests against a real server.** The shared `testing.py` module spins up a real Next.js server backed by a Postgres database, so Lambda and watcher integration tests exercise the actual API surface. diff --git a/developer-docs/ci-and-deployment.md b/developer-docs/ci-and-deployment.md index 0cb330fd..0d1aea7a 100644 --- a/developer-docs/ci-and-deployment.md +++ b/developer-docs/ci-and-deployment.md @@ -230,7 +230,7 @@ make sam-deploy ENV=staging ### Watcher (PyPI) -The `data-hub-watcher` Python package is published to [PyPI](https://pypi.org/project/data-hub-watcher/) so lab PCs can install and self-update via `uv tool install data-hub-watcher`. The full release flow — version bump, tag, approval, env-var roll-out, mandatory updates, and rollback — is documented in the admin-facing [Managing watchers → Releases and fleet updates](https://arcadia-data-hub-docs.vercel.app/docs/managing-watchers#releases-and-fleet-updates) guide; this section is intentionally a pointer rather than a second source of truth so the two can't drift. +The `data-hub-watcher` Python package is published to [PyPI](https://pypi.org/project/data-hub-watcher/) so lab PCs can install and self-update via `uv tool install data-hub-watcher`. The full release flow — version bump, tag, approval, env-var roll-out, mandatory updates, and rollback — is documented in the admin-facing [Managing watchers → Releases and fleet updates](https://datahub.arcadiascience.com/docs/managing-watchers#releases-and-fleet-updates) guide; this section is intentionally a pointer rather than a second source of truth so the two can't drift. Trusted publishing is configured under **Project → Publishing** on PyPI for `Arcadia-Science/data-hub` and the workflow `publish-watcher.yml`; no API token lives in repo secrets. If trust is ever revoked or rotated, update it there and re-run the workflow. diff --git a/developer-docs/lambda.md b/developer-docs/lambda.md index 9b3e4ca6..540d419a 100644 --- a/developer-docs/lambda.md +++ b/developer-docs/lambda.md @@ -72,7 +72,7 @@ Slack channel notifications are sent by the **web app** (`web/lib/slack.ts`), no 4. **Add tests.** Add unit tests in `lambda/tests/` for the new processor. -5. **Configure the S3 trigger and deploy.** See [Deploying AWS infrastructure → Adding a Lambda processor for a new instrument](https://arcadia-data-hub-docs.vercel.app/docs/deploying-aws-infrastructure#adding-a-lambda-processor-for-a-new-instrument) for the `infra/template.yaml` trigger entry and the deploy steps. +5. **Configure the S3 trigger and deploy.** See [Deploying AWS infrastructure → Adding a Lambda processor for a new instrument](https://datahub.arcadiascience.com/docs/deploying-aws-infrastructure#adding-a-lambda-processor-for-a-new-instrument) for the `infra/template.yaml` trigger entry and the deploy steps. ## Local processing CLI diff --git a/developer-docs/local-development.md b/developer-docs/local-development.md index e8285354..42fb6b90 100644 --- a/developer-docs/local-development.md +++ b/developer-docs/local-development.md @@ -226,5 +226,5 @@ The same builders back the integration test harness in [web/tests/integration/he - [Getting started](getting-started.md) — full setup with real Google OAuth and AWS credentials. - [Architecture](architecture.md) — system overview and data flow. -- [REST API](https://arcadia-data-hub-docs.vercel.app/docs/api-reference) — endpoint reference for the seeded PAT. -- [MCP server](https://arcadia-data-hub-docs.vercel.app/docs/mcp-server) — Model Context Protocol tools at `/api/v1/mcp`. +- [REST API](https://datahub.arcadiascience.com/docs/api-reference) — endpoint reference for the seeded PAT. +- [MCP server](https://datahub.arcadiascience.com/docs/mcp-server) — Model Context Protocol tools at `/api/v1/mcp`. diff --git a/developer-docs/run-archives.md b/developer-docs/run-archives.md index 0714f731..3e8b0007 100644 --- a/developer-docs/run-archives.md +++ b/developer-docs/run-archives.md @@ -4,7 +4,7 @@ The "Download all" actions on the run detail page and the runs table deliver eve Each archive can mix files from the raw bucket and the processed bucket in a single zip. This matters for instruments that produce processed artifacts via Lambda preprocessing (SpectraMax raw `.xls` → processed CSV; Hina `.nd2` → processed JPG; Azure 600 Gel Doc `.tif` → processed PNG): the run's file rows reference both buckets, and "Download all" zips them together. -This page covers the end-to-end flow, the cache + dedup model, and the on-call runbook. For the Lambda invocation contract, see [Lambda → Function URL (archive build)](lambda.md#function-url-archive-build). For the HTTP endpoints, see [REST API → Archive jobs](https://arcadia-data-hub-docs.vercel.app/docs/api-reference#archive-jobs). +This page covers the end-to-end flow, the cache + dedup model, and the on-call runbook. For the Lambda invocation contract, see [Lambda → Function URL (archive build)](lambda.md#function-url-archive-build). For the HTTP endpoints, see [REST API → Archive jobs](https://datahub.arcadiascience.com/docs/api-reference#archive-jobs). ## Flow diff --git a/developer-docs/watcher.md b/developer-docs/watcher.md index 10e11cf3..70876437 100644 --- a/developer-docs/watcher.md +++ b/developer-docs/watcher.md @@ -30,7 +30,7 @@ uv run data-hub-watcher init uv run data-hub-watcher watch ``` -For lab-PC installs (PyPI), see [Installing a watcher](https://arcadia-data-hub-docs.vercel.app/docs/installing-a-watcher). For releasing new versions and how the in-place upgrade flow works (CLI `self-update` and the Windows-service auto-updater), see [Upgrading the watcher](https://arcadia-data-hub-docs.vercel.app/docs/upgrading-the-watcher). +For lab-PC installs (PyPI), see [Installing a watcher](https://datahub.arcadiascience.com/docs/installing-a-watcher). For releasing new versions and how the in-place upgrade flow works (CLI `self-update` and the Windows-service auto-updater), see [Upgrading the watcher](https://datahub.arcadiascience.com/docs/upgrading-the-watcher). ## Commands @@ -71,7 +71,7 @@ While running: - **Upload worker** (manual mode only) polls the server's upload queue on its own long-lived thread every 60 seconds, decoupled from the heartbeat so a slow or large upload can't delay heartbeats and make a busy watcher look offline. On shutdown it is stopped and joined before the state DB is closed. Auto mode has no worker: uploads run on the monitor's stability-checker thread via the run detector's upload callback. - **Heartbeat loop** sends periodic heartbeats (every 60 seconds) to the API. The payload includes the watcher version, instrument ID, watch directory, upload mode, per-interval activity counters, and process uptime; a final `status="stopped"` heartbeat is sent on graceful shutdown. - **Event reporter** batches and flushes lifecycle events (started, stopped, file uploaded, errors) to the API. See [Observability](#observability) for the full taxonomy. -- **Auto-updater** runs from the same heartbeat tick on every platform — not only Windows services. It polls `GET /watchers/:id/update-check` roughly hourly and applies new releases when the watcher has been idle long enough not to clobber an in-flight run. The full activity-window guard, mandatory-update behavior, and rollback flow are documented in [Upgrading the watcher](https://arcadia-data-hub-docs.vercel.app/docs/upgrading-the-watcher); auto-update is hard-disabled in the `preview` environment. +- **Auto-updater** runs from the same heartbeat tick on every platform — not only Windows services. It polls `GET /watchers/:id/update-check` roughly hourly and applies new releases when the watcher has been idle long enough not to clobber an in-flight run. The full activity-window guard, mandatory-update behavior, and rollback flow are documented in [Upgrading the watcher](https://datahub.arcadiascience.com/docs/upgrading-the-watcher); auto-update is hard-disabled in the `preview` environment. Use `--dry-run` to validate config and preview what would happen without starting the monitor. @@ -126,7 +126,7 @@ Manage the watcher as a Windows service: ### `self-update` -Checks the API for a newer published version and runs the appropriate `uv tool install --reinstall` (or `pip install -U`) subprocess in place. See [Upgrading the watcher](https://arcadia-data-hub-docs.vercel.app/docs/upgrading-the-watcher) for the supported install methods, the activity-window guard, mandatory updates, and rollback flow. +Checks the API for a newer published version and runs the appropriate `uv tool install --reinstall` (or `pip install -U`) subprocess in place. See [Upgrading the watcher](https://datahub.arcadiascience.com/docs/upgrading-the-watcher) for the supported install methods, the activity-window guard, mandatory updates, and rollback flow. ## Configuration diff --git a/watcher/README.md b/watcher/README.md index 450f55bb..d0e21c9d 100644 --- a/watcher/README.md +++ b/watcher/README.md @@ -22,7 +22,7 @@ data-hub-watcher self-update # check for and apply package updates data-hub-watcher service install # Windows: install as a service ``` -See [the operator guide](https://arcadia-data-hub-docs.vercel.app/docs/installing-a-watcher) for the full setup walk-through, configuration reference, and troubleshooting. +See [the operator guide](https://datahub.arcadiascience.com/docs/installing-a-watcher) for the full setup walk-through, configuration reference, and troubleshooting. ## License diff --git a/watcher/pyproject.toml b/watcher/pyproject.toml index b52a7bf9..47eb6ee2 100644 --- a/watcher/pyproject.toml +++ b/watcher/pyproject.toml @@ -42,7 +42,7 @@ Homepage = "https://github.com/Arcadia-Science/data-hub" # the PyPI page actually needs. The developer-facing `developer-docs/watcher.md` # describes the editable-checkout workflow and is reachable from the # repository link below. -Documentation = "https://arcadia-data-hub-docs.vercel.app/docs/installing-a-watcher" +Documentation = "https://datahub.arcadiascience.com/docs/installing-a-watcher" Repository = "https://github.com/Arcadia-Science/data-hub" Issues = "https://github.com/Arcadia-Science/data-hub/issues" diff --git a/web/.env.example b/web/.env.example index ccb75174..c1e3aca9 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,6 +1,7 @@ -# Public docs site base URL. Defaults to https://arcadia-data-hub-docs.vercel.app -# when unset. -# DOCS_BASE_URL= +# Public docs site origin. Defaults to https://datahub.arcadiascience.com when +# unset; docs links are built as `/docs/...`. Must be NEXT_PUBLIC_ so +# client-rendered docs links (e.g. the sidebar user menu) pick it up. +# NEXT_PUBLIC_DOCS_BASE_URL= # Database. DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/data-hub-local diff --git a/web/components/app-sidebar/user-menu-footer.tsx b/web/components/app-sidebar/user-menu-footer.tsx index 71eae124..51637a17 100644 --- a/web/components/app-sidebar/user-menu-footer.tsx +++ b/web/components/app-sidebar/user-menu-footer.tsx @@ -23,7 +23,7 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; -import { DOCS_BASE_URL } from "@/lib/docs"; +import { DOCS_URL } from "@/lib/docs"; interface UserMenuFooterProps { signOutAction: () => Promise; @@ -92,7 +92,7 @@ export function UserMenuFooter({ user, signOutAction }: UserMenuFooterProps) { sideOffset={4} > - + Docs diff --git a/web/components/auth/auth-screen.tsx b/web/components/auth/auth-screen.tsx index b30b4a19..29e8a4f9 100644 --- a/web/components/auth/auth-screen.tsx +++ b/web/components/auth/auth-screen.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import { DevSignInForm } from "@/components/auth/dev-sign-in-form"; import { Button } from "@/components/ui/button"; import { isDevAuthEnabled, signIn } from "@/lib/auth"; -import { DOCS_BASE_URL, QUICKSTART_DOCS_URL } from "@/lib/docs"; +import { DOCS_URL, QUICKSTART_DOCS_URL } from "@/lib/docs"; interface AuthScreenProps { callbackUrl: string; @@ -131,7 +131,7 @@ export function AuthScreen({ 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`; From 8990e6bb427b3e63189bc726f8dd2f24b43eb467 Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:51:20 -0700 Subject: [PATCH 2/3] Add admin retire flow for instruments (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add admin retire flow for instruments with status tabs Retiring soft-sets an instrument to `inactive` (surfaced as "Retired") and always deregisters its watchers so agents stop heartbeating and pending uploads drain. Reactivating sets it back to `active`. The /instruments page now splits into Active / Pending / Retired tabs, and row actions move into a three-dot menu (View details, Edit, Retire/Reactivate) with an inline Confirm for pending rows. Co-authored-by: Cursor * Sync web lockfile so npm ci works The committed package-lock.json was missing esbuild's optional per-platform binaries and had stale emnapi entries, so `npm ci` failed with an out-of-sync error. Regenerated via `npm install` (Node 24 / npm 11), which adds the missing optional platform packages while keeping all versions within their existing ranges — @biomejs/biome and ultracite stay pinned so lint behavior is unchanged. Co-authored-by: Cursor * Scope dashboard to active instruments; drop View details menu item The dashboard runs-table instrument dropdown and the stat cards now only consider active instruments (the instruments table was already active-only), so retired/pending instruments no longer appear as filter targets or inflate the KPI counts. `getInstruments` gains an opt-in `activeOnly` flag so the MCP resource still lists all instruments. Also removes the redundant "View details" item from the instrument row menu (rows remain clickable). Co-authored-by: Cursor * Hide retired instruments in sidebar and notifications settings The sidebar's client-side "recently viewed" list wasn't status-aware, so a retired instrument the user had visited kept showing. The layout now passes the set of active instrument IDs down to the nav, which prunes recently-viewed to active instruments only. The notifications settings page also now lists active instruments only via a new opt-in `activeOnly` flag on `listInstrumentSubscriptions` (the /instruments notify column is unchanged). Co-authored-by: Cursor * Keep watcher link on retired instrument header Resolve the canonical watcher from soft-deleted rows when no live watcher remains, and show the Retired lifecycle badge instead of "No Watcher". Co-authored-by: Cursor * Show Deregistered badge for watcherless instruments Distinguish an instrument whose watcher was deregistered from one that never had a watcher, in both the instrument header and the /instruments table, and keep linking to the deregistered watcher. Also tidy up the row-action labels, skeleton row count, and drop the actions-column skeleton on /instruments. Co-authored-by: Cursor * Add Edit/Retire actions to instrument header Reuse the instruments-table admin actions on the detail page via a shared InstrumentActions component (menu vs expanded variants), move the status badge into the metadata row, and turn the notifications control into a click/hover-friendly labelled pill. Also darken the Retired badge for contrast. Co-authored-by: Cursor * Fix runs-table z-index over sidebar; tidy deregistered watcher header Isolate the runs-table stacking context so row cells no longer paint over the sidebar, and stop fading the deregistered watcher header (drop the opacity/dashed border, match the Deregistered badge size). Co-authored-by: Cursor * Show lifecycle/deregistered badge in search results Mirror the instruments table in the global search palette: render the Retired/Pending lifecycle badge for those instruments and a deregistered-aware watcher badge for active ones, instead of always showing watcher connectivity. Co-authored-by: Cursor * Trim over-verbose comments to follow code-comments rule Co-authored-by: Cursor * Make retire atomic and address review feedback - Wrap the instrument status flip and watcher teardown in one transaction via a threaded `DbExecutor`, so a failed teardown can't strand watchers. - Hide the notifications pill on non-active instruments. - Memoize the instruments-view row-actions renderer. - Give `getInstruments` a primitive `activeOnly` arg for reliable cache dedup. - Drop the sidebar recents active-instrument filter to avoid a layout-wide query. Co-authored-by: Cursor * Record and display who retired/deregistered Add nullable `retired_by`/`retired_at` on instruments and `deregistered_by` on watchers (FK to user, set null on delete), captured from the acting session/PAT user in the instrument PATCH and watcher DELETE flows. Surface the actor with an avatar in the instrument and watcher headers. Co-authored-by: Cursor * Regenerate web lockfile on Linux so npm ci works `npm ci` on the ubuntu CI runner kept failing with "Missing: @emnapi/runtime@1.11.2 / @emnapi/core@1.11.2 from lock file" because the lockfile was previously regenerated on macOS, where npm prunes the emnapi peer deps that the Linux WASM-fallback path (@napi-rs/wasm-runtime) needs. Regenerated via `npm install --package-lock-only` in a node:24 Linux container (matching CI), then confirmed the cross-platform union is stable with per-platform passes. Net effect: adds the two required @emnapi nodes, drops 26 stale phantom vitest/@esbuild binaries, and changes no resolved versions. Verified npm ci passes for linux and darwin targets. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- .../v1/instruments/[instrumentId]/route.ts | 47 +- web/app/api/v1/watchers/[watcherId]/route.ts | 20 +- web/app/instruments/[instrumentId]/page.tsx | 11 +- web/app/instruments/page.tsx | 33 +- web/app/page.tsx | 3 +- web/app/settings/notifications/page.tsx | 2 +- web/components/dashboard/runs-table.tsx | 4 +- .../instruments/edit-instrument-dialog.tsx | 40 +- .../instruments/instrument-actions.tsx | 161 ++ .../instruments/instrument-header.tsx | 136 +- .../instruments/instrument-status-badge.tsx | 5 +- .../instruments/instruments-table.tsx | 57 +- .../instruments/instruments-view.tsx | 98 + .../reactivate-instrument-dialog.tsx | 80 + .../instruments/retire-instrument-dialog.tsx | 121 + .../instruments/runs-table/index.tsx | 4 +- .../runs-table/instrument-runs-skeleton.tsx | 2 +- .../instrument-notification-switch.tsx | 84 +- web/components/search/search-result-item.tsx | 23 +- web/components/watchers/watcher-header.tsx | 33 +- .../watchers/watcher-status-badge.tsx | 14 +- web/drizzle/0030_mature_manta.sql | 5 + web/drizzle/meta/0030_snapshot.json | 2225 +++++++++++++++++ web/drizzle/meta/_journal.json | 7 + web/lib/api/actor.ts | 36 + web/lib/api/dashboard.ts | 16 +- web/lib/api/instruments.ts | 116 +- web/lib/api/notifications.ts | 4 +- web/lib/api/search.ts | 9 +- web/lib/api/watchers.ts | 111 +- web/lib/db/index.ts | 6 + web/lib/db/schema.ts | 20 + web/package-lock.json | 75 +- 33 files changed, 3307 insertions(+), 301 deletions(-) create mode 100644 web/components/instruments/instrument-actions.tsx create mode 100644 web/components/instruments/instruments-view.tsx create mode 100644 web/components/instruments/reactivate-instrument-dialog.tsx create mode 100644 web/components/instruments/retire-instrument-dialog.tsx create mode 100644 web/drizzle/0030_mature_manta.sql create mode 100644 web/drizzle/meta/0030_snapshot.json create mode 100644 web/lib/api/actor.ts diff --git a/web/app/api/v1/instruments/[instrumentId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/route.ts index 4893c298..a9b0ccd0 100644 --- a/web/app/api/v1/instruments/[instrumentId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/route.ts @@ -2,6 +2,7 @@ import { and, count, eq, isNull } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { authorize, requireAdminForSession } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; +import { deregisterInstrumentWatchers } from "@/lib/api/watchers"; import { db } from "@/lib/db"; import { instrumentRuns, @@ -143,6 +144,15 @@ export async function PATCH( const updates: Record = {}; if ("status" in body) { updates.status = body.status; + // Keep the retirement audit fields in lockstep with the status: only an + // `inactive` instrument has a retirer. + if (body.status === "inactive") { + updates.retiredAt = new Date(); + updates.retiredBy = authResult.userId; + } else { + updates.retiredAt = null; + updates.retiredBy = null; + } } if ("display_name" in body) { updates.displayName = body.display_name; @@ -155,18 +165,31 @@ export async function PATCH( return apiError(400, VALIDATION_ERROR, "No valid fields to update"); } - const [updated] = await db - .update(instruments) - .set(updates) - .where(eq(instruments.id, instrumentId)) - .returning({ - id: instruments.id, - display_name: instruments.displayName, - status: instruments.status, - instrument_type: instruments.instrumentType, - created_at: instruments.createdAt, - updated_at: instruments.updatedAt, - }); + // Retirement flips the status and tears down every watcher; both run in one + // transaction so a mid-teardown failure can't leave the instrument + // `inactive` while its watchers stay live and heartbeating. + const updated = await db.transaction(async (tx) => { + const [row] = await tx + .update(instruments) + .set(updates) + .where(eq(instruments.id, instrumentId)) + .returning({ + id: instruments.id, + display_name: instruments.displayName, + status: instruments.status, + instrument_type: instruments.instrumentType, + created_at: instruments.createdAt, + updated_at: instruments.updatedAt, + }); + + // A retired instrument has no live agent, so always tear down its watchers, + // attributing the teardown to the same actor that retired it. + if (updates.status === "inactive") { + await deregisterInstrumentWatchers(instrumentId, authResult.userId, tx); + } + + return row; + }); return Response.json(updated); } diff --git a/web/app/api/v1/watchers/[watcherId]/route.ts b/web/app/api/v1/watchers/[watcherId]/route.ts index d97d7b1b..56f93445 100644 --- a/web/app/api/v1/watchers/[watcherId]/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/route.ts @@ -10,8 +10,8 @@ import { import { isValidUUID } from "@/lib/api/validators"; import { computeEffectiveStatus, + deregisterWatcherRow, findActiveWatcher, - revertUploadQueueIfWatcherOffline, } from "@/lib/api/watchers"; import { db } from "@/lib/db"; import { instruments, watchers } from "@/lib/db/schema"; @@ -90,19 +90,11 @@ export async function DELETE( return apiError(409, CONFLICT, "Watcher is already deleted"); } - const now = new Date(); - await db - .update(watchers) - .set({ deletedAt: now }) - .where(eq(watchers.id, watcherId)); - - // 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, - reason: "watcher_deregistered", - }); + const now = await deregisterWatcherRow( + { id: watcher.id, instrumentId: watcher.instrumentId }, + "watcher_deregistered", + authResult.userId + ); return Response.json({ id: watcherId, deleted_at: now }); } diff --git a/web/app/instruments/[instrumentId]/page.tsx b/web/app/instruments/[instrumentId]/page.tsx index dc628a74..e5d00561 100644 --- a/web/app/instruments/[instrumentId]/page.tsx +++ b/web/app/instruments/[instrumentId]/page.tsx @@ -159,10 +159,16 @@ export default async function InstrumentDetailPage({ // Suspense child so their queries run in parallel (the shared // `getInstrumentById` is `cache()`-deduped) and the header paints without // waiting on the heavier runs / filter-option queries. + const isAdmin = session.user.isAdmin === true; + return (
}> - + }> @@ -52,9 +47,7 @@ export default async function InstrumentsPage() { setupGuideUrl={ADD_INSTRUMENT_DOCS_URL} />
- } - > + }> @@ -83,14 +76,22 @@ async function InstrumentsListSection({ subscriptions.map((s) => [s.instrumentId, s.enabled]) ); + // The catalogue is small, so one query plus a client-side split beats three + // status-filtered queries. + const activeData = instruments.filter((i) => i.status === "active"); + const pendingData = instruments.filter((i) => i.status === "pending"); + const retiredData = instruments.filter((i) => i.status === "inactive"); + return ( - ); } diff --git a/web/app/page.tsx b/web/app/page.tsx index 6c7ccbeb..d581d23f 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -153,8 +153,9 @@ async function DashboardRunsSection({ const defaultDateFrom = last24hISOString(); // The toolbar instrument list and the filtered run page are independent. + // Only active instruments are useful filter targets on the dashboard. const [instruments, runResult] = await Promise.all([ - getInstruments(), + getInstruments(true), buildRunListQuery({ instrumentId: instrumentIds, search: params.search || undefined, diff --git a/web/app/settings/notifications/page.tsx b/web/app/settings/notifications/page.tsx index 7d305e4b..3dc6db92 100644 --- a/web/app/settings/notifications/page.tsx +++ b/web/app/settings/notifications/page.tsx @@ -76,7 +76,7 @@ async function NotificationsFormSection({ const [prefs, subscriptions, slackConn, slackChannelConfig] = await Promise.all([ getPreferences(userId), - listInstrumentSubscriptions(userId), + listInstrumentSubscriptions(userId, { activeOnly: true }), getSlackConnection(userId), isAdmin ? getSlackChannelConfigForAdmin() : Promise.resolve(null), ]); diff --git a/web/components/dashboard/runs-table.tsx b/web/components/dashboard/runs-table.tsx index 1e7d0006..f2f6571c 100644 --- a/web/components/dashboard/runs-table.tsx +++ b/web/components/dashboard/runs-table.tsx @@ -58,7 +58,9 @@ export function RunsTable({ const runRefs: RunRef[] = data.map(runRowToRef); return ( -
+ // `isolate` contains the rows' internal `z-10` (status icon / run-id link) + // so they don't paint over the fixed sidebar, which shares `z-10`. +
diff --git a/web/components/instruments/edit-instrument-dialog.tsx b/web/components/instruments/edit-instrument-dialog.tsx index 484353ec..a199c443 100644 --- a/web/components/instruments/edit-instrument-dialog.tsx +++ b/web/components/instruments/edit-instrument-dialog.tsx @@ -1,8 +1,8 @@ "use client"; -import { Loader2, Pencil } from "lucide-react"; +import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -12,7 +12,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -45,17 +44,29 @@ export function EditInstrumentDialog({ instrumentId, displayName, instrumentType, + open, + onOpenChange, }: { instrumentId: string; displayName: string; instrumentType: string; + open: boolean; + onOpenChange: (open: boolean) => void; }) { const router = useRouter(); - const [open, setOpen] = useState(false); const [name, setName] = useState(displayName); const [type, setType] = useState(instrumentType); const [isPending, startTransition] = useTransition(); + // Re-sync form state from props each time the dialog opens so it reflects + // any server-side changes since the last edit. + useEffect(() => { + if (open) { + setName(displayName); + setType(instrumentType); + } + }, [open, displayName, instrumentType]); + // Disable Save until the user actually changes something — comparing the // trimmed name avoids treating whitespace-only edits as a real change. const isUnchanged = @@ -79,30 +90,13 @@ export function EditInstrumentDialog({ } toast.success("Instrument updated"); - setOpen(false); + onOpenChange(false); router.refresh(); }); } return ( - { - setOpen(value); - // Re-sync form state from props on open so the dialog reflects any - // server-side changes since the last time it was opened. - if (value) { - setName(displayName); - setType(instrumentType); - } - }} - open={open} - > - - - + Edit instrument diff --git a/web/components/instruments/instrument-actions.tsx b/web/components/instruments/instrument-actions.tsx new file mode 100644 index 00000000..1549fd85 --- /dev/null +++ b/web/components/instruments/instrument-actions.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Archive, EllipsisVertical, Pencil, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { EditInstrumentDialog } from "@/components/instruments/edit-instrument-dialog"; +import { ReactivateInstrumentDialog } from "@/components/instruments/reactivate-instrument-dialog"; +import { RetireInstrumentDialog } from "@/components/instruments/retire-instrument-dialog"; +import { StatusActions } from "@/components/instruments/status-actions"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { InstrumentListItem } from "@/lib/api/instruments"; + +// Both `InstrumentListItem` (table) and `InstrumentDetail` (header) satisfy +// this, so one component drives both surfaces. +export type InstrumentActionTarget = Pick< + InstrumentListItem, + | "displayName" + | "id" + | "instrumentType" + | "runCount" + | "status" + | "watcherCount" +>; + +// `variant` picks the layout: `menu` folds Edit into the three-dot menu (dense +// table); `expanded` pulls Edit out as its own button beside the menu (header). +export function InstrumentActions({ + instrument, + variant = "menu", +}: { + instrument: InstrumentActionTarget; + variant?: "menu" | "expanded"; +}) { + const [menuOpen, setMenuOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [retireOpen, setRetireOpen] = useState(false); + const [reactivateOpen, setReactivateOpen] = useState(false); + + const isRetired = instrument.status === "inactive"; + const expanded = variant === "expanded"; + + return ( +
+ {instrument.status === "pending" ? ( + + ) : null} + + {expanded ? ( + + ) : null} + + + + {expanded ? ( + + ) : ( + + )} + + + {expanded ? null : ( + <> + { + e.preventDefault(); + setMenuOpen(false); + setEditOpen(true); + }} + > + + Edit + + + + )} + {isRetired ? ( + { + e.preventDefault(); + setMenuOpen(false); + setReactivateOpen(true); + }} + > + + Reactivate + + ) : ( + { + e.preventDefault(); + setMenuOpen(false); + setRetireOpen(true); + }} + variant="destructive" + > + + Retire + + )} + + + + + + +
+ ); +} diff --git a/web/components/instruments/instrument-header.tsx b/web/components/instruments/instrument-header.tsx index 87bb39fb..de0558ea 100644 --- a/web/components/instruments/instrument-header.tsx +++ b/web/components/instruments/instrument-header.tsx @@ -1,4 +1,7 @@ +"use client"; + import Link from "next/link"; +import { InstrumentActions } from "@/components/instruments/instrument-actions"; import { InstrumentStatusBadge } from "@/components/instruments/instrument-status-badge"; import { InstrumentNotificationSwitch } from "@/components/notifications/instrument-notification-switch"; import { RecordInstrumentVisit } from "@/components/recent-instrument-visit"; @@ -11,13 +14,18 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Skeleton } from "@/components/ui/skeleton"; +import { UserAvatar } from "@/components/user-avatar"; import { getWatcherOnlineStatus, type WatcherOnlineStatus, } from "@/components/watchers/watcher-online-status"; import { WatcherStatusBadge } from "@/components/watchers/watcher-status-badge"; import type { InstrumentDetail } from "@/lib/api/instruments"; +import { formatDate } from "@/lib/date"; +// Must stay a client component: the retired-by line's `formatDate` resolves the +// timezone at runtime, so server rendering would use UTC and can land the date +// on the wrong calendar day near midnight. export function InstrumentHeaderSkeleton() { return (
-
- -
- - +
+
+ + +
+
+ +
-
); } -// While an instrument is `pending` its lifecycle state (awaiting admin Confirm) -// is the relevant signal, so it pre-empts the watcher connectivity badge. Once -// active, the watcher badge — linked to the canonical watcher when present — -// takes over. +// For `pending`/`inactive` instruments the lifecycle badge pre-empts the +// watcher badge, since "No Watcher"/"Offline" would read as a fault rather than +// an intentional decommission. The hostname beside it links to the watcher. function renderStatusBadge( instrument: InstrumentDetail, watcherStatus: WatcherOnlineStatus ) { - if (instrument.status === "pending") { - return ; + if (instrument.status === "pending" || instrument.status === "inactive") { + return ; } - const badge = ( + return ( ); - - if (instrument.activeWatcherId) { - return ( - - {badge} - - ); - } - - return badge; } export function InstrumentHeader({ instrument, notifications, + isAdmin = false, }: { instrument: InstrumentDetail; + /** Admins get the inline Edit / Retire / Reactivate actions. */ + isAdmin?: boolean; /** * Per-viewer notification state for this instrument. When omitted * (e.g. unauthenticated callers, or contexts that don't want to - * surface the switch), the action row falls back to the watcher-status - * layout. + * surface the switch), the notifications control is hidden. */ notifications?: { enabled: boolean; @@ -118,39 +116,67 @@ export function InstrumentHeader({ -
-

- {instrument.displayName} -

+
+
+

+ {instrument.displayName} +

-
- {notifications ? ( +
+ {renderStatusBadge(instrument, watcherStatus)} + · + + {instrument.runCount} {instrument.runCount === 1 ? "run" : "runs"} + + {instrument.activeWatcherId && instrument.activeWatcherHostname ? ( + <> + · + + {instrument.activeWatcherHostname} + + + ) : null} + {instrument.status === "inactive" && instrument.retiredAt ? ( + <> + · + + Retired {formatDate(instrument.retiredAt)} + {instrument.retiredByUser ? ( + + by + + + {instrument.retiredByUser.displayName} + + + ) : null} + + + ) : null} +
+
+ +
+ {/* Retired/pending instruments emit no new runs, so the subscribe + pill would be a dead control — hide it outside the active state + (matches `/settings/notifications`, which lists active only). */} + {notifications && instrument.status === "active" ? ( ) : null} - {renderStatusBadge(instrument, watcherStatus)} + {isAdmin ? ( + + ) : null}
- -
- - {instrument.runCount} {instrument.runCount === 1 ? "run" : "runs"} - - {instrument.activeWatcherId && instrument.activeWatcherHostname ? ( - <> - · - - {instrument.activeWatcherHostname} - - - ) : null} -
); } diff --git a/web/components/instruments/instrument-status-badge.tsx b/web/components/instruments/instrument-status-badge.tsx index 087dffb5..cdf5eb9e 100644 --- a/web/components/instruments/instrument-status-badge.tsx +++ b/web/components/instruments/instrument-status-badge.tsx @@ -28,9 +28,10 @@ const STATUS_CONFIG: Record< "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300", }, inactive: { - label: "Inactive", + label: "Retired", Icon: Power, - className: "bg-muted text-muted-foreground", + className: + "bg-slate-600 text-slate-50 dark:bg-slate-700 dark:text-slate-100", }, }; diff --git a/web/components/instruments/instruments-table.tsx b/web/components/instruments/instruments-table.tsx index 7e0ab182..73831b78 100644 --- a/web/components/instruments/instruments-table.tsx +++ b/web/components/instruments/instruments-table.tsx @@ -1,11 +1,9 @@ import { ArrowRight, SearchX } from "lucide-react"; import type { ReactNode } from "react"; import { RelativeTime } from "@/components/dashboard/relative-time"; -import { EditInstrumentDialog } from "@/components/instruments/edit-instrument-dialog"; import { InstrumentStatusBadge } from "@/components/instruments/instrument-status-badge"; import { RowActionsCell } from "@/components/instruments/row-actions-cell"; import { ClickableRow } from "@/components/instruments/runs-table/clickable-row"; -import { StatusActions } from "@/components/instruments/status-actions"; import { InstrumentNotificationsCell } from "@/components/notifications/instrument-notifications-cell"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -107,48 +105,16 @@ export function InstrumentsTableSkeleton({ ); } -/** Skeleton for the full `/instruments` management table (all catalogue rows). */ -export function InstrumentsListPageSkeleton({ - withRowActions = false, -}: { - withRowActions?: boolean; -}) { - return ( - - ); -} - -/** - * Default row actions used by the management page: an approval action for - * pending instruments and an edit dialog for everyone. Exported so the - * `/instruments` page can pass it directly to `` - * while contexts without management intent (e.g. the dashboard) simply omit - * the prop and the actions column disappears entirely. - */ -export function InstrumentRowManagementActions(row: InstrumentListItem) { - return ( -
- {row.status === "pending" && } - -
- ); -} - export function InstrumentsTable({ data, footer, renderRowActions, notifications, + emptyMessage = "No instruments configured yet.", }: { data: InstrumentListItem[]; + /** Message shown in the empty state; per-tab callers override the default. */ + emptyMessage?: string; /** * Optional content rendered inside the bordered container, below the table. * Used on the dashboard to surface a "View all" link beneath a truncated list. @@ -175,9 +141,7 @@ export function InstrumentsTable({ return (
-

- No instruments configured yet. -

+

{emptyMessage}

); } @@ -200,7 +164,12 @@ export function InstrumentsTable({ {data.map((row) => { - const watcherStatus = getWatcherOnlineStatus(row); + // Active + no live watcher but a deregistered one reads as + // "Deregistered" rather than "No Watcher". + const watcherStatus = + row.watcherCount === 0 && row.hasDeregisteredWatcher + ? "deregistered" + : getWatcherOnlineStatus(row); return ( - {row.status === "pending" ? ( - - ) : ( + {row.status === "active" ? ( + ) : ( + )} diff --git a/web/components/instruments/instruments-view.tsx b/web/components/instruments/instruments-view.tsx new file mode 100644 index 00000000..74c70d37 --- /dev/null +++ b/web/components/instruments/instruments-view.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { InstrumentActions } from "@/components/instruments/instrument-actions"; +import { + InstrumentsTable, + InstrumentsTableSkeleton, +} from "@/components/instruments/instruments-table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import type { InstrumentListItem } from "@/lib/api/instruments"; + +type Tab = "active" | "pending" | "retired"; + +export function InstrumentsViewSkeleton() { + return ( +
+ + + Active + Pending + Retired + + + {/* Actions column omitted: it's admin-only and renders instantly, + so a placeholder just adds flicker. */} + + + +
+ ); +} + +// All partitions are pre-fetched on the server, so tab switching needs no +// refetch (the instrument catalogue is small). +export function InstrumentsView({ + activeData, + pendingData, + retiredData, + notifications, + isAdmin, +}: { + activeData: InstrumentListItem[]; + pendingData: InstrumentListItem[]; + retiredData: InstrumentListItem[]; + notifications: { + subscriptions: Map; + masterMuted: boolean; + }; + isAdmin: boolean; +}) { + const [tab, setTab] = useState("active"); + + // `InstrumentActions` uses hooks, so render it as JSX, not a function call. + // Memoized so tab switches don't hand the tables a fresh function each render. + const renderRow = useCallback( + (row: InstrumentListItem) => , + [] + ); + const renderRowActions = isAdmin ? renderRow : undefined; + + return ( + setTab(v as Tab)} value={tab}> + + Active ({activeData.length}) + + Pending ({pendingData.length}) + + + Retired ({retiredData.length}) + + + + + + + + + + + + + ); +} diff --git a/web/components/instruments/reactivate-instrument-dialog.tsx b/web/components/instruments/reactivate-instrument-dialog.tsx new file mode 100644 index 00000000..1a20fb90 --- /dev/null +++ b/web/components/instruments/reactivate-instrument-dialog.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Loader2, RotateCcw } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type MouseEvent, useTransition } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +// Sets the instrument back to `active`. Previously deregistered watchers do +// not reconnect automatically — a watcher must re-register. +export function ReactivateInstrumentDialog({ + instrumentId, + displayName, + open, + onOpenChange, +}: { + instrumentId: string; + displayName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + function handleReactivate(e: MouseEvent) { + e.preventDefault(); + startTransition(async () => { + const res = await fetch(`/api/v1/instruments/${instrumentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "active" }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + toast.error(body?.error ?? "Failed to reactivate instrument"); + return; + } + + toast.success("Instrument reactivated"); + onOpenChange(false); + router.refresh(); + }); + } + + return ( + + + + + + + Reactivate {displayName}? + + The instrument returns to Active and reappears in the sidebar and + dashboard. Any watchers deregistered when it was retired must + re-register to reconnect. + + + + Cancel + + {isPending && } + Reactivate instrument + + + + + ); +} diff --git a/web/components/instruments/retire-instrument-dialog.tsx b/web/components/instruments/retire-instrument-dialog.tsx new file mode 100644 index 00000000..c6d8277e --- /dev/null +++ b/web/components/instruments/retire-instrument-dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Archive, Check, Loader2, Power } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type MouseEvent, useTransition } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; + +// Sets the instrument to `inactive` (shown as "Retired"); the server always +// deregisters its watchers as part of the same request. +export function RetireInstrumentDialog({ + instrumentId, + displayName, + runCount, + watcherCount, + open, + onOpenChange, +}: { + instrumentId: string; + displayName: string; + runCount: number; + watcherCount: number; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + function handleRetire(e: MouseEvent) { + // Prevent the AlertDialog from auto-closing before the request resolves so + // the pending spinner stays visible until we explicitly close on success. + e.preventDefault(); + startTransition(async () => { + const res = await fetch(`/api/v1/instruments/${instrumentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "inactive" }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + toast.error(body?.error ?? "Failed to retire instrument"); + return; + } + + toast.success("Instrument retired"); + onOpenChange(false); + router.refresh(); + }); + } + + const hasWatchers = watcherCount > 0; + + return ( + + + + + + + Retire {displayName}? + + The instrument moves to Retired and disappears from the sidebar and + dashboard. It stops accepting new runs and watchers. + + +
    +
  • + + + All {runCount} {runCount === 1 ? "run" : "runs"}{" "} + and their files are kept and stay browsable + +
  • +
  • + + Nothing is deleted from storage — this is reversible +
  • + {hasWatchers ? ( +
  • + + + Its {watcherCount}{" "} + {watcherCount === 1 ? "watcher" : "watchers"} will be + deregistered so {watcherCount === 1 ? "it stops" : "they stop"}{" "} + heartbeating + +
  • + ) : null} +
+ + Cancel + + {isPending && } + Retire instrument + + +
+
+ ); +} diff --git a/web/components/instruments/runs-table/index.tsx b/web/components/instruments/runs-table/index.tsx index 2a1914f8..d7488cca 100644 --- a/web/components/instruments/runs-table/index.tsx +++ b/web/components/instruments/runs-table/index.tsx @@ -66,7 +66,9 @@ export function InstrumentRunsTableShell({ } return ( -
+ // `isolate` contains the rows' internal `z-10` so they don't paint over + // the fixed sidebar, which shares `z-10` in the root stacking context. +
{children}
diff --git a/web/components/notifications/instrument-notification-switch.tsx b/web/components/notifications/instrument-notification-switch.tsx index 98cd34e9..630cc108 100644 --- a/web/components/notifications/instrument-notification-switch.tsx +++ b/web/components/notifications/instrument-notification-switch.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useTransition } from "react"; +import { Bell } from "lucide-react"; +import { useId, useState, useTransition } from "react"; import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; import { @@ -8,27 +9,31 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; -// Composition over a `tooltip` boolean prop: the Tooltip is wrapped here -// so callers don't have to know about the active vs muted copy. Three -// places mount this component — the per-instrument cell in the -// instruments table, the row in the InstrumentHeader's action area, and -// the per-instrument list inside the Notifications settings form — and -// they all want identical behaviour. - +// The Tooltip is wrapped here so callers don't have to know about the active +// vs muted copy. export function InstrumentNotificationSwitch({ instrumentId, initialEnabled, masterMuted, size = "default", + variant = "switch", ariaLabel = "Notify me about new runs on this instrument", }: { instrumentId: string; initialEnabled: boolean; masterMuted: boolean; size?: "sm" | "default"; + /** + * `switch` renders a bare toggle (table cell, settings list). `button` + * renders a labelled pill — a bell icon, "Notifications", and the toggle — + * where the *entire* pill is clickable and hover shows the tooltip. + */ + variant?: "switch" | "button"; ariaLabel?: string; }) { + const switchId = useId(); // Optimistic local state mirrors the row's `enabled` column. We flip // it immediately for visual feedback and roll back if the server says // no — the alternative ("await the round-trip then update") makes the @@ -75,29 +80,60 @@ export function InstrumentNotificationSwitch({ // is true — the user's per-instrument intent is preserved through a // master toggle round-trip — but the switch reads as off until the // master mute is cleared. + const disabled = masterMuted || isPending; + + const tooltip = masterMuted + ? "All instrument notifications muted in Settings" + : enabled + ? "Notifying you about new runs on this instrument" + : "Click to be notified about new runs on this instrument"; + + const control = ( + + ); + + if (variant === "button") { + return ( + + + {/* A ` + {tooltip} + + ); + } + return ( {/* The wrapper span keeps the tooltip target hoverable even when the switch is disabled — a disabled Radix Switch swallows pointer events otherwise. */} - - - + {control} - - {masterMuted - ? "All instrument notifications muted in Settings" - : enabled - ? "Notifying you about new runs on this instrument" - : "Click to be notified about new runs on this instrument"} - + {tooltip} ); } diff --git a/web/components/search/search-result-item.tsx b/web/components/search/search-result-item.tsx index 374e97b3..1f2af9b0 100644 --- a/web/components/search/search-result-item.tsx +++ b/web/components/search/search-result-item.tsx @@ -9,6 +9,7 @@ import { Image as ImageIcon, type LucideIcon, } from "lucide-react"; +import { InstrumentStatusBadge } from "@/components/instruments/instrument-status-badge"; import { Highlight } from "@/components/search/highlight"; import { WatcherStatusBadge } from "@/components/watchers/watcher-status-badge"; import type { @@ -148,14 +149,20 @@ export function SearchInstrumentRow({ + // Lifecycle badge for `pending`/`inactive`, watcher badge otherwise + // (mirrors the instruments table). + result.status === "active" ? ( + + ) : ( + + ) } > diff --git a/web/components/watchers/watcher-header.tsx b/web/components/watchers/watcher-header.tsx index 12396f88..7714aec8 100644 --- a/web/components/watchers/watcher-header.tsx +++ b/web/components/watchers/watcher-header.tsx @@ -11,6 +11,7 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Skeleton } from "@/components/ui/skeleton"; +import { UserAvatar } from "@/components/user-avatar"; import { DeregisterDialog } from "@/components/watchers/deregister-dialog"; import { WatcherStatusBadge } from "@/components/watchers/watcher-status-badge"; import type { WatcherDetail } from "@/lib/api/watchers"; @@ -71,16 +72,9 @@ export function WatcherHeader({ watcher }: { watcher: WatcherDetail }) { - {/* Deregistered watchers get a muted, dashed-border treatment to - visually signal that this is historical data. The deregister action - is hidden since the watcher is already soft-deleted. */} -
+ {/* 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/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/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/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", From 40669b542c15390a859a62cd9c669bb7f8680333 Mon Sep 17 00:00:00 2001 From: Wasim Amiri <7220175+wasimxyz@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:30:08 -0700 Subject: [PATCH 3/3] Add run status filter to instrument runs (#132) * Add run status filter to instrument runs Derive run status from raw-file aggregates and expose it as a multi-select filter on the instrument page, dashboard, REST run-list endpoints, and the MCP search_runs tool. Filtering uses priority- exclusive EXISTS/NOT EXISTS predicates so the pagination count query stays index-backed without a schema change. Reorder RunStatusIcon to Failed > Pending > Uploaded > Processing > Completed > Empty, add a dedicated empty state, and drive both the icon and the SQL from a single shared run-status table. Co-authored-by: Cursor * Align run-status filter URL key with REST/MCP `status` Rename the frontend nuqs key from `run_status` to `status` so the shareable dashboard/instrument URL matches the REST `?status=` param and the MCP `status` arg. The internal `RunListFilters.statuses` array stays. Co-authored-by: Cursor * Drop unused fileCount from deriveRunStatus input `empty` is derived as the fall-through when every status bucket is zero, so the `fileCount` field was never read. Remove it from `RunStatusCounts` and the icon call site. Co-authored-by: Cursor * Add tests for derived run status filter Unit tests cover `deriveRunStatus` priority ordering and `parseRunStatusParam` parsing (repeated/comma-separated, unknown-drop, dedup). Integration tests assert the `?status=` filter and pagination total match the priority-exclusive SQL on both run-list endpoints. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- web/app/api/v1/instrument-runs/route.ts | 3 +- .../instruments/[instrumentId]/runs/route.ts | 3 +- web/app/instruments/[instrumentId]/page.tsx | 4 +- web/app/page.tsx | 1 + web/components/dashboard/runs-toolbar.tsx | 3 + .../instruments/instrument-runs-toolbar.tsx | 4 + .../runs-table/run-status-icon.tsx | 47 +++--- web/components/runs/run-filters-combobox.tsx | 52 ++++++- web/lib/api/instrument-runs.ts | 59 +++++++- web/lib/api/validators.ts | 22 +++ web/lib/mcp/tools.ts | 10 +- web/lib/runs/run-status.ts | 105 ++++++++++++++ web/lib/search-params.ts | 13 +- .../integration/run-status-filter.test.ts | 134 ++++++++++++++++++ web/tests/unit/run-status.test.ts | 132 +++++++++++++++++ 15 files changed, 555 insertions(+), 37 deletions(-) create mode 100644 web/lib/runs/run-status.ts create mode 100644 web/tests/integration/run-status-filter.test.ts create mode 100644 web/tests/unit/run-status.test.ts diff --git a/web/app/api/v1/instrument-runs/route.ts b/web/app/api/v1/instrument-runs/route.ts index fcfee38a..ac1486f4 100644 --- a/web/app/api/v1/instrument-runs/route.ts +++ b/web/app/api/v1/instrument-runs/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { buildRunListQuery } from "@/lib/api/instrument-runs"; -import { parseIntParam } from "@/lib/api/validators"; +import { parseIntParam, parseRunStatusParam } from "@/lib/api/validators"; // --------------------------------------------------------------------------- // GET /api/v1/instrument-runs @@ -38,6 +38,7 @@ export async function GET(request: NextRequest) { }), includeDeleted: searchParams.get("include_deleted") === "true", ranBy: searchParams.get("ran_by") ?? undefined, + statuses: parseRunStatusParam(searchParams), }); return Response.json(result); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts index 83f2b77a..ac53b7eb 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts @@ -4,7 +4,7 @@ import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { buildRunListQuery, parseAcquiredAt } from "@/lib/api/instrument-runs"; import { notifyRunCreated } from "@/lib/api/notifications"; -import { parseIntParam } from "@/lib/api/validators"; +import { parseIntParam, parseRunStatusParam } from "@/lib/api/validators"; import { db } from "@/lib/db"; import { files, instrumentRuns, instruments, watchers } from "@/lib/db/schema"; import { sendSlackMessage } from "@/lib/slack"; @@ -258,6 +258,7 @@ export async function GET(request: NextRequest, { params }: RouteContext) { }), includeDeleted: searchParams.get("include_deleted") === "true", ranBy: searchParams.get("ran_by") ?? undefined, + statuses: parseRunStatusParam(searchParams), }); return Response.json(result); diff --git a/web/app/instruments/[instrumentId]/page.tsx b/web/app/instruments/[instrumentId]/page.tsx index e5d00561..e778f7a9 100644 --- a/web/app/instruments/[instrumentId]/page.tsx +++ b/web/app/instruments/[instrumentId]/page.tsx @@ -264,6 +264,7 @@ async function InstrumentRunsSection({ dpi: filters.dpi ?? undefined, colorMode: filters.color_mode ?? undefined, ranBy: filters.ran_by ?? undefined, + statuses: filters.status.length > 0 ? filters.status : undefined, }), getInstrumentFilterOptions(instrument.instrumentType, instrumentId), getRanByFilterOptions(instrumentId), @@ -287,7 +288,8 @@ async function InstrumentRunsSection({ filters.hina_size !== null || filters.dpi !== null || filters.color_mode !== null || - filters.ran_by !== null; + filters.ran_by !== null || + filters.status.length > 0; const pendingUploadCount = runResult.data.filter( (row) => row.files_pending_upload > 0 diff --git a/web/app/page.tsx b/web/app/page.tsx index d581d23f..f99f6a9f 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -164,6 +164,7 @@ async function DashboardRunsSection({ page: params.page, perPage: params.per_page, includeDeleted: params.include_deleted, + statuses: params.status.length > 0 ? params.status : undefined, }), ]); diff --git a/web/components/dashboard/runs-toolbar.tsx b/web/components/dashboard/runs-toolbar.tsx index a86536c5..abf06eb7 100644 --- a/web/components/dashboard/runs-toolbar.tsx +++ b/web/components/dashboard/runs-toolbar.tsx @@ -57,6 +57,7 @@ export function RunsToolbar({ instruments }: { instruments: Instrument[] }) { date_from: null, date_to: null, include_deleted: false, + status: [], page: 1, }); } @@ -160,6 +161,8 @@ export function RunsToolbar({ instruments }: { instruments: Instrument[] }) { onChange={({ includeDeleted }) => setFilters({ include_deleted: includeDeleted, page: 1 }) } + onStatusChange={(next) => setFilters({ status: next, page: 1 })} + selectedStatuses={filters.status} values={{ includeDeleted: filters.include_deleted }} />
diff --git a/web/components/instruments/instrument-runs-toolbar.tsx b/web/components/instruments/instrument-runs-toolbar.tsx index cf80a34a..820d0981 100644 --- a/web/components/instruments/instrument-runs-toolbar.tsx +++ b/web/components/instruments/instrument-runs-toolbar.tsx @@ -25,6 +25,7 @@ export function InstrumentRunsToolbar() { filters.date_from !== null || filters.date_to !== null || filters.include_deleted || + filters.status.length > 0 || filters.wavelength !== null || filters.measurement_mode !== null || filters.measurement_type !== null; @@ -35,6 +36,7 @@ export function InstrumentRunsToolbar() { date_from: null, date_to: null, include_deleted: false, + status: [], wavelength: null, measurement_mode: null, measurement_type: null, @@ -84,6 +86,8 @@ export function InstrumentRunsToolbar() { onChange={({ includeDeleted }) => setFilters({ include_deleted: includeDeleted, page: 1 }) } + onStatusChange={(next) => setFilters({ status: next, page: 1 })} + selectedStatuses={filters.status} values={{ includeDeleted: filters.include_deleted }} />
diff --git a/web/components/instruments/runs-table/run-status-icon.tsx b/web/components/instruments/runs-table/run-status-icon.tsx index 7a6c5a16..0810cb85 100644 --- a/web/components/instruments/runs-table/run-status-icon.tsx +++ b/web/components/instruments/runs-table/run-status-icon.tsx @@ -1,17 +1,11 @@ "use client"; -import { - CircleCheck, - CircleDashed, - CircleX, - Clock, - LoaderCircle, -} from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { deriveRunStatus, RUN_STATUS_META } from "@/lib/runs/run-status"; import { cn } from "@/lib/utils"; export function RunStatusIcon({ @@ -31,28 +25,23 @@ export function RunStatusIcon({ filesProcessing: number; errorMessages: string[]; }) { + const status = deriveRunStatus({ + filesCompleted, + filesFailed, + filesPendingUpload, + filesUploaded, + filesProcessing, + }); + const { Icon, colorClassName, spin } = RUN_STATUS_META[status]; + const hasFailed = filesFailed > 0; - const hasProcessing = filesProcessing > 0; - const hasUploaded = filesUploaded > 0; const hasPending = filesPendingUpload > 0; + const hasUploaded = filesUploaded > 0; + const hasProcessing = filesProcessing > 0; const hasCompleted = filesCompleted > 0; - // Icon priority: Error > Processing > Completed > Uploaded > Pending upload. - // Fallback (no files at all) shows the healthy "completed" icon. - const icon = hasFailed ? ( - - ) : hasProcessing ? ( - - ) : hasCompleted ? ( - - ) : hasUploaded ? ( - - ) : hasPending ? ( - - ) : ( - - ); - + // Tooltip lists every non-empty bucket in priority order, so a run reveals + // its lower-priority states on hover even though the icon shows only the top. const lines: string[] = []; if (hasFailed) { if (errorMessages.length > 0) { @@ -82,13 +71,17 @@ export function RunStatusIcon({ ); } if (lines.length === 0) { - lines.push("All files processed successfully"); + lines.push("No files"); } return ( - {icon} + + + void; + // When both are provided, a multi-select STATUS group is rendered. + selectedStatuses?: RunStatus[]; + onStatusChange?: (next: RunStatus[]) => void; }) { const [open, setOpen] = useState(false); - const activeCount = FILTERS.reduce( - (count, f) => (values[f.key] ? count + 1 : count), - 0 - ); + const showStatus = + selectedStatuses !== undefined && onStatusChange !== undefined; + const activeCount = + FILTERS.reduce((count, f) => (values[f.key] ? count + 1 : count), 0) + + (selectedStatuses?.length ?? 0); + + function toggleStatus(status: RunStatus) { + const current = selectedStatuses ?? []; + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status]; + onStatusChange?.(next); + } return ( @@ -84,6 +101,33 @@ export function RunFiltersCombobox({ ); })} + {showStatus && ( + <> + + + {RUN_STATUS_OPTIONS.map((status) => { + const Icon = status.Icon; + const active = selectedStatuses?.includes(status.value); + return ( + toggleStatus(status.value)} + value={status.label} + > + + {status.label} + + ); + })} + + + )} 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/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/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/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] + ); + }); +});