;
+
+// A page references the light file. If it is a generated screenshot, this
+// shows the light and dark pair, inside a browser frame for full windows.
+// Any other image renders as it always has.
+export function MdxImage({
+ alt = "",
+ className,
+ height,
+ placeholder,
+ sizes,
+ src,
+ style: _style,
+ title,
+ width,
+}: MdxImageProps) {
+ const imageSrc = resolveMdxSrc(src);
+ if (!imageSrc) {
+ return null;
+ }
+
+ const screenshot = findScreenshot(imageSrc);
+ if (screenshot) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/docs/site/src/components/mdx.tsx b/docs/site/src/components/mdx.tsx
index 819f0f0a..93fce2fe 100644
--- a/docs/site/src/components/mdx.tsx
+++ b/docs/site/src/components/mdx.tsx
@@ -3,10 +3,10 @@ import defaultMdxComponents from "fumadocs-ui/mdx";
import type { MDXComponents } from "mdx/types";
import { Audience } from "./audience";
import { WatcherCliCatalog } from "./cli/cli-catalog";
-import { MdxImage } from "./docs-image";
import { McpPromptCatalog } from "./mcp/prompt-catalog";
import { McpResourceCatalog } from "./mcp/resource-catalog";
import { McpToolCatalog } from "./mcp/tool-catalog";
+import { MdxImage } from "./mdx-image";
import { Mermaid } from "./mermaid";
export function getMDXComponents(components?: MDXComponents) {
diff --git a/docs/site/src/components/screenshot.tsx b/docs/site/src/components/screenshot.tsx
new file mode 100644
index 00000000..b369d12c
--- /dev/null
+++ b/docs/site/src/components/screenshot.tsx
@@ -0,0 +1,134 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/cn";
+import {
+ SCREENSHOT_SCALE,
+ type Screenshot as ScreenshotEntry,
+ screenshots,
+} from "@/lib/screenshots.generated";
+import { DocsImage } from "./docs-image";
+
+// MDX puts a standalone image inside a , so everything here is a .
+// A would be invalid HTML there and fail hydration.
+
+const bySource = new Map(
+ Object.values(screenshots).map((entry) => [entry.light.src, entry])
+);
+
+/** Finds the entry for an image imported by an MDX page, by its built URL. */
+export function findScreenshot(src: unknown): ScreenshotEntry | undefined {
+ if (!src || typeof src !== "object") {
+ return;
+ }
+ const staticImage = "default" in src ? src.default : src;
+ if (
+ !staticImage ||
+ typeof staticImage !== "object" ||
+ !("src" in staticImage)
+ ) {
+ return;
+ }
+ return bySource.get(String(staticImage.src));
+}
+
+export function BrowserFrame({
+ address,
+ children,
+ className,
+}: {
+ address: string;
+ children: ReactNode;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+
+
+ {address}
+
+ {/* Balances the three dots so the address stays centered. */}
+
+
+ {children}
+
+ );
+}
+
+// Both themes render, and CSS shows the one that matches. Lazy loading skips
+// the hidden image, so a reader downloads one of the two.
+function ThemedImage({
+ alt,
+ entry,
+ preload,
+ sizes,
+}: {
+ alt: string;
+ entry: ScreenshotEntry;
+ preload?: boolean;
+ sizes?: string;
+}) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export function Screenshot({
+ alt,
+ className,
+ entry,
+ preload,
+ sizes,
+}: {
+ alt: string;
+ className?: string;
+ entry: ScreenshotEntry;
+ preload?: boolean;
+ sizes?: string;
+}) {
+ if (entry.kind === "window") {
+ return (
+
+
+
+ );
+ }
+
+ // A close-up shows at the size it had on screen. Stretching a narrow dialog
+ // to the width of the page would blur it.
+ return (
+
+
+
+ );
+}
diff --git a/docs/site/src/lib/screenshots.generated.ts b/docs/site/src/lib/screenshots.generated.ts
new file mode 100644
index 00000000..19735e66
--- /dev/null
+++ b/docs/site/src/lib/screenshots.generated.ts
@@ -0,0 +1,281 @@
+// Generated by `npm run docs:screenshots` in `web/`. Do not edit by hand.
+import type { StaticImageData } from "next/image";
+import homeLight from "../../public/images/home.webp";
+import homeDark from "../../public/images/home.dark.webp";
+import plateReaderRunsTableLight from "../../public/images/plate-reader-runs-table.webp";
+import plateReaderRunsTableDark from "../../public/images/plate-reader-runs-table.dark.webp";
+import myRunsLight from "../../public/images/my-runs.webp";
+import myRunsDark from "../../public/images/my-runs.dark.webp";
+import searchModalLight from "../../public/images/search-modal.webp";
+import searchModalDark from "../../public/images/search-modal.dark.webp";
+import gelDocRunDetailsLight from "../../public/images/gel-doc-run-details.webp";
+import gelDocRunDetailsDark from "../../public/images/gel-doc-run-details.dark.webp";
+import plateReaderRunDetails1Light from "../../public/images/plate-reader-run-details-1.webp";
+import plateReaderRunDetails1Dark from "../../public/images/plate-reader-run-details-1.dark.webp";
+import plateReaderRunDetails2Light from "../../public/images/plate-reader-run-details-2.webp";
+import plateReaderRunDetails2Dark from "../../public/images/plate-reader-run-details-2.dark.webp";
+import bulkActionsLight from "../../public/images/bulk-actions.webp";
+import bulkActionsDark from "../../public/images/bulk-actions.dark.webp";
+import gelDocRunCommentsLight from "../../public/images/gel-doc-run-comments.webp";
+import gelDocRunCommentsDark from "../../public/images/gel-doc-run-comments.dark.webp";
+import commentsPageLight from "../../public/images/comments-page.webp";
+import commentsPageDark from "../../public/images/comments-page.dark.webp";
+import csvPreviewLight from "../../public/images/csv-preview.webp";
+import csvPreviewDark from "../../public/images/csv-preview.dark.webp";
+import notificationBellLight from "../../public/images/notification-bell.webp";
+import notificationBellDark from "../../public/images/notification-bell.dark.webp";
+import notificationsLight from "../../public/images/notifications.webp";
+import notificationsDark from "../../public/images/notifications.dark.webp";
+import instrumentsTableLight from "../../public/images/instruments-table.webp";
+import instrumentsTableDark from "../../public/images/instruments-table.dark.webp";
+import instrumentsPendingLight from "../../public/images/instruments-pending.webp";
+import instrumentsPendingDark from "../../public/images/instruments-pending.dark.webp";
+import instrumentEditDialogLight from "../../public/images/instrument-edit-dialog.webp";
+import instrumentEditDialogDark from "../../public/images/instrument-edit-dialog.dark.webp";
+import instrumentRetireDialogLight from "../../public/images/instrument-retire-dialog.webp";
+import instrumentRetireDialogDark from "../../public/images/instrument-retire-dialog.dark.webp";
+import watchersListLight from "../../public/images/watchers-list.webp";
+import watchersListDark from "../../public/images/watchers-list.dark.webp";
+import watcherDetailLight from "../../public/images/watcher-detail.webp";
+import watcherDetailDark from "../../public/images/watcher-detail.dark.webp";
+import qpcrRunDetailLight from "../../public/images/qpcr-run-detail.webp";
+import qpcrRunDetailDark from "../../public/images/qpcr-run-detail.dark.webp";
+import auntyRunDetailLight from "../../public/images/aunty-run-detail.webp";
+import auntyRunDetailDark from "../../public/images/aunty-run-detail.dark.webp";
+import workspaceMembersLight from "../../public/images/workspace-members.webp";
+import workspaceMembersDark from "../../public/images/workspace-members.dark.webp";
+import watcherSettingsLight from "../../public/images/watcher-settings.webp";
+import watcherSettingsDark from "../../public/images/watcher-settings.dark.webp";
+import tokensListLight from "../../public/images/tokens-list.webp";
+import tokensListDark from "../../public/images/tokens-list.dark.webp";
+import tokenCreateDialogLight from "../../public/images/token-create-dialog.webp";
+import tokenCreateDialogDark from "../../public/images/token-create-dialog.dark.webp";
+import tokenCreatedLight from "../../public/images/token-created.webp";
+import tokenCreatedDark from "../../public/images/token-created.dark.webp";
+import integrationsLight from "../../public/images/integrations.webp";
+import integrationsDark from "../../public/images/integrations.dark.webp";
+import feedbackSendDialogLight from "../../public/images/feedback-send-dialog.webp";
+import feedbackSendDialogDark from "../../public/images/feedback-send-dialog.dark.webp";
+import feedbackReviewLight from "../../public/images/feedback-review.webp";
+import feedbackReviewDark from "../../public/images/feedback-review.dark.webp";
+import changelogDialogLight from "../../public/images/changelog-dialog.webp";
+import changelogDialogDark from "../../public/images/changelog-dialog.dark.webp";
+import mcpConsentLight from "../../public/images/mcp-consent.webp";
+import mcpConsentDark from "../../public/images/mcp-consent.dark.webp";
+import manualUploadLight from "../../public/images/manual-upload.webp";
+import manualUploadDark from "../../public/images/manual-upload.dark.webp";
+import deletedRunLight from "../../public/images/deleted-run.webp";
+import deletedRunDark from "../../public/images/deleted-run.dark.webp";
+
+/** Pixels in the image for each CSS pixel on screen. */
+export const SCREENSHOT_SCALE = 2;
+
+export interface Screenshot {
+ /** Shown in the browser frame, on a placeholder host. */
+ address: string;
+ dark: StaticImageData;
+ /** Close-ups have no browser frame. */
+ kind: "closeup" | "window";
+ light: StaticImageData;
+}
+
+export const screenshots: Record
= {
+ "home": {
+ address: "datahub.example.com",
+ dark: homeDark,
+ kind: "window",
+ light: homeLight,
+ },
+ "plate-reader-runs-table": {
+ address: "datahub.example.com/instruments/spectramax-id5-plate-reader",
+ dark: plateReaderRunsTableDark,
+ kind: "window",
+ light: plateReaderRunsTableLight,
+ },
+ "my-runs": {
+ address: "datahub.example.com/users/3f2a9c1e-7b4d-4e0a-9c55-1d2e8f6a7b90",
+ dark: myRunsDark,
+ kind: "window",
+ light: myRunsLight,
+ },
+ "search-modal": {
+ address: "datahub.example.com",
+ dark: searchModalDark,
+ kind: "window",
+ light: searchModalLight,
+ },
+ "gel-doc-run-details": {
+ address: "datahub.example.com/instruments/azure-600-gel-doc/runs/26.02.02_10.45.05",
+ dark: gelDocRunDetailsDark,
+ kind: "window",
+ light: gelDocRunDetailsLight,
+ },
+ "plate-reader-run-details-1": {
+ address: "datahub.example.com/instruments/spectramax-id5-plate-reader/runs/260721_OD750_AAA",
+ dark: plateReaderRunDetails1Dark,
+ kind: "window",
+ light: plateReaderRunDetails1Light,
+ },
+ "plate-reader-run-details-2": {
+ address: "datahub.example.com/instruments/spectramax-id5-plate-reader/runs/260710_OD595_kinetic_EEE",
+ dark: plateReaderRunDetails2Dark,
+ kind: "window",
+ light: plateReaderRunDetails2Light,
+ },
+ "bulk-actions": {
+ address: "datahub.example.com/instruments/azure-600-gel-doc",
+ dark: bulkActionsDark,
+ kind: "window",
+ light: bulkActionsLight,
+ },
+ "gel-doc-run-comments": {
+ address: "datahub.example.com/instruments/azure-600-gel-doc/runs/26.02.02_10.45.05",
+ dark: gelDocRunCommentsDark,
+ kind: "window",
+ light: gelDocRunCommentsLight,
+ },
+ "comments-page": {
+ address: "datahub.example.com/comments",
+ dark: commentsPageDark,
+ kind: "window",
+ light: commentsPageLight,
+ },
+ "csv-preview": {
+ address: "datahub.example.com/instruments/spectramax-id5-plate-reader/runs/260721_OD750_AAA",
+ dark: csvPreviewDark,
+ kind: "closeup",
+ light: csvPreviewLight,
+ },
+ "notification-bell": {
+ address: "datahub.example.com",
+ dark: notificationBellDark,
+ kind: "closeup",
+ light: notificationBellLight,
+ },
+ "notifications": {
+ address: "datahub.example.com/settings/notifications",
+ dark: notificationsDark,
+ kind: "window",
+ light: notificationsLight,
+ },
+ "instruments-table": {
+ address: "datahub.example.com/instruments",
+ dark: instrumentsTableDark,
+ kind: "window",
+ light: instrumentsTableLight,
+ },
+ "instruments-pending": {
+ address: "datahub.example.com/instruments?tab=pending",
+ dark: instrumentsPendingDark,
+ kind: "window",
+ light: instrumentsPendingLight,
+ },
+ "instrument-edit-dialog": {
+ address: "datahub.example.com/instruments",
+ dark: instrumentEditDialogDark,
+ kind: "closeup",
+ light: instrumentEditDialogLight,
+ },
+ "instrument-retire-dialog": {
+ address: "datahub.example.com/instruments",
+ dark: instrumentRetireDialogDark,
+ kind: "closeup",
+ light: instrumentRetireDialogLight,
+ },
+ "watchers-list": {
+ address: "datahub.example.com/watchers",
+ dark: watchersListDark,
+ kind: "window",
+ light: watchersListLight,
+ },
+ "watcher-detail": {
+ address: "datahub.example.com/watchers/3f2a9c1e-7b4d-4e0a-9c55-1d2e8f6a7b90",
+ dark: watcherDetailDark,
+ kind: "window",
+ light: watcherDetailLight,
+ },
+ "qpcr-run-detail": {
+ address: "datahub.example.com/instruments/azure-cielo-qpcr/runs/Experiment_20260129",
+ dark: qpcrRunDetailDark,
+ kind: "window",
+ light: qpcrRunDetailLight,
+ },
+ "aunty-run-detail": {
+ address: "datahub.example.com/instruments/unchained-labs-aunty/runs/2026-03-15T09-18-44",
+ dark: auntyRunDetailDark,
+ kind: "window",
+ light: auntyRunDetailLight,
+ },
+ "workspace-members": {
+ address: "datahub.example.com/settings/members",
+ dark: workspaceMembersDark,
+ kind: "window",
+ light: workspaceMembersLight,
+ },
+ "watcher-settings": {
+ address: "datahub.example.com/settings/watchers",
+ dark: watcherSettingsDark,
+ kind: "window",
+ light: watcherSettingsLight,
+ },
+ "tokens-list": {
+ address: "datahub.example.com/settings/tokens",
+ dark: tokensListDark,
+ kind: "window",
+ light: tokensListLight,
+ },
+ "token-create-dialog": {
+ address: "datahub.example.com/settings/tokens",
+ dark: tokenCreateDialogDark,
+ kind: "closeup",
+ light: tokenCreateDialogLight,
+ },
+ "token-created": {
+ address: "datahub.example.com/settings/tokens",
+ dark: tokenCreatedDark,
+ kind: "closeup",
+ light: tokenCreatedLight,
+ },
+ "integrations": {
+ address: "datahub.example.com/settings/integrations",
+ dark: integrationsDark,
+ kind: "window",
+ light: integrationsLight,
+ },
+ "feedback-send-dialog": {
+ address: "datahub.example.com",
+ dark: feedbackSendDialogDark,
+ kind: "closeup",
+ light: feedbackSendDialogLight,
+ },
+ "feedback-review": {
+ address: "datahub.example.com/settings/feedback",
+ dark: feedbackReviewDark,
+ kind: "window",
+ light: feedbackReviewLight,
+ },
+ "changelog-dialog": {
+ address: "datahub.example.com",
+ dark: changelogDialogDark,
+ kind: "closeup",
+ light: changelogDialogLight,
+ },
+ "mcp-consent": {
+ address: "datahub.example.com/consent",
+ dark: mcpConsentDark,
+ kind: "window",
+ light: mcpConsentLight,
+ },
+ "manual-upload": {
+ address: "datahub.example.com/instruments/hina-microscope/runs/20260316_101845_330",
+ dark: manualUploadDark,
+ kind: "window",
+ light: manualUploadLight,
+ },
+ "deleted-run": {
+ address: "datahub.example.com/instruments/spectramax-id5-plate-reader/runs/260720_OD595_flat_BBB",
+ dark: deletedRunDark,
+ kind: "window",
+ light: deletedRunLight,
+ },
+};
diff --git a/web/.gitignore b/web/.gitignore
index 6e2cd8fe..b989e7bc 100644
--- a/web/.gitignore
+++ b/web/.gitignore
@@ -14,6 +14,10 @@
# next.js
/.next/
/out/
+
+# `npm run docs:screenshots` scratch space: dev server output, local file
+# mirror, and the failure summary.
+/.docs-screenshots/
# Leftover nested output from an earlier Turbopack workspace root.
/web/
diff --git a/web/biome.jsonc b/web/biome.jsonc
index 92e01d3e..77d697e5 100644
--- a/web/biome.jsonc
+++ b/web/biome.jsonc
@@ -9,7 +9,11 @@
// Drizzle Kit writes the `drizzle/meta` snapshots. Biome's JSON formatter
// collapses short arrays, and the next `drizzle-kit generate` expands them
// again. `make docs-catalogs` writes the docs bundle, so Biome skips it too.
- "includes": ["!!drizzle/meta", "!lib/mcp/docs/docs-bundle.snapshot.json"]
+ "includes": [
+ "!!drizzle/meta",
+ "!!.docs-screenshots",
+ "!lib/mcp/docs/docs-bundle.snapshot.json"
+ ]
},
"linter": {
"rules": {
diff --git a/web/lib/mcp/docs/docs-bundle.snapshot.json b/web/lib/mcp/docs/docs-bundle.snapshot.json
index 5badf5d5..0d786cbd 100644
--- a/web/lib/mcp/docs/docs-bundle.snapshot.json
+++ b/web/lib/mcp/docs/docs-bundle.snapshot.json
@@ -16,31 +16,31 @@
"page": "set-up-an-instrument",
"title": "Set up an instrument",
"description": "Go from a fresh instrument PC to an active instrument uploading runs into Data Hub.",
- "markdown": "# Set up an instrument (/docs/set-up-an-instrument)\n\n\n\n**For:** Lab operators and admins\n\nThis walkthrough takes you from nothing to an uploading instrument: create an access token, install the watcher, register the instrument, get an admin to confirm it, then start watching. CLI flags, the full config schema, and Windows service setup each have their own page; this guide links them once.\n\n\n Data Hub is self-hosted. The web app, PostgreSQL database, and AWS stack (S3 plus the processing Lambda) must already be running. If they are not, an engineer needs the [Deploy Data Hub](/docs/self-hosting) guide first.\n\n\n## Before you start [#before-you-start]\n\nYou need:\n\n* **The watch directory**: the folder your instrument writes output into.\n* **File patterns**: globs to upload, for example `*.csv`, `*.xlsx`, `*.tiff`.\n* **[uv](https://docs.astral.sh/uv/getting-started/installation/)** installed on the instrument PC (it manages Python for you).\n\nTo make setup and maintenance easier, we recommend setting up remote access to the instrument PC via [Windows Remote Desktop](https://learn.microsoft.com/en-us/windows-server/remote/remote-desktop-services/remotepc/remote-desktop-allow-access).\n\n## 1. Create an access token [#1-create-an-access-token]\n\nThe watcher authenticates to Data Hub with a personal access token. Only workspace admins can create tokens, so ask an admin if you are not one.\n\n\n \n In the Data Hub web app, go to **Settings → Access Tokens** and click **Create token**.\n \n\n \n Give the token a **Name** that identifies the instrument PC, for example `Plate Reader PC`.\n \n\n \n Set **Expiration** to “No expiry”. (The token can always be revoked if necessary.)\n \n\n \n Under **Scopes**, click the **Watcher** preset. It grants only what a watcher needs: registering, reporting runs, and uploading files.\n \n\n \n Click **Create token**, then copy the token. It starts with `dhub_` and is shown only once.\n \n\n\nKeep the token handy: you paste it into the setup wizard in step 3. For rotation, revocation, and the full scope list, see [Issue and revoke tokens](/docs/manage-tokens).\n\n## 2. Install the watcher [#2-install-the-watcher]\n\nOn the instrument PC, install the watcher with `uv`. The `windows-service` extra adds `pywin32` so you can follow [Run as a Windows service](/docs/windows-service), which is the default on a lab PC:\n\n```sh\nuv tool install \"data-hub-watcher[windows-service]\"\n```\n\nOn macOS or Linux, omit the extra:\n\n```sh\nuv tool install data-hub-watcher\n```\n\nFull install, verify, and update steps live in [Install and update the watcher](/docs/install-the-watcher).\n\n## 3. Run the setup wizard [#3-run-the-setup-wizard]\n\nStart the wizard with `data-hub-watcher init`. It prompts you for one thing at a time:\n\n\n \n Choose the environment. For a live lab, this is production.\n \n\n \n Enter your Data Hub API URL e.g. `https://datahub.your-domain.com/api/v1`.\n \n\n \n Paste the token from step 1 when it asks for the API key. The wizard checks the key, then lists the instruments already registered.\n \n\n \n Pick your instrument from the list, or choose **Register a new instrument** if it is not there.\n \n\n \n For a new instrument, enter an ID and a display name. The ID is a short, lowercase, hyphenated name like `bio-rad-cfx96`. **It cannot be changed later.**\n \n\n \n Enter the full path to the folder your instrument writes into.\n \n\n \n List which files to upload, such as `*.pcrd,*.csv`. The watcher ignores files that do not match.\n \n\n \n Choose how files get grouped into runs. Pick the preset that matches how your instrument names its output, or write your own. The wizard previews a few matches so you can check the run IDs. For example, the default preset uses the filename prefix before the first underscore, so `RUN001_quant.csv` and `RUN001_melt.csv` both group into run `RUN001`.\n \n\n \n Set the stability period. This is how much time (in seconds) a file must stay unchanged before the watcher counts it as finished. The default is 5 seconds; raise it for instruments that write large files slowly.\n \n\n \n Choose the upload mode. “Auto” uploads each file as soon as it is stable; “Manual” waits for your approval first.\n \n\n\nFor run detection options, see [Configure run detection and uploads](/docs/configure-run-detection); [Watcher configuration](/docs/configuration-reference#run-detection) lists every preset.\n\nThe wizard then registers the watcher, saves the config to `~/.data-hub/config.yaml` (`C:\\Users\\\\data-hub\\config.yaml` on Windows), and syncs it to Data Hub. When it reports that setup is complete, start watching.\n\n## 4. Confirm the instrument’s registration [#4-confirm-the-instruments-registration]\n\nA pending instrument cannot upload to Data Hub until an admin confirms it from the web app.\n\n\n \n Open the Data Hub web app and navigate to **Instruments > View all** from the app sidebar.\n \n\n \n Select the “Pending” tab and find your instrument. It should show a yellow `pending` badge.\n \n\n \n Click **Confirm**. The instrument should move to the “Active” tab and appear as online shortly.\n \n\n\nOpen the instrument’s page and check the watched folder and file patterns shown under its name. They should match what you entered in the wizard; files outside that folder, or with names that don’t match a pattern, won’t reach Data Hub.\n\nSome instruments also get automated preprocessing (metadata extraction, image conversion, and related outputs). To choose a processor, an admin opens the three-dot menu, selects **Edit details**, and picks a supported instrument type. What each type produces is in [Instrument data preprocessing](/docs/instrument-preprocessing).\n\n\n Adding a new processor requires code changes; see [Adding a new instrument](https://github.com/Arcadia-Science/data-hub/blob/production/docs/developer/lambda.md#adding-a-new-instrument) in the developer docs.\n\n\nSee [Manage instruments](/docs/manage-instruments) for more info.\n\n## 5. Start the watcher service [#5-start-the-watcher-service]\n\nFirst, preview what the watcher will do without starting it:\n\n```sh\ndata-hub-watcher watch --dry-run\n```\n\nThis validates config, checks that the API is reachable and the instrument is active, and previews which files would be picked up.\n\nWhen it looks right, install and start the watcher as a Windows service. Run these from an **Administrator** terminal:\n\n```sh\ndata-hub-watcher service install\ndata-hub-watcher service start\n```\n\nThe service starts on boot, keeps running after you log out, and auto-updates in the background. Lifecycle and log details are in [Run as a Windows service](/docs/windows-service).\n\nOn macOS or Linux, run the watcher in the foreground instead:\n\n```sh\ndata-hub-watcher watch\n```\n\nThe watcher detects new files, groups them into runs, uploads them (in auto mode), and sends a heartbeat every 60 seconds. Runs appear in the dashboard after upload.\n\n## What you get once it is running [#what-you-get-once-it-is-running]\n\n* Raw files upload to cloud storage\n* Instrument runs and files appear in the web dashboard\n* Watcher service automatically restarts on PC restart\n* Watcher health (heartbeats, events) appears under **Watchers**\n* Subscribed users can get in-app and Slack notifications for new runs\n"
+ "markdown": "# Set up an instrument (/docs/set-up-an-instrument)\n\n\n\n\n\n\n\n**For:** Lab operators and admins\n\nThis walkthrough takes you from nothing to an uploading instrument: create an access token, install the watcher, register the instrument, get an admin to confirm it, then start watching. CLI flags, the full config schema, and Windows service setup each have their own page; this guide links them once.\n\n\n Data Hub is self-hosted. The web app, PostgreSQL database, and AWS stack (S3 plus the processing Lambda) must already be running. If they are not, an engineer needs the [Deploy Data Hub](/docs/self-hosting) guide first.\n\n\n## Before you start [#before-you-start]\n\nYou need:\n\n* **The watch directory**: the folder your instrument writes output into.\n* **File patterns**: globs to upload, for example `*.csv`, `*.xlsx`, `*.tiff`.\n* **[uv](https://docs.astral.sh/uv/getting-started/installation/)** installed on the instrument PC (it manages Python for you).\n\nTo make setup and maintenance easier, we recommend setting up remote access to the instrument PC via [Windows Remote Desktop](https://learn.microsoft.com/en-us/windows-server/remote/remote-desktop-services/remotepc/remote-desktop-allow-access).\n\n## 1. Create an access token [#1-create-an-access-token]\n\nThe watcher authenticates to Data Hub with a personal access token. Only workspace admins can create tokens, so ask an admin if you are not one.\n\n\n \n In the Data Hub web app, go to **Settings → Access Tokens** and click **Create token**.\n \n\n \n Give the token a **Name** that identifies the instrument PC, for example `Plate Reader PC`.\n \n\n \n Set **Expiration** to “No expiry”. (The token can always be revoked if necessary.)\n \n\n \n Under **Scopes**, click the **Watcher** preset. It grants only what a watcher needs: registering, reporting runs, and uploading files.\n \n\n \n Click **Create token**, then copy the token. It starts with `dhub_` and is shown only once.\n \n\n\n
\n\nKeep the token handy: you paste it into the setup wizard in step 3. For rotation, revocation, and the full scope list, see [Issue and revoke tokens](/docs/manage-tokens).\n\n## 2. Install the watcher [#2-install-the-watcher]\n\nOn the instrument PC, install the watcher with `uv`. The `windows-service` extra adds `pywin32` so you can follow [Run as a Windows service](/docs/windows-service), which is the default on a lab PC:\n\n```sh\nuv tool install \"data-hub-watcher[windows-service]\"\n```\n\nOn macOS or Linux, omit the extra:\n\n```sh\nuv tool install data-hub-watcher\n```\n\nFull install, verify, and update steps live in [Install and update the watcher](/docs/install-the-watcher).\n\n## 3. Run the setup wizard [#3-run-the-setup-wizard]\n\nStart the wizard with `data-hub-watcher init`. It prompts you for one thing at a time:\n\n\n \n Choose the environment. For a live lab, this is production.\n \n\n \n Enter your Data Hub API URL e.g. `https://datahub.your-domain.com/api/v1`.\n \n\n \n Paste the token from step 1 when it asks for the API key. The wizard checks the key, then lists the instruments already registered.\n \n\n \n Pick your instrument from the list, or choose **Register a new instrument** if it is not there.\n \n\n \n For a new instrument, enter an ID and a display name. The ID is a short, lowercase, hyphenated name like `bio-rad-cfx96`. **It cannot be changed later.**\n \n\n \n Enter the full path to the folder your instrument writes into.\n \n\n \n List which files to upload, such as `*.pcrd,*.csv`. The watcher ignores files that do not match.\n \n\n \n Choose how files get grouped into runs. Pick the preset that matches how your instrument names its output, or write your own. The wizard previews a few matches so you can check the run IDs. For example, the default preset uses the filename prefix before the first underscore, so `RUN001_quant.csv` and `RUN001_melt.csv` both group into run `RUN001`.\n \n\n \n Set the stability period. This is how much time (in seconds) a file must stay unchanged before the watcher counts it as finished. The default is 5 seconds; raise it for instruments that write large files slowly.\n \n\n \n Choose the upload mode. “Auto” uploads each file as soon as it is stable; “Manual” waits for your approval first.\n \n\n\nFor run detection options, see [Configure run detection and uploads](/docs/configure-run-detection); [Watcher configuration](/docs/configuration-reference#run-detection) lists every preset.\n\nThe wizard then registers the watcher, saves the config to `~/.data-hub/config.yaml` (`C:\\Users\\\\data-hub\\config.yaml` on Windows), and syncs it to Data Hub. When it reports that setup is complete, start watching.\n\n## 4. Confirm the instrument’s registration [#4-confirm-the-instruments-registration]\n\nA pending instrument cannot upload to Data Hub until an admin confirms it from the web app.\n\n\n \n Open the Data Hub web app and navigate to **Instruments > View all** from the app sidebar.\n \n\n \n Select the “Pending” tab and find your instrument. It should show a yellow `pending` badge.\n \n\n \n Click **Confirm**. The instrument should move to the “Active” tab and appear as online shortly.\n \n\n\n
\n\nOpen the instrument’s page and check the watched folder and file patterns shown under its name. They should match what you entered in the wizard; files outside that folder, or with names that don’t match a pattern, won’t reach Data Hub.\n\nSome instruments also get automated preprocessing (metadata extraction, image conversion, and related outputs). To choose a processor, an admin opens the three-dot menu, selects **Edit details**, and picks a supported instrument type. What each type produces is in [Instrument data preprocessing](/docs/instrument-preprocessing).\n\n\n Adding a new processor requires code changes; see [Adding a new instrument](https://github.com/Arcadia-Science/data-hub/blob/production/docs/developer/lambda.md#adding-a-new-instrument) in the developer docs.\n\n\nSee [Manage instruments](/docs/manage-instruments) for more info.\n\n## 5. Start the watcher service [#5-start-the-watcher-service]\n\nFirst, preview what the watcher will do without starting it:\n\n```sh\ndata-hub-watcher watch --dry-run\n```\n\nThis validates config, checks that the API is reachable and the instrument is active, and previews which files would be picked up.\n\nWhen it looks right, install and start the watcher as a Windows service. Run these from an **Administrator** terminal:\n\n```sh\ndata-hub-watcher service install\ndata-hub-watcher service start\n```\n\nThe service starts on boot, keeps running after you log out, and auto-updates in the background. Lifecycle and log details are in [Run as a Windows service](/docs/windows-service).\n\nOn macOS or Linux, run the watcher in the foreground instead:\n\n```sh\ndata-hub-watcher watch\n```\n\nThe watcher detects new files, groups them into runs, uploads them (in auto mode), and sends a heartbeat every 60 seconds. Runs appear in the dashboard after upload.\n\n## What you get once it is running [#what-you-get-once-it-is-running]\n\n* Raw files upload to cloud storage\n* Instrument runs and files appear in the web dashboard\n* Watcher service automatically restarts on PC restart\n* Watcher health (heartbeats, events) appears under **Watchers**\n* Subscribed users can get in-app and Slack notifications for new runs\n"
},
{
"page": "browse-runs",
"title": "Browse and analyze runs",
"description": "Use the web app to find runs, inspect processed results, claim attribution, download files, comment, and manage run lifecycle.",
- "markdown": "# Browse and analyze runs (/docs/browse-runs)\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n**For:** Everyone\n\nAfter instruments are uploading, the web app is where you find runs and act on them: inspect results, claim work, download files, and leave lab notes. The sections below follow the order you’ll use them in.\n\n## Orient yourself in the app [#find-a-run]\n\nThe sidebar and the home page are the two starting points for daily work.\n\n**Home** lists recent runs across instruments, most recent first, with counts for today and this week at the top. Filter by date range, who ran it, and instrument-specific metadata when it’s available, then page through results. Below the runs, Home shows up to four of today’s comments; use the date picker to see comments from a different range.\n\n
\n\n**Instruments** in the sidebar lists five instruments, and **View all** opens the full list. Each instrument’s page shows the folder its watcher reads from and the file patterns it uploads, then a runs table with column filters that match the instrument type (for example wavelengths on a plate reader).\n\n
\n\n**My runs** lists runs you’ve claimed, so you can return to your own work without re-filtering the full dashboard.\n\n
\n\n**Comments** lists every run comment by day. Open it from **View all comments** on Home. Each person’s profile page also shows the comments they wrote and the comments on runs they ran.\n\n### Narrow a long run list [#narrow-a-long-run-list]\n\nHome and the instrument pages share the same four controls:\n\n* **Search runs**: filters the list by run name as you type\n* **Date range**: presets from Today through Last 4 weeks, plus a custom range\n* **Add filter**: filter by run status, or turn on **Show deleted runs**\n* **Column headers**: sort by **Run Started**, **Last Updated**, size, who ran it, or any instrument-specific column\n\n**Run Started** is when the instrument acquired the run. **Last Updated** changes when the run’s files change, or when someone edits, claims, or comments on it. The default order is newest run first. The sort order is saved in the page link, so a link you share opens with the same order.\n\n### What each run status means [#run-status]\n\nA run’s status tells you how far its files have got from the instrument to the dashboard:\n\n| Status | Meaning |\n| -------------- | ------------------------------------------------------------------------------------------------------------ |\n| Failed | At least one file failed processing. The file’s row on the run page gives the reason. |\n| Stalled | At least one file has been processing for more than 20 minutes without finishing. Reprocess it to try again. |\n| Pending upload | The watcher found the files but hasn’t sent them yet. |\n| Uploaded | The files are in storage. Processing hasn’t started, or this instrument type has no processor. |\n| Processing | The server is reading the files, extracting metadata, and building reports. |\n| Completed | Processing finished. |\n| Empty | The run has no files. |\n\nA run with files in several states shows the first status in this table that applies. For example, a run with one failed file and nine completed files shows **Failed**.\n\n## Search the workspace [#search]\n\nPress **⌘ K** (macOS) or **Ctrl K** (Windows/Linux) to open global search from any page. Results are grouped into runs, files, instruments, users, and comments. That means you can also look up a colleague’s work or the text of a lab note. A file hit opens the parent run with the files table already filtered to that file.\n\n
\n\n## Open a run and inspect results [#open-run-detail]\n\nEach run has a details page at `/instruments/your_instrument_id/runs/your_run_id`. The arrows beside the run name step you to the next or previous run without going back to the list.\n\nThe top of the page lists who ran it, the number of raw files, and their total size. What you see below that depends on the instrument type and whether a processor ran:\n\n* Metadata tags for extracted fields, such as experiment type, wavelength, or temperature\n* Processed reports such as tables, images, PDFs, and videos, including interactive plate maps for plate readers and a video player for DishCam runs\n* A files table of everything in the run, labeled Raw or Processed, with each file’s size and status\n\nClick a metadata tag to open the instrument’s runs table filtered to that value. For example, clicking a wavelength tag lists every run on that instrument recorded at the same wavelength.\n\nReports with many images, PDFs, or spectra show one item at a time. Search by filename to jump anywhere in the run, or use the arrows to step through it. The counter beside the arrows shows your position out of the total. Plate maps with more than 12 columns, such as 384-well plates, scroll sideways inside the plate.\n\nPer-type inputs and outputs are in [Instrument data preprocessing](/docs/instrument-preprocessing).\n\n
\n\n
\n\n
\n\n### Filter the files table [#filter-files]\n\nThe files table has separate **Type** and **Status** filters. Use them to show, for example, only failed files or only processed CSVs. **Download all** and the run archive include only the files the filters show.\n\n### Live file updates [#live-file-updates]\n\nWhile files are uploading or processing, the run page checks for updates every 5 seconds for up to 5 minutes. After that it stops and shows **Stopped checking for file updates**. Choose **Refresh** to check for another 5 minutes. Uploading or reprocessing files from the page also starts a new 5 minutes.\n\n## Claim who ran it [#claim-who-ran-it]\n\nUse **Claim** on the run details page to attribute yourself. You can claim or unclaim your own user only, so nobody else can attribute a run to you. Claims show on the run and appear in “ran by” filters on Home and elsewhere. API tokens can’t claim runs; a signed-in person has to.\n\n## Download files [#download-files]\n\nFrom the files table you can:\n\n* Preview a CSV, TSV, JSON, or other text file by clicking its row. The preview shows the first 1 MB of the file. Use the back and forward buttons to move between previewable files on the same page of the table. **Download** in that window saves the file\n* Select files and choose **Download** in the bar that appears above the files table. One file downloads directly. Two or more come as a single zip, which builds in the background like **Download all**\n* Choose **Download all** to build a zip of active files in the run, with any active file filters applied\n\nLarge zips build in the background. When one isn’t ready yet, Data Hub tells you how long to wait before trying again.\n\n## Act on several runs at once [#bulk-actions]\n\nSelect the checkbox on one or more run rows to open an action bar at the bottom of the list. Run rows have no buttons of their own; every run action goes through this bar.\n\n* **I ran these**: claim every selected run in one step\n* **Remove my attribution**: unclaim them\n* **Upload**: ask the watcher to upload files that are still on the instrument PC\n* **Download**: download a zip of each selected run\n* **Reprocess**: run server-side processing again, including on runs whose files have stalled\n* **Delete**: mark the selected runs deleted\n\n**Upload**, **Download**, **Reprocess**, and **Delete** appear only when every selected run allows them. For example, **Reprocess** is hidden if one of the selected runs belongs to an instrument type with no processor. The checkbox in the header row selects every run on the page, and **Clear** drops the selection.\n\nThe selection stays when you move to another page of the same results. It clears when you change the search, a filter, or the sort, so the bar never acts on runs from a list you are no longer looking at.\n\nThe files table on a run page works the same way: select files to get **Upload**, **Dismiss**, **Reprocess**, and **Download** for just those files. Its selection also clears when you change the files search, a filter, or the sort.\n\n
\n\n## Comment on a run [#comment]\n\nThe comments thread on run details is for lab notes and follow-ups. Comment activity can notify people who claimed the run or already commented, and clicking a comment notification scrolls to that comment. Set preferences under **Settings → Notifications**; see [Configure notifications](/docs/configure-notifications).\n\nOnly the author of a comment can edit it. Authors can delete their own comments, and workspace admins can delete any comment. Comments posted by a script through an API token show the token’s name with a key icon instead of a person.\n\n
\n\n## Reprocess or delete a run [#reprocess-and-delete]\n\nReprocessing runs server-side processing again. Use it after a file failed or stalled, or after an engineer fixes the code that reads a file type. The stored result doesn’t change until you reprocess.\n\n* On the run page, hover over a file and choose **Reprocess**.\n* To reprocess several files, select them in the files table and choose **Reprocess** in the bar.\n* To reprocess whole runs, select them in a run list and choose **Reprocess** in the bar.\n\nReprocessing sends only the original instrument files. Files Data Hub generated, such as videos, previews, and CSVs, have no **Reprocess** button and are rebuilt from the originals.\n\n**Delete** marks the run deleted without removing stored files. To find a deleted run again, turn on **Show deleted runs** under **Add filter**, then open it and choose **Restore**. Anyone signed in can delete or restore a run.\n\n### Request an upload [#request-upload]\n\nA file that the watcher found but hasn’t uploaded shows **Pending**. Choose **Upload** on that file, or select runs and choose **Upload**, to ask the watcher to send it. The watcher picks up the request within a minute. On instruments that upload automatically, this needs watcher 1.2.1 or later; see [Roll out watcher releases](/docs/watcher-releases#what-each-version-adds).\n\nFor instruments in [manual upload mode](/docs/concepts#upload-mode), every file waits for this approval. **Dismiss** tells the watcher not to upload a file. See [Manage the watcher fleet](/docs/manage-watchers#approving-manual-uploads).\n\n## Use the same data outside the UI [#programmatic-access]\n\nYou can also list and fetch the same runs and files over the [REST API](/docs/api) or the [Model Context Protocol (MCP) endpoint](/docs/mcp). REST needs a [personal access token](/docs/concepts#personal-access-token); scopes and creation are covered in [Issue and revoke tokens](/docs/manage-tokens). MCP clients sign in through the browser instead, and clients that support MCP Apps show the same interactive run report you see here.\n"
+ "markdown": "# Browse and analyze runs (/docs/browse-runs)\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n**For:** Everyone\n\nAfter instruments are uploading, the web app is where you find runs and act on them: inspect results, claim work, download files, and leave lab notes. The sections below follow the order you’ll use them in.\n\n## Orient yourself in the app [#find-a-run]\n\nThe sidebar and the home page are the two starting points for daily work.\n\n**Home** lists recent runs across instruments, most recent first, with counts for today and this week at the top. Filter by date range, who ran it, and instrument-specific metadata when it’s available, then page through results. Below the runs, Home shows up to four of today’s comments; use the date picker to see comments from a different range.\n\n
\n\n**Instruments** in the sidebar lists five instruments, and **View all** opens the full list. Each instrument’s page shows the folder its watcher reads from and the file patterns it uploads, then a runs table with column filters that match the instrument type (for example wavelengths on a plate reader).\n\n
\n\n**My runs** lists runs you’ve claimed, so you can return to your own work without re-filtering the full dashboard.\n\n
\n\n**Comments** lists every run comment by day. Open it from **View all comments** on Home. Each person’s profile page also shows the comments they wrote and the comments on runs they ran.\n\n
\n\n### Narrow a long run list [#narrow-a-long-run-list]\n\nHome and the instrument pages share the same four controls:\n\n* **Search runs**: filters the list by run name as you type\n* **Date range**: presets from Today through Last 4 weeks, plus a custom range\n* **Add filter**: filter by run status, or turn on **Show deleted runs**\n* **Column headers**: sort by **Run Started**, **Last Updated**, size, who ran it, or any instrument-specific column\n\n**Run Started** is when the instrument acquired the run. **Last Updated** changes when the run’s files change, or when someone edits, claims, or comments on it. The default order is newest run first. The sort order is saved in the page link, so a link you share opens with the same order.\n\n### What each run status means [#run-status]\n\nA run’s status tells you how far its files have got from the instrument to the dashboard:\n\n| Status | Meaning |\n| -------------- | ------------------------------------------------------------------------------------------------------------ |\n| Failed | At least one file failed processing. The file’s row on the run page gives the reason. |\n| Stalled | At least one file has been processing for more than 20 minutes without finishing. Reprocess it to try again. |\n| Pending upload | The watcher found the files but hasn’t sent them yet. |\n| Uploaded | The files are in storage. Processing hasn’t started, or this instrument type has no processor. |\n| Processing | The server is reading the files, extracting metadata, and building reports. |\n| Completed | Processing finished. |\n| Empty | The run has no files. |\n\nA run with files in several states shows the first status in this table that applies. For example, a run with one failed file and nine completed files shows **Failed**.\n\n## Search the workspace [#search]\n\nPress **⌘ K** (macOS) or **Ctrl K** (Windows/Linux) to open global search from any page. Results are grouped into runs, files, instruments, users, and comments. That means you can also look up a colleague’s work or the text of a lab note. A file hit opens the parent run with the files table already filtered to that file.\n\n
\n\n## Open a run and inspect results [#open-run-detail]\n\nEach run has a details page at `/instruments/your_instrument_id/runs/your_run_id`. The arrows beside the run name step you to the next or previous run without going back to the list.\n\nThe top of the page lists who ran it, the number of raw files, and their total size. What you see below that depends on the instrument type and whether a processor ran:\n\n* Metadata tags for extracted fields, such as experiment type, wavelength, or temperature\n* Processed reports such as tables, images, PDFs, and videos, including interactive plate maps for plate readers and a video player for DishCam runs\n* A files table of everything in the run, labeled Raw or Processed, with each file’s size and status\n\nClick a metadata tag to open the instrument’s runs table filtered to that value. For example, clicking a wavelength tag lists every run on that instrument recorded at the same wavelength.\n\nReports with many images, PDFs, or spectra show one item at a time. Search by filename to jump anywhere in the run, or use the arrows to step through it. The counter beside the arrows shows your position out of the total. Plate maps with more than 12 columns, such as 384-well plates, scroll sideways inside the plate.\n\nPer-type inputs and outputs are in [Instrument data preprocessing](/docs/instrument-preprocessing).\n\n
\n\n
\n\n
\n\n### Filter the files table [#filter-files]\n\nThe files table has separate **Type** and **Status** filters. Use them to show, for example, only failed files or only processed CSVs. **Download all** and the run archive include only the files the filters show.\n\n### Live file updates [#live-file-updates]\n\nWhile files are uploading or processing, the run page checks for updates every 5 seconds for up to 5 minutes. After that it stops and shows **Stopped checking for file updates**. Choose **Refresh** to check for another 5 minutes. Uploading or reprocessing files from the page also starts a new 5 minutes.\n\n## Claim who ran it [#claim-who-ran-it]\n\nUse **Claim** on the run details page to attribute yourself. You can claim or unclaim your own user only, so nobody else can attribute a run to you. Claims show on the run and appear in “ran by” filters on Home and elsewhere. API tokens can’t claim runs; a signed-in person has to.\n\n## Download files [#download-files]\n\nFrom the files table you can:\n\n* Preview a CSV, TSV, JSON, or other text file by clicking its row. The preview shows the first 1 MB of the file. Use the back and forward buttons to move between previewable files on the same page of the table. **Download** in that window saves the file\n* Select files and choose **Download** in the bar that appears above the files table. One file downloads directly. Two or more come as a single zip, which builds in the background like **Download all**\n* Choose **Download all** to build a zip of active files in the run, with any active file filters applied\n\n
\n\nLarge zips build in the background. When one isn’t ready yet, Data Hub tells you how long to wait before trying again.\n\n## Act on several runs at once [#bulk-actions]\n\nSelect the checkbox on one or more run rows to open an action bar at the bottom of the list. Run rows have no buttons of their own; every run action goes through this bar.\n\n* **I ran these**: claim every selected run in one step\n* **Remove my attribution**: unclaim them\n* **Upload**: ask the watcher to upload files that are still on the instrument PC\n* **Download**: download a zip of each selected run\n* **Reprocess**: run server-side processing again, including on runs whose files have stalled\n* **Delete**: mark the selected runs deleted\n\n**Upload**, **Download**, **Reprocess**, and **Delete** appear only when every selected run allows them. For example, **Reprocess** is hidden if one of the selected runs belongs to an instrument type with no processor. The checkbox in the header row selects every run on the page, and **Clear** drops the selection.\n\nThe selection stays when you move to another page of the same results. It clears when you change the search, a filter, or the sort, so the bar never acts on runs from a list you are no longer looking at.\n\nThe files table on a run page works the same way: select files to get **Upload**, **Dismiss**, **Reprocess**, and **Download** for just those files. Its selection also clears when you change the files search, a filter, or the sort.\n\n
\n\n## Comment on a run [#comment]\n\nThe comments thread on run details is for lab notes and follow-ups. Comment activity can notify people who claimed the run or already commented, and clicking a comment notification scrolls to that comment. Set preferences under **Settings → Notifications**; see [Configure notifications](/docs/configure-notifications).\n\nOnly the author of a comment can edit it. Authors can delete their own comments, and workspace admins can delete any comment. Comments posted by a script through an API token show the token’s name with a key icon instead of a person.\n\n
\n\n## Reprocess or delete a run [#reprocess-and-delete]\n\nReprocessing runs server-side processing again. Use it after a file failed or stalled, or after an engineer fixes the code that reads a file type. The stored result doesn’t change until you reprocess.\n\n* On the run page, hover over a file and choose **Reprocess**.\n* To reprocess several files, select them in the files table and choose **Reprocess** in the bar.\n* To reprocess whole runs, select them in a run list and choose **Reprocess** in the bar.\n\nReprocessing sends only the original instrument files. Files Data Hub generated, such as videos, previews, and CSVs, have no **Reprocess** button and are rebuilt from the originals.\n\n**Delete** marks the run deleted without removing stored files. To find a deleted run again, turn on **Show deleted runs** under **Add filter**, then open it and choose **Restore**. Anyone signed in can delete or restore a run.\n\n
\n\n### Request an upload [#request-upload]\n\nA file that the watcher found but hasn’t uploaded shows **Pending**. Choose **Upload** on that file, or select runs and choose **Upload**, to ask the watcher to send it. The watcher picks up the request within a minute. On instruments that upload automatically, this needs watcher 1.2.1 or later; see [Roll out watcher releases](/docs/watcher-releases#what-each-version-adds).\n\nFor instruments in [manual upload mode](/docs/concepts#upload-mode), every file waits for this approval. **Dismiss** tells the watcher not to upload a file. See [Manage the watcher fleet](/docs/manage-watchers#approving-manual-uploads).\n\n
\n\n## Use the same data outside the UI [#programmatic-access]\n\nYou can also list and fetch the same runs and files over the [REST API](/docs/api) or the [Model Context Protocol (MCP) endpoint](/docs/mcp). REST needs a [personal access token](/docs/concepts#personal-access-token); scopes and creation are covered in [Issue and revoke tokens](/docs/manage-tokens). MCP clients sign in through the browser instead, and clients that support MCP Apps show the same interactive run report you see here.\n"
},
{
"page": "instrument-preprocessing",
"title": "Instrument data preprocessing",
"description": "What Data Hub does to a raw file after upload, which instrument types have a processor, and what supporting a new instrument takes.",
- "markdown": "# Instrument data preprocessing (/docs/instrument-preprocessing)\n\n\n\n**For:** Lab operators, admins, and engineers\n\nAfter a [watcher](/docs/concepts#watcher) uploads a raw file, Data Hub can run a **preprocessor** on it: code that reads the vendor’s file, pulls out settings like wavelength or imaging mode, and builds something you can look at in the browser. Nine instrument types have one today. This page covers what each produces, why a file sometimes gets no processing, and what adding a new instrument involves.\n\n\n Support for a new instrument isn’t a settings change. It takes a new Python module, a database migration, an edit in two repositories, and a Lambda redeploy. See [Add support for a new instrument](#add-support-for-a-new-instrument).\n\n\n## How a file reaches a processor [#how-preprocessing-works]\n\n1. A file arrives in raw storage, which triggers the Lambda. The bucket notifies on every upload, with no per-instrument filtering.\n2. The Lambda checks the filename against a **filename gate**, a cheap test that skips files no processor wants before making any API call.\n3. It reads the instrument, then looks up a processor by the instrument’s [type](#set-the-instrument-type). Unmapped types stop here.\n4. The processor reads the file, extracts metadata, writes processed files when it has any, and updates the run through the API.\n5. The file’s status moves from `processing` to `completed` or `failed`.\n\n### Why an upload sometimes produces nothing [#why-an-upload-produces-nothing]\n\nTwo silent no-ops explain a file that uploaded and then produced nothing. Neither marks the file failed, because nothing went wrong:\n\n* **The filename missed the gate.** Each type accepts a narrow pattern, listed in the table below. A TapeStation run only triggers on its PDFs, so the peak-table CSVs it writes are stored but don’t start processing on their own.\n* **The instrument has no type set, or a type with no processor.** Files upload and stay browsable; you get no metadata and no reports.\n\nFilename gates apply to uploads only. Choosing **Reprocess** in the web app skips the gate, because clicking it states your intent. That’s why a file that produced nothing on upload can still process when you reprocess it by hand.\n\n### Set the instrument type [#set-the-instrument-type]\n\nProcessing is chosen by the instrument’s **type**, not by its instrument ID. The ID (`azure-cielo-qpcr`) names one physical machine. The type (`qpcr`) names a vendor’s file format, and several instruments can share one.\n\nTo set it, open the instrument page as an admin, choose **Edit details** from the three-dot menu, and pick an **Instrument type**.\n\n\n One type means one vendor’s output format. Names like `qpcr` and `fplc` read as generic, but each is bound to a single vendor’s parser: `fplc` expects ÄKTA PDFs, `qpcr` expects Azure Cielo exports. Typing a different vendor’s instrument into an existing type feeds files to a parser that can’t read them. Supporting a second vendor means splitting the type, which is a code change.\n\n\n## Supported instrument types [#supported-instruments-at-a-glance]\n\n| Type | Vendor and model | Gate for uploads | You get |\n| -------------------- | --------------------------- | --------------------------- | --------------------------------------------------------------- |\n| `tape_station` | Agilent 4150 TapeStation | `.pdf` | Tape-type badge, PDF reports |\n| `fplc` | Cytiva ÄKTA FPLC | `.pdf` | PDF you can read on the run page, no metadata |\n| `gel_doc` | Azure 600 Gel Doc | `.tif`, `.tiff` | Contrast-enhanced PNG, imaging badges |\n| `qpcr` | Azure Cielo qPCR | `.csv`, `.pdf`, `.aze` | Dye-channel badges, melting curves, the instrument’s PDF report |\n| `epson_v700_scanner` | Epson V700 Scanner | `.tif`, `.tiff` | Plate preview JPG, colony measurements |\n| `hina_microscope` | Hina Microscope (Nikon ND2) | `.nd2` | Channel overlay JPG, channel badges |\n| `plate_reader` | SpectraMax iD3 and iD5 | `.xls` | Well-data CSV, interactive plate maps |\n| `dishcam` | DishCam time-lapse camera | `.tif`, `.tiff`, `run.json` | A video of each photo stack, camera-setting badges |\n| `aunty` | Unchained Labs Aunty | `.xlsx` | A plate with a curve chart in each well, experiment badges |\n\n## Agilent 4150 TapeStation [#agilent-4150-tapestation]\n\nReads the tape type out of peak-table CSV filenames and shows TapeStation reports on the run page.\n\n* **Expected files**: CSVs named like `YYYY-MM-DD - HH-MM-SS-_peakTable.csv`, plus matching PDFs\n* **Metadata**: `Tape Type`, for example `gDNA` or `HSD1000`, when the filename matches that pattern\n* **Processed files**: none\n* **On the run page**: tape-type badge, a PDF viewer, and peak-table reports\n\nUploads trigger on the PDF, not the CSV. A run of CSVs alone stays unprocessed until you reprocess it by hand.\n\n## Cytiva ÄKTA FPLC [#akta-fplc]\n\nStores ÄKTA chromatography PDFs so you can read them in the browser. The processor reads nothing out of the PDF itself yet, so this type gets no badges or filters.\n\n* **Expected files**: `.pdf` reports from the ÄKTA\n* **Metadata**: none\n* **Processed files**: none\n* **On the run page**: files table, plus each PDF embedded under Report Data\n\nA newly seeded FPLC stays `generic` on purpose until an operator has checked its output format.\n\n## Azure 600 Gel Doc [#azure-600-gel-doc]\n\nRebuilds each gel TIFF as a viewable image and reads the imaging settings the instrument recorded.\n\nThe processor stretches intensities to the 1st to 99th percentile, per RGB channel or as grayscale, then lays every page of the TIFF side by side in one 300 dpi PNG. The stretch is cosmetic: it makes faint bands visible on screen and doesn’t change the raw file.\n\n* **Expected files**: TIFF from the Azure 600\n* **Metadata**: `capture_type`, `imaging_mode` (true color, fluorescence, or chemiluminescence), `wavelengths`, `colors`, read from the TIFF `XPComment` tag\n* **Processed files**: contrast-enhanced PNG\n* **On the run page**: imaging badges, files table, image viewer for the PNGs\n\nThose metadata fields also drive the column filters on the instrument page.\n\n## Azure Cielo qPCR [#azure-cielo-qpcr]\n\nReads dye channel names, melting curves, and the instrument’s PDF report.\n\n* **Expected files**:\n * the Cq Values CSV, for dye channels\n * the melting curve CSV (a name ending in `_MeltingCurve.csv`)\n * the PDF report the instrument writes\n * `.AZE` project files\n* **Metadata**: `dye_channels`, the unique names in the Cq Values CSV’s `Fluorescence` column\n* **Processed files**: `{run_id}_melting_curve_derivatives.csv` and `{run_id}_melting_curve_plate.json` from the melting curve CSV. An `.AZE` file produces `{run_id}_aze_melting_curve_derivatives.csv`, `{run_id}_aze_melting_curve_plate.json`, and `{run_id}_aze_experiment.json`.\n* **On the run page**: dye-channel badges, the PDF report under **Report Data**, and a **Melting Curves** section\n\nThe **Melting Curves** section shows the plate for one dye channel at a time. Click a well to see its melting curve and derivative, with a dashed line at the melting temperature. A run with more than one PDF gets previous and next buttons under **Report Data**.\n\nThe other CSV files a qPCR run writes upload normally and show as completed, but contribute no metadata.\n\n### `.AZE` project files [#aze-project-files]\n\nData Hub reads melting curves from the Azure Cielo’s native `.AZE` project files as well as from the CSV export. Melting temperatures match the CSV export, but other values can differ. That’s because `.AZE` files store raw fluorescence, while the CSV export can be adjusted for baseline. A project with no melt data completes with no processed files.\n\nThe watcher uploads `.AZE` files only when `*.AZE` is in the instrument’s file patterns. File patterns live in the watcher’s config on the instrument PC, so add it there with `data-hub-watcher config edit` and restart the watcher. Patterns are case-sensitive on some systems, so match the case the instrument uses. See [Watcher configuration](/docs/configuration-reference#field-reference).\n\n## Epson V700 Scanner [#epson-v700-scanner]\n\nFinds agar plates in a flatbed scan and measures the colonies on them.\n\nThe processor looks for plates sitting in gold 3D-printed frames. For each plate it finds, it crops the plate, then detects colonies: crop the edge, compare against the plate background, apply a difference-of-Gaussians filter, threshold, and measure whatever survives. Measurements come out in physical units, so colony areas are comparable between scans.\n\n* **Expected files**: TIFF plate scans\n* **Metadata**: `dpi`, `color_mode`, `plate_count`, `plate_boxes`, plus `colony_detection` summaries when colonies were measured\n* **Processed files**: a web-sized JPG with plate outlines and colony boxes drawn on, and a `_colonies.csv` of per-colony area, centroid, eccentricity, and mean RGB\n* **On the run page**: scanner badges, files table, and reports for the processed CSV\n\nWhen no plates are found, you still get a resized JPG of the whole scan, and colony detection is skipped. An empty `plate_count` usually means the frames weren’t recognized rather than that the plates were blank.\n\n## Hina Microscope [#hina-microscope]\n\nTurns each Nikon ND2 into a single composite image you can view in the browser.\n\nEvery channel is reduced to one 2D frame (a maximum projection over Z, the first index over time or position), stretched to the 1st to 99th percentile, then composited onto a brightfield background in each channel’s fluorophore color. The processor reads one frame at a time, so large stacks process without loading the whole file.\n\n* **Expected files**: ND2\n* **Metadata**: `sizes`, `channels` (name, excitation and emission wavelength, color), and `dimensions`\n* **Processed files**: an RGB overlay JPG for every file in the run\n* **On the run page**: channel and size badges, files table, image viewer for the JPGs\n\nChannel layout is identical across a Hina run, so metadata is written once by whichever file finishes processing first. Later files still get their JPG and skip the metadata write. Reprocessing one file therefore won’t correct run metadata if the layout changed mid-run.\n\nAn image too large to preview at full size gets a smaller preview instead. For example, a 6221 × 6221 pixel image is previewed at 3110 × 3110 pixels. The raw file is unchanged, and you can download it at full size. A file that showed **Failed** for this reason earlier gets its preview when you choose **Reprocess** on the file or on the run.\n\nTwo kinds of image still show **Failed** instead of a preview: an image from a color camera, which Data Hub can’t preview yet, and an image far larger than the Hina Microscope has produced so far. The message on the file says which. In both cases the raw file is stored and you can download it.\n\n## SpectraMax iD3 and iD5 plate readers [#spectramax-id3-and-id5-plate-readers]\n\nBoth models share one processor, which converts SoftMax Pro exports into the well data behind the interactive plate maps.\n\n* **Expected files**: SoftMax `.xls`, which is UTF-16 text with a `Plate:` header rather than a real Excel workbook\n* **Metadata**: `measurement_mode` (Absorbance, Fluorescence, or Luminescence), `measurement_type` (Endpoint, Kinetic, Spectrum, or Well Scan), and `wavelengths` as bare numbers with no unit suffix\n* **Processed files**: `{run_id}_raw_well_data.csv`\n* **On the run page**: wavelength, mode, and type badges, plus plate maps built from that CSV\n\nWhat the plate map shows depends on the read:\n\n* **Kinetic** reads get a time slider. Reads that skip the edge wells keep their time values, and runs that SoftMax Pro stopped early still process.\n* **Spectrum** reads (wavelength scans) get a wavelength slider.\n* **Luminescence** reads show no wavelength, because luminescence doesn’t use one.\n\nSoftMax Pro writes `#SAT` in a saturated well and `#Low` when the flash lamp missed a well. Those wells show as blank on the plate map, and the rest of the file processes normally.\n\n## DishCam [#dishcam]\n\nTurns each DishCam photo stack into a video you can play on the run page.\n\n* **Expected files**: multi-page TIFF photo stacks, plus the `run.json` file the camera writes next to them\n* **Metadata**: camera settings from `run.json`: `fps`, `measured_fps`, `frames`, `duration_seconds`, `quality`, `format`, `started`, and `finished`\n* **Processed files**: an MP4 video and a JPEG still frame for each stack\n* **On the run page**: a video player at the top of the report, with play, pause, and a timeline you can drag. The still frame shows before you press play.\n\nThe video plays at the capture rate or at 10 photos per second, whichever is faster, so slow captures stay watchable. The stored `fps` metadata still records the real capture rate.\n\nA stack processes once both the stack and its `run.json` are in storage, in either order. When a run gathers captures from several folders, each stack uses the `run.json` from its own folder. Watcher 1.1.0 and later keep every folder’s `run.json`, renaming later copies with a short tag such as `run~3f9a1c2b.json`. See [Roll out watcher releases](/docs/watcher-releases#what-each-version-adds).\n\nLarge stacks are read one photo at a time. If a stack can’t finish within the [15-minute processing limit](#when-processing-fails), it shows **Failed**, and the message says how far through the photos it got. You can still download the raw file.\n\n## Unchained Labs Aunty [#unchained-labs-aunty]\n\nReads Aunty exports, which measure protein melting temperature and particle size, and draws a plate of curves.\n\n* **Expected files**: the `.xlsx` export from the Aunty software. Thermal ramps, sizing runs, isothermal holds, and exports that contain only the results table without curves all process.\n* **Metadata**: `experiment_type` (`thermal_ramp`, `sizing`, or `isothermal`), `experiment_count`, and the first experiment’s settings: `analysis_mode`, `start_temp_c`, `end_temp_c`, `rate_c_per_min`, `temperature_c`, `analysis_version`, and `experiment_name`\n* **Processed files**: `{run_id}_aunty_curves.csv`, with every curve point for every well, and `{run_id}_aunty_plate.json`, which drives the plate view\n* **On the run page**: a plate with a small chart in each well. Click a well to open its full chart.\n\nOn the instrument page, filter Aunty runs by experiment type, analysis mode, temperature, and ramp rate.\n\n## Types with no processor [#instruments-without-a-matching-processor]\n\n`generic` and `instant_raman` have no processor, and neither does an instrument whose type is unset. Those uploads still work: files are stored, and you can browse, search, claim, comment on, and download them. What you don’t get is extracted metadata, badges, filters, or processed artifacts.\n\nLeaving a new instrument as `generic` is the safe default. It keeps data flowing while someone confirms which processor, if any, can read its files.\n\n## When processing fails [#when-processing-fails]\n\nA file with status `failed` carries an error message on the run page. Open the run, then choose **Reprocess** on that file, or select runs in a run list and choose **Reprocess**. See [Reprocess or delete a run](/docs/browse-runs#reprocess-and-delete).\n\nProcessing one file has a 15-minute limit. A file that hits the limit, or is too large to process at all, shows **Failed** with a message that explains why. The raw file stays in storage, and you can download it.\n\nA file that stays in processing for more than 20 minutes without finishing shows **Stalled**. That usually means the processor stopped without reporting back. Reprocess it to try again.\n\nReprocessing is also the fix after an engineer corrects a parser: the code changed, but the stored result didn’t, until you ask for it again.\n\n## Add support for a new instrument [#add-support-for-a-new-instrument]\n\n**For:** Engineers\n\nAdding an instrument type is a development task spanning the web app and the Lambda, not a configuration change an admin can make in the dashboard. Budget for a database migration and a Lambda image deploy.\n\n\n \n **Add the type.** Extend `instrumentTypeEnum` in `web/lib/db/schema.ts` and generate an `ALTER TYPE … ADD VALUE` migration. Reuse an existing type only when the new instrument writes the same vendor’s format.\n \n\n \n **Write the processor.** Add a module under `lambda/src/data_hub_lambda/` exposing `process_file(instrument_id, run_id, filename)`, which reports progress through the Data Hub API.\n \n\n \n **Register it twice.** Add the type to `PROCESSORS` in `lambda/src/data_hub_lambda/processors.py`, with its filename gate, and to `PROCESSABLE_INSTRUMENT_TYPES` in `web/lib/instruments/processable-types.ts`. These two lists have to agree: the Lambda uses the first to dispatch, and the web app uses the second to decide whether to offer **Reprocess**.\n \n\n \n **Add tests** for the processor and for the registry gate.\n \n\n \n **Deploy the Lambda image.** No new S3 trigger is needed, since the raw bucket already notifies on every upload.\n \n\n\nYou can exercise a processor locally without S3 or API access using the `data-hub-process` CLI, and drive the whole handler against a local S3 mirror with its `handler` subcommand.\n\nFull detail, including the archive builder and the Docker build, is in [the Lambda developer docs](https://github.com/Arcadia-Science/data-hub/blob/production/docs/developer/lambda.md#adding-a-new-instrument--processor).\n"
+ "markdown": "# Instrument data preprocessing (/docs/instrument-preprocessing)\n\n\n\n\n\n\n\n\n\n**For:** Lab operators, admins, and engineers\n\nAfter a [watcher](/docs/concepts#watcher) uploads a raw file, Data Hub can run a **preprocessor** on it: code that reads the vendor’s file, pulls out settings like wavelength or imaging mode, and builds something you can look at in the browser. Nine instrument types have one today. This page covers what each produces, why a file sometimes gets no processing, and what adding a new instrument involves.\n\n\n Support for a new instrument isn’t a settings change. It takes a new Python module, a database migration, an edit in two repositories, and a Lambda redeploy. See [Add support for a new instrument](#add-support-for-a-new-instrument).\n\n\n## How a file reaches a processor [#how-preprocessing-works]\n\n1. A file arrives in raw storage, which triggers the Lambda. The bucket notifies on every upload, with no per-instrument filtering.\n2. The Lambda checks the filename against a **filename gate**, a cheap test that skips files no processor wants before making any API call.\n3. It reads the instrument, then looks up a processor by the instrument’s [type](#set-the-instrument-type). Unmapped types stop here.\n4. The processor reads the file, extracts metadata, writes processed files when it has any, and updates the run through the API.\n5. The file’s status moves from `processing` to `completed` or `failed`.\n\n### Why an upload sometimes produces nothing [#why-an-upload-produces-nothing]\n\nTwo silent no-ops explain a file that uploaded and then produced nothing. Neither marks the file failed, because nothing went wrong:\n\n* **The filename missed the gate.** Each type accepts a narrow pattern, listed in the table below. A TapeStation run only triggers on its PDFs, so the peak-table CSVs it writes are stored but don’t start processing on their own.\n* **The instrument has no type set, or a type with no processor.** Files upload and stay browsable; you get no metadata and no reports.\n\nFilename gates apply to uploads only. Choosing **Reprocess** in the web app skips the gate, because clicking it states your intent. That’s why a file that produced nothing on upload can still process when you reprocess it by hand.\n\n### Set the instrument type [#set-the-instrument-type]\n\nProcessing is chosen by the instrument’s **type**, not by its instrument ID. The ID (`azure-cielo-qpcr`) names one physical machine. The type (`qpcr`) names a vendor’s file format, and several instruments can share one.\n\nTo set it, open the instrument page as an admin, choose **Edit details** from the three-dot menu, and pick an **Instrument type**.\n\n
\n\n\n One type means one vendor’s output format. Names like `qpcr` and `fplc` read as generic, but each is bound to a single vendor’s parser: `fplc` expects ÄKTA PDFs, `qpcr` expects Azure Cielo exports. Typing a different vendor’s instrument into an existing type feeds files to a parser that can’t read them. Supporting a second vendor means splitting the type, which is a code change.\n\n\n## Supported instrument types [#supported-instruments-at-a-glance]\n\n| Type | Vendor and model | Gate for uploads | You get |\n| -------------------- | --------------------------- | --------------------------- | --------------------------------------------------------------- |\n| `tape_station` | Agilent 4150 TapeStation | `.pdf` | Tape-type badge, PDF reports |\n| `fplc` | Cytiva ÄKTA FPLC | `.pdf` | PDF you can read on the run page, no metadata |\n| `gel_doc` | Azure 600 Gel Doc | `.tif`, `.tiff` | Contrast-enhanced PNG, imaging badges |\n| `qpcr` | Azure Cielo qPCR | `.csv`, `.pdf`, `.aze` | Dye-channel badges, melting curves, the instrument’s PDF report |\n| `epson_v700_scanner` | Epson V700 Scanner | `.tif`, `.tiff` | Plate preview JPG, colony measurements |\n| `hina_microscope` | Hina Microscope (Nikon ND2) | `.nd2` | Channel overlay JPG, channel badges |\n| `plate_reader` | SpectraMax iD3 and iD5 | `.xls` | Well-data CSV, interactive plate maps |\n| `dishcam` | DishCam time-lapse camera | `.tif`, `.tiff`, `run.json` | A video of each photo stack, camera-setting badges |\n| `aunty` | Unchained Labs Aunty | `.xlsx` | A plate with a curve chart in each well, experiment badges |\n\n## Agilent 4150 TapeStation [#agilent-4150-tapestation]\n\nReads the tape type out of peak-table CSV filenames and shows TapeStation reports on the run page.\n\n* **Expected files**: CSVs named like `YYYY-MM-DD - HH-MM-SS-_peakTable.csv`, plus matching PDFs\n* **Metadata**: `Tape Type`, for example `gDNA` or `HSD1000`, when the filename matches that pattern\n* **Processed files**: none\n* **On the run page**: tape-type badge, a PDF viewer, and peak-table reports\n\nUploads trigger on the PDF, not the CSV. A run of CSVs alone stays unprocessed until you reprocess it by hand.\n\n## Cytiva ÄKTA FPLC [#akta-fplc]\n\nStores ÄKTA chromatography PDFs so you can read them in the browser. The processor reads nothing out of the PDF itself yet, so this type gets no badges or filters.\n\n* **Expected files**: `.pdf` reports from the ÄKTA\n* **Metadata**: none\n* **Processed files**: none\n* **On the run page**: files table, plus each PDF embedded under Report Data\n\nA newly seeded FPLC stays `generic` on purpose until an operator has checked its output format.\n\n## Azure 600 Gel Doc [#azure-600-gel-doc]\n\nRebuilds each gel TIFF as a viewable image and reads the imaging settings the instrument recorded.\n\nThe processor stretches intensities to the 1st to 99th percentile, per RGB channel or as grayscale, then lays every page of the TIFF side by side in one 300 dpi PNG. The stretch is cosmetic: it makes faint bands visible on screen and doesn’t change the raw file.\n\n* **Expected files**: TIFF from the Azure 600\n* **Metadata**: `capture_type`, `imaging_mode` (true color, fluorescence, or chemiluminescence), `wavelengths`, `colors`, read from the TIFF `XPComment` tag\n* **Processed files**: contrast-enhanced PNG\n* **On the run page**: imaging badges, files table, image viewer for the PNGs\n\nThose metadata fields also drive the column filters on the instrument page.\n\n## Azure Cielo qPCR [#azure-cielo-qpcr]\n\nReads dye channel names, melting curves, and the instrument’s PDF report.\n\n* **Expected files**:\n * the Cq Values CSV, for dye channels\n * the melting curve CSV (a name ending in `_MeltingCurve.csv`)\n * the PDF report the instrument writes\n * `.AZE` project files\n* **Metadata**: `dye_channels`, the unique names in the Cq Values CSV’s `Fluorescence` column\n* **Processed files**: `{run_id}_melting_curve_derivatives.csv` and `{run_id}_melting_curve_plate.json` from the melting curve CSV. An `.AZE` file produces `{run_id}_aze_melting_curve_derivatives.csv`, `{run_id}_aze_melting_curve_plate.json`, and `{run_id}_aze_experiment.json`.\n* **On the run page**: dye-channel badges, the PDF report under **Report Data**, and a **Melting Curves** section\n\nThe **Melting Curves** section shows the plate for one dye channel at a time. Click a well to see its melting curve and derivative, with a dashed line at the melting temperature. A run with more than one PDF gets previous and next buttons under **Report Data**.\n\n
\n\nThe other CSV files a qPCR run writes upload normally and show as completed, but contribute no metadata.\n\n### `.AZE` project files [#aze-project-files]\n\nData Hub reads melting curves from the Azure Cielo’s native `.AZE` project files as well as from the CSV export. Melting temperatures match the CSV export, but other values can differ. That’s because `.AZE` files store raw fluorescence, while the CSV export can be adjusted for baseline. A project with no melt data completes with no processed files.\n\nThe watcher uploads `.AZE` files only when `*.AZE` is in the instrument’s file patterns. File patterns live in the watcher’s config on the instrument PC, so add it there with `data-hub-watcher config edit` and restart the watcher. Patterns are case-sensitive on some systems, so match the case the instrument uses. See [Watcher configuration](/docs/configuration-reference#field-reference).\n\n## Epson V700 Scanner [#epson-v700-scanner]\n\nFinds agar plates in a flatbed scan and measures the colonies on them.\n\nThe processor looks for plates sitting in gold 3D-printed frames. For each plate it finds, it crops the plate, then detects colonies: crop the edge, compare against the plate background, apply a difference-of-Gaussians filter, threshold, and measure whatever survives. Measurements come out in physical units, so colony areas are comparable between scans.\n\n* **Expected files**: TIFF plate scans\n* **Metadata**: `dpi`, `color_mode`, `plate_count`, `plate_boxes`, plus `colony_detection` summaries when colonies were measured\n* **Processed files**: a web-sized JPG with plate outlines and colony boxes drawn on, and a `_colonies.csv` of per-colony area, centroid, eccentricity, and mean RGB\n* **On the run page**: scanner badges, files table, and reports for the processed CSV\n\nWhen no plates are found, you still get a resized JPG of the whole scan, and colony detection is skipped. An empty `plate_count` usually means the frames weren’t recognized rather than that the plates were blank.\n\n## Hina Microscope [#hina-microscope]\n\nTurns each Nikon ND2 into a single composite image you can view in the browser.\n\nEvery channel is reduced to one 2D frame (a maximum projection over Z, the first index over time or position), stretched to the 1st to 99th percentile, then composited onto a brightfield background in each channel’s fluorophore color. The processor reads one frame at a time, so large stacks process without loading the whole file.\n\n* **Expected files**: ND2\n* **Metadata**: `sizes`, `channels` (name, excitation and emission wavelength, color), and `dimensions`\n* **Processed files**: an RGB overlay JPG for every file in the run\n* **On the run page**: channel and size badges, files table, image viewer for the JPGs\n\nChannel layout is identical across a Hina run, so metadata is written once by whichever file finishes processing first. Later files still get their JPG and skip the metadata write. Reprocessing one file therefore won’t correct run metadata if the layout changed mid-run.\n\nAn image too large to preview at full size gets a smaller preview instead. For example, a 6221 × 6221 pixel image is previewed at 3110 × 3110 pixels. The raw file is unchanged, and you can download it at full size. A file that showed **Failed** for this reason earlier gets its preview when you choose **Reprocess** on the file or on the run.\n\nTwo kinds of image still show **Failed** instead of a preview: an image from a color camera, which Data Hub can’t preview yet, and an image far larger than the Hina Microscope has produced so far. The message on the file says which. In both cases the raw file is stored and you can download it.\n\n## SpectraMax iD3 and iD5 plate readers [#spectramax-id3-and-id5-plate-readers]\n\nBoth models share one processor, which converts SoftMax Pro exports into the well data behind the interactive plate maps.\n\n* **Expected files**: SoftMax `.xls`, which is UTF-16 text with a `Plate:` header rather than a real Excel workbook\n* **Metadata**: `measurement_mode` (Absorbance, Fluorescence, or Luminescence), `measurement_type` (Endpoint, Kinetic, Spectrum, or Well Scan), and `wavelengths` as bare numbers with no unit suffix\n* **Processed files**: `{run_id}_raw_well_data.csv`\n* **On the run page**: wavelength, mode, and type badges, plus plate maps built from that CSV\n\nWhat the plate map shows depends on the read:\n\n* **Kinetic** reads get a time slider. Reads that skip the edge wells keep their time values, and runs that SoftMax Pro stopped early still process.\n* **Spectrum** reads (wavelength scans) get a wavelength slider.\n* **Luminescence** reads show no wavelength, because luminescence doesn’t use one.\n\nSoftMax Pro writes `#SAT` in a saturated well and `#Low` when the flash lamp missed a well. Those wells show as blank on the plate map, and the rest of the file processes normally.\n\n## DishCam [#dishcam]\n\nTurns each DishCam photo stack into a video you can play on the run page.\n\n* **Expected files**: multi-page TIFF photo stacks, plus the `run.json` file the camera writes next to them\n* **Metadata**: camera settings from `run.json`: `fps`, `measured_fps`, `frames`, `duration_seconds`, `quality`, `format`, `started`, and `finished`\n* **Processed files**: an MP4 video and a JPEG still frame for each stack\n* **On the run page**: a video player at the top of the report, with play, pause, and a timeline you can drag. The still frame shows before you press play.\n\nThe video plays at the capture rate or at 10 photos per second, whichever is faster, so slow captures stay watchable. The stored `fps` metadata still records the real capture rate.\n\nA stack processes once both the stack and its `run.json` are in storage, in either order. When a run gathers captures from several folders, each stack uses the `run.json` from its own folder. Watcher 1.1.0 and later keep every folder’s `run.json`, renaming later copies with a short tag such as `run~3f9a1c2b.json`. See [Roll out watcher releases](/docs/watcher-releases#what-each-version-adds).\n\nLarge stacks are read one photo at a time. If a stack can’t finish within the [15-minute processing limit](#when-processing-fails), it shows **Failed**, and the message says how far through the photos it got. You can still download the raw file.\n\n## Unchained Labs Aunty [#unchained-labs-aunty]\n\nReads Aunty exports, which measure protein melting temperature and particle size, and draws a plate of curves.\n\n* **Expected files**: the `.xlsx` export from the Aunty software. Thermal ramps, sizing runs, isothermal holds, and exports that contain only the results table without curves all process.\n* **Metadata**: `experiment_type` (`thermal_ramp`, `sizing`, or `isothermal`), `experiment_count`, and the first experiment’s settings: `analysis_mode`, `start_temp_c`, `end_temp_c`, `rate_c_per_min`, `temperature_c`, `analysis_version`, and `experiment_name`\n* **Processed files**: `{run_id}_aunty_curves.csv`, with every curve point for every well, and `{run_id}_aunty_plate.json`, which drives the plate view\n* **On the run page**: a plate with a small chart in each well. Click a well to open its full chart.\n\n
\n\nOn the instrument page, filter Aunty runs by experiment type, analysis mode, temperature, and ramp rate.\n\n## Types with no processor [#instruments-without-a-matching-processor]\n\n`generic` and `instant_raman` have no processor, and neither does an instrument whose type is unset. Those uploads still work: files are stored, and you can browse, search, claim, comment on, and download them. What you don’t get is extracted metadata, badges, filters, or processed artifacts.\n\nLeaving a new instrument as `generic` is the safe default. It keeps data flowing while someone confirms which processor, if any, can read its files.\n\n## When processing fails [#when-processing-fails]\n\nA file with status `failed` carries an error message on the run page. Open the run, then choose **Reprocess** on that file, or select runs in a run list and choose **Reprocess**. See [Reprocess or delete a run](/docs/browse-runs#reprocess-and-delete).\n\nProcessing one file has a 15-minute limit. A file that hits the limit, or is too large to process at all, shows **Failed** with a message that explains why. The raw file stays in storage, and you can download it.\n\nA file that stays in processing for more than 20 minutes without finishing shows **Stalled**. That usually means the processor stopped without reporting back. Reprocess it to try again.\n\nReprocessing is also the fix after an engineer corrects a parser: the code changed, but the stored result didn’t, until you ask for it again.\n\n## Add support for a new instrument [#add-support-for-a-new-instrument]\n\n**For:** Engineers\n\nAdding an instrument type is a development task spanning the web app and the Lambda, not a configuration change an admin can make in the dashboard. Budget for a database migration and a Lambda image deploy.\n\n\n \n **Add the type.** Extend `instrumentTypeEnum` in `web/lib/db/schema.ts` and generate an `ALTER TYPE … ADD VALUE` migration. Reuse an existing type only when the new instrument writes the same vendor’s format.\n \n\n \n **Write the processor.** Add a module under `lambda/src/data_hub_lambda/` exposing `process_file(instrument_id, run_id, filename)`, which reports progress through the Data Hub API.\n \n\n \n **Register it twice.** Add the type to `PROCESSORS` in `lambda/src/data_hub_lambda/processors.py`, with its filename gate, and to `PROCESSABLE_INSTRUMENT_TYPES` in `web/lib/instruments/processable-types.ts`. These two lists have to agree: the Lambda uses the first to dispatch, and the web app uses the second to decide whether to offer **Reprocess**.\n \n\n \n **Add tests** for the processor and for the registry gate.\n \n\n \n **Deploy the Lambda image.** No new S3 trigger is needed, since the raw bucket already notifies on every upload.\n \n\n\nYou can exercise a processor locally without S3 or API access using the `data-hub-process` CLI, and drive the whole handler against a local S3 mirror with its `handler` subcommand.\n\nFull detail, including the archive builder and the Docker build, is in [the Lambda developer docs](https://github.com/Arcadia-Science/data-hub/blob/production/docs/developer/lambda.md#adding-a-new-instrument--processor).\n"
},
{
"page": "configure-notifications",
"title": "Configure notifications",
"description": "Choose in-app and Slack notifications for new runs, comments, service messages, and feedback, including per-instrument subscriptions and personal Slack direct messages.",
- "markdown": "# Configure notifications (/docs/configure-notifications)\n\n\n\n\n\n**For:** Everyone\n\nData Hub can notify you in the app and in Slack when new runs arrive, when someone comments on a run you care about, when a script sends you a message, and when feedback you sent is closed. Open **Settings → Notifications** while signed in. Everything on that page is a personal choice that affects only you.\n\n
\n\nConnections that the whole workspace shares, such as the Slack channel for new runs and the Slack app behind direct messages, are under **Settings → Integrations** and only admins can change them. See [Set up integrations](/docs/integrations).\n\nIn-app and Slack preferences are independent: you can turn on either channel for a type without turning on the other.\n\n## In-app preferences [#in-app-preferences]\n\n| Setting | Effect |\n| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| **Mute all instrument notifications** | Suppresses new-run notifications for every instrument. Per-instrument choices are preserved and resume when you unmute. |\n| **Comments on runs you ran** | Notify when someone comments on a run you are attributed to. |\n| **Replies to your comments** | Notify when someone comments on a run you previously commented on. |\n| **Service notifications** | Show messages that integrations and scripts send you through the API. |\n| **Updates on your feedback** | Notify when a [feedback report](/docs/send-feedback) you sent is resolved or declined. |\n| **New feedback** | Admins only. Notify when someone sends feedback about Data Hub. |\n\n### Per-instrument subscriptions [#per-instrument-subscriptions]\n\nUnder **Per-instrument**, toggle the instruments you want new-run notifications for. Rows are disabled while **Mute all** is on. You can also subscribe from the notifications switch on an instrument’s page, or from the instruments table’s notifications column when that column is shown.\n\n## The notification bell [#notification-bell]\n\nThe bell icon in the app lists recent notifications:\n\n* New runs are grouped by day and instrument, and each group expands to show its runs.\n* Clicking a comment notification opens the run and scrolls to that comment.\n* Messages sent by a script through an API token show the token’s name with a key icon.\n\n## Slack direct messages [#slack-direct-messages]\n\nConnect your Slack account on the same settings page to receive matching events as personal direct messages. After connecting, choose which types also go to Slack:\n\n* New instrument runs (respects your instrument subscriptions and mute)\n* Comments on runs you ran\n* Replies to your comments\n* Service notifications\n* Updates on your feedback\n* New feedback (admins only)\n\nDisconnect or reconnect from the same card. You have to connect with your organization’s Slack workspace; Data Hub rejects any other workspace.\n\nIf **Connect to Slack** is missing or doesn’t finish, an admin hasn’t set up the Slack app yet. See [Slack app for direct messages](/docs/integrations#slack-app).\n\n## Workspace Slack channel [#workspace-slack-channel-admins]\n\nThe shared Slack channel that gets a message whenever a new run is created is set by admins under **Settings → Integrations**. See [Slack channel for new runs](/docs/integrations#slack-channel).\n\n## Set up the Slack app for personal DMs [#set-up-the-slack-app-for-personal-dms]\n\nThe Slack app setup, including scopes, redirect URLs, and where to enter its credentials, is in [Slack app for direct messages](/docs/integrations#slack-app).\n"
+ "markdown": "# Configure notifications (/docs/configure-notifications)\n\n\n\n\n\n\n\n**For:** Everyone\n\nData Hub can notify you in the app and in Slack when new runs arrive, when someone comments on a run you care about, when a script sends you a message, and when feedback you sent is closed. Open **Settings → Notifications** while signed in. Everything on that page is a personal choice that affects only you.\n\n
\n\nConnections that the whole workspace shares, such as the Slack channel for new runs and the Slack app behind direct messages, are under **Settings → Integrations** and only admins can change them. See [Set up integrations](/docs/integrations).\n\nIn-app and Slack preferences are independent: you can turn on either channel for a type without turning on the other.\n\n## In-app preferences [#in-app-preferences]\n\n| Setting | Effect |\n| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |\n| **Mute all instrument notifications** | Suppresses new-run notifications for every instrument. Per-instrument choices are preserved and resume when you unmute. |\n| **Comments on runs you ran** | Notify when someone comments on a run you are attributed to. |\n| **Replies to your comments** | Notify when someone comments on a run you previously commented on. |\n| **Service notifications** | Show messages that integrations and scripts send you through the API. |\n| **Updates on your feedback** | Notify when a [feedback report](/docs/send-feedback) you sent is resolved or declined. |\n| **New feedback** | Admins only. Notify when someone sends feedback about Data Hub. |\n\n### Per-instrument subscriptions [#per-instrument-subscriptions]\n\nUnder **Per-instrument**, toggle the instruments you want new-run notifications for. Rows are disabled while **Mute all** is on. You can also subscribe from the notifications switch on an instrument’s page, or from the instruments table’s notifications column when that column is shown.\n\n## The notification bell [#notification-bell]\n\nThe bell icon in the app lists recent notifications:\n\n* New runs are grouped by day and instrument, and each group expands to show its runs.\n* Clicking a comment notification opens the run and scrolls to that comment.\n* Messages sent by a script through an API token show the token’s name with a key icon.\n\n
\n\n## Slack direct messages [#slack-direct-messages]\n\nConnect your Slack account on the same settings page to receive matching events as personal direct messages. After connecting, choose which types also go to Slack:\n\n* New instrument runs (respects your instrument subscriptions and mute)\n* Comments on runs you ran\n* Replies to your comments\n* Service notifications\n* Updates on your feedback\n* New feedback (admins only)\n\nDisconnect or reconnect from the same card. You have to connect with your organization’s Slack workspace; Data Hub rejects any other workspace.\n\nIf **Connect to Slack** is missing or doesn’t finish, an admin hasn’t set up the Slack app yet. See [Slack app for direct messages](/docs/integrations#slack-app).\n\n## Workspace Slack channel [#workspace-slack-channel-admins]\n\nThe shared Slack channel that gets a message whenever a new run is created is set by admins under **Settings → Integrations**. See [Slack channel for new runs](/docs/integrations#slack-channel).\n\n## Set up the Slack app for personal DMs [#set-up-the-slack-app-for-personal-dms]\n\nThe Slack app setup, including scopes, redirect URLs, and where to enter its credentials, is in [Slack app for direct messages](/docs/integrations#slack-app).\n"
},
{
"page": "send-feedback",
"title": "Send feedback and see what’s new",
"description": "Report a bug or request a feature about Data Hub from the app or an AI assistant, follow the report’s status, and read the in-app changelog.",
- "markdown": "# Send feedback and see what’s new (/docs/send-feedback)\n\n\n\n**For:** Everyone\n\nUse **Feedback** to tell the team that runs Data Hub about a bug, a missing feature, or anything else about the app itself. Each report becomes an issue in your workspace’s Linear, and Data Hub tells you when that issue is closed.\n\nFeedback is for problems with Data Hub. A problem with one run’s data, such as a wrong plate map, belongs in a comment on that run, where the people who ran it will see it.\n\n## Send a report [#send-a-report]\n\n\n \n Open the account menu at the bottom of the sidebar and choose **Feedback**.\n \n\n \n Pick a **Type**: **Bug**, **Feature request**, or **Other**.\n \n\n \n Enter a **Title** and a **Description**. Say what you expected and what happened instead.\n \n\n \n Optionally open **More details** and fill in **Trying to do**, **Tool**, and **Error message**.\n \n\n \n Choose **Send feedback**.\n \n\n\nData Hub also records the page you were on, so you don’t need to paste a link. If you send a report with the same title within 24 hours, Data Hub returns the report you already sent instead of creating a second one, unless that report has been closed.\n\n\n The **Feedback** item appears only after an admin connects Data Hub to Linear. If you don’t see it, ask an admin to follow [Set up integrations](/docs/integrations#linear).\n\n\n## What happens to a report [#what-happens-to-a-report]\n\n* Data Hub creates an issue in the Linear team your admins chose, with the label they picked for that type of report, if any.\n* The team works on the issue in Linear.\n* When the issue closes, Data Hub sends you a notification, and a Slack message if you turned that on.\n\nA report’s status follows its Linear issue:\n\n| Linear issue | Report status |\n| ---------------------------------- | ------------- |\n| Completed, such as **Done** | Resolved |\n| Canceled, or closed as a duplicate | Declined |\n| Anything else | Open |\n\nThe notification names the Linear status too, such as **Resolved (Done)** or **Declined (Duplicate)**. Turn it on or off with **Updates on your feedback** under **Settings → Notifications**; see [Configure notifications](/docs/configure-notifications#in-app-preferences).\n\nYou hear about a report once each time it closes. Moving the issue from one closed status to another doesn’t notify you again, but reopening and closing it does.\n\n## Send feedback from an AI assistant [#from-an-ai-assistant]\n\nAI assistants connected to Data Hub over [MCP](/docs/mcp) can send feedback for you, for example when a tool call fails. The assistant shows you the draft first and sends it only after you agree. It can also list the reports you sent and read one in full, by report ID or by Linear issue ID such as `ENG-1476`. The tools are listed under [MCP tools](/docs/mcp/tools).\n\nAPI tokens can’t send feedback, because every report needs a person behind it. Scripts that call `POST /api/v1/feedback` have to use a signed-in session.\n\n## Who sees which reports [#who-sees-which-reports]\n\nMembers see only the reports they sent. They can list them through the API or an AI assistant, without the Linear assignee, priority, or activity.\n\nAdmins review every report under **Settings → Feedback**:\n\n* **Open** and **Closed** are separate tabs.\n* Each row shows the Linear issue ID, status, label, priority, and assignee.\n* A report’s detail includes the Linear status changes and comments, and an **Open in Linear** link.\n\nStatus changes happen in Linear. Data Hub has no control for changing a report’s status. Admins can also turn on **New feedback** under **Settings → Notifications** to hear when someone sends a report.\n\n## See what’s new in Data Hub [#changelog]\n\nChoose **Changelog** in the account menu to see what has changed in Data Hub, newest first. It opens on top of the page you’re on. A dot on your account button means there’s an entry you haven’t opened yet. Many entries link to the page on this site that covers the change.\n"
+ "markdown": "# Send feedback and see what’s new (/docs/send-feedback)\n\n\n\n\n\n\n\n\n\n**For:** Everyone\n\nUse **Feedback** to tell the team that runs Data Hub about a bug, a missing feature, or anything else about the app itself. Each report becomes an issue in your workspace’s Linear, and Data Hub tells you when that issue is closed.\n\nFeedback is for problems with Data Hub. A problem with one run’s data, such as a wrong plate map, belongs in a comment on that run, where the people who ran it will see it.\n\n## Send a report [#send-a-report]\n\n\n \n Open the account menu at the bottom of the sidebar and choose **Feedback**.\n \n\n \n Pick a **Type**: **Bug**, **Feature request**, or **Other**.\n \n\n \n Enter a **Title** and a **Description**. Say what you expected and what happened instead.\n \n\n \n Optionally open **More details** and fill in **Trying to do**, **Tool**, and **Error message**.\n \n\n \n Choose **Send feedback**.\n \n\n\n
\n\nData Hub also records the page you were on, so you don’t need to paste a link. If you send a report with the same title within 24 hours, Data Hub returns the report you already sent instead of creating a second one, unless that report has been closed.\n\n\n The **Feedback** item appears only after an admin connects Data Hub to Linear. If you don’t see it, ask an admin to follow [Set up integrations](/docs/integrations#linear).\n\n\n## What happens to a report [#what-happens-to-a-report]\n\n* Data Hub creates an issue in the Linear team your admins chose, with the label they picked for that type of report, if any.\n* The team works on the issue in Linear.\n* When the issue closes, Data Hub sends you a notification, and a Slack message if you turned that on.\n\nA report’s status follows its Linear issue:\n\n| Linear issue | Report status |\n| ---------------------------------- | ------------- |\n| Completed, such as **Done** | Resolved |\n| Canceled, or closed as a duplicate | Declined |\n| Anything else | Open |\n\nThe notification names the Linear status too, such as **Resolved (Done)** or **Declined (Duplicate)**. Turn it on or off with **Updates on your feedback** under **Settings → Notifications**; see [Configure notifications](/docs/configure-notifications#in-app-preferences).\n\nYou hear about a report once each time it closes. Moving the issue from one closed status to another doesn’t notify you again, but reopening and closing it does.\n\n## Send feedback from an AI assistant [#from-an-ai-assistant]\n\nAI assistants connected to Data Hub over [MCP](/docs/mcp) can send feedback for you, for example when a tool call fails. The assistant shows you the draft first and sends it only after you agree. It can also list the reports you sent and read one in full, by report ID or by Linear issue ID such as `ENG-1476`. The tools are listed under [MCP tools](/docs/mcp/tools).\n\nAPI tokens can’t send feedback, because every report needs a person behind it. Scripts that call `POST /api/v1/feedback` have to use a signed-in session.\n\n## Who sees which reports [#who-sees-which-reports]\n\nMembers see only the reports they sent. They can list them through the API or an AI assistant, without the Linear assignee, priority, or activity.\n\nAdmins review every report under **Settings → Feedback**:\n\n* **Open** and **Closed** are separate tabs.\n* Each row shows the Linear issue ID, status, label, priority, and assignee.\n* A report’s detail includes the Linear status changes and comments, and an **Open in Linear** link.\n\n
\n\nStatus changes happen in Linear. Data Hub has no control for changing a report’s status. Admins can also turn on **New feedback** under **Settings → Notifications** to hear when someone sends a report.\n\n## See what’s new in Data Hub [#changelog]\n\nChoose **Changelog** in the account menu to see what has changed in Data Hub, newest first. It opens on top of the page you’re on. A dot on your account button means there’s an entry you haven’t opened yet. Many entries link to the page on this site that covers the change.\n\n
\n"
},
{
"page": "install-the-watcher",
@@ -64,7 +64,7 @@
"page": "manage-watchers",
"title": "Manage the watcher fleet",
"description": "Monitor watcher health, deregister watchers, and approve or request uploads from the admin dashboard.",
- "markdown": "# Manage the watcher fleet (/docs/manage-watchers)\n\n\n\n**For:** Administrators\n\nThe **Watchers** area is the fleet view: every registered watcher, its health, deregistration, and the upload queue. Cutting releases and advertising versions is separate: [Roll out watcher releases](/docs/watcher-releases).\n\n## Monitor watcher health [#monitor-watcher-health]\n\nEach watcher sends a [heartbeat](/docs/concepts#heartbeat) every 60 seconds with version, instrument, watch directory, upload mode, activity counters, and uptime. The fleet list surfaces:\n\n* **Status**: **Online**, **Unresponsive** (no heartbeat for more than 5 minutes), **Stopped** (shut down cleanly), or **Registered** (no first heartbeat yet). See [Watcher status](/docs/concepts#watcher-status).\n* **Last heartbeat**: a stale value is the first sign of a stopped, disconnected, or crashed watcher\n* **Hostname and instrument assignment**\n* **Events**: per-watcher log (`watcher_started`, `run_reported`, `file_uploaded`, `update_*`, and `error` with a `details.kind` discriminator)\n\nEvents are the primary remote-diagnosis tool. For operator-side symptoms, see [Troubleshoot a watcher](/docs/troubleshoot-a-watcher).\n\n## Deregister a watcher [#deregister-a-watcher]\n\nEach instrument can have at most one active watcher. Deregister the old one when a PC is reimaged or replaced, or to clear “instrument already has an active watcher” on a new install.\n\n* **Web app**: open the watcher under **Watchers** and click **Deregister**, or choose **Deregister** from the watcher’s row menu\n* **API**: `DELETE /api/v1/watchers/:watcherId` (requires `watchers:admin`)\n\nDeregistration is a soft-delete: heartbeats, events, and runs stay under **Watchers → Deregistered**. A watcher removed through the API with a token shows that token’s name as the one that removed it.\n\nRetiring an instrument also deregisters its watchers automatically; see [Retire an instrument](/docs/retire-an-instrument).\n\nYou don’t need to deregister a watcher just because its token was revoked. Re-running `data-hub-watcher init` with a new token takes over the existing registration; see [After revoking a token](/docs/manage-tokens#after-revoking-a-token).\n\n## Request uploads [#request-uploads]\n\n**Upload** on a run page, or on selected runs in a run list, asks the watcher to send files that are still on the instrument PC. The watcher polls the server’s upload queue once a minute and uploads the files that were requested.\n\n* In **manual** upload mode, every file waits in this queue until someone requests it.\n* In **auto** upload mode, files upload on their own, and the queue covers files that need another try. This works with watcher 1.2.1 and later; older watchers in auto mode never pick up requests, so the file shows **Uploading** and stays that way.\n\n## Approving manual uploads [#approving-manual-uploads]\n\nFor instruments in [manual upload mode](/docs/concepts#upload-mode), the watcher reports runs but waits for server approval before uploading file bytes. Approve a file with **Upload**, or choose **Dismiss** to tell the watcher not to upload it.\n\nSafeguards that keep the queue healthy:\n\n* Changing an instrument’s `watch_directory` reverts pending upload requests to `detected` so stale paths drain instead of erroring\n* A queued file that keeps failing (missing on disk or failing to upload) is retried on at most three polls, then cancelled server-side\n"
+ "markdown": "# Manage the watcher fleet (/docs/manage-watchers)\n\n\n\n\n\n\n\n\n\n**For:** Administrators\n\nThe **Watchers** area is the fleet view: every registered watcher, its health, deregistration, and the upload queue. Cutting releases and advertising versions is separate: [Roll out watcher releases](/docs/watcher-releases).\n\n## Monitor watcher health [#monitor-watcher-health]\n\nEach watcher sends a [heartbeat](/docs/concepts#heartbeat) every 60 seconds with version, instrument, watch directory, upload mode, activity counters, and uptime. The fleet list surfaces:\n\n* **Status**: **Online**, **Unresponsive** (no heartbeat for more than 5 minutes), **Stopped** (shut down cleanly), or **Registered** (no first heartbeat yet). See [Watcher status](/docs/concepts#watcher-status).\n* **Last heartbeat**: a stale value is the first sign of a stopped, disconnected, or crashed watcher\n* **Hostname and instrument assignment**\n* **Events**: per-watcher log (`watcher_started`, `run_reported`, `file_uploaded`, `update_*`, and `error` with a `details.kind` discriminator)\n\n
\n\nEvents are the primary remote-diagnosis tool. For operator-side symptoms, see [Troubleshoot a watcher](/docs/troubleshoot-a-watcher).\n\nOpen a watcher to see its event log, a status chart with a connectivity strip of recent heartbeats, and its configuration.\n\n
\n\n## Deregister a watcher [#deregister-a-watcher]\n\nEach instrument can have at most one active watcher. Deregister the old one when a PC is reimaged or replaced, or to clear “instrument already has an active watcher” on a new install.\n\n* **Web app**: open the watcher under **Watchers** and click **Deregister**, or choose **Deregister** from the watcher’s row menu\n* **API**: `DELETE /api/v1/watchers/:watcherId` (requires `watchers:admin`)\n\nDeregistration is a soft-delete: heartbeats, events, and runs stay under **Watchers → Deregistered**. A watcher removed through the API with a token shows that token’s name as the one that removed it.\n\nRetiring an instrument also deregisters its watchers automatically; see [Retire an instrument](/docs/retire-an-instrument).\n\nYou don’t need to deregister a watcher just because its token was revoked. Re-running `data-hub-watcher init` with a new token takes over the existing registration; see [After revoking a token](/docs/manage-tokens#after-revoking-a-token).\n\n## Request uploads [#request-uploads]\n\n**Upload** on a run page, or on selected runs in a run list, asks the watcher to send files that are still on the instrument PC. The watcher polls the server’s upload queue once a minute and uploads the files that were requested.\n\n* In **manual** upload mode, every file waits in this queue until someone requests it.\n* In **auto** upload mode, files upload on their own, and the queue covers files that need another try. This works with watcher 1.2.1 and later; older watchers in auto mode never pick up requests, so the file shows **Uploading** and stays that way.\n\n## Approving manual uploads [#approving-manual-uploads]\n\nFor instruments in [manual upload mode](/docs/concepts#upload-mode), the watcher reports runs but waits for server approval before uploading file bytes. Approve a file with **Upload**, or choose **Dismiss** to tell the watcher not to upload it. To act on several files at once, select them in the files table and use the bar that appears.\n\n
\n\nSafeguards that keep the queue healthy:\n\n* Changing an instrument’s `watch_directory` reverts pending upload requests to `detected` so stale paths drain instead of erroring\n* A queued file that keeps failing (missing on disk or failing to upload) is retried on at most three polls, then cancelled server-side\n"
},
{
"page": "watcher-releases",
@@ -88,19 +88,19 @@
"page": "manage-instruments",
"title": "Manage instruments",
"description": "Confirm pending instruments, read an instrument’s watcher status, watched folder, and file patterns, and edit its name and type.",
- "markdown": "# Manage instruments (/docs/manage-instruments)\n\n\n\n\n\n**For:** Administrators\n\nWhen a lab operator registers an instrument from the watcher, it appears as `pending` and cannot upload until you confirm it. Operator-side setup is in [Set up an instrument](/docs/set-up-an-instrument). Retiring and reactivating are in [Retire an instrument](/docs/retire-an-instrument).\n\n## Instrument states [#instrument-states]\n\nThe **Instruments** page has tabs for active, pending, and retired instruments.\n\n| Status | UI label | Meaning |\n| ---------- | -------- | ------------------------------------------------------------ |\n| `pending` | Pending | Registered but not confirmed. Watchers cannot upload. |\n| `active` | Active | Confirmed and accepting uploads. |\n| `inactive` | Retired | Deactivated; no new uploads. Historical runs stay browsable. |\n\n
\n\n## Confirm a pending instrument [#confirm-a-pending-instrument]\n\n\n \n Open the Data Hub web app and go to **Instruments**.\n \n\n \n Open the **Pending** tab (or find the yellow `pending` badge).\n \n\n \n Click **Confirm**. Status becomes `active` and the operator’s watcher can upload.\n \n\n\nConfirming is admin-only for signed-in users. Under the hood it is `PATCH /api/v1/instruments/:id`. Token automation uses the `instruments:write` scope instead. See the [generated API docs](/docs/api).\n\n## Read an instrument’s page [#view-instrument-health]\n\nThe top of each instrument page shows what you need to tell whether its files will reach Data Hub:\n\n* **Watcher status**: a badge next to the instrument name. **Online** means the watcher checked in recently, **Offline** means it hasn’t, and **No Watcher** means none has registered. The watcher checks in once a minute, so a watcher that was just restarted can take up to a minute to show as online. Hover over the badge for details.\n* **Watched folder**: the folder the watcher reads from. Hover over it to see which PC it is on.\n* **File patterns**: the patterns the watcher uploads, such as `*.csv`.\n\nFiles saved anywhere other than the watched folder, such as a shared network drive, don’t appear in Data Hub. Neither do files whose names don’t match a pattern. The folder and patterns come from the watcher’s config on the instrument PC, so change them there with `data-hub-watcher config edit`; see [Watcher configuration](/docs/configuration-reference).\n\nWatcher health comes from heartbeats; see [Manage the watcher fleet](/docs/manage-watchers).\n\n## Edit an instrument [#edit-an-instrument]\n\nThe three-dot menu on an instrument page, and the row menu on the instruments table, hold the admin actions:\n\n* **Edit details**: change the **Display name** and the **Instrument type**\n* **View watcher**: open the watcher registered for this instrument\n* **Retire** or **Reactivate**: see [Retire an instrument](/docs/retire-an-instrument)\n\nThe instrument type decides which processor reads the instrument’s files, including DishCam and Aunty; see [Instrument data preprocessing](/docs/instrument-preprocessing). The instrument ID never changes, because it is the storage key prefix.\n"
+ "markdown": "# Manage instruments (/docs/manage-instruments)\n\n\n\n\n\n\n\n\n\n**For:** Administrators\n\nWhen a lab operator registers an instrument from the watcher, it appears as `pending` and cannot upload until you confirm it. Operator-side setup is in [Set up an instrument](/docs/set-up-an-instrument). Retiring and reactivating are in [Retire an instrument](/docs/retire-an-instrument).\n\n## Instrument states [#instrument-states]\n\nThe **Instruments** page has tabs for active, pending, and retired instruments.\n\n| Status | UI label | Meaning |\n| ---------- | -------- | ------------------------------------------------------------ |\n| `pending` | Pending | Registered but not confirmed. Watchers cannot upload. |\n| `active` | Active | Confirmed and accepting uploads. |\n| `inactive` | Retired | Deactivated; no new uploads. Historical runs stay browsable. |\n\n
\n\n## Confirm a pending instrument [#confirm-a-pending-instrument]\n\n\n \n Open the Data Hub web app and go to **Instruments**.\n \n\n \n Open the **Pending** tab (or find the yellow `pending` badge).\n \n\n \n Click **Confirm**. Status becomes `active` and the operator’s watcher can upload.\n \n\n\n
\n\nConfirming is admin-only for signed-in users. Under the hood it is `PATCH /api/v1/instruments/:id`. Token automation uses the `instruments:write` scope instead. See the [generated API docs](/docs/api).\n\n## Read an instrument’s page [#view-instrument-health]\n\nThe top of each instrument page shows what you need to tell whether its files will reach Data Hub:\n\n* **Watcher status**: a badge next to the instrument name. **Online** means the watcher checked in recently, **Offline** means it hasn’t, and **No Watcher** means none has registered. The watcher checks in once a minute, so a watcher that was just restarted can take up to a minute to show as online. Hover over the badge for details.\n* **Watched folder**: the folder the watcher reads from. Hover over it to see which PC it is on.\n* **File patterns**: the patterns the watcher uploads, such as `*.csv`.\n\nFiles saved anywhere other than the watched folder, such as a shared network drive, don’t appear in Data Hub. Neither do files whose names don’t match a pattern. The folder and patterns come from the watcher’s config on the instrument PC, so change them there with `data-hub-watcher config edit`; see [Watcher configuration](/docs/configuration-reference).\n\nWatcher health comes from heartbeats; see [Manage the watcher fleet](/docs/manage-watchers).\n\n## Edit an instrument [#edit-an-instrument]\n\nThe three-dot menu on an instrument page, and the row menu on the instruments table, hold the admin actions:\n\n* **Edit details**: change the **Display name** and the **Instrument type**\n* **View watcher**: open the watcher registered for this instrument\n* **Retire** or **Reactivate**: see [Retire an instrument](/docs/retire-an-instrument)\n\n
\n\nThe instrument type decides which processor reads the instrument’s files, including DishCam and Aunty; see [Instrument data preprocessing](/docs/instrument-preprocessing). The instrument ID never changes, because it is the storage key prefix.\n"
},
{
"page": "retire-an-instrument",
"title": "Retire an instrument",
"description": "Retire an instrument so it stops accepting uploads and watchers, while keeping historical runs and files.",
- "markdown": "# Retire an instrument (/docs/retire-an-instrument)\n\n\n\n**For:** Administrators\n\nRetire an instrument when it is decommissioned or out of service for a long stretch. Retirement sets status to `inactive` (shown as **Retired**), removes it from the sidebar and dashboard lists of live instruments, and stops new uploads. Nothing is deleted from storage; you can reactivate later.\n\n## What retirement does [#what-retirement-does]\n\nWhen you retire an instrument:\n\n* Status becomes `inactive` (UI: **Retired**), with `retiredAt` / `retiredBy` audit fields\n* It stops accepting new runs and watchers\n* All existing runs and files stay browsable\n* Every active watcher on that instrument is deregistered in the same request so they stop heartbeating\n\nInstrument IDs stay permanent. Retire rather than trying to rename or delete an instrument that has already produced data.\n\n## Retire from the web app [#retire-from-the-web-app]\n\n\n \n Open the instrument’s detail page.\n \n\n \n Open the admin actions menu and choose **Retire**.\n \n\n \n Confirm in the dialog. The dialog lists run count and how many watchers will be deregistered.\n \n\n\nThe instrument moves to the **Retired** tab on **Instruments**.\n\n## Reactivate [#reactivate]\n\nFrom a retired instrument’s detail page, choose **Reactivate**. Status returns to `active` and the instrument reappears in the sidebar and dashboard.\n\nWatchers deregistered at retirement do **not** reconnect automatically. An operator must re-register (usually `data-hub-watcher init` on the instrument PC) after reactivation.\n\n## Via the API [#via-the-api]\n\n```sh\n# Retire\ncurl -X PATCH https://datahub.example.com/api/v1/instruments/bio-rad-cfx96 \\\n -H \"Authorization: Bearer dhub_your_token_here\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"status\":\"inactive\"}'\n\n# Reactivate\ncurl -X PATCH https://datahub.example.com/api/v1/instruments/bio-rad-cfx96 \\\n -H \"Authorization: Bearer dhub_your_token_here\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"status\":\"active\"}'\n```\n\nSession callers need the admin role; bearer tokens need `instruments:write`.\n"
+ "markdown": "# Retire an instrument (/docs/retire-an-instrument)\n\n\n\n\n\n**For:** Administrators\n\nRetire an instrument when it is decommissioned or out of service for a long stretch. Retirement sets status to `inactive` (shown as **Retired**), removes it from the sidebar and dashboard lists of live instruments, and stops new uploads. Nothing is deleted from storage; you can reactivate later.\n\n## What retirement does [#what-retirement-does]\n\nWhen you retire an instrument:\n\n* Status becomes `inactive` (UI: **Retired**), with `retiredAt` / `retiredBy` audit fields\n* It stops accepting new runs and watchers\n* All existing runs and files stay browsable\n* Every active watcher on that instrument is deregistered in the same request so they stop heartbeating\n\nInstrument IDs stay permanent. Retire rather than trying to rename or delete an instrument that has already produced data.\n\n## Retire from the web app [#retire-from-the-web-app]\n\n\n \n Open the instrument’s detail page.\n \n\n \n Open the admin actions menu and choose **Retire**.\n \n\n \n Confirm in the dialog. The dialog lists run count and how many watchers will be deregistered.\n \n\n\n
\n\nThe instrument moves to the **Retired** tab on **Instruments**.\n\n## Reactivate [#reactivate]\n\nFrom a retired instrument’s detail page, choose **Reactivate**. Status returns to `active` and the instrument reappears in the sidebar and dashboard.\n\nWatchers deregistered at retirement do **not** reconnect automatically. An operator must re-register (usually `data-hub-watcher init` on the instrument PC) after reactivation.\n\n## Via the API [#via-the-api]\n\n```sh\n# Retire\ncurl -X PATCH https://datahub.example.com/api/v1/instruments/bio-rad-cfx96 \\\n -H \"Authorization: Bearer dhub_your_token_here\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"status\":\"inactive\"}'\n\n# Reactivate\ncurl -X PATCH https://datahub.example.com/api/v1/instruments/bio-rad-cfx96 \\\n -H \"Authorization: Bearer dhub_your_token_here\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"status\":\"active\"}'\n```\n\nSession callers need the admin role; bearer tokens need `instruments:write`.\n"
},
{
"page": "manage-tokens",
"title": "Issue and revoke tokens",
"description": "Create, use, and revoke personal access tokens that authenticate the watcher and other Data Hub API clients, and see what a token can and can’t do.",
- "markdown": "# Issue and revoke tokens (/docs/manage-tokens)\n\n\n\n**For:** Administrators\n\nPersonal access tokens authenticate the watcher, the processing Lambda, and scripts that call the Data Hub API. Creating and revoking tokens requires the workspace admin role. Members can view the list at **Settings → Access Tokens** but cannot create or delete tokens. Roles and scopes: [Security and permissions](/docs/security).\n\n## How a token acts [#how-a-token-acts]\n\nA token acts as itself, under its own name. It doesn’t act as the admin who created it or as any other person.\n\n* Anything a token does is recorded under the token’s name: comments, deleting a run, retiring an instrument, removing a watcher, and notifications sent through the API.\n* Run pages, instrument pages, watcher pages, comments, and the notification bell show the token’s name with a key icon.\n* A token can post comments, and it can edit and delete only the comments it posted.\n\nSome actions need a signed-in person, so tokens can’t do them at all: claiming runs, sending feedback, and reviewing feedback. AI assistants aren’t affected, because they sign in as the person who approved them; see [MCP overview](/docs/mcp).\n\nThe reverse also holds: the API routes that the watcher and the Lambda call accept only tokens. A browser session gets `401 Unauthorized` on watcher registration and heartbeats, upload links, file updates, run creation and updates, and instrument creation.\n\n## Create a token [#create-a-token]\n\n### In the web app [#in-the-web-app]\n\n\n \n Sign in as an admin.\n \n\n \n Go to **Settings → Access Tokens**.\n \n\n \n Click **Create token**.\n \n\n \n Enter a **Name** that identifies the client (for example, `FPLC watcher, Lab 201`). This is the name people see wherever the token acts, so make it recognizable.\n \n\n \n Pick an **Expiration**: 30 days, 90 days (the default), 1 year, or no expiry.\n \n\n \n Under **Scopes**, click a preset, or click **Customize scopes** to pick individual scopes.\n \n\n \n Click **Create token**.\n \n\n\nThe plaintext token is shown once: copy it immediately. It starts with `dhub_` followed by a 64-character hex string.\n\n### Scope presets [#scope-presets]\n\nA preset selects the minimum scopes a common client needs:\n\n* **Read-only**: view instruments, runs, files, watchers, and archive jobs; change nothing\n* **Watcher**: what a watcher needs to register, report runs, and upload files\n* **Lambda**: what the processing Lambda needs to read instruments and runs, and write run and file results\n\n\n The **Lambda** preset includes `runs:read`, which DishCam processing needs to pair each photo stack with its `run.json`. A Lambda token created from an older preset gets `403 FORBIDDEN` on DishCam files. Create a new token from the current preset and update the Lambda’s `DATA_HUB_API_KEY`; the [Lambda developer docs](https://github.com/Arcadia-Science/data-hub/blob/production/docs/developer/lambda.md#supported-instrument-types) describe the swap.\n\n\nYou cannot change a token’s scopes after creation. To change them, revoke the token and create a new one.\n\n### Via the API [#via-the-api]\n\nCreating and deleting tokens is admin-only and needs a signed-in browser session (cookie). Tokens cannot manage other tokens.\n\n```sh\ncurl -X POST https://datahub.example.com/api/v1/tokens \\\n -H \"Cookie: session_cookie_here\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"FPLC watcher\",\n \"scopes\": [\"instruments:read\", \"instruments:write\", \"watchers:read\", \"watchers:report\", \"runs:create\", \"runs:update\", \"runs:upload\", \"files:update\"],\n \"expires_at\": \"2027-01-01T00:00:00Z\"\n }'\n```\n\n`scopes` is required and must be a non-empty list of explicit scopes. The wildcard `*` and the older coarse scopes `runs:write`, `files:write`, and `watchers:write` are rejected. `expires_at` is optional; if omitted, the token never expires. The request is rejected if it includes `user_id`, because tokens don’t belong to a person. The response includes the plaintext once in the `token` field.\n\n## Use a token [#use-a-token]\n\n### With the watcher [#with-the-watcher]\n\nDuring `data-hub-watcher init`, paste the token when prompted. Or set it first:\n\n```sh\nexport DATA_HUB_API_KEY=dhub_your_token_here\ndata-hub-watcher init\n```\n\n### With the REST API [#with-the-rest-api]\n\n```sh\ncurl https://datahub.example.com/api/v1/instruments \\\n -H \"Authorization: Bearer dhub_your_token_here\"\n```\n\nMore in the [API overview](/docs/api).\n\nTokens don’t authenticate MCP clients, which sign in with OAuth instead. See [MCP overview](/docs/mcp).\n\n## View tokens [#view-tokens]\n\n**Settings → Access Tokens** lists every active token with these columns:\n\n* **Name**\n* **Created by**: the admin who made the token. For tokens created before October 6, 2026, this shows the person the token used to act as.\n* **Token**: the prefix only, because the full token is never stored\n* **Scopes**\n* **Last used**\n* **Expires**\n* **Created**\n\n## Revoke a token [#revoke-a-token]\n\n\n \n Go to **Settings → Access Tokens**.\n \n\n \n Click the delete icon on the token’s row and confirm.\n \n\n\nThe token stops working immediately. Clients using it start receiving `401 Unauthorized`.\n\n```sh\ncurl -X DELETE https://datahub.example.com/api/v1/tokens/ \\\n -H \"Cookie: session_cookie_here\"\n```\n\nA revoked token leaves the list, but Data Hub keeps its record. Wherever the token acted, its name stays visible and is marked **(revoked)**.\n\n## After revoking a token [#after-revoking-a-token]\n\nIf a running watcher used that token:\n\n\n \n Create a new token with the **Watcher** preset.\n \n\n \n On the instrument PC, re-run `data-hub-watcher init` and enter the new token. The new token takes over the existing watcher registration, so you don’t need to deregister the old one first.\n \n\n \n Restart the watcher (`data-hub-watcher watch` or the Windows service).\n \n\n\n## When someone leaves [#when-someone-leaves]\n\nTokens keep working after the admin who created them leaves or their account is deleted, because tokens act as themselves. When someone leaves, check the **Created by** column and revoke any of their tokens that nobody else should keep using.\n\n## Token hygiene [#token-hygiene]\n\n* Name tokens so you can tell which PC or client each belongs to\n* Set expirations for temporary setups\n* Scope to least privilege; see [Token scopes](/docs/security#token-scopes)\n* Revoke immediately when a watcher is decommissioned or a token may have been exposed\n\nTokens are hashed with SHA-256 before storage; plaintext is never persisted.\n"
+ "markdown": "# Issue and revoke tokens (/docs/manage-tokens)\n\n\n\n\n\n\n\n\n\n**For:** Administrators\n\nPersonal access tokens authenticate the watcher, the processing Lambda, and scripts that call the Data Hub API. Creating and revoking tokens requires the workspace admin role. Members can view the list at **Settings → Access Tokens** but cannot create or delete tokens. Roles and scopes: [Security and permissions](/docs/security).\n\n## How a token acts [#how-a-token-acts]\n\nA token acts as itself, under its own name. It doesn’t act as the admin who created it or as any other person.\n\n* Anything a token does is recorded under the token’s name: comments, deleting a run, retiring an instrument, removing a watcher, and notifications sent through the API.\n* Run pages, instrument pages, watcher pages, comments, and the notification bell show the token’s name with a key icon.\n* A token can post comments, and it can edit and delete only the comments it posted.\n\nSome actions need a signed-in person, so tokens can’t do them at all: claiming runs, sending feedback, and reviewing feedback. AI assistants aren’t affected, because they sign in as the person who approved them; see [MCP overview](/docs/mcp).\n\nThe reverse also holds: the API routes that the watcher and the Lambda call accept only tokens. A browser session gets `401 Unauthorized` on watcher registration and heartbeats, upload links, file updates, run creation and updates, and instrument creation.\n\n## Create a token [#create-a-token]\n\n### In the web app [#in-the-web-app]\n\n\n \n Sign in as an admin.\n \n\n \n Go to **Settings → Access Tokens**.\n \n\n \n Click **Create token**.\n \n\n \n Enter a **Name** that identifies the client (for example, `FPLC watcher, Lab 201`). This is the name people see wherever the token acts, so make it recognizable.\n \n\n \n Pick an **Expiration**: 30 days, 90 days (the default), 1 year, or no expiry.\n \n\n \n Under **Scopes**, click a preset, or click **Customize scopes** to pick individual scopes.\n \n\n \n Click **Create token**.\n \n\n\n
\n\nThe plaintext token is shown once: copy it immediately. It starts with `dhub_` followed by a 64-character hex string.\n\n
\n\n### Scope presets [#scope-presets]\n\nA preset selects the minimum scopes a common client needs:\n\n* **Read-only**: view instruments, runs, files, watchers, and archive jobs; change nothing\n* **Watcher**: what a watcher needs to register, report runs, and upload files\n* **Lambda**: what the processing Lambda needs to read instruments and runs, and write run and file results\n\n\n The **Lambda** preset includes `runs:read`, which DishCam processing needs to pair each photo stack with its `run.json`. A Lambda token created from an older preset gets `403 FORBIDDEN` on DishCam files. Create a new token from the current preset and update the Lambda’s `DATA_HUB_API_KEY`; the [Lambda developer docs](https://github.com/Arcadia-Science/data-hub/blob/production/docs/developer/lambda.md#supported-instrument-types) describe the swap.\n\n\nYou cannot change a token’s scopes after creation. To change them, revoke the token and create a new one.\n\n### Via the API [#via-the-api]\n\nCreating and deleting tokens is admin-only and needs a signed-in browser session (cookie). Tokens cannot manage other tokens.\n\n```sh\ncurl -X POST https://datahub.example.com/api/v1/tokens \\\n -H \"Cookie: session_cookie_here\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"name\": \"FPLC watcher\",\n \"scopes\": [\"instruments:read\", \"instruments:write\", \"watchers:read\", \"watchers:report\", \"runs:create\", \"runs:update\", \"runs:upload\", \"files:update\"],\n \"expires_at\": \"2027-01-01T00:00:00Z\"\n }'\n```\n\n`scopes` is required and must be a non-empty list of explicit scopes. The wildcard `*` and the older coarse scopes `runs:write`, `files:write`, and `watchers:write` are rejected. `expires_at` is optional; if omitted, the token never expires. The request is rejected if it includes `user_id`, because tokens don’t belong to a person. The response includes the plaintext once in the `token` field.\n\n## Use a token [#use-a-token]\n\n### With the watcher [#with-the-watcher]\n\nDuring `data-hub-watcher init`, paste the token when prompted. Or set it first:\n\n```sh\nexport DATA_HUB_API_KEY=dhub_your_token_here\ndata-hub-watcher init\n```\n\n### With the REST API [#with-the-rest-api]\n\n```sh\ncurl https://datahub.example.com/api/v1/instruments \\\n -H \"Authorization: Bearer dhub_your_token_here\"\n```\n\nMore in the [API overview](/docs/api).\n\nTokens don’t authenticate MCP clients, which sign in with OAuth instead. See [MCP overview](/docs/mcp).\n\n## View tokens [#view-tokens]\n\n**Settings → Access Tokens** lists every active token with these columns:\n\n* **Name**\n* **Created by**: the admin who made the token. For tokens created before October 6, 2026, this shows the person the token used to act as.\n* **Token**: the prefix only, because the full token is never stored\n* **Scopes**\n* **Last used**\n* **Expires**\n* **Created**\n\n
\n\n## Revoke a token [#revoke-a-token]\n\n\n \n Go to **Settings → Access Tokens**.\n \n\n \n Click the delete icon on the token’s row and confirm.\n \n\n\nThe token stops working immediately. Clients using it start receiving `401 Unauthorized`.\n\n```sh\ncurl -X DELETE https://datahub.example.com/api/v1/tokens/ \\\n -H \"Cookie: session_cookie_here\"\n```\n\nA revoked token leaves the list, but Data Hub keeps its record. Wherever the token acted, its name stays visible and is marked **(revoked)**.\n\n## After revoking a token [#after-revoking-a-token]\n\nIf a running watcher used that token:\n\n\n \n Create a new token with the **Watcher** preset.\n \n\n \n On the instrument PC, re-run `data-hub-watcher init` and enter the new token. The new token takes over the existing watcher registration, so you don’t need to deregister the old one first.\n \n\n \n Restart the watcher (`data-hub-watcher watch` or the Windows service).\n \n\n\n## When someone leaves [#when-someone-leaves]\n\nTokens keep working after the admin who created them leaves or their account is deleted, because tokens act as themselves. When someone leaves, check the **Created by** column and revoke any of their tokens that nobody else should keep using.\n\n## Token hygiene [#token-hygiene]\n\n* Name tokens so you can tell which PC or client each belongs to\n* Set expirations for temporary setups\n* Scope to least privilege; see [Token scopes](/docs/security#token-scopes)\n* Revoke immediately when a watcher is decommissioned or a token may have been exposed\n\nTokens are hashed with SHA-256 before storage; plaintext is never persisted.\n"
},
{
"page": "manage-members",
@@ -112,7 +112,7 @@
"page": "integrations",
"title": "Set up integrations",
"description": "Connect Data Hub to Linear for feedback reports, and to Slack for personal direct messages and a shared new-run channel, from Settings → Integrations.",
- "markdown": "# Set up integrations (/docs/integrations)\n\n\n\n**For:** Administrators and engineers\n\n**Settings → Integrations** holds the connections the whole workspace shares. Only admins see it. The page has three parts:\n\n* **Linear**: where feedback reports go, and where their status comes from\n* **Slack app**: the credentials behind personal Slack direct messages and **Connect to Slack**\n* **Slack channel**: a webhook that posts a message to a shared channel whenever a new run arrives\n\nEach person’s own notification choices are separate, under **Settings → Notifications**. See [Configure notifications](/docs/configure-notifications).\n\n## How secrets are stored [#integration-secrets-key]\n\nData Hub encrypts the secrets you paste on this page before saving them: the Linear client secret and signing secret, and the Slack bot token and client secret. It never shows a saved secret again. To change one, paste a new value and save.\n\nEncryption needs the `INTEGRATION_SECRETS_KEY` environment variable on the web app. It is 64 hexadecimal characters; generate one with `openssl rand -hex 32`, set it in Vercel, and redeploy. Until it is set, saving a secret fails, and the Slack app card says so.\n\nIf the key changes after secrets were saved, Data Hub can’t read them anymore. The affected field says **Can’t read saved value**. Put the old key back, or paste the value again and save.\n\n## Linear for feedback reports [#linear]\n\nFeedback that people send from Data Hub becomes an issue in your Linear workspace, and the report’s status follows that issue. Until an admin saves a Linear team here, the **Feedback** item stays out of the account menu, and sending feedback through the API or an AI assistant returns an error saying feedback isn’t set up. What reporters see is in [Send feedback](/docs/send-feedback).\n\nThe Linear card walks you through four steps.\n\n### 1. Connect your Linear app [#linear-connect]\n\nData Hub signs in to Linear as an app, so you create one app in Linear for it.\n\n\n \n In Linear, go to **Settings → API → OAuth applications** and create a new application.\n \n\n \n Name it **Data Hub**. Linear shows this name on every issue it files.\n \n\n \n Linear asks for a callback URL. Data Hub doesn’t use one, so paste the URL shown in the step, which is your deployment’s address.\n \n\n \n Turn on **Client credentials**, then save.\n \n\n \n Copy the client ID and client secret into the Linear card in Data Hub, and choose **Connect**.\n \n\n\nData Hub checks the credentials with Linear before it saves them. If client credentials are turned off for the app, the card says so; turn them on in the app’s settings in Linear and choose **Connect** again.\n\nIf the credentials belong to a different Linear workspace than the one already connected, Data Hub asks before switching. Switching clears the team, project, labels, and signing secret. Issues already in Linear stay there.\n\n### 2. Choose where reports go [#linear-destination]\n\n\n \n Pick the **Team** that receives reports. Only public teams are listed, because Linear doesn’t show private teams to apps.\n \n\n \n Optionally pick a **Project**.\n \n\n \n Optionally pick a label for each kind of report: **Bug**, **Feature request**, and **Other**. Labels can come from the team or from the whole workspace. Linear adds the matching label to each new issue.\n \n\n \n Choose **Save and turn on feedback**. Everyone sees **Feedback** in their account menu from then on.\n \n\n\nChanging the team later clears the project and labels. Status updates also stop until the Linear webhook from step 3 points at the new team.\n\n### 3. Get status updates from Linear [#linear-status-updates]\n\nA webhook tells Data Hub when an issue changes, so it can update the report and tell the person who sent it when the issue closes.\n\n\n \n In Linear, go to **Settings → API** and choose **New webhook**.\n \n\n \n Paste the **Webhook URL** shown in the step. It ends in `/api/v1/integrations/linear/webhook`.\n \n\n \n For **Team**, choose the team from step 2. Under events, turn on **Issues** only.\n \n\n \n Save the webhook, then copy its signing secret into **Signing secret** in Data Hub and save.\n \n\n\nData Hub uses the signing secret to check that each update really comes from Linear. The step shows **Waiting for an update from Linear** until the first one arrives. Any change to an issue in the team sends one, including the test report in the next step.\n\n### 4. Send a test report [#linear-test-report]\n\nChoose **Send test report**. Data Hub files a report as you and links to the new Linear issue. Move that issue to **Done** in Linear. The step confirms that the issue closed and that you got a Data Hub notification. You can also skip the test.\n\n### After setup [#linear-after-setup]\n\nOnce setup is finished, the Linear card shows a summary: the connected workspace, where reports go, whether status updates are working and when the last one arrived, and what reporters see for each Linear status.\n\n| When the Linear issue moves to | The reporter sees |\n| --------------------------------------- | ----------------- |\n| **Done**, or any other completed status | Resolved |\n| **Canceled** | Declined |\n| **Duplicate** | Declined |\n| Any other status | Open |\n\nData Hub notifies the reporter once, when the issue first closes. Moving an issue from one closed status to another, such as **Done** to **Released**, doesn’t notify again. Reopening an issue and closing it again does.\n\nIf Data Hub keeps rejecting updates from Linear, **Status updates** shows **Not working**. The usual cause is a wrong signing secret, or Linear turning the webhook off after repeated failures. Paste the signing secret again from the webhook’s page in Linear, and check that the webhook is still turned on.\n\n**Disconnect Linear** turns feedback off. Issues already in Linear stay there.\n\n## Slack app for direct messages [#slack-app]\n\nPersonal Slack direct messages and the **Connect to Slack** button use one shared Slack app. Create the app once in the [Slack API portal](https://api.slack.com/apps), then give Data Hub its credentials. You can reuse an existing Slack app, including the one behind the channel webhook.\n\n### Create the app [#create-the-slack-app]\n\n\n \n In the Slack API portal, choose **Create New App → From scratch**, or open an existing app.\n \n\n \n Under **OAuth & Permissions**, add these scopes:\n\n **Bot Token Scopes:**\n\n | Scope | Purpose |\n | ------------ | -------------------------------------------- |\n | `chat:write` | Send direct messages with `chat.postMessage` |\n\n **User Token Scopes:**\n\n | Scope | Purpose |\n | --------- | ----------------------------------- |\n | `openid` | Sign in with Slack (OpenID Connect) |\n | `profile` | Display name in the identity token |\n \n\n \n Under **Redirect URLs**, add one callback URL per environment. The path is always `/api/v1/settings/slack/callback`:\n\n | Environment | Example URL |\n | ----------- | -------------------------------------------------------------------- |\n | Local | `http://localhost:3000/api/v1/settings/slack/callback` |\n | Staging | `https://staging.datahub.example.com/api/v1/settings/slack/callback` |\n | Production | `https://datahub.example.com/api/v1/settings/slack/callback` |\n\n `SLACK_REDIRECT_URI`, or the request origin plus that path when unset, must match a listed URL exactly.\n \n\n \n Under **Install App**, choose **Install to Workspace** (or **Reinstall** after changing scopes).\n \n\n\n### Give Data Hub the credentials [#slack-app-credentials]\n\nEnter the credentials in the **Slack app** card under **Settings → Integrations**, or set them as environment variables on the web app. A value saved in the card takes effect right away, with no redeploy, and is used instead of the matching environment variable.\n\n| Card field | Environment variable | Where to find it | Required |\n| ------------------------ | --------------------- | ------------------------------------------------------- | --------------------------------------------------------- |\n| **Bot token** | `SLACK_BOT_TOKEN` | **Install App** → Bot User OAuth Token (`xoxb-…`) | Yes |\n| **Client ID** | `SLACK_CLIENT_ID` | **Basic Information** → App Credentials | Yes |\n| **Client secret** | `SLACK_CLIENT_SECRET` | **Basic Information** → App Credentials | Yes |\n| **Allowed workspace ID** | `SLACK_TEAM_ID` | Workspace URL segment `T…` in `app.slack.com/client/T…` | No, but recommended so connections stay on your workspace |\n\nEach field shows where its value comes from: **Saved here**, **Using** followed by the variable name, or **Not set**. **Remove saved value** makes the environment variable the source again. If Data Hub can’t read a saved value, for example because `INTEGRATION_SECRETS_KEY` changed, it uses the environment variable in the meantime.\n\nTwo settings exist only as environment variables, and changing them needs a redeploy:\n\n| Variable | Purpose | Required |\n| -------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------- |\n| `SLACK_STATE_SECRET` | Signs the short-lived value Data Hub checks while a person connects Slack. Generate with `openssl rand -base64 32` | No (falls back to `AUTH_SECRET`) |\n| `SLACK_REDIRECT_URI` | Full callback URL, when it differs from the request origin | No |\n\nMembers can then connect under **Settings → Notifications**. Deploy runbooks are linked from [Deploy Data Hub](/docs/self-hosting).\n\n## Slack channel for new runs [#slack-channel]\n\nThe **Slack channel** card holds a Slack incoming webhook. Data Hub posts one message to that channel each time a new run is reported. This is separate from personal direct messages, which each person turns on for themselves.\n\n\n \n In your Slack app, open **Incoming Webhooks**, turn them on, and add a webhook for the channel you want.\n \n\n \n Copy the webhook URL. It starts with `https://hooks.slack.com/services/`.\n \n\n \n Paste it into the **Slack channel** card and save. The card shows **Configured** and who last changed it.\n \n\n\nTo change channels, paste a new webhook URL. To stop the messages, remove the webhook. This setting has no environment variable.\n"
+ "markdown": "# Set up integrations (/docs/integrations)\n\n\n\n\n\n**For:** Administrators and engineers\n\n**Settings → Integrations** holds the connections the whole workspace shares. Only admins see it. The page has three parts:\n\n* **Linear**: where feedback reports go, and where their status comes from\n* **Slack app**: the credentials behind personal Slack direct messages and **Connect to Slack**\n* **Slack channel**: a webhook that posts a message to a shared channel whenever a new run arrives\n\nEach person’s own notification choices are separate, under **Settings → Notifications**. See [Configure notifications](/docs/configure-notifications).\n\n
\n\n## How secrets are stored [#integration-secrets-key]\n\nData Hub encrypts the secrets you paste on this page before saving them: the Linear client secret and signing secret, and the Slack bot token and client secret. It never shows a saved secret again. To change one, paste a new value and save.\n\nEncryption needs the `INTEGRATION_SECRETS_KEY` environment variable on the web app. It is 64 hexadecimal characters; generate one with `openssl rand -hex 32`, set it in Vercel, and redeploy. Until it is set, saving a secret fails, and the Slack app card says so.\n\nIf the key changes after secrets were saved, Data Hub can’t read them anymore. The affected field says **Can’t read saved value**. Put the old key back, or paste the value again and save.\n\n## Linear for feedback reports [#linear]\n\nFeedback that people send from Data Hub becomes an issue in your Linear workspace, and the report’s status follows that issue. Until an admin saves a Linear team here, the **Feedback** item stays out of the account menu, and sending feedback through the API or an AI assistant returns an error saying feedback isn’t set up. What reporters see is in [Send feedback](/docs/send-feedback).\n\nThe Linear card walks you through four steps.\n\n### 1. Connect your Linear app [#linear-connect]\n\nData Hub signs in to Linear as an app, so you create one app in Linear for it.\n\n\n \n In Linear, go to **Settings → API → OAuth applications** and create a new application.\n \n\n \n Name it **Data Hub**. Linear shows this name on every issue it files.\n \n\n \n Linear asks for a callback URL. Data Hub doesn’t use one, so paste the URL shown in the step, which is your deployment’s address.\n \n\n \n Turn on **Client credentials**, then save.\n \n\n \n Copy the client ID and client secret into the Linear card in Data Hub, and choose **Connect**.\n \n\n\nData Hub checks the credentials with Linear before it saves them. If client credentials are turned off for the app, the card says so; turn them on in the app’s settings in Linear and choose **Connect** again.\n\nIf the credentials belong to a different Linear workspace than the one already connected, Data Hub asks before switching. Switching clears the team, project, labels, and signing secret. Issues already in Linear stay there.\n\n### 2. Choose where reports go [#linear-destination]\n\n\n \n Pick the **Team** that receives reports. Only public teams are listed, because Linear doesn’t show private teams to apps.\n \n\n \n Optionally pick a **Project**.\n \n\n \n Optionally pick a label for each kind of report: **Bug**, **Feature request**, and **Other**. Labels can come from the team or from the whole workspace. Linear adds the matching label to each new issue.\n \n\n \n Choose **Save and turn on feedback**. Everyone sees **Feedback** in their account menu from then on.\n \n\n\nChanging the team later clears the project and labels. Status updates also stop until the Linear webhook from step 3 points at the new team.\n\n### 3. Get status updates from Linear [#linear-status-updates]\n\nA webhook tells Data Hub when an issue changes, so it can update the report and tell the person who sent it when the issue closes.\n\n\n \n In Linear, go to **Settings → API** and choose **New webhook**.\n \n\n \n Paste the **Webhook URL** shown in the step. It ends in `/api/v1/integrations/linear/webhook`.\n \n\n \n For **Team**, choose the team from step 2. Under events, turn on **Issues** only.\n \n\n \n Save the webhook, then copy its signing secret into **Signing secret** in Data Hub and save.\n \n\n\nData Hub uses the signing secret to check that each update really comes from Linear. The step shows **Waiting for an update from Linear** until the first one arrives. Any change to an issue in the team sends one, including the test report in the next step.\n\n### 4. Send a test report [#linear-test-report]\n\nChoose **Send test report**. Data Hub files a report as you and links to the new Linear issue. Move that issue to **Done** in Linear. The step confirms that the issue closed and that you got a Data Hub notification. You can also skip the test.\n\n### After setup [#linear-after-setup]\n\nOnce setup is finished, the Linear card shows a summary: the connected workspace, where reports go, whether status updates are working and when the last one arrived, and what reporters see for each Linear status.\n\n| When the Linear issue moves to | The reporter sees |\n| --------------------------------------- | ----------------- |\n| **Done**, or any other completed status | Resolved |\n| **Canceled** | Declined |\n| **Duplicate** | Declined |\n| Any other status | Open |\n\nData Hub notifies the reporter once, when the issue first closes. Moving an issue from one closed status to another, such as **Done** to **Released**, doesn’t notify again. Reopening an issue and closing it again does.\n\nIf Data Hub keeps rejecting updates from Linear, **Status updates** shows **Not working**. The usual cause is a wrong signing secret, or Linear turning the webhook off after repeated failures. Paste the signing secret again from the webhook’s page in Linear, and check that the webhook is still turned on.\n\n**Disconnect Linear** turns feedback off. Issues already in Linear stay there.\n\n## Slack app for direct messages [#slack-app]\n\nPersonal Slack direct messages and the **Connect to Slack** button use one shared Slack app. Create the app once in the [Slack API portal](https://api.slack.com/apps), then give Data Hub its credentials. You can reuse an existing Slack app, including the one behind the channel webhook.\n\n### Create the app [#create-the-slack-app]\n\n\n \n In the Slack API portal, choose **Create New App → From scratch**, or open an existing app.\n \n\n \n Under **OAuth & Permissions**, add these scopes:\n\n **Bot Token Scopes:**\n\n | Scope | Purpose |\n | ------------ | -------------------------------------------- |\n | `chat:write` | Send direct messages with `chat.postMessage` |\n\n **User Token Scopes:**\n\n | Scope | Purpose |\n | --------- | ----------------------------------- |\n | `openid` | Sign in with Slack (OpenID Connect) |\n | `profile` | Display name in the identity token |\n \n\n \n Under **Redirect URLs**, add one callback URL per environment. The path is always `/api/v1/settings/slack/callback`:\n\n | Environment | Example URL |\n | ----------- | -------------------------------------------------------------------- |\n | Local | `http://localhost:3000/api/v1/settings/slack/callback` |\n | Staging | `https://staging.datahub.example.com/api/v1/settings/slack/callback` |\n | Production | `https://datahub.example.com/api/v1/settings/slack/callback` |\n\n `SLACK_REDIRECT_URI`, or the request origin plus that path when unset, must match a listed URL exactly.\n \n\n \n Under **Install App**, choose **Install to Workspace** (or **Reinstall** after changing scopes).\n \n\n\n### Give Data Hub the credentials [#slack-app-credentials]\n\nEnter the credentials in the **Slack app** card under **Settings → Integrations**, or set them as environment variables on the web app. A value saved in the card takes effect right away, with no redeploy, and is used instead of the matching environment variable.\n\n| Card field | Environment variable | Where to find it | Required |\n| ------------------------ | --------------------- | ------------------------------------------------------- | --------------------------------------------------------- |\n| **Bot token** | `SLACK_BOT_TOKEN` | **Install App** → Bot User OAuth Token (`xoxb-…`) | Yes |\n| **Client ID** | `SLACK_CLIENT_ID` | **Basic Information** → App Credentials | Yes |\n| **Client secret** | `SLACK_CLIENT_SECRET` | **Basic Information** → App Credentials | Yes |\n| **Allowed workspace ID** | `SLACK_TEAM_ID` | Workspace URL segment `T…` in `app.slack.com/client/T…` | No, but recommended so connections stay on your workspace |\n\nEach field shows where its value comes from: **Saved here**, **Using** followed by the variable name, or **Not set**. **Remove saved value** makes the environment variable the source again. If Data Hub can’t read a saved value, for example because `INTEGRATION_SECRETS_KEY` changed, it uses the environment variable in the meantime.\n\nTwo settings exist only as environment variables, and changing them needs a redeploy:\n\n| Variable | Purpose | Required |\n| -------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------- |\n| `SLACK_STATE_SECRET` | Signs the short-lived value Data Hub checks while a person connects Slack. Generate with `openssl rand -base64 32` | No (falls back to `AUTH_SECRET`) |\n| `SLACK_REDIRECT_URI` | Full callback URL, when it differs from the request origin | No |\n\nMembers can then connect under **Settings → Notifications**. Deploy runbooks are linked from [Deploy Data Hub](/docs/self-hosting).\n\n## Slack channel for new runs [#slack-channel]\n\nThe **Slack channel** card holds a Slack incoming webhook. Data Hub posts one message to that channel each time a new run is reported. This is separate from personal direct messages, which each person turns on for themselves.\n\n\n \n In your Slack app, open **Incoming Webhooks**, turn them on, and add a webhook for the channel you want.\n \n\n \n Copy the webhook URL. It starts with `https://hooks.slack.com/services/`.\n \n\n \n Paste it into the **Slack channel** card and save. The card shows **Configured** and who last changed it.\n \n\n\nTo change channels, paste a new webhook URL. To stop the messages, remove the webhook. This setting has no environment variable.\n"
},
{
"page": "api",
@@ -124,7 +124,7 @@
"page": "mcp",
"title": "Overview",
"description": "Connect an AI client to Data Hub over the Model Context Protocol, including OAuth sign-in, the read and write scopes, and per-client setup.",
- "markdown": "# Overview (/docs/mcp)\n\n\n\n**For:** Developers and integrators\n\nData Hub exposes a Model Context Protocol (MCP) server at `/mcp/v1` so AI clients can query instruments, runs, files, and watcher health. Clients authenticate by sending you through a browser sign-in.\n\nCatalogs for [tools](/docs/mcp/tools), [prompts](/docs/mcp/prompts), and [resources](/docs/mcp/resources) are generated from the live server schema, so they match what your client discovers when it connects.\n\n## Authentication [#authentication]\n\nThe MCP server authenticates with OAuth. All you need to provide is the endpoint URL; for example, `https://datahub.example.com/mcp/v1`.\n\nThe first connection opens your browser:\n\n\n \n Sign in to Data Hub with Google, unless you already have a session open.\n \n\n \n Read the **Authorize application** screen. It names the client, the host it redirects to, and the access it’s asking for.\n \n\n \n Click **Allow**.\n \n\n\nYour client stores the access token it receives and renews it without asking again. **Allow** grants everything on the screen: there are no per-permission checkboxes, so click **Deny** if any line looks wrong.\n\n### Scopes [#scopes]\n\nMCP uses two scopes rather than the `:` scopes that gate REST requests:\n\n* **`read`**: required to connect at all, and covers every read-only tool, resource, and prompt\n* **`write`**: additionally required by tools that change something, including claiming runs, commenting, requesting uploads, reprocessing, and deleting\n\nMost clients ask for both, because the server advertises both. A client granted only `read` still connects and still reads; its first mutating tool call fails with a message naming the missing scope.\n\nEach entry in the [tools catalog](/docs/mcp/tools) lists the scope its REST equivalent requires, which tells you what data the tool touches. Over MCP, only `read` and `write` are enforced.\n\nThe client acts as you, with your role. Admin-only actions, such as deleting someone else’s comment, work over MCP only when you are an admin. Claiming runs and sending feedback work because a signed-in person is behind the client, even though API tokens can’t do either.\n\n## Connect a client [#connect-a-client]\n\nExamples use `https://datahub.example.com`; substitute your deployment host.\n\n### Claude Code [#claude-code]\n\nRegister the server with [`claude mcp add`](https://code.claude.com/docs/en/mcp-quickstart). Run this in your terminal, not inside a `claude` session:\n\n```bash\nclaude mcp add --transport http --scope user data-hub https://datahub.example.com/mcp/v1\n```\n\nConfirm that the server was added by running `claude mcp list`. Then, start a session with `claude` and run `/mcp` to authenticate.\n\n### Claude Desktop [#claude-desktop]\n\nRemote servers go through **Settings → Connectors → Add custom connector**. The `claude_desktop_config.json` file handles local servers only. Paste `https://datahub.example.com/mcp/v1` as the URL, click **Add**, then click **Connect** to sign in. Leave the OAuth client fields under **Advanced settings** empty, since Data Hub registers clients on demand.\n\n\n Claude reaches your server from Anthropic’s infrastructure rather than from your own machine, so the deployment has to be reachable from the public internet. A deployment running on `localhost` can’t be added this way; use Claude Code or Cursor for local work.\n\n\n### Cursor [#cursor]\n\nAdd the server to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` for every project:\n\n```json\n{\n \"mcpServers\": {\n \"data-hub\": {\n \"url\": \"https://datahub.example.com/mcp/v1\"\n }\n }\n}\n```\n\nOpen **Settings → Tools & MCP**. The server appears with a **Needs authentication** label; click it to start the browser flow. The tools show up once you approve the grant.\n\n### Other clients [#other-clients]\n\nAny client that speaks Streamable HTTP and the MCP authorization spec needs one setting, the URL `https://datahub.example.com/mcp/v1`. For local development, point at `http://localhost:3000/mcp/v1` instead.\n\nClients that speak MCP but not OAuth can bridge through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which runs the browser flow on their behalf.\n\n## Questions about Data Hub [#questions-about-data-hub]\n\nThe assistant can answer questions about Data Hub itself, such as how to install a watcher or what a run status means. It searches the documentation and the [changelog](/docs/send-feedback#changelog), reads the page that fits, and links it in its answer. Two tools do this, and both need only the `read` scope:\n\n* `search_docs` returns up to five matching sections, each with a short excerpt and a link\n* `read_doc` returns one page, or one section of a page, as Markdown\n\nThe answers come from the docs that ship with your deployment, so they match the version you run, including a staging deployment that is ahead of the published site. Clients that let you attach resources by hand can also attach any page as `datahub://docs/`, for example `datahub://docs/manage-tokens`.\n\nThe assistant reads the guides and the changelog. It doesn’t read the generated [API](/docs/api) endpoint pages or the [tools](/docs/mcp/tools), [prompts](/docs/mcp/prompts), and [resources](/docs/mcp/resources) catalogs, because the client already receives the catalog when it connects. When the docs don’t cover a question, the assistant says so and offers to [send feedback](#feedback) about the gap.\n\n## Interactive run reports [#interactive-run-reports]\n\nClients that support MCP Apps, an extension of the Model Context Protocol, show the same interactive run report as the web app when the assistant calls `get_run_report`. The report includes image carousels, plate maps, and the Aunty plate with its well charts. Clients without MCP Apps support get the same data as text and describe it instead.\n\n## Feedback [#feedback]\n\nThe assistant can send [feedback about Data Hub](/docs/send-feedback) for you, for example after a tool call fails. It shows you the draft first and sends it only after you agree. Sending feedback needs only the `read` scope. The assistant can also list the reports you sent and read one in full, by report ID or by Linear issue ID such as `ENG-1476`. Feedback tools return an error saying feedback isn’t set up until an admin [connects Linear](/docs/integrations#linear).\n\nProblems with one run’s data belong in a run comment instead, which the assistant can also post.\n\n## Machine-readable catalog [#machine-readable-catalog]\n\nFetch the same catalog these pages render from:\n\n```bash\ncurl https://datahub.example.com/mcp/v1/schema.json\n```\n\nIt lists every tool, prompt, and resource, with JSON Schema for arguments and each tool's response. The schema document needs no authentication.\n"
+ "markdown": "# Overview (/docs/mcp)\n\n\n\n\n\n**For:** Developers and integrators\n\nData Hub exposes a Model Context Protocol (MCP) server at `/mcp/v1` so AI clients can query instruments, runs, files, and watcher health. Clients authenticate by sending you through a browser sign-in.\n\nCatalogs for [tools](/docs/mcp/tools), [prompts](/docs/mcp/prompts), and [resources](/docs/mcp/resources) are generated from the live server schema, so they match what your client discovers when it connects.\n\n## Authentication [#authentication]\n\nThe MCP server authenticates with OAuth. All you need to provide is the endpoint URL; for example, `https://datahub.example.com/mcp/v1`.\n\nThe first connection opens your browser:\n\n\n \n Sign in to Data Hub with Google, unless you already have a session open.\n \n\n \n Read the **Authorize application** screen. It names the client, the host it redirects to, and the access it’s asking for.\n \n\n \n Click **Allow**.\n \n\n\n
\n\nYour client stores the access token it receives and renews it without asking again. **Allow** grants everything on the screen: there are no per-permission checkboxes, so click **Deny** if any line looks wrong.\n\n### Scopes [#scopes]\n\nMCP uses two scopes rather than the `:` scopes that gate REST requests:\n\n* **`read`**: required to connect at all, and covers every read-only tool, resource, and prompt\n* **`write`**: additionally required by tools that change something, including claiming runs, commenting, requesting uploads, reprocessing, and deleting\n\nMost clients ask for both, because the server advertises both. A client granted only `read` still connects and still reads; its first mutating tool call fails with a message naming the missing scope.\n\nEach entry in the [tools catalog](/docs/mcp/tools) lists the scope its REST equivalent requires, which tells you what data the tool touches. Over MCP, only `read` and `write` are enforced.\n\nThe client acts as you, with your role. Admin-only actions, such as deleting someone else’s comment, work over MCP only when you are an admin. Claiming runs and sending feedback work because a signed-in person is behind the client, even though API tokens can’t do either.\n\n## Connect a client [#connect-a-client]\n\nExamples use `https://datahub.example.com`; substitute your deployment host.\n\n### Claude Code [#claude-code]\n\nRegister the server with [`claude mcp add`](https://code.claude.com/docs/en/mcp-quickstart). Run this in your terminal, not inside a `claude` session:\n\n```bash\nclaude mcp add --transport http --scope user data-hub https://datahub.example.com/mcp/v1\n```\n\nConfirm that the server was added by running `claude mcp list`. Then, start a session with `claude` and run `/mcp` to authenticate.\n\n### Claude Desktop [#claude-desktop]\n\nRemote servers go through **Settings → Connectors → Add custom connector**. The `claude_desktop_config.json` file handles local servers only. Paste `https://datahub.example.com/mcp/v1` as the URL, click **Add**, then click **Connect** to sign in. Leave the OAuth client fields under **Advanced settings** empty, since Data Hub registers clients on demand.\n\n\n Claude reaches your server from Anthropic’s infrastructure rather than from your own machine, so the deployment has to be reachable from the public internet. A deployment running on `localhost` can’t be added this way; use Claude Code or Cursor for local work.\n\n\n### Cursor [#cursor]\n\nAdd the server to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` for every project:\n\n```json\n{\n \"mcpServers\": {\n \"data-hub\": {\n \"url\": \"https://datahub.example.com/mcp/v1\"\n }\n }\n}\n```\n\nOpen **Settings → Tools & MCP**. The server appears with a **Needs authentication** label; click it to start the browser flow. The tools show up once you approve the grant.\n\n### Other clients [#other-clients]\n\nAny client that speaks Streamable HTTP and the MCP authorization spec needs one setting, the URL `https://datahub.example.com/mcp/v1`. For local development, point at `http://localhost:3000/mcp/v1` instead.\n\nClients that speak MCP but not OAuth can bridge through [`mcp-remote`](https://www.npmjs.com/package/mcp-remote), which runs the browser flow on their behalf.\n\n## Questions about Data Hub [#questions-about-data-hub]\n\nThe assistant can answer questions about Data Hub itself, such as how to install a watcher or what a run status means. It searches the documentation and the [changelog](/docs/send-feedback#changelog), reads the page that fits, and links it in its answer. Two tools do this, and both need only the `read` scope:\n\n* `search_docs` returns up to five matching sections, each with a short excerpt and a link\n* `read_doc` returns one page, or one section of a page, as Markdown\n\nThe answers come from the docs that ship with your deployment, so they match the version you run, including a staging deployment that is ahead of the published site. Clients that let you attach resources by hand can also attach any page as `datahub://docs/`, for example `datahub://docs/manage-tokens`.\n\nThe assistant reads the guides and the changelog. It doesn’t read the generated [API](/docs/api) endpoint pages or the [tools](/docs/mcp/tools), [prompts](/docs/mcp/prompts), and [resources](/docs/mcp/resources) catalogs, because the client already receives the catalog when it connects. When the docs don’t cover a question, the assistant says so and offers to [send feedback](#feedback) about the gap.\n\n## Interactive run reports [#interactive-run-reports]\n\nClients that support MCP Apps, an extension of the Model Context Protocol, show the same interactive run report as the web app when the assistant calls `get_run_report`. The report includes image carousels, plate maps, and the Aunty plate with its well charts. Clients without MCP Apps support get the same data as text and describe it instead.\n\n## Feedback [#feedback]\n\nThe assistant can send [feedback about Data Hub](/docs/send-feedback) for you, for example after a tool call fails. It shows you the draft first and sends it only after you agree. Sending feedback needs only the `read` scope. The assistant can also list the reports you sent and read one in full, by report ID or by Linear issue ID such as `ENG-1476`. Feedback tools return an error saying feedback isn’t set up until an admin [connects Linear](/docs/integrations#linear).\n\nProblems with one run’s data belong in a run comment instead, which the assistant can also post.\n\n## Machine-readable catalog [#machine-readable-catalog]\n\nFetch the same catalog these pages render from:\n\n```bash\ncurl https://datahub.example.com/mcp/v1/schema.json\n```\n\nIt lists every tool, prompt, and resource, with JSON Schema for arguments and each tool's response. The schema document needs no authentication.\n"
},
{
"page": "concepts",
diff --git a/web/next.config.mjs b/web/next.config.mjs
index d6058b76..23485b5f 100644
--- a/web/next.config.mjs
+++ b/web/next.config.mjs
@@ -64,4 +64,7 @@ export default {
...withMfe,
outputFileTracingRoot: appDir,
turbopack: { ...withMfe.turbopack, root: appDir },
+ // `npm run docs:screenshots` runs its own dev server. A separate output
+ // folder keeps it from fighting a running `make dev` over `.next`.
+ ...(process.env.NEXT_DIST_DIR ? { distDir: process.env.NEXT_DIST_DIR } : {}),
};
diff --git a/web/package-lock.json b/web/package-lock.json
index 080efb52..f6237a76 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -67,8 +67,10 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"drizzle-kit": "^0.31.10",
+ "playwright": "^1.64.1",
"postcss": "^8",
"shadcn": "^4.1.1",
+ "sharp": "^0.35.5",
"tailwindcss": "^4.2.1",
"tsx": "^4.23.1",
"typescript": "^5.9.3",
@@ -2688,8 +2690,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+ "devOptional": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -12994,6 +12996,35 @@
"node": ">=8"
}
},
+ "node_modules/playwright": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
+ "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.64.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.64.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
+ "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
@@ -13981,8 +14012,8 @@
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
+ "devOptional": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
@@ -14031,8 +14062,8 @@
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "devOptional": true,
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/web/package.json b/web/package.json
index 27c9af9a..eb000b95 100644
--- a/web/package.json
+++ b/web/package.json
@@ -23,6 +23,7 @@
"db:reseed": "npm run db:reset && npm run db:push && npm run db:seed",
"db:process-fixtures": "tsx scripts/process-seeded-fixtures.ts",
"db:clear-runs": "tsx scripts/clear-run-file-records.ts",
+ "docs:screenshots": "tsx scripts/docs-screenshots/run.ts",
"openapi:generate": "tsx scripts/generate-openapi.ts",
"mcp-catalog:generate": "tsx scripts/generate-mcp-catalog.ts",
"test:unit": "vitest run --config vitest.unit.config.ts",
@@ -91,8 +92,10 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"drizzle-kit": "^0.31.10",
+ "playwright": "^1.64.1",
"postcss": "^8",
"shadcn": "^4.1.1",
+ "sharp": "^0.35.5",
"tailwindcss": "^4.2.1",
"tsx": "^4.23.1",
"typescript": "^5.9.3",
diff --git a/web/scripts/docs-screenshots/capture.ts b/web/scripts/docs-screenshots/capture.ts
new file mode 100644
index 00000000..b5056303
--- /dev/null
+++ b/web/scripts/docs-screenshots/capture.ts
@@ -0,0 +1,197 @@
+// Takes a screenshot of each shot in a light and a dark browser window, signed
+// in as the seeded admin. Every capture gets a fresh window, because the app
+// remembers things in the browser (such as which instruments were visited
+// last) and a shot should not depend on the ones before it.
+
+import { mkdirSync } from "node:fs";
+import path from "node:path";
+import { type Browser, chromium, type Page } from "playwright";
+import {
+ CLOSEUP_VIEWPORT,
+ DEVICE_SCALE_FACTOR,
+ WINDOW_VIEWPORT,
+} from "./constants";
+import { TIME_ZONE, WORK_DIR } from "./environment";
+import type { World } from "./setup";
+import type { Shot, Theme } from "./types";
+
+// The dev server's build indicator and any toast would otherwise end up in the
+// image. The indicator also sits over the account menu and would block clicks
+// on it, so this applies for the whole session and not only the capture.
+const HIDE_CSS = `
+ nextjs-portal,
+ [data-nextjs-toast],
+ [data-sonner-toaster] { display: none !important; }
+`;
+
+// Runs in the browser before the page's own scripts. React removes elements it
+// did not render when it hydrates, so the style is added again whenever it goes
+// missing. It is a string because `tsx` wraps named functions in a helper that
+// does not exist in the browser, which makes a function version fail silently.
+const HIDE_OVERLAYS_SCRIPT = `
+ (() => {
+ const id = "docs-screenshots-hide";
+ const css = ${JSON.stringify(HIDE_CSS)};
+ const ensure = () => {
+ // At document start there is no yet. The observer calls this
+ // again once it exists.
+ const target = document.head ?? document.documentElement;
+ if (!target || document.getElementById(id)) {
+ return;
+ }
+ const style = document.createElement("style");
+ style.id = id;
+ style.textContent = css;
+ target.appendChild(style);
+ };
+ ensure();
+ new MutationObserver(ensure).observe(document, {
+ childList: true,
+ subtree: true,
+ });
+ })();
+`;
+
+// Breathing room around a close-up, in CSS pixels.
+const CLOSEUP_MARGIN = 32;
+
+// The first visit to a route makes the dev server compile it.
+const NAVIGATION_TIMEOUT_MS = 180_000;
+
+export async function launchBrowser(): Promise {
+ return await chromium.launch();
+}
+
+// Waits for fonts, images, and the network to go quiet. The app polls, so a
+// quiet network is a best effort and never fails the shot.
+export async function settle(page: Page): Promise {
+ await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {
+ // Polling kept the network busy. The next checks still apply.
+ });
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ await Promise.all(
+ Array.from(document.images).map((image) =>
+ image.complete ? undefined : image.decode().catch(() => undefined)
+ )
+ );
+ });
+ await page.waitForTimeout(400);
+}
+
+// The app prints its own origin in a few places, such as the Linear callback
+// URL. The port changes on every run, so the docs show the placeholder host.
+async function maskOrigin(page: Page, origin: string): Promise {
+ await page.evaluate(
+ ({ from, to }) => {
+ const walker = document.createTreeWalker(
+ document.body,
+ NodeFilter.SHOW_TEXT
+ );
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ if (node.nodeValue?.includes(from)) {
+ node.nodeValue = node.nodeValue.replaceAll(from, to);
+ }
+ }
+ for (const input of document.querySelectorAll("input, textarea")) {
+ const field = input as HTMLInputElement;
+ if (field.value.includes(from)) {
+ field.value = field.value.replaceAll(from, to);
+ }
+ }
+ },
+ { from: origin, to: "https://datahub.example.com" }
+ );
+}
+
+export async function captureShot(
+ browser: Browser,
+ shot: Shot,
+ theme: Theme,
+ world: World
+): Promise {
+ const base = shot.closeup ? CLOSEUP_VIEWPORT : WINDOW_VIEWPORT;
+ const viewport = {
+ width: shot.viewport?.width ?? base.width,
+ height: shot.viewport?.height ?? base.height,
+ };
+ const context = await browser.newContext({
+ viewport,
+ // The app's content security policy blocks the injected style that hides
+ // the dev server's overlays. Those overlays also block clicks.
+ bypassCSP: true,
+ deviceScaleFactor: DEVICE_SCALE_FACTOR,
+ colorScheme: theme,
+ locale: "en-US",
+ timezoneId: TIME_ZONE,
+ });
+ const cookie = await world.cookieFor(world.admin.userId);
+ await context.addCookies([
+ { name: cookie.name, value: cookie.value, url: world.baseUrl },
+ ]);
+ context.setDefaultTimeout(30_000);
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
+ await context.addInitScript({ content: HIDE_OVERLAYS_SCRIPT });
+ const page = await context.newPage();
+
+ try {
+ const route =
+ typeof shot.path === "string" ? shot.path : await shot.path(world);
+ await page.goto(`${world.baseUrl}${route}`, { waitUntil: "load" });
+ await shot.ready(page);
+ // A click before the page's scripts have loaded does nothing. That happens
+ // on the first visit to a route, while the dev server is still compiling.
+ await settle(page);
+ await shot.steps?.({ page, theme, world });
+ await settle(page);
+ await maskOrigin(page, world.baseUrl);
+
+ const options = {
+ type: "png",
+ animations: "disabled",
+ caret: "hide",
+ style: HIDE_CSS,
+ } as const;
+
+ if (!shot.closeup) {
+ return await page.screenshot(options);
+ }
+
+ const box = await shot.closeup(page).boundingBox();
+ if (!box) {
+ throw new Error("The close-up element is not on screen");
+ }
+ const x = Math.max(0, box.x - CLOSEUP_MARGIN);
+ const y = Math.max(0, box.y - CLOSEUP_MARGIN);
+ return await page.screenshot({
+ ...options,
+ clip: {
+ x,
+ y,
+ width: Math.min(viewport.width - x, box.width + CLOSEUP_MARGIN * 2),
+ height: Math.min(viewport.height - y, box.height + CLOSEUP_MARGIN * 2),
+ },
+ });
+ } catch (err) {
+ await saveDebugScreenshot(page, shot.name, theme);
+ throw err;
+ } finally {
+ await context.close();
+ }
+}
+
+// A failed shot leaves its page behind in the scratch folder, because the
+// error message rarely says what was on screen.
+async function saveDebugScreenshot(
+ page: Page,
+ name: string,
+ theme: Theme
+): Promise {
+ const dir = path.join(WORK_DIR, "failures");
+ mkdirSync(dir, { recursive: true });
+ await page
+ .screenshot({ path: path.join(dir, `${name}.${theme}.png`) })
+ .catch(() => {
+ // The page may already be closed.
+ });
+}
diff --git a/web/scripts/docs-screenshots/constants.ts b/web/scripts/docs-screenshots/constants.ts
new file mode 100644
index 00000000..2d7f67af
--- /dev/null
+++ b/web/scripts/docs-screenshots/constants.ts
@@ -0,0 +1,36 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
+const REPO_DIR = path.resolve(SCRIPT_DIR, "..", "..", "..");
+
+// Better Auth drops the `__Secure-` prefix on an `http://` origin.
+export const SESSION_COOKIE_NAME = "better-auth.session_token";
+
+export const IMAGES_DIR = path.join(
+ REPO_DIR,
+ "docs",
+ "site",
+ "public",
+ "images"
+);
+export const MANIFEST_FILE = path.join(
+ REPO_DIR,
+ "docs",
+ "site",
+ "src",
+ "lib",
+ "screenshots.generated.ts"
+);
+
+// Matches the docs home page's code sample, which uses the same placeholder
+// host for a deployment.
+export const PLACEHOLDER_HOST = "datahub.example.com";
+
+export const WINDOW_VIEWPORT = { width: 1440, height: 800 } as const;
+// A close-up is cropped to one element, so this only sets how much page there
+// is around it. Popovers and dialogs size themselves to it.
+export const CLOSEUP_VIEWPORT = { width: 1440, height: 900 } as const;
+// Twice the pixels keeps text sharp on high-density screens.
+export const DEVICE_SCALE_FACTOR = 2;
+export const WEBP_QUALITY = 82;
diff --git a/web/scripts/docs-screenshots/environment.ts b/web/scripts/docs-screenshots/environment.ts
new file mode 100644
index 00000000..4f7e7451
--- /dev/null
+++ b/web/scripts/docs-screenshots/environment.ts
@@ -0,0 +1,327 @@
+// Everything the screenshot run starts and stops: the throwaway database, the
+// local file mirror, and a dev server with its own output folder. It uses
+// `next dev` because the local file mirror, which serves the gel images, PDFs,
+// and plate maps, is switched off in production builds.
+
+import type { ChildProcess } from "node:child_process";
+import { execSync, spawn } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import net from "node:net";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { makeSignature } from "better-auth/crypto";
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
+import { Client } from "pg";
+import { CREATE_NATURAL_FILENAME_COLLATION } from "@/lib/db/collations";
+import { sessions } from "@/lib/db/schema";
+import { SESSION_COOKIE_NAME } from "./constants";
+
+const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
+export const WEB_DIR = path.resolve(SCRIPT_DIR, "..", "..");
+export const REPO_DIR = path.resolve(WEB_DIR, "..");
+export const WORK_DIR = path.join(WEB_DIR, ".docs-screenshots");
+export const MIRROR_DIR = path.join(WORK_DIR, "s3");
+const PID_FILE = path.join(WORK_DIR, "server.pid");
+const NEXT_BIN = path.join(
+ WEB_DIR,
+ "node_modules",
+ "next",
+ "dist",
+ "bin",
+ "next"
+);
+
+const DEFAULT_PG_URL = "postgres://postgres:postgres@127.0.0.1:5432";
+export const DATABASE_NAME = "data_hub_screenshots";
+
+// Files in `web/` that `next dev` edits when it starts.
+const REWRITTEN_BY_NEXT = ["next-env.d.ts", "tsconfig.json", "AGENTS.md"];
+
+// Fixed so repeat runs show the same dates and times regardless of the
+// machine's own time zone. The browser context uses the same one.
+export const TIME_ZONE = "America/Los_Angeles";
+
+// Not secret: this server only ever holds seeded data on a loopback port.
+const AUTH_SECRET = "docs-screenshots-secret-at-least-32-characters";
+const INTEGRATION_SECRETS_KEY = "b".repeat(64);
+
+export function postgresUrl(): string {
+ return process.env.DOCS_SCREENSHOTS_PG_URL ?? DEFAULT_PG_URL;
+}
+
+export function databaseUrl(): string {
+ return `${postgresUrl()}/${DATABASE_NAME}`;
+}
+
+// Drops and recreates the schema instead of the whole database so a running
+// `psql` session or a leftover connection can't block the reset.
+export async function resetDatabase(): Promise {
+ const admin = new Client({ connectionString: `${postgresUrl()}/postgres` });
+ await admin.connect();
+ try {
+ const existing = await admin.query(
+ "SELECT 1 FROM pg_database WHERE datname = $1",
+ [DATABASE_NAME]
+ );
+ if (existing.rows.length === 0) {
+ // CREATE DATABASE can't take parameters; the name is a constant above.
+ await admin.query(`CREATE DATABASE ${DATABASE_NAME}`);
+ }
+ } finally {
+ await admin.end();
+ }
+
+ const client = new Client({ connectionString: databaseUrl() });
+ await client.connect();
+ try {
+ await client.query("DROP SCHEMA public CASCADE");
+ await client.query("CREATE SCHEMA public");
+ // Push installs neither the trigram extension nor the filename collation
+ // that the schema's indexes need.
+ await client.query("CREATE EXTENSION IF NOT EXISTS pg_trgm");
+ await client.query(CREATE_NATURAL_FILENAME_COLLATION);
+ } finally {
+ await client.end();
+ }
+
+ execSync("npx drizzle-kit push --force", {
+ cwd: WEB_DIR,
+ env: { ...process.env, DATABASE_URL: databaseUrl() },
+ stdio: "pipe",
+ });
+}
+
+export function resetMirror(): void {
+ rmSync(MIRROR_DIR, { recursive: true, force: true });
+ mkdirSync(MIRROR_DIR, { recursive: true });
+}
+
+function getFreePort(): Promise {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.listen(0, () => {
+ const address = server.address();
+ if (address && typeof address === "object") {
+ const { port } = address;
+ server.close(() => resolve(port));
+ } else {
+ reject(new Error("Could not get a free port"));
+ }
+ });
+ server.on("error", reject);
+ });
+}
+
+export interface DevServer {
+ baseUrl: string;
+ stop: () => Promise;
+}
+
+// Variables the developer's own `web/.env` might set. Next.js never overrides a
+// variable that is already defined, so an empty value keeps them out.
+const BLANKED_ENV = [
+ "ADMIN_EMAILS",
+ "AWS_ROLE_ARN",
+ "AWS_SESSION_TOKEN",
+ "CRON_SECRET",
+ "DISHCAM_LOCAL_PREVIEW_DIR",
+ "LAMBDA_FUNCTION_URL",
+ "LAMBDA_INVOKE_TOKEN",
+ "MCP_ALLOW_PAT_AUTH",
+ "OAUTH_PROXY_SECRET",
+ "OAUTH_PROXY_URL",
+ "S3_ARCHIVES_BUCKET",
+ "SLACK_BOT_TOKEN",
+ "SLACK_CLIENT_ID",
+ "SLACK_CLIENT_SECRET",
+ "SLACK_REDIRECT_URI",
+ "SLACK_STATE_SECRET",
+ "SLACK_TEAM_ID",
+ "STALLED_PROCESSING_AFTER_MINUTES",
+ "VERCEL",
+ "VERCEL_ENV",
+ "VERCEL_PROJECT_PRODUCTION_URL",
+ "VERCEL_URL",
+] as const;
+
+async function waitForServer(url: string, timeoutMs = 180_000): Promise {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ try {
+ const res = await fetch(url, { redirect: "manual" });
+ if (res.status < 500) {
+ return;
+ }
+ } catch {
+ // Not listening yet.
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ throw new Error(`The dev server did not answer at ${url} in time`);
+}
+
+// Puts the files `next dev` rewrote back from their backups. Backups are kept
+// until `deleteBackups` is set, so the files can be restored a second time.
+function restoreFiles(deleteBackups: boolean): void {
+ for (const name of REWRITTEN_BY_NEXT) {
+ const backup = path.join(WORK_DIR, `${name}.bak`);
+ if (!existsSync(backup)) {
+ continue;
+ }
+ copyFileSync(backup, path.join(WEB_DIR, name));
+ if (deleteBackups) {
+ rmSync(backup, { force: true });
+ }
+ }
+}
+
+// A run that was killed abnormally can leave its dev server behind, and Next
+// refuses to start a second one in the same output folder. The process group
+// ID is saved so the next run can stop the old one.
+function stopStaleServer(): void {
+ if (!existsSync(PID_FILE)) {
+ return;
+ }
+ const pid = Number(readFileSync(PID_FILE, "utf8"));
+ if (Number.isInteger(pid) && pid > 1) {
+ try {
+ process.kill(-pid, "SIGTERM");
+ } catch {
+ // Already gone.
+ }
+ }
+ rmSync(PID_FILE, { force: true });
+ // Gives the old server a moment to release its port and lock file.
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500);
+}
+
+export async function startDevServer(options: {
+ linearApiUrl: string;
+}): Promise {
+ const port = await getFreePort();
+ const baseUrl = `http://localhost:${port}`;
+
+ mkdirSync(WORK_DIR, { recursive: true });
+ stopStaleServer();
+ // Backups still here mean the last run died before it restored the files.
+ // They hold the originals, so put them back before taking new backups.
+ restoreFiles(true);
+ // `next dev` rewrites these files to suit its own output folder. Restoring
+ // them keeps `npm run typecheck` working and the git tree clean afterwards.
+ for (const name of REWRITTEN_BY_NEXT) {
+ const file = path.join(WEB_DIR, name);
+ if (existsSync(file)) {
+ copyFileSync(file, path.join(WORK_DIR, `${name}.bak`));
+ }
+ }
+
+ const env: NodeJS.ProcessEnv = { ...process.env };
+ for (const key of BLANKED_ENV) {
+ env[key] = "";
+ }
+ Object.assign(env, {
+ NODE_ENV: "development",
+ NEXT_DIST_DIR: path.relative(WEB_DIR, path.join(WORK_DIR, "next")),
+ NEXT_TELEMETRY_DISABLED: "1",
+ DATABASE_URL: databaseUrl(),
+ AUTH_SECRET,
+ AUTH_GOOGLE_ID: "stub",
+ AUTH_GOOGLE_SECRET: "stub",
+ BETTER_AUTH_URL: baseUrl,
+ NEXT_PUBLIC_DOCS_BASE_URL: "https://datahub.arcadiascience.com",
+ LOCAL_S3_MIRROR: MIRROR_DIR,
+ S3_RAW_DATA_BUCKET: "test-raw-data-bucket",
+ AWS_ACCESS_KEY_ID: "docs-screenshots",
+ AWS_SECRET_ACCESS_KEY: "docs-screenshots",
+ AWS_REGION: "us-east-1",
+ INTEGRATION_SECRETS_KEY,
+ __TEST_LINEAR_API_URL: options.linearApiUrl,
+ TZ: TIME_ZONE,
+ });
+
+ const child: ChildProcess = spawn(
+ process.execPath,
+ [NEXT_BIN, "dev", "--turbopack", "-p", String(port)],
+ // A new process group lets `stop` end Next's own child processes too.
+ { cwd: WEB_DIR, env, stdio: "pipe", detached: true }
+ );
+ if (child.pid) {
+ writeFileSync(PID_FILE, String(child.pid));
+ }
+ let recentOutput = "";
+ const remember = (data: Buffer) => {
+ recentOutput = (recentOutput + data.toString()).slice(-4000);
+ };
+ child.stdout?.on("data", remember);
+ child.stderr?.on("data", remember);
+
+ // Synchronous so it can also run from the `exit` event, which is the only
+ // hook left after Ctrl-C. Skipping it would leave the dev server running.
+ let stopped = false;
+ const stopNow = () => {
+ if (stopped) {
+ return;
+ }
+ stopped = true;
+ rmSync(PID_FILE, { force: true });
+ if (child.pid && child.exitCode === null) {
+ try {
+ process.kill(-child.pid, "SIGTERM");
+ } catch {
+ // Already gone.
+ }
+ }
+ restoreFiles(false);
+ };
+ // Nothing can wait after `exit`, so this restores for good.
+ process.once("exit", () => {
+ stopNow();
+ restoreFiles(true);
+ });
+ const stop = async () => {
+ stopNow();
+ // Next may touch the files once more as it shuts down.
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ restoreFiles(true);
+ };
+
+ try {
+ await waitForServer(baseUrl);
+ } catch (err) {
+ await stop();
+ throw new Error(`${(err as Error).message}\n${recentOutput}`);
+ }
+ return { baseUrl, stop };
+}
+
+// Better Auth reads this cookie, signed with the server's secret. Writing the
+// session row directly avoids the dev-only sign-in form, so the run doesn't
+// depend on it.
+export async function createSessionCookie(
+ db: NodePgDatabase>,
+ userId: string
+): Promise<{ name: string; value: string }> {
+ const token = randomBytes(32).toString("base64url");
+ const now = new Date();
+ await db.insert(sessions).values({
+ id: crypto.randomUUID(),
+ token,
+ userId,
+ expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000),
+ createdAt: now,
+ updatedAt: now,
+ });
+ const signature = await makeSignature(token, AUTH_SECRET);
+ return {
+ name: SESSION_COOKIE_NAME,
+ value: encodeURIComponent(`${token}.${signature}`),
+ };
+}
diff --git a/web/scripts/docs-screenshots/output.ts b/web/scripts/docs-screenshots/output.ts
new file mode 100644
index 00000000..42344c3e
--- /dev/null
+++ b/web/scripts/docs-screenshots/output.ts
@@ -0,0 +1,154 @@
+// Turns captured PNGs into the WebP files the docs site serves, and writes the
+// small generated file that tells the docs site which images exist, what
+// address to show in the browser frame, and which images are close-ups.
+
+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import sharp from "sharp";
+import {
+ DEVICE_SCALE_FACTOR,
+ IMAGES_DIR,
+ MANIFEST_FILE,
+ PLACEHOLDER_HOST,
+ WEBP_QUALITY,
+} from "./constants";
+import { WORK_DIR } from "./environment";
+import type { ManualShot, Shot, Theme } from "./types";
+
+export function imagePath(name: string, theme: Theme): string {
+ return path.join(
+ IMAGES_DIR,
+ theme === "dark" ? `${name}.dark.webp` : `${name}.webp`
+ );
+}
+
+export async function writeImage(
+ name: string,
+ theme: Theme,
+ png: Buffer
+): Promise {
+ mkdirSync(IMAGES_DIR, { recursive: true });
+ const info = await sharp(png)
+ .webp({ quality: WEBP_QUALITY, effort: 6, smartSubsample: true })
+ .toFile(imagePath(name, theme));
+ return info.size;
+}
+
+function identifier(name: string, theme: Theme): string {
+ const camel = name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase());
+ return `${camel}${theme === "dark" ? "Dark" : "Light"}`;
+}
+
+export interface ResolvedShot {
+ address: string;
+ shot: Shot;
+}
+
+// Only lists shots whose light and dark files both exist, so the docs site
+// never imports a file that is missing.
+export function writeManifest(resolved: readonly ResolvedShot[]): void {
+ const present = resolved.filter(
+ ({ shot }) =>
+ existsSync(imagePath(shot.name, "light")) &&
+ existsSync(imagePath(shot.name, "dark"))
+ );
+
+ const imports = present.flatMap(({ shot }) =>
+ (["light", "dark"] as const).map(
+ (theme) =>
+ `import ${identifier(shot.name, theme)} from "../../public/images/${path.basename(imagePath(shot.name, theme))}";`
+ )
+ );
+ const entries = present.map(({ shot, address }) => {
+ const kind = shot.closeup ? "closeup" : "window";
+ return ` "${shot.name}": {\n address: ${JSON.stringify(address)},\n dark: ${identifier(shot.name, "dark")},\n kind: "${kind}",\n light: ${identifier(shot.name, "light")},\n },`;
+ });
+
+ const source = `// Generated by \`npm run docs:screenshots\` in \`web/\`. Do not edit by hand.
+import type { StaticImageData } from "next/image";
+${imports.join("\n")}
+
+/** Pixels in the image for each CSS pixel on screen. */
+export const SCREENSHOT_SCALE = ${DEVICE_SCALE_FACTOR};
+
+export interface Screenshot {
+ /** Shown in the browser frame, on a placeholder host. */
+ address: string;
+ dark: StaticImageData;
+ /** Close-ups have no browser frame. */
+ kind: "closeup" | "window";
+ light: StaticImageData;
+}
+
+export const screenshots: Record = {
+${entries.join("\n")}
+};
+`;
+ writeFileSync(MANIFEST_FILE, source);
+}
+
+export function displayAddress(
+ pathAndQuery: string,
+ override?: string
+): string {
+ const pathname = override ?? pathAndQuery;
+ return `${PLACEHOLDER_HOST}${pathname === "/" ? "" : pathname}`;
+}
+
+function ageInDays(date: string): number {
+ return Math.floor((Date.now() - new Date(date).getTime()) / 86_400_000);
+}
+
+export interface RunReport {
+ failures: { error: string; name: string }[];
+ manual: readonly ManualShot[];
+ taken: string[];
+ /** Problems that did not stop a screenshot, such as a failed cleanup. */
+ warnings: { error: string; name: string }[];
+}
+
+export function writeSummary(report: RunReport): string {
+ const lines: string[] = [];
+ lines.push(
+ "Automated refresh of the docs screenshots. Each image is a light and a dark WebP file in `docs/site/public/images/`.",
+ ""
+ );
+ lines.push(`Retaken: ${report.taken.length}`, "");
+ if (report.failures.length > 0) {
+ lines.push(
+ "## Screens that failed",
+ "",
+ "These keep their previous images. Fix the steps in `web/scripts/docs-screenshots/shots.ts`, then retake them with `make docs-screenshots ONLY=`.",
+ ""
+ );
+ for (const failure of report.failures) {
+ lines.push(`- \`${failure.name}\`: ${failure.error.split("\n")[0]}`);
+ }
+ lines.push("");
+ }
+ if (report.warnings.length > 0) {
+ lines.push("## Warnings", "");
+ for (const warning of report.warnings) {
+ lines.push(`- \`${warning.name}\`: ${warning.error.split("\n")[0]}`);
+ }
+ lines.push("");
+ }
+ if (report.manual.length > 0) {
+ lines.push(
+ "## Screenshots taken by hand",
+ "",
+ "The script cannot capture these.",
+ ""
+ );
+ for (const shot of report.manual) {
+ lines.push(
+ `- \`${shot.name}\`, last taken ${shot.lastTaken} (${ageInDays(shot.lastTaken)} days ago). ${shot.note}`
+ );
+ }
+ lines.push("");
+ }
+ const text = lines.join("\n");
+ mkdirSync(WORK_DIR, { recursive: true });
+ writeFileSync(path.join(WORK_DIR, "summary.md"), text);
+ return text;
+}
diff --git a/web/scripts/docs-screenshots/run.ts b/web/scripts/docs-screenshots/run.ts
new file mode 100644
index 00000000..871e2b7c
--- /dev/null
+++ b/web/scripts/docs-screenshots/run.ts
@@ -0,0 +1,281 @@
+// Retakes the docs screenshots from a throwaway local database.
+//
+// npm run docs:screenshots # every screenshot
+// npm run docs:screenshots -- --only home,my-runs
+// npm run docs:screenshots -- --skip-seed # reuse the last run's data
+// npm run docs:screenshots -- --serve # seed and start the app, then wait
+//
+// See docs/developer/local-development.md for what it needs.
+
+import { spawnSync } from "node:child_process";
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { parseArgs } from "node:util";
+import { asc, eq, ne } from "drizzle-orm";
+import { drizzle } from "drizzle-orm/node-postgres";
+import { Pool } from "pg";
+// biome-ignore lint/performance/noNamespaceImport: drizzle scripts need the full schema module for Db typing
+import * as schema from "@/lib/db/schema";
+import { SEED_ADMIN_EMAIL } from "@/lib/db/seed";
+import { processSeededFixtures } from "../process-fixtures";
+import { seedWorkspace } from "../seed-workspace";
+import { captureShot, launchBrowser } from "./capture";
+import {
+ type DevServer,
+ databaseUrl,
+ MIRROR_DIR,
+ resetDatabase,
+ resetMirror,
+ startDevServer,
+ WORK_DIR,
+} from "./environment";
+import {
+ displayAddress,
+ type ResolvedShot,
+ type RunReport,
+ writeImage,
+ writeManifest,
+ writeSummary,
+} from "./output";
+import {
+ addSampleTokens,
+ createWorld,
+ keepWatchersOnline,
+ startFakeLinear,
+ type World,
+} from "./setup";
+import { MANUAL_SHOTS, SHOTS } from "./shots";
+import { THEMES } from "./types";
+
+const { values: args } = parseArgs({
+ options: {
+ only: { type: "string" },
+ "skip-seed": { type: "boolean", default: false },
+ serve: { type: "boolean", default: false },
+ },
+});
+
+function selectShots() {
+ if (!args.only) {
+ return SHOTS;
+ }
+ const wanted = new Set(args.only.split(",").map((name) => name.trim()));
+ const unknown = [...wanted].filter(
+ (name) => !SHOTS.some((shot) => shot.name === name)
+ );
+ if (unknown.length > 0) {
+ throw new Error(
+ `Unknown screenshot: ${unknown.join(", ")}. Names are listed in shots.ts.`
+ );
+ }
+ return SHOTS.filter((shot) => wanted.has(shot.name));
+}
+
+function hasCommand(command: string): boolean {
+ return spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0;
+}
+
+// The first request to an API route makes the dev server compile it, and the
+// fixture step gives each request only a couple of seconds.
+async function warmUpApi(baseUrl: string, token: string): Promise {
+ const res = await fetch(`${baseUrl}/api/v1/instruments?per_page=1`, {
+ headers: { Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(180_000),
+ });
+ if (!res.ok) {
+ throw new Error(
+ `The app's API answered ${res.status} to a warm-up request`
+ );
+ }
+}
+
+async function loadSeededWorkspace(pool: Pool) {
+ const db = drizzle(pool, { schema });
+ const [admin] = await db
+ .select({ id: schema.users.id, email: schema.users.email })
+ .from(schema.users)
+ .where(eq(schema.users.email, SEED_ADMIN_EMAIL))
+ .limit(1);
+ if (!admin) {
+ throw new Error(
+ "The screenshot database has no seeded data. Run without --skip-seed first."
+ );
+ }
+ const teammates = await db
+ .select({
+ id: schema.users.id,
+ name: schema.users.name,
+ email: schema.users.email,
+ })
+ .from(schema.users)
+ .where(ne(schema.users.email, SEED_ADMIN_EMAIL))
+ .orderBy(asc(schema.users.name));
+ const runs = await db
+ .select({
+ id: schema.instrumentRuns.id,
+ instrumentId: schema.instrumentRuns.instrumentId,
+ runId: schema.instrumentRuns.runId,
+ })
+ .from(schema.instrumentRuns);
+ return {
+ admin: { userId: admin.id, email: admin.email, token: "" },
+ runs,
+ teammates,
+ };
+}
+
+async function main(): Promise {
+ const shots = selectShots();
+ mkdirSync(WORK_DIR, { recursive: true });
+ rmSync(path.join(WORK_DIR, "failures"), { recursive: true, force: true });
+
+ const seed = !args["skip-seed"];
+ if (seed) {
+ resetMirror();
+ await resetDatabase();
+ }
+ // The seed and the fixture step read this when they copy and process files.
+ process.env.LOCAL_S3_MIRROR = MIRROR_DIR;
+
+ const pool = new Pool({ connectionString: databaseUrl() });
+ const fakeLinear = await startFakeLinear();
+ let server: DevServer | null = null;
+ let world: World | null = null;
+
+ try {
+ const workspace = seed
+ ? await seedWorkspace(drizzle(pool, { schema }))
+ : await loadSeededWorkspace(pool);
+
+ console.log("Starting the app…");
+ server = await startDevServer({ linearApiUrl: fakeLinear.url });
+
+ if (seed) {
+ if (hasCommand("uv")) {
+ await warmUpApi(server.baseUrl, workspace.admin.token);
+ await processSeededFixtures(drizzle(pool, { schema }), {
+ apiKey: workspace.admin.token,
+ apiUrl: `${server.baseUrl}/api/v1`,
+ });
+ } else {
+ console.warn(
+ "uv is not installed, so sample files are not processed. Reports and plots will be missing."
+ );
+ }
+ }
+
+ world = createWorld({
+ ...workspace,
+ baseUrl: server.baseUrl,
+ databaseUrl: databaseUrl(),
+ linearApiUrl: fakeLinear.url,
+ });
+ if (seed) {
+ await addSampleTokens(world);
+ }
+
+ if (args.serve) {
+ const cookie = await world.cookieFor(world.admin.userId);
+ writeFileSync(
+ path.join(WORK_DIR, "serve.json"),
+ JSON.stringify({ baseUrl: server.baseUrl, cookie }, null, 2)
+ );
+ console.log(`\nServing at ${server.baseUrl}. Press Ctrl-C to stop.`);
+ await new Promise(() => {
+ // Waits for a signal. The handlers below stop everything.
+ });
+ }
+
+ return await takeScreenshots(world, shots);
+ } finally {
+ await world?.close();
+ await server?.stop();
+ await fakeLinear.close();
+ await pool.end();
+ }
+}
+
+async function takeScreenshots(
+ world: World,
+ shots: typeof SHOTS
+): Promise {
+ const report: RunReport = {
+ failures: [],
+ manual: MANUAL_SHOTS,
+ taken: [],
+ warnings: [],
+ };
+ const browser = await launchBrowser();
+
+ try {
+ for (const shot of shots) {
+ console.log(`\n${shot.name}`);
+ try {
+ await keepWatchersOnline(world);
+ await shot.prepare?.(world);
+ const images = new Map<(typeof THEMES)[number], Buffer>();
+ for (const theme of THEMES) {
+ try {
+ images.set(theme, await captureShot(browser, shot, theme, world));
+ } catch (err) {
+ throw new Error(
+ `${theme}: ${err instanceof Error ? err.message : String(err)}`
+ );
+ }
+ }
+ // Both themes or neither, so the docs never mix old and new images.
+ for (const theme of THEMES) {
+ const bytes = await writeImage(
+ shot.name,
+ theme,
+ images.get(theme) as Buffer
+ );
+ console.log(` ${theme}: ${Math.round(bytes / 1024)} KB`);
+ }
+ report.taken.push(shot.name);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(` FAILED: ${message.split("\n")[0]}`);
+ report.failures.push({ name: shot.name, error: message });
+ } finally {
+ // A cleanup that fails leaves changed data behind for later shots, so
+ // it is reported, but it must not stop the run before the summary.
+ try {
+ await shot.cleanup?.(world);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error(` CLEANUP FAILED: ${message.split("\n")[0]}`);
+ report.warnings.push({
+ name: shot.name,
+ error: `Cleanup failed, so later screenshots may show leftover data. ${message}`,
+ });
+ }
+ }
+ }
+ } finally {
+ await browser.close();
+ }
+
+ const resolved: ResolvedShot[] = [];
+ for (const shot of SHOTS) {
+ const route =
+ typeof shot.path === "string" ? shot.path : await shot.path(world);
+ resolved.push({
+ shot,
+ address: displayAddress(route, shot.address),
+ });
+ }
+ writeManifest(resolved);
+
+ console.log(`\n${writeSummary(report)}`);
+ return report.failures.length > 0 ? 1 : 0;
+}
+
+for (const signal of ["SIGINT", "SIGTERM"] as const) {
+ process.on(signal, () => {
+ // The `exit` handler in `environment.ts` stops the dev server.
+ process.exit(130);
+ });
+}
+
+process.exit(await main());
diff --git a/web/scripts/docs-screenshots/setup.ts b/web/scripts/docs-screenshots/setup.ts
new file mode 100644
index 00000000..01421cea
--- /dev/null
+++ b/web/scripts/docs-screenshots/setup.ts
@@ -0,0 +1,402 @@
+// Data that exists only for screenshots, added on top of the regular seed. The
+// shared seed stays as it is, so `make db-reseed` is unaffected.
+
+import {
+ LINEAR_STATES,
+ LINEAR_TEAM_ID,
+ startLinearFakeServer,
+} from "@arcadiascience/app-feedback-toolkit/testing";
+import { and, eq, sql } from "drizzle-orm";
+import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
+import { Pool } from "pg";
+import { SCOPE_PRESETS } from "@/lib/api/scope-catalog";
+// biome-ignore lint/performance/noNamespaceImport: drizzle scripts need the full schema module for Db typing
+import * as schema from "@/lib/db/schema";
+import type { SeededRun, SeededTeammate } from "@/lib/db/seed";
+import { generateToken, getTokenPrefix, hashToken } from "@/lib/tokens";
+import { createSessionCookie } from "./environment";
+
+type Db = NodePgDatabase;
+
+export interface Cookie {
+ name: string;
+ value: string;
+}
+
+export interface World {
+ admin: { email: string; token: string; userId: string };
+ baseUrl: string;
+ close: () => Promise;
+ cookieFor: (userId: string) => Promise;
+ db: Db;
+ /** Forgets the Linear setup, so the integrations page shows its first step. */
+ disconnectLinear: () => Promise;
+ /** Connects the fake Linear workspace and files sample reports. Safe to repeat. */
+ ensureLinearConnected: () => Promise;
+ /** Calls the app's own API as a signed-in user. */
+ fetchAs: (
+ userId: string,
+ path: string,
+ init?: { body?: unknown; method?: string }
+ ) => Promise;
+ runs: SeededRun[];
+ teammates: SeededTeammate[];
+}
+
+const LINEAR_SETTINGS = "/api/v1/settings/integrations/linear";
+
+interface SampleReport {
+ description: string;
+ kind: "bug" | "feature_request" | "other";
+ reporter: number;
+ resolved?: boolean;
+ title: string;
+}
+
+// Written the way a lab member would, so the review page looks lived in.
+const SAMPLE_REPORTS: readonly SampleReport[] = [
+ {
+ kind: "bug",
+ reporter: 0,
+ title: "Plate map colors don't match the legend on 384-well runs",
+ description:
+ "On a 384-well kinetic run the wells in the bottom rows use the same blue as the legend's lowest value, even when the reading is much higher.",
+ },
+ {
+ kind: "feature_request",
+ reporter: 1,
+ title: "Download only the processed files for a run",
+ description:
+ "When I only need the CSVs and plots, I still get the raw files in the zip. A filter on the Download all button would save a lot of time on large runs.",
+ },
+ {
+ kind: "bug",
+ reporter: 2,
+ title: "Run stays on Processing after I reprocess it",
+ description:
+ "I reprocessed a gel doc run after fixing the exposure setting and the status never left Processing. Refreshing the page did not help.",
+ },
+ {
+ kind: "other",
+ reporter: 3,
+ resolved: true,
+ title: "Which watcher version do I need on Windows 11?",
+ description:
+ "The new lab PC runs Windows 11 and I could not tell from the docs whether the latest watcher version supports it.",
+ },
+];
+
+export function createWorld(options: {
+ admin: { email: string; token: string; userId: string };
+ baseUrl: string;
+ databaseUrl: string;
+ linearApiUrl: string;
+ runs: SeededRun[];
+ teammates: SeededTeammate[];
+}): World {
+ const pool = new Pool({ connectionString: options.databaseUrl });
+ const db = drizzle(pool, { schema });
+ const cookies = new Map();
+
+ const cookieFor = async (userId: string): Promise => {
+ const cached = cookies.get(userId);
+ if (cached) {
+ return cached;
+ }
+ const cookie = await createSessionCookie(db, userId);
+ cookies.set(userId, cookie);
+ return cookie;
+ };
+
+ const fetchAs: World["fetchAs"] = async (userId, path, init = {}) => {
+ const cookie = await cookieFor(userId);
+ return await fetch(`${options.baseUrl}${path}`, {
+ method: init.method ?? "GET",
+ headers: {
+ Cookie: `${cookie.name}=${cookie.value}`,
+ "Content-Type": "application/json",
+ },
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
+ });
+ };
+
+ async function expectOk(res: Response, what: string): Promise {
+ if (!res.ok) {
+ throw new Error(`${what} failed with ${res.status}: ${await res.text()}`);
+ }
+ }
+
+ let linearReady: Promise | null = null;
+ const ensureLinearConnected = () => {
+ linearReady ??= connectLinear();
+ return linearReady;
+ };
+
+ // A database reused with `--skip-seed` may already have Linear connected from
+ // an earlier run, and the integrations shot needs the unconnected state.
+ const disconnectLinear = async () => {
+ linearReady = null;
+ await expectOk(
+ await fetchAs(options.admin.userId, LINEAR_SETTINGS, {
+ method: "DELETE",
+ }),
+ "Disconnecting Linear"
+ );
+ };
+
+ async function connectLinear(): Promise {
+ const adminId = options.admin.userId;
+ await expectOk(
+ await fetchAs(adminId, `${LINEAR_SETTINGS}/connect`, {
+ method: "POST",
+ body: { client_id: "docs-screenshots", client_secret: "unused" },
+ }),
+ "Connecting Linear"
+ );
+ await expectOk(
+ await fetchAs(adminId, LINEAR_SETTINGS, {
+ method: "PUT",
+ body: {
+ webhook_secret: "whsec_docs_screenshots",
+ team: { id: LINEAR_TEAM_ID, name: "Data Hub", key: "DH" },
+ },
+ }),
+ "Saving the Linear team"
+ );
+
+ for (const report of SAMPLE_REPORTS) {
+ const reporter = options.teammates[report.reporter];
+ const res = await fetchAs(reporter.id, "/api/v1/feedback", {
+ method: "POST",
+ body: {
+ kind: report.kind,
+ title: report.title,
+ description: report.description,
+ },
+ });
+ await expectOk(res, `Filing "${report.title}"`);
+ if (report.resolved) {
+ const { feedback } = (await res.json()) as { feedback: { id: string } };
+ await expectOk(
+ await fetch(`${options.linearApiUrl}/__test/issues`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: feedback.id,
+ state: LINEAR_STATES.done,
+ completedAt: new Date().toISOString(),
+ }),
+ }),
+ "Closing the sample report"
+ );
+ }
+ }
+ }
+
+ return {
+ admin: options.admin,
+ baseUrl: options.baseUrl,
+ close: async () => {
+ await pool.end();
+ },
+ cookieFor,
+ db,
+ disconnectLinear,
+ ensureLinearConnected,
+ fetchAs,
+ runs: options.runs,
+ teammates: options.teammates,
+ };
+}
+
+export async function startFakeLinear() {
+ return await startLinearFakeServer();
+}
+
+// A watcher counts as online when its last heartbeat is under five minutes
+// old, and a full run takes longer than that after seeding. This moves every
+// heartbeat and event forward by the same amount, so the newest one is two
+// minutes old and the history around it keeps its shape.
+export async function keepWatchersOnline(world: World): Promise {
+ const result = await world.db.execute(
+ sql`SELECT extract(epoch FROM (now() - interval '2 minutes' - max(last_heartbeat_at))) AS seconds
+ FROM watchers WHERE last_heartbeat_at IS NOT NULL`
+ );
+ const seconds = Number(result.rows[0]?.seconds);
+ if (!(seconds > 0)) {
+ return;
+ }
+ const shift = sql`make_interval(secs => ${seconds})`;
+ await world.db.transaction(async (tx) => {
+ await tx.execute(
+ sql`UPDATE watchers SET last_heartbeat_at = last_heartbeat_at + ${shift}
+ WHERE last_heartbeat_at IS NOT NULL`
+ );
+ await tx.execute(
+ sql`UPDATE watcher_heartbeats SET timestamp = timestamp + ${shift}, created_at = created_at + ${shift}`
+ );
+ await tx.execute(
+ sql`UPDATE watcher_events SET timestamp = timestamp + ${shift}, created_at = created_at + ${shift}`
+ );
+ });
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+// The seed's one token has the old full-access scope and a name that looks
+// like test data. Replace it with the tokens an admin would actually have.
+export async function addSampleTokens(world: World): Promise {
+ const now = Date.now();
+ const preset = (id: string) =>
+ SCOPE_PRESETS.find((p) => p.id === id)?.scopes ?? [];
+
+ await world.db
+ .update(schema.personalAccessTokens)
+ .set({
+ name: "Processing Lambda",
+ scopes: preset("lambda"),
+ createdAt: new Date(now - 60 * DAY_MS),
+ })
+ .where(eq(schema.personalAccessTokens.createdBy, world.admin.userId));
+
+ const samples = [
+ {
+ name: "FPLC watcher, Lab 201",
+ scopes: preset("watcher"),
+ createdAt: new Date(now - 30 * DAY_MS),
+ lastUsedAt: new Date(now - 3 * 60_000),
+ expiresAt: new Date(now + 60 * DAY_MS),
+ },
+ {
+ name: "Plate reader analysis notebook",
+ scopes: preset("read-only"),
+ createdAt: new Date(now - 12 * DAY_MS),
+ lastUsedAt: new Date(now - 2 * DAY_MS),
+ expiresAt: new Date(now + 78 * DAY_MS),
+ },
+ ];
+ for (const sample of samples) {
+ const plaintext = generateToken();
+ await world.db.insert(schema.personalAccessTokens).values({
+ createdBy: world.admin.userId,
+ tokenHash: hashToken(plaintext),
+ tokenPrefix: getTokenPrefix(plaintext),
+ ...sample,
+ });
+ }
+}
+
+const PENDING_RUN_ID = "20260316_101845_330";
+
+// The seed has no files waiting for approval, and the Hina microscope uploads
+// only after someone approves each file. The run is removed again afterwards
+// so the dashboard's pending count goes back to zero for the other shots.
+export async function addPendingUploadRun(world: World): Promise {
+ const [watcher] = await world.db
+ .select({ id: schema.watchers.id })
+ .from(schema.watchers)
+ .where(eq(schema.watchers.instrumentId, "hina-microscope"))
+ .limit(1);
+ const now = Date.now();
+ const [run] = await world.db
+ .insert(schema.instrumentRuns)
+ .values({
+ instrumentId: "hina-microscope",
+ runId: PENDING_RUN_ID,
+ source: "watcher",
+ watcherId: watcher?.id,
+ acquiredAt: new Date(now - 25 * 60_000),
+ })
+ .onConflictDoNothing()
+ .returning({ id: schema.instrumentRuns.id });
+ if (!run) {
+ return;
+ }
+ await world.db.insert(schema.files).values(
+ ["A01", "A02", "A03", "B01", "B02", "B03"].map((well, index) => ({
+ instrumentRunId: run.id,
+ relativePath: `Well${well}_Point${well}_0000_ChannelBF,FITC,TRITC_Seq000${index}.nd2`,
+ filename: `Well${well}_Point${well}_0000_ChannelBF,FITC,TRITC_Seq000${index}.nd2`,
+ contentType: "image/nd2",
+ sizeBytes: 32_190_464,
+ category: "raw" as const,
+ status: "detected" as const,
+ detectedAt: new Date(now - 20 * 60_000),
+ fileCreatedAt: new Date(now - 24 * 60_000),
+ }))
+ );
+}
+
+export async function removePendingUploadRun(world: World): Promise {
+ const match = and(
+ eq(schema.instrumentRuns.instrumentId, "hina-microscope"),
+ eq(schema.instrumentRuns.runId, PENDING_RUN_ID)
+ );
+ const [run] = await world.db
+ .select({ id: schema.instrumentRuns.id })
+ .from(schema.instrumentRuns)
+ .where(match)
+ .limit(1);
+ if (!run) {
+ return;
+ }
+ // Files reference the run, so they go first.
+ await world.db
+ .delete(schema.files)
+ .where(eq(schema.files.instrumentRunId, run.id));
+ await world.db.delete(schema.instrumentRuns).where(match);
+}
+
+export function pendingUploadRunPath(): string {
+ return `/instruments/hina-microscope/runs/${PENDING_RUN_ID}`;
+}
+
+export async function setRunDeleted(
+ world: World,
+ instrumentId: string,
+ runId: string,
+ deleted: boolean
+): Promise {
+ await world.db
+ .update(schema.instrumentRuns)
+ .set(
+ deleted
+ ? { deletedAt: new Date(), deletedBy: world.admin.userId }
+ : { deletedAt: null, deletedBy: null }
+ )
+ .where(
+ and(
+ eq(schema.instrumentRuns.instrumentId, instrumentId),
+ eq(schema.instrumentRuns.runId, runId)
+ )
+ );
+}
+
+// The consent page names the app asking for access, so it needs a client row.
+export async function insertConsentClient(
+ world: World,
+ clientId: string
+): Promise {
+ const existing = await world.db
+ .select({ id: schema.oauthClients.id })
+ .from(schema.oauthClients)
+ .where(eq(schema.oauthClients.clientId, clientId))
+ .limit(1);
+ if (existing.length > 0) {
+ return;
+ }
+ const now = new Date();
+ await world.db.insert(schema.oauthClients).values({
+ id: crypto.randomUUID(),
+ clientId,
+ name: "Claude",
+ redirectUris: ["https://claude.ai/api/mcp/auth_callback"],
+ scopes: ["openid", "read", "write"],
+ tokenEndpointAuthMethod: "none",
+ grantTypes: ["authorization_code"],
+ responseTypes: ["code"],
+ public: true,
+ createdAt: now,
+ updatedAt: now,
+ });
+}
diff --git a/web/scripts/docs-screenshots/shots.ts b/web/scripts/docs-screenshots/shots.ts
new file mode 100644
index 00000000..b21a2ad9
--- /dev/null
+++ b/web/scripts/docs-screenshots/shots.ts
@@ -0,0 +1,471 @@
+// Every docs screenshot the script retakes, in the order it takes them.
+//
+// To add one: add an entry here, run
+// `npm run docs:screenshots -- --only `, then reference
+// `/images/.webp` from a docs page. Order matters in two places. The
+// integrations shot comes before any feedback shot, because feedback connects
+// Linear. The shots that change data come last, and undo their change.
+
+import { eq } from "drizzle-orm";
+import type { Locator, Page } from "playwright";
+import { personalAccessTokens, watchers } from "@/lib/db/schema";
+import {
+ addPendingUploadRun,
+ insertConsentClient,
+ pendingUploadRunPath,
+ removePendingUploadRun,
+ setRunDeleted,
+ type World,
+} from "./setup";
+import type { ManualShot, Shot } from "./types";
+
+// Shown in the browser frame where the real address holds a random ID.
+const EXAMPLE_ID = "3f2a9c1e-7b4d-4e0a-9c55-1d2e8f6a7b90";
+
+// Run pages are long, so they get a taller window than the default.
+const RUN_PAGE = { height: 1000 } as const;
+
+const GEL_DOC_RUN = "/instruments/azure-600-gel-doc/runs/26.02.02_10.45.05";
+const PLATE_READER_RUN =
+ "/instruments/spectramax-id5-plate-reader/runs/260721_OD750_AAA";
+const KINETIC_RUN =
+ "/instruments/spectramax-id5-plate-reader/runs/260710_OD595_kinetic_EEE";
+
+const DELETED_RUN = {
+ instrumentId: "spectramax-id5-plate-reader",
+ runId: "260720_OD595_flat_BBB",
+} as const;
+
+const TOKEN_NAME = "Gel doc watcher, Lab 204";
+// Same length as a real token, so the layout does not change.
+const PLACEHOLDER_TOKEN = `dhub_${"0123456789abcdef".repeat(4)}`;
+
+const waitFor = (page: Page, text: string | RegExp) =>
+ page.getByText(text).first().waitFor();
+
+const connectLinear = (world: World) => world.ensureLinearConnected();
+
+async function openAccountMenu(page: Page, item: string | RegExp) {
+ await page.getByText("Alice Zimmerman").first().click();
+ await page.getByRole("menuitem", { name: item }).click();
+ await page.getByRole("dialog").waitFor();
+}
+
+// Retiring asks for confirmation in an alert dialog, which has its own role.
+async function openInstrumentMenu(
+ page: Page,
+ item: string,
+ row: RegExp,
+ dialogRole: "dialog" | "alertdialog" = "dialog"
+) {
+ await page.getByRole("row", { name: row }).getByRole("button").last().click();
+ await page.getByRole("menuitem", { name: item }).click();
+ await page.getByRole(dialogRole).waitFor();
+}
+
+// Tabs are server-rendered, so a click that lands before hydration is lost.
+// Clicking again after a short wait gets past that without a fixed delay.
+async function clickUntilVisible(
+ target: Locator,
+ expected: Locator,
+ attempts = 5
+) {
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ await target.click();
+ try {
+ await expected.first().waitFor({ timeout: 3000 });
+ return;
+ } catch (err) {
+ if (attempt === attempts) {
+ throw err;
+ }
+ }
+ }
+}
+
+async function scrollToBottom(page: Page) {
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+}
+
+async function fillTokenForm(page: Page) {
+ const dialog = page.getByRole("dialog");
+ await dialog.getByPlaceholder(/Plate Reader PC/).fill(TOKEN_NAME);
+ await dialog.getByRole("button", { name: "Watcher", exact: true }).click();
+}
+
+export const SHOTS: Shot[] = [
+ // Web app tour, from Browse and analyze runs.
+ {
+ name: "home",
+ pages: ["browse-runs", "home"],
+ path: "/",
+ ready: (page) => waitFor(page, "Recent instrument runs"),
+ },
+ {
+ name: "plate-reader-runs-table",
+ pages: ["browse-runs"],
+ path: "/instruments/spectramax-id5-plate-reader",
+ ready: (page) => waitFor(page, "260721_OD750_AAA"),
+ },
+ {
+ name: "my-runs",
+ pages: ["browse-runs"],
+ path: (world) => `/users/${world.admin.userId}`,
+ address: `/users/${EXAMPLE_ID}`,
+ ready: (page) => waitFor(page, "Recent instrument runs"),
+ },
+ {
+ name: "search-modal",
+ pages: ["browse-runs"],
+ path: "/",
+ ready: (page) => waitFor(page, "Recent instrument runs"),
+ steps: async ({ page }) => {
+ await page.keyboard.press("Control+k");
+ const input = page.getByPlaceholder(/Search runs, files/);
+ await input.fill("colony");
+ await waitFor(page, /results for "colony"/);
+ },
+ },
+ {
+ name: "gel-doc-run-details",
+ pages: ["browse-runs"],
+ path: GEL_DOC_RUN,
+ viewport: RUN_PAGE,
+ ready: (page) => waitFor(page, "Report Data"),
+ },
+ {
+ name: "plate-reader-run-details-1",
+ pages: ["browse-runs", "home"],
+ path: PLATE_READER_RUN,
+ viewport: RUN_PAGE,
+ ready: (page) => waitFor(page, "Plate Maps"),
+ },
+ {
+ name: "plate-reader-run-details-2",
+ pages: ["browse-runs"],
+ path: KINETIC_RUN,
+ viewport: RUN_PAGE,
+ ready: (page) => waitFor(page, "Plate Maps"),
+ steps: async ({ page }) => {
+ await waitFor(page, "Comments (1)");
+ await scrollToBottom(page);
+ },
+ },
+ {
+ name: "bulk-actions",
+ pages: ["browse-runs"],
+ path: "/instruments/azure-600-gel-doc",
+ ready: (page) => waitFor(page, "26.01.26_15.10.30"),
+ steps: async ({ page }) => {
+ for (const runId of [
+ "26.01.26_15.10.30",
+ "26.01.05_09.30.00",
+ "26.01.12_14.22.18",
+ ]) {
+ await page
+ .getByRole("row", { name: new RegExp(runId.replaceAll(".", "\\.")) })
+ .getByRole("checkbox")
+ .check();
+ }
+ await waitFor(page, "3 runs selected");
+ },
+ },
+ {
+ name: "gel-doc-run-comments",
+ pages: ["browse-runs"],
+ path: GEL_DOC_RUN,
+ viewport: RUN_PAGE,
+ ready: (page) => waitFor(page, "Comments (3)"),
+ steps: async ({ page }) => {
+ await scrollToBottom(page);
+ },
+ },
+ {
+ name: "comments-page",
+ pages: ["browse-runs"],
+ path: "/comments",
+ ready: (page) => waitFor(page, "All comments"),
+ },
+ {
+ name: "csv-preview",
+ pages: ["browse-runs"],
+ path: PLATE_READER_RUN,
+ viewport: RUN_PAGE,
+ closeup: (page) => page.getByRole("dialog"),
+ ready: (page) => waitFor(page, "260721_OD750_AAA_raw_well_data.csv"),
+ steps: async ({ page }) => {
+ await page.getByText("260721_OD750_AAA_raw_well_data.csv").click();
+ await page.getByRole("dialog").getByRole("table").waitFor();
+ },
+ },
+ {
+ name: "notification-bell",
+ pages: ["configure-notifications"],
+ path: "/",
+ closeup: (page) => page.getByRole("dialog"),
+ ready: (page) => waitFor(page, "Recent instrument runs"),
+ steps: async ({ page }) => {
+ await page
+ .getByRole("button", { name: /notification/i })
+ .first()
+ .click();
+ await waitFor(page, "Mark all read");
+ },
+ },
+ {
+ name: "notifications",
+ pages: ["configure-notifications"],
+ path: "/settings/notifications",
+ ready: (page) => waitFor(page, "Notifications"),
+ },
+
+ // Instruments, watchers, and run processing.
+ {
+ name: "instruments-table",
+ pages: ["manage-instruments"],
+ path: "/instruments",
+ ready: (page) => waitFor(page, "Agilent 4150 TapeStation"),
+ },
+ {
+ name: "instruments-pending",
+ pages: ["set-up-an-instrument", "manage-instruments"],
+ path: "/instruments?tab=pending",
+ ready: (page) => page.getByRole("button", { name: "Confirm" }).waitFor(),
+ },
+ {
+ name: "instrument-edit-dialog",
+ pages: ["manage-instruments", "instrument-preprocessing"],
+ path: "/instruments",
+ address: "/instruments",
+ closeup: (page) => page.getByRole("dialog"),
+ ready: (page) => waitFor(page, "Azure 600 Gel Doc"),
+ steps: async ({ page }) => {
+ await openInstrumentMenu(page, "Edit details", /Azure 600 Gel Doc/);
+ // The dialog selects the name shortly after it opens. Waiting, then
+ // moving the cursor to the end, clears the highlight.
+ await page.waitForTimeout(500);
+ await page
+ .getByRole("dialog")
+ .getByRole("textbox")
+ .first()
+ .evaluate((input: HTMLInputElement) => {
+ input.setSelectionRange(input.value.length, input.value.length);
+ });
+ },
+ },
+ {
+ name: "instrument-retire-dialog",
+ pages: ["retire-an-instrument"],
+ path: "/instruments",
+ closeup: (page) => page.getByRole("alertdialog"),
+ ready: (page) => waitFor(page, "Azure 600 Gel Doc"),
+ steps: async ({ page }) => {
+ await openInstrumentMenu(
+ page,
+ "Retire",
+ /Azure 600 Gel Doc/,
+ "alertdialog"
+ );
+ },
+ },
+ {
+ name: "watchers-list",
+ pages: ["manage-watchers"],
+ path: "/watchers",
+ ready: (page) => waitFor(page, "Azure 600 Gel Doc"),
+ },
+ {
+ name: "watcher-detail",
+ pages: ["manage-watchers"],
+ path: async (world) => {
+ const [watcher] = await world.db
+ .select({ id: watchers.id })
+ .from(watchers)
+ .where(eq(watchers.instrumentId, "azure-600-gel-doc"))
+ .limit(1);
+ return `/watchers/${watcher.id}`;
+ },
+ address: `/watchers/${EXAMPLE_ID}`,
+ ready: (page) => waitFor(page, "Event Log"),
+ steps: async ({ page }) => {
+ await clickUntilVisible(
+ page.getByText("Status", { exact: true }).first(),
+ page.getByText("Connectivity")
+ );
+ },
+ },
+ {
+ name: "qpcr-run-detail",
+ pages: ["instrument-preprocessing"],
+ path: "/instruments/azure-cielo-qpcr/runs/Experiment_20260129",
+ viewport: { height: 900 },
+ ready: (page) => waitFor(page, "Melting Curves"),
+ steps: async ({ page }) => {
+ // Channel 1 of the sample file is flat. Channel 2 has real curves.
+ await page
+ .getByRole("combobox", { name: "Channel", exact: true })
+ .click();
+ await page.getByRole("option", { name: "Channel2" }).click();
+ await page.locator('[data-well="A1"]').waitFor();
+ },
+ },
+ {
+ name: "aunty-run-detail",
+ pages: ["instrument-preprocessing"],
+ path: "/instruments/unchained-labs-aunty/runs/2026-03-15T09-18-44",
+ viewport: { height: 900 },
+ ready: (page) => waitFor(page, "Report Data"),
+ },
+
+ // Settings.
+ {
+ name: "workspace-members",
+ pages: ["manage-members"],
+ path: "/settings/members",
+ ready: (page) => waitFor(page, "bob@example.com"),
+ },
+ {
+ name: "watcher-settings",
+ pages: ["watcher-releases"],
+ path: "/settings/watchers",
+ ready: (page) => waitFor(page, "Latest version"),
+ },
+ {
+ name: "tokens-list",
+ pages: ["manage-tokens", "set-up-an-instrument"],
+ path: "/settings/tokens",
+ ready: (page) => waitFor(page, "FPLC watcher, Lab 201"),
+ },
+ {
+ name: "token-create-dialog",
+ pages: ["manage-tokens", "set-up-an-instrument"],
+ path: "/settings/tokens",
+ closeup: (page) => page.getByRole("dialog"),
+ ready: (page) => waitFor(page, "FPLC watcher, Lab 201"),
+ steps: async ({ page }) => {
+ await page.getByRole("button", { name: "Create token" }).click();
+ await fillTokenForm(page);
+ await waitFor(page, "This token will be able to:");
+ },
+ },
+ {
+ name: "token-created",
+ pages: ["manage-tokens"],
+ path: "/settings/tokens",
+ closeup: (page) => page.getByRole("dialog"),
+ ready: (page) => waitFor(page, "FPLC watcher, Lab 201"),
+ steps: async ({ page }) => {
+ await page.getByRole("button", { name: "Create token" }).click();
+ await fillTokenForm(page);
+ await page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Create token" })
+ .click();
+ const token = page.getByRole("dialog").locator("code");
+ await token.waitFor();
+ // The real token is a throwaway, but the docs should never show one.
+ await token.evaluate((el, text) => {
+ el.textContent = text;
+ }, PLACEHOLDER_TOKEN);
+ },
+ // Creating the token wrote a row. Later shots list tokens, so remove it.
+ cleanup: async (world) => {
+ await world.db
+ .delete(personalAccessTokens)
+ .where(eq(personalAccessTokens.name, TOKEN_NAME));
+ },
+ },
+ {
+ // Before any feedback shot, because those connect Linear.
+ name: "integrations",
+ pages: ["integrations"],
+ path: "/settings/integrations",
+ viewport: { height: 1000 },
+ prepare: (world) => world.disconnectLinear(),
+ ready: (page) => waitFor(page, "Connect your Linear app"),
+ },
+
+ // Feedback, which needs Linear connected.
+ {
+ name: "feedback-send-dialog",
+ pages: ["send-feedback"],
+ path: "/",
+ closeup: (page) => page.getByRole("dialog"),
+ prepare: connectLinear,
+ ready: (page) => waitFor(page, "Recent instrument runs"),
+ steps: async ({ page }) => {
+ await openAccountMenu(page, "Feedback");
+ const dialog = page.getByRole("dialog");
+ await dialog
+ .getByPlaceholder(/Export fails/)
+ .fill("Plate map colors don't match the legend");
+ await dialog
+ .getByPlaceholder(/What you expected/)
+ .fill(
+ "On a 384-well kinetic run the bottom rows use the same blue as the legend's lowest value."
+ );
+ },
+ },
+ {
+ name: "feedback-review",
+ pages: ["send-feedback"],
+ path: "/settings/feedback",
+ prepare: connectLinear,
+ ready: (page) => waitFor(page, "Plate map colors don't match the legend"),
+ },
+ {
+ name: "changelog-dialog",
+ pages: ["send-feedback"],
+ path: "/",
+ closeup: (page) => page.getByRole("dialog"),
+ ready: (page) => waitFor(page, "Recent instrument runs"),
+ steps: async ({ page }) => {
+ await openAccountMenu(page, "Changelog");
+ await waitFor(page, /newest first/);
+ },
+ },
+ {
+ name: "mcp-consent",
+ pages: ["mcp"],
+ path: "/consent?client_id=docs-claude&scope=openid%20read%20write",
+ address: "/consent",
+ viewport: { height: 550 },
+ prepare: (world) => insertConsentClient(world, "docs-claude"),
+ ready: (page) => waitFor(page, "Authorize application"),
+ },
+
+ // These change data, so they run last and undo it.
+ {
+ name: "manual-upload",
+ pages: ["manage-watchers", "browse-runs"],
+ path: pendingUploadRunPath(),
+ prepare: addPendingUploadRun,
+ cleanup: removePendingUploadRun,
+ ready: (page) => waitFor(page, /Files \(6 raw/),
+ // Selecting the files opens the bar with Upload and Dismiss. The row
+ // buttons only appear on hover, so this is how the screenshot shows them.
+ steps: async ({ page }) => {
+ await page.getByRole("table").getByRole("checkbox").first().check();
+ await page.getByRole("button", { name: "Dismiss" }).first().waitFor();
+ },
+ },
+ {
+ name: "deleted-run",
+ pages: ["browse-runs"],
+ path: `/instruments/${DELETED_RUN.instrumentId}/runs/${DELETED_RUN.runId}`,
+ prepare: (world) =>
+ setRunDeleted(world, DELETED_RUN.instrumentId, DELETED_RUN.runId, true),
+ cleanup: (world) =>
+ setRunDeleted(world, DELETED_RUN.instrumentId, DELETED_RUN.runId, false),
+ ready: (page) => page.getByRole("button", { name: "Restore" }).waitFor(),
+ },
+];
+
+export const MANUAL_SHOTS: readonly ManualShot[] = [
+ {
+ name: "mcp",
+ lastTaken: "2026-07-22",
+ pages: ["home"],
+ note: "The Claude desktop app answering a question about microscope runs over MCP. Retake it by hand when the Claude app or the MCP setup changes.",
+ },
+];
diff --git a/web/scripts/docs-screenshots/types.ts b/web/scripts/docs-screenshots/types.ts
new file mode 100644
index 00000000..7df508e5
--- /dev/null
+++ b/web/scripts/docs-screenshots/types.ts
@@ -0,0 +1,47 @@
+import type { Page } from "playwright";
+import type { World } from "./setup";
+
+export type Theme = "light" | "dark";
+
+export const THEMES: readonly Theme[] = ["light", "dark"];
+
+export interface ShotContext {
+ page: Page;
+ theme: Theme;
+ world: World;
+}
+
+export interface Shot {
+ /** Address bar text, when the opened path has more than the docs should show. */
+ address?: string;
+ /** Runs after both themes are captured, to undo what `prepare` changed. */
+ cleanup?: (world: World) => Promise;
+ /**
+ * Crops to this element plus some margin. Leave it out for a full window,
+ * which the docs site draws inside a browser frame.
+ */
+ closeup?: (page: Page) => ReturnType;
+ /** File name without the extension. The dark version adds `.dark`. */
+ name: string;
+ /** The docs pages that show it. Used in reports, not to place images. */
+ pages: readonly string[];
+ /** The route to open. Computed from the seeded data when it names a run. */
+ path: string | ((world: World) => string | Promise);
+ /** Runs once, before the first theme. Must be safe to run twice. */
+ prepare?: (world: World) => Promise;
+ /** Resolves when the page shows what the screenshot is about. */
+ ready: (page: Page) => Promise;
+ /** Clicks and typing after the page is ready, in each theme. */
+ steps?: (context: ShotContext) => Promise;
+ /** Taller or wider than the default window, for pages that need it. */
+ viewport?: { height?: number; width?: number };
+}
+
+export interface ManualShot {
+ /** `YYYY-MM-DD`, from the date visible in the image. */
+ lastTaken: string;
+ name: string;
+ /** What the screenshot shows and how to retake it. */
+ note: string;
+ pages: readonly string[];
+}
diff --git a/web/scripts/seed-database.ts b/web/scripts/seed-database.ts
index 6bf4e03a..c3d0eec5 100644
--- a/web/scripts/seed-database.ts
+++ b/web/scripts/seed-database.ts
@@ -14,113 +14,33 @@
// biome-ignore lint/performance/noNamespaceImport: drizzle scripts need the full schema module for Db typing
import * as schema from "@/lib/db/schema";
-import {
- clearAll,
- SEED_ADMIN_EMAIL,
- SEED_ADMIN_NAME,
- SEED_WATCHER_VERSION,
- type SeededRun,
- seedArchiveJobs,
- seedDevUser,
- seedInstrumentSubscriptions,
- seedInstruments,
- seedNotifications,
- seedRunAttributions,
- seedRunComments,
- seedRuns,
- seedSlackChannelConfig,
- seedTeammates,
- seedWatcherReleaseConfig,
- seedWatchers,
-} from "@/lib/db/seed";
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { assertLocalDatabaseUrl } from "./assert-local-db";
import { processSeededFixtures } from "./process-fixtures";
+import { seedWorkspace } from "./seed-workspace";
const databaseUrl = assertLocalDatabaseUrl("db:seed");
const pool = new Pool({ connectionString: databaseUrl });
const db = drizzle(pool, { schema });
-console.log("Clearing existing rows…");
-await clearAll(db);
-
-console.log("Seeding watcher_release_config…");
-await seedWatcherReleaseConfig(db, {
- latestVersion: SEED_WATCHER_VERSION,
- minSupportedVersion: "0.1.0",
-});
-
-console.log("Seeding slack_channel_config…");
-await seedSlackChannelConfig(db);
-
-console.log("Seeding admin user…");
-const { userId, email, token } = await seedDevUser(db, {
- email: SEED_ADMIN_EMAIL,
- name: SEED_ADMIN_NAME,
- isAdmin: true,
-});
-
-console.log("Seeding instruments…");
-const instruments = await seedInstruments(db);
-
-console.log("Seeding watchers + heartbeats + events…");
-await seedWatchers(db, instruments);
-
-console.log("Seeding runs + files…");
-const activeInstruments = instruments.filter((i) => i.status === "active");
-const runs: SeededRun[] = [];
-for (const instrument of activeInstruments) {
- runs.push(...(await seedRuns(db, instrument.id, 8)));
-}
-
-console.log("Seeding teammate users…");
-const teammates = await seedTeammates(db);
-
-console.log("Seeding run comments + attributions…");
-const commentAuthors = [
- { id: userId, name: SEED_ADMIN_NAME },
- ...teammates.map((t) => ({ id: t.id, name: t.name })),
-];
-await seedRunComments(db, runs, commentAuthors);
-const attributionUserIds = [userId, ...teammates.slice(0, 8).map((t) => t.id)];
-await seedRunAttributions(db, runs, attributionUserIds);
-
-console.log("Seeding archive_jobs…");
-await seedArchiveJobs(db, runs, userId);
-
-console.log("Seeding instrument subscriptions for the admin…");
-await seedInstrumentSubscriptions(
- db,
- userId,
- activeInstruments.map((i) => i.id)
-);
-
-console.log("Seeding notifications (run_created + comment rows)…");
-const notifResult = await seedNotifications(db, {
- recipientUserId: userId,
- runs,
- teammates,
-});
-console.log(
- ` ${notifResult.total} notifications inserted (${notifResult.unread} unread).`
-);
+const { admin } = await seedWorkspace(db);
// Drive the lambda handler over each fixture-bearing run so the
// dashboard shows real processed artifacts (gel-doc PNGs, plate-
// reader CSVs, qPCR metadata) immediately after a reseed. Skips
// quietly with an actionable hint when the dev API isn't running
// yet — see web/scripts/process-fixtures.ts for the gating logic.
-await processSeededFixtures(db, { apiKey: token });
+await processSeededFixtures(db, { apiKey: admin.token });
await pool.end();
console.log("");
console.log("Done. Sign in at http://localhost:3000/login with:");
-console.log(` email: ${email}`);
+console.log(` email: ${admin.email}`);
console.log("");
console.log("Or call the API with the seeded PAT:");
-console.log(` curl -H 'Authorization: Bearer ${token}' \\`);
+console.log(` curl -H 'Authorization: Bearer ${admin.token}' \\`);
console.log(" http://localhost:3000/api/v1/instruments");
diff --git a/web/scripts/seed-workspace.ts b/web/scripts/seed-workspace.ts
new file mode 100644
index 00000000..45502f47
--- /dev/null
+++ b/web/scripts/seed-workspace.ts
@@ -0,0 +1,108 @@
+// The seeded workspace: Alice Zimmerman (admin) and teammates, the instrument
+// catalog, watchers, runs, comments, attributions, archive jobs, and
+// notifications. Shared by `seed-database.ts` (local dev) and the docs
+// screenshot run so both see the same data. Callers own the database safety
+// check and the fixture-processing step, because both depend on how the
+// caller reaches the app.
+
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
+import type * as schema from "@/lib/db/schema";
+import {
+ clearAll,
+ SEED_ADMIN_EMAIL,
+ SEED_ADMIN_NAME,
+ SEED_WATCHER_VERSION,
+ type SeededRun,
+ type SeededTeammate,
+ seedArchiveJobs,
+ seedDevUser,
+ seedInstrumentSubscriptions,
+ seedInstruments,
+ seedNotifications,
+ seedRunAttributions,
+ seedRunComments,
+ seedRuns,
+ seedSlackChannelConfig,
+ seedTeammates,
+ seedWatcherReleaseConfig,
+ seedWatchers,
+} from "@/lib/db/seed";
+
+type Db = NodePgDatabase;
+
+export interface SeededWorkspace {
+ admin: { userId: string; email: string; token: string };
+ runs: SeededRun[];
+ teammates: SeededTeammate[];
+}
+
+export async function seedWorkspace(db: Db): Promise {
+ console.log("Clearing existing rows…");
+ await clearAll(db);
+
+ console.log("Seeding watcher_release_config…");
+ await seedWatcherReleaseConfig(db, {
+ latestVersion: SEED_WATCHER_VERSION,
+ minSupportedVersion: "0.1.0",
+ });
+
+ console.log("Seeding slack_channel_config…");
+ await seedSlackChannelConfig(db);
+
+ console.log("Seeding admin user…");
+ const { userId, email, token } = await seedDevUser(db, {
+ email: SEED_ADMIN_EMAIL,
+ name: SEED_ADMIN_NAME,
+ isAdmin: true,
+ });
+
+ console.log("Seeding instruments…");
+ const instruments = await seedInstruments(db);
+
+ console.log("Seeding watchers + heartbeats + events…");
+ await seedWatchers(db, instruments);
+
+ console.log("Seeding runs + files…");
+ const activeInstruments = instruments.filter((i) => i.status === "active");
+ const runs: SeededRun[] = [];
+ for (const instrument of activeInstruments) {
+ runs.push(...(await seedRuns(db, instrument.id, 8)));
+ }
+
+ console.log("Seeding teammate users…");
+ const teammates = await seedTeammates(db);
+
+ console.log("Seeding run comments + attributions…");
+ const commentAuthors = [
+ { id: userId, name: SEED_ADMIN_NAME },
+ ...teammates.map((t) => ({ id: t.id, name: t.name })),
+ ];
+ await seedRunComments(db, runs, commentAuthors);
+ const attributionUserIds = [
+ userId,
+ ...teammates.slice(0, 8).map((t) => t.id),
+ ];
+ await seedRunAttributions(db, runs, attributionUserIds);
+
+ console.log("Seeding archive_jobs…");
+ await seedArchiveJobs(db, runs, userId);
+
+ console.log("Seeding instrument subscriptions for the admin…");
+ await seedInstrumentSubscriptions(
+ db,
+ userId,
+ activeInstruments.map((i) => i.id)
+ );
+
+ console.log("Seeding notifications (run_created + comment rows)…");
+ const notifResult = await seedNotifications(db, {
+ recipientUserId: userId,
+ runs,
+ teammates,
+ });
+ console.log(
+ ` ${notifResult.total} notifications inserted (${notifResult.unread} unread).`
+ );
+
+ return { admin: { userId, email, token }, runs, teammates };
+}