From d3d66ce83162c0da0dc8e6299aaabb10b940a2f4 Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Sat, 16 May 2026 12:17:52 -0700 Subject: [PATCH 1/3] Web: Move watcher release config from env vars to admin-only settings page Replaces the WATCHER_LATEST_VERSION / WATCHER_MIN_SUPPORTED_VERSION / WATCHER_RELEASE_CHANNEL / WATCHER_MANDATORY_UPDATE env vars with a singleton `watcher_release_config` row, edited via a new admin-only /settings/watcher-release page (PUT /api/v1/watcher-release, gated by requireAdmin). The update-check endpoint reads from the DB and falls back to latest_version: null when no row exists, preserving the existing "unset means skip self-update" contract for the watcher fleet. Both test harnesses now seed the singleton row directly; resetDb() re-seeds after each cycle because TRUNCATE CASCADE on "user" wipes the row via the updated_by FK regardless of ON DELETE SET NULL. Co-authored-by: Cursor --- docs/guides/upgrading-the-watcher.md | 36 +- .../shared/src/data_hub_shared/testing.py | 48 +- watcher/tests/test_updater.py | 5 +- web/.env.example | 13 +- web/app/api/v1/watcher-release/route.ts | 220 ++ .../[watcherId]/update-check/route.ts | 55 +- web/app/settings/watcher-release/page.tsx | 113 ++ web/components/app-sidebar/settings-nav.tsx | 5 + .../watcher-release/watcher-release-form.tsx | 200 ++ .../0024_add_watcher_release_config.sql | 12 + web/drizzle/meta/0024_snapshot.json | 1797 +++++++++++++++++ web/drizzle/meta/_journal.json | 7 + web/lib/db/schema.ts | 52 + web/tests/integration/global-setup.ts | 34 +- web/tests/integration/helpers.ts | 22 + web/tests/integration/watcher-release.test.ts | 248 +++ web/tests/integration/watchers.test.ts | 6 +- 17 files changed, 2802 insertions(+), 71 deletions(-) create mode 100644 web/app/api/v1/watcher-release/route.ts create mode 100644 web/app/settings/watcher-release/page.tsx create mode 100644 web/components/watcher-release/watcher-release-form.tsx create mode 100644 web/drizzle/0024_add_watcher_release_config.sql create mode 100644 web/drizzle/meta/0024_snapshot.json create mode 100644 web/tests/integration/watcher-release.test.ts diff --git a/docs/guides/upgrading-the-watcher.md b/docs/guides/upgrading-the-watcher.md index 280e9fd3..6e139878 100644 --- a/docs/guides/upgrading-the-watcher.md +++ b/docs/guides/upgrading-the-watcher.md @@ -64,7 +64,7 @@ If you want a specific PC to stay on a particular release rather than tracking t uv tool install data-hub-watcher== ``` -Run `data-hub-watcher self-update --check` afterwards to confirm what the server's target is. As long as the pinned version matches `latest_version`, the auto-update tick is a no-op. The moment the server's target moves past your pin, the next tick will try to upgrade past it again — pinning is per-machine state, not server-side state. To park a fleet on a given version intentionally, the right knob is the server-side `WATCHER_LATEST_VERSION` env var (see [Cutting a new release](#cutting-a-new-release)). +Run `data-hub-watcher self-update --check` afterwards to confirm what the server's target is. As long as the pinned version matches `latest_version`, the auto-update tick is a no-op. The moment the server's target moves past your pin, the next tick will try to upgrade past it again — pinning is per-machine state, not server-side state. To park a fleet on a given version intentionally, the right knob is the server-side **Latest version** field on the **Settings → Watcher Release** page in Data Hub (see [Cutting a new release](#cutting-a-new-release)). ## Cutting a new release @@ -99,26 +99,26 @@ Manual dispatch from any branch other than `production` is refused by the workfl ### 4. Roll the release out -Once the new version is live on PyPI, bump the server-side `WATCHER_LATEST_VERSION` env var in Vercel (per environment) so the `/update-check` endpoint advertises the new target. Lab PCs running an auto-update-capable build will pick it up on their next hourly tick. +Once the new version is live on PyPI, open **Settings → Watcher Release** in Data Hub (admins only) and set **Latest version** to the new release. Save; the `/update-check` endpoint will start advertising the new target immediately. Lab PCs running an auto-update-capable build will pick it up on their next hourly tick. -The supported watcher release env vars are: +The supported watcher release fields are: -| Env var | Purpose | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| `WATCHER_LATEST_VERSION` | Required to advertise a release. `null`/unset means "no update info available" and the watcher skips its update attempt. | -| `WATCHER_MIN_SUPPORTED_VERSION` | Optional floor; surfaced in the response for future use. Not yet enforced server-side. | -| `WATCHER_RELEASE_CHANNEL` | Defaults to `stable`. Surfaced in the response and shown in `self-update` output. | -| `WATCHER_MANDATORY_UPDATE` | Set to `true` / `1` to flag the release as mandatory (see below). | +| Field | Purpose | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| **Latest version** | Required to advertise a release. Leaving it blank means "no update info available" and the watcher skips its update attempt. | +| **Minimum supported version** | Optional floor; surfaced in the response for future use. Not yet enforced server-side. | +| **Release channel** | Defaults to `stable`. Surfaced in the response and shown in `self-update` output. | +| **Mandatory update** | Toggle on to flag the release as mandatory (see below). | -Do **not** bump `WATCHER_LATEST_VERSION` ahead of the PyPI publish — the watcher's upgrade subprocess will fail to resolve a version that doesn't yet exist on the index, and you'll see a wave of `update_failed` events from the fleet. Always: tag → publish → verify → bump env var. +Do **not** save a **Latest version** ahead of the PyPI publish — the watcher's upgrade subprocess will fail to resolve a version that doesn't yet exist on the index, and you'll see a wave of `update_failed` events from the fleet. Always: tag → publish → verify → save. -If the rollout needs to be paged through (e.g. you want only `staging` lab PCs to see the new version while you babysit it for a day), bump only the staging environment's `WATCHER_LATEST_VERSION`. The `production` environment keeps advertising the previous version until you bump it explicitly. +If the rollout needs to be paged through (e.g. you want only `staging` lab PCs to see the new version while you babysit it for a day), save the new version on the staging deployment only. Each Vercel environment has its own database, so the `production` deployment keeps advertising the previous version until you also open production's settings page and save the bump there. ## Mandatory updates -By default the activity-window guard means a watcher mid-acquisition won't auto-update — it'll wait for the next idle window. For releases that fix a security issue, a wire-protocol break, or any other case where running the known-bad version is worse than a brief outage, set `WATCHER_MANDATORY_UPDATE=true` alongside the version bump. Mandatory rollouts skip the activity-window guard and fire on the very next hourly check on every lab PC. +By default the activity-window guard means a watcher mid-acquisition won't auto-update — it'll wait for the next idle window. For releases that fix a security issue, a wire-protocol break, or any other case where running the known-bad version is worse than a brief outage, toggle **Mandatory update** on alongside the version bump on the **Watcher Release** settings page. Mandatory rollouts skip the activity-window guard and fire on the very next hourly check on every lab PC. Use this sparingly. The activity-window guard exists for a reason — a forced upgrade in the middle of a multi-hour microscopy run will lose data. Reserve it for cases where leaving the bad version in place is strictly worse than restarting the watcher in flight. @@ -128,10 +128,10 @@ Note also that mandatory rollouts are versioned, not absolute: the server compar Rollback is just another release. To revert the fleet from `0.3.0` to `0.2.5`: -1. Set `WATCHER_LATEST_VERSION=0.2.5` in Vercel for the affected environment(s). -2. Set `WATCHER_MANDATORY_UPDATE=true` if you need the rollback to bypass the activity-window guard (most rollback scenarios qualify — you're rolling back precisely because the running version is misbehaving). +1. Open **Settings → Watcher Release** in Data Hub and set **Latest version** to `0.2.5` for the affected environment(s). +2. Toggle **Mandatory update** on if you need the rollback to bypass the activity-window guard (most rollback scenarios qualify — you're rolling back precisely because the running version is misbehaving). 3. Wait for the next hourly tick on each PC. Lab PCs running an auto-update-capable build will downgrade themselves; PCs being upgraded manually need a `data-hub-watcher self-update` (or an `uv tool install data-hub-watcher==0.2.5` if `self-update` itself is what's broken). -4. Once the fleet has converged, set `WATCHER_MANDATORY_UPDATE` back to `false`. +4. Once the fleet has converged, toggle **Mandatory update** back off. There is no separate "yank" step — a rolled-back release is still on PyPI and still reinstallable, just not advertised by `/update-check`. @@ -149,7 +149,7 @@ The upgrade subprocess started but didn't end up running the new version on the When `details.attempted_subprocess` is `false`, the auto-updater never ran the upgrade command — it refused before starting one. The `details.reason` field tells you why: -- `**install method '' not eligible for auto-update**` — the watcher detected a development-style install (editable `uv sync`, or a distribution whose metadata couldn't be located) and refused so it wouldn't silently shadow the source tree with an index build. Resolve by switching the host to a PyPI install (`uv tool install data-hub-watcher`) or, on a developer machine, ignoring the event. To avoid spamming the events stream, the watcher emits this at most once per server target — a rebump of `WATCHER_LATEST_VERSION` will trigger one fresh event per stuck PC. +- `**install method '' not eligible for auto-update**` — the watcher detected a development-style install (editable `uv sync`, or a distribution whose metadata couldn't be located) and refused so it wouldn't silently shadow the source tree with an index build. Resolve by switching the host to a PyPI install (`uv tool install data-hub-watcher`) or, on a developer machine, ignoring the event. To avoid spamming the events stream, the watcher emits this at most once per server target — saving a new **Latest version** on the **Watcher Release** settings page will trigger one fresh event per stuck PC. ### Auto-update never fires @@ -157,12 +157,12 @@ Check, in order: - The watcher is running as a service, not in a console window. Foreground `data-hub-watcher watch` does run the in-process updater, but on a developer-style install it'll typically be refused as editable. - The environment isn't `preview`. Auto-update is hard-disabled there. -- The activity-window guard isn't holding things up. The instrument has to have been quiet for several heartbeats; on a busy plate reader you may simply never hit the idle window. Use the CLI path (`data-hub-watcher self-update`) for an immediate upgrade, or set `WATCHER_MANDATORY_UPDATE=true` if the release warrants it. +- The activity-window guard isn't holding things up. The instrument has to have been quiet for several heartbeats; on a busy plate reader you may simply never hit the idle window. Use the CLI path (`data-hub-watcher self-update`) for an immediate upgrade, or toggle **Mandatory update** on the **Watcher Release** settings page if the release warrants it. - The dashboard's **Last Heartbeat** is recent. If the watcher has gone stale, it's not ticking and won't auto-update. ### `data-hub-watcher self-update --check` says "(none configured)" -`WATCHER_LATEST_VERSION` is unset for that environment. Either you're running against a `preview` build that doesn't have the env var set, or someone unset it in Vercel. The CLI returns successfully and treats this as "no update available" — the same response code path used by an up-to-date watcher — so this is benign, just informational. +The **Watcher Release** settings page has a blank **Latest version** for that environment. Either you're running against a `preview` build that has never had a release saved, or an admin cleared the field in Data Hub. The CLI returns successfully and treats this as "no update available" — the same response code path used by an up-to-date watcher — so this is benign, just informational. ### "Refusing to self-update an editable / unknown install" diff --git a/packages/shared/src/data_hub_shared/testing.py b/packages/shared/src/data_hub_shared/testing.py index 10703d88..aa8f4f1e 100644 --- a/packages/shared/src/data_hub_shared/testing.py +++ b/packages/shared/src/data_hub_shared/testing.py @@ -162,6 +162,40 @@ def seed_auth(dsn: str) -> str: return token_plaintext +def seed_watcher_release( + dsn: str, + *, + latest_version: str = "9.9.9", + min_supported_version: str = "0.1.0", + channel: str = "stable", + mandatory: bool = False, +) -> None: + """Upsert the singleton ``watcher_release_config`` row. + + The ``update-check`` endpoint reads from this table; tests assert on + the seeded values. Previously these were plumbed via ``WATCHER_*`` + env vars; the source of truth is now the DB, edited via the + admin-only ``/settings/watcher-release`` page in the web app. + """ + conn = psycopg2.connect(dsn) + conn.autocommit = True + with conn.cursor() as cur: + cur.execute( + """INSERT INTO watcher_release_config + (id, latest_version, min_supported_version, channel, mandatory) + VALUES + (true, %s, %s, %s, %s) + ON CONFLICT (id) DO UPDATE SET + latest_version = EXCLUDED.latest_version, + min_supported_version = EXCLUDED.min_supported_version, + channel = EXCLUDED.channel, + mandatory = EXCLUDED.mandatory, + updated_at = now()""", + (latest_version, min_supported_version, channel, mandatory), + ) + conn.close() + + def seed_instruments(dsn: str, instruments: dict[str, str]) -> None: """Insert instrument rows (ON CONFLICT DO NOTHING).""" conn = psycopg2.connect(dsn) @@ -236,6 +270,12 @@ def start_test_server() -> Generator[IntegrationEnv, None, None]: capture_output=True, ) + # 2a. Seed the singleton watcher_release_config row so the + # update-check endpoint returns deterministic values during + # integration tests. Previously this came from WATCHER_* env + # vars; the source of truth is now the DB. + seed_watcher_release(_PG_TEST_DSN) + # 3. Build and start the Next.js production server. port = get_free_port() base_url = f"http://127.0.0.1:{port}" @@ -250,12 +290,8 @@ def start_test_server() -> Generator[IntegrationEnv, None, None]: "AWS_SECRET_ACCESS_KEY": os.environ.get("AWS_SECRET_ACCESS_KEY", "test-secret"), "AWS_REGION": os.environ.get("AWS_REGION", "us-east-1"), "S3_RAW_DATA_BUCKET": os.environ.get("S3_RAW_DATA_BUCKET", "data-hub-test-raw"), - # Stable defaults for the watcher update-check endpoint so Python - # integration tests can assert on a known target version. - "WATCHER_LATEST_VERSION": os.environ.get("WATCHER_LATEST_VERSION", "9.9.9"), - "WATCHER_MIN_SUPPORTED_VERSION": os.environ.get("WATCHER_MIN_SUPPORTED_VERSION", "0.1.0"), - "WATCHER_RELEASE_CHANNEL": os.environ.get("WATCHER_RELEASE_CHANNEL", "stable"), - "WATCHER_MANDATORY_UPDATE": os.environ.get("WATCHER_MANDATORY_UPDATE", "false"), + # Watcher release defaults are seeded into watcher_release_config + # above (see seed_watcher_release); the env-var fallback is gone. } build_result = subprocess.run( diff --git a/watcher/tests/test_updater.py b/watcher/tests/test_updater.py index 296581ab..298758ea 100644 --- a/watcher/tests/test_updater.py +++ b/watcher/tests/test_updater.py @@ -767,8 +767,9 @@ def test_editable_refusal_throttled_to_one_event_per_target( def test_editable_refusal_re_emits_when_target_advances( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - # When the server bumps `WATCHER_LATEST_VERSION` to a new - # release, we *do* want to re-notify — the dashboard event for + # When the server advertises a new release (via the admin-only + # /settings/watcher-release page), we *do* want to re-notify — + # the dashboard event for # the old target doesn't tell the admin this PC is missing the # new one. Throttling is per-target, not "fire at most once # ever". diff --git a/web/.env.example b/web/.env.example index 62585c7c..71887a7f 100644 --- a/web/.env.example +++ b/web/.env.example @@ -28,11 +28,8 @@ LAMBDA_FUNCTION_URL= # Leave empty to disable Slack notifications. SLACK_WEBHOOK_URL= -# Watcher release metadata served by GET /api/v1/watchers/:id/update-check. -# Leave WATCHER_LATEST_VERSION unset to disable self-updates (the endpoint -# returns latest_version: null and clients skip the upgrade). Set -# WATCHER_MANDATORY_UPDATE to "true" or "1" to force the rollout. -WATCHER_LATEST_VERSION= -WATCHER_MIN_SUPPORTED_VERSION= -WATCHER_RELEASE_CHANNEL=stable -WATCHER_MANDATORY_UPDATE=false +# Watcher release metadata is now stored in the `watcher_release_config` +# table and edited at /settings/watcher-release (admin-only). Until an +# admin saves a row, GET /api/v1/watchers/:id/update-check returns +# latest_version: null and watchers skip the self-update — the same +# behavior as leaving WATCHER_LATEST_VERSION unset used to be. diff --git a/web/app/api/v1/watcher-release/route.ts b/web/app/api/v1/watcher-release/route.ts new file mode 100644 index 00000000..aa557662 --- /dev/null +++ b/web/app/api/v1/watcher-release/route.ts @@ -0,0 +1,220 @@ +import { requireAdmin } from "@/lib/api/auth"; +import { apiError, VALIDATION_ERROR } from "@/lib/api/errors"; +import { db } from "@/lib/db"; +import { users, watcherReleaseConfig } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; + +// Admin-only read/write of the singleton `watcher_release_config` row, +// edited via `/settings/watcher-release`. The `update-check` endpoint +// reads the same row but is open to any watcher-scoped PAT — this route +// is the privileged write path and is therefore session-only via +// `requireAdmin()`, matching the `/api/v1/users/[userId]` PATCH model. + +// Loose PEP-440-style version match — covers the values we already +// advertise (`9.9.9`, `0.1.0`) plus the common `1.2.3rc1` / `1.2.3.post1` +// shapes. We intentionally don't validate against PyPI here; a typo will +// surface to operators as an `update_failed` event from the fleet, which +// is the same failure mode they already debug today. +const VERSION_REGEX = /^\d+\.\d+\.\d+([.-].+)?$/; + +const ALLOWED_PUT_FIELDS = new Set([ + "latest_version", + "min_supported_version", + "channel", + "mandatory", +]); + +type WatcherReleaseResponse = { + latest_version: string | null; + min_supported_version: string | null; + channel: string; + mandatory: boolean; + updated_at: string | null; + updated_by: { + id: string; + name: string | null; + email: string | null; + } | null; +}; + +const EMPTY_RESPONSE: WatcherReleaseResponse = { + latest_version: null, + min_supported_version: null, + channel: "stable", + mandatory: false, + updated_at: null, + updated_by: null, +}; + +async function readCurrent(): Promise { + // Left-join on `user` so we can render "Last updated by …" without a + // second round-trip. The singleton constraint guarantees at most one + // row, so this is a constant-cost query regardless of fleet size. + const [row] = await db + .select({ + latestVersion: watcherReleaseConfig.latestVersion, + minSupportedVersion: watcherReleaseConfig.minSupportedVersion, + channel: watcherReleaseConfig.channel, + mandatory: watcherReleaseConfig.mandatory, + updatedAt: watcherReleaseConfig.updatedAt, + updatedById: users.id, + updatedByName: users.name, + updatedByEmail: users.email, + }) + .from(watcherReleaseConfig) + .leftJoin(users, eq(users.id, watcherReleaseConfig.updatedBy)); + + if (!row) { + return EMPTY_RESPONSE; + } + + return { + latest_version: row.latestVersion, + min_supported_version: row.minSupportedVersion, + channel: row.channel, + mandatory: row.mandatory, + updated_at: row.updatedAt.toISOString(), + updated_by: row.updatedById + ? { + id: row.updatedById, + name: row.updatedByName, + email: row.updatedByEmail, + } + : null, + }; +} + +export async function GET() { + const authResult = await requireAdmin(); + if (authResult instanceof Response) return authResult; + + return Response.json(await readCurrent()); +} + +// Normalises a string input from the form: trims whitespace and treats +// an empty string the same as an omitted/explicit-null value. Keeps the +// "version unset" semantics from the env-var era — operators don't have +// to remember to send `null` instead of `""`. +function normalizeOptionalString(value: unknown): string | null { + if (value === null) return null; + if (typeof value !== "string") return value as never; + const trimmed = value.trim(); + return trimmed.length === 0 ? null : trimmed; +} + +export async function PUT(request: NextRequest) { + const authResult = await requireAdmin(); + if (authResult instanceof Response) return authResult; + + let body: Record; + try { + body = await request.json(); + } catch { + return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + } + + const unknownKeys = Object.keys(body).filter( + (k) => !ALLOWED_PUT_FIELDS.has(k) + ); + if (unknownKeys.length > 0) { + return apiError(400, VALIDATION_ERROR, "Unknown fields", { + unknown_fields: unknownKeys, + allowed_fields: [...ALLOWED_PUT_FIELDS], + }); + } + + // Type-check each field individually so the error messages name the + // offending property (rather than the all-or-nothing failure you'd get + // from a single zod parse). + if ( + body.latest_version !== undefined && + body.latest_version !== null && + typeof body.latest_version !== "string" + ) { + return apiError( + 400, + VALIDATION_ERROR, + "latest_version must be a string or null" + ); + } + if ( + body.min_supported_version !== undefined && + body.min_supported_version !== null && + typeof body.min_supported_version !== "string" + ) { + return apiError( + 400, + VALIDATION_ERROR, + "min_supported_version must be a string or null" + ); + } + if (body.channel !== undefined && typeof body.channel !== "string") { + return apiError(400, VALIDATION_ERROR, "channel must be a string"); + } + if (body.mandatory !== undefined && typeof body.mandatory !== "boolean") { + return apiError(400, VALIDATION_ERROR, "mandatory must be a boolean"); + } + + const latestVersion = normalizeOptionalString(body.latest_version); + const minSupportedVersion = normalizeOptionalString( + body.min_supported_version + ); + const channel = + typeof body.channel === "string" ? body.channel.trim() : "stable"; + const mandatory = body.mandatory === true; + + if (latestVersion !== null && !VERSION_REGEX.test(latestVersion)) { + return apiError( + 400, + VALIDATION_ERROR, + `latest_version '${latestVersion}' is not a valid PEP 440-style version` + ); + } + if ( + minSupportedVersion !== null && + !VERSION_REGEX.test(minSupportedVersion) + ) { + return apiError( + 400, + VALIDATION_ERROR, + `min_supported_version '${minSupportedVersion}' is not a valid PEP 440-style version` + ); + } + if (channel.length === 0) { + return apiError( + 400, + VALIDATION_ERROR, + "channel must be a non-empty string" + ); + } + + const now = new Date(); + // Singleton upsert: `id = true` is the primary key, so ON CONFLICT + // collapses concurrent admin saves onto the same row. The set clause + // explicitly omits `id` to keep the constraint happy. + await db + .insert(watcherReleaseConfig) + .values({ + id: true, + latestVersion, + minSupportedVersion, + channel, + mandatory, + updatedAt: now, + updatedBy: authResult.userId, + }) + .onConflictDoUpdate({ + target: watcherReleaseConfig.id, + set: { + latestVersion, + minSupportedVersion, + channel, + mandatory, + updatedAt: now, + updatedBy: authResult.userId, + }, + }); + + return Response.json(await readCurrent()); +} diff --git a/web/app/api/v1/watchers/[watcherId]/update-check/route.ts b/web/app/api/v1/watchers/[watcherId]/update-check/route.ts index 3380577d..ba3ab8d2 100644 --- a/web/app/api/v1/watchers/[watcherId]/update-check/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/update-check/route.ts @@ -2,23 +2,18 @@ import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { isValidUUID } from "@/lib/api/validators"; import { findActiveWatcher } from "@/lib/api/watchers"; +import { db } from "@/lib/db"; +import { watcherReleaseConfig } from "@/lib/db/schema"; import type { NextRequest } from "next/server"; /** * Server-reported watcher release metadata. * - * Source of truth is environment variables today; a dedicated - * `watcher_releases` table can replace this once we need per-channel - * rollouts or per-watcher pinned versions: - * - * - `WATCHER_LATEST_VERSION` — required to advertise a release - * - `WATCHER_MIN_SUPPORTED_VERSION` — optional floor for forced upgrades - * - `WATCHER_RELEASE_CHANNEL` — defaults to "stable" - * - `WATCHER_MANDATORY_UPDATE` — "true" / "1" to force rollout - * - * When `WATCHER_LATEST_VERSION` is unset the endpoint still returns 200 so - * watchers don't log spurious 5xxs; `latest_version: null` tells the - * client to skip the upgrade attempt. + * Source of truth is the `watcher_release_config` singleton row, edited + * by admins via `/settings/watcher-release`. Until that row exists + * (fresh deploy, before any admin save) the endpoint still returns 200 + * with `latest_version: null` so watchers don't log spurious 5xxs and + * the client treats it as "no update available". */ type WatcherReleaseInfo = { latest_version: string | null; @@ -27,20 +22,28 @@ type WatcherReleaseInfo = { mandatory: boolean; }; -function readReleaseInfo(): WatcherReleaseInfo { - const latest = process.env.WATCHER_LATEST_VERSION?.trim() || null; - const minSupported = - process.env.WATCHER_MIN_SUPPORTED_VERSION?.trim() || null; - const channel = process.env.WATCHER_RELEASE_CHANNEL?.trim() || "stable"; - const mandatoryRaw = process.env.WATCHER_MANDATORY_UPDATE?.trim() ?? ""; - const mandatory = - mandatoryRaw === "1" || mandatoryRaw.toLowerCase() === "true"; - +async function readReleaseInfo(): Promise { + // The singleton check constraint on `id` guarantees at most one row; + // no LIMIT 1 or ORDER BY discipline required on read. + const [row] = await db.select().from(watcherReleaseConfig); + if (!row) { + return { + latest_version: null, + min_supported_version: null, + channel: "stable", + mandatory: false, + }; + } return { - latest_version: latest, - min_supported_version: minSupported, - channel, - mandatory: mandatory && latest !== null, + latest_version: row.latestVersion, + min_supported_version: row.minSupportedVersion, + channel: row.channel, + // Collapsing mandatory→false when no version is advertised keeps the + // wire response self-consistent. The watcher's mandatory branch is + // gated on `latest_version` anyway, but mirroring that invariant + // here means a misconfigured `mandatory=true` with a blank version + // never leaks out of the API. + mandatory: row.mandatory && row.latestVersion !== null, }; } @@ -64,5 +67,5 @@ export async function GET( return apiError(404, NOT_FOUND, `Watcher '${watcherId}' not found`); } - return Response.json(readReleaseInfo()); + return Response.json(await readReleaseInfo()); } diff --git a/web/app/settings/watcher-release/page.tsx b/web/app/settings/watcher-release/page.tsx new file mode 100644 index 00000000..14001f3b --- /dev/null +++ b/web/app/settings/watcher-release/page.tsx @@ -0,0 +1,113 @@ +import { SignInRequired } from "@/components/auth/sign-in-required"; +import { WatcherReleaseForm } from "@/components/watcher-release/watcher-release-form"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { users, watcherReleaseConfig } from "@/lib/db/schema"; +import { eq } from "drizzle-orm"; +import { ShieldOff } from "lucide-react"; +import type { Metadata } from "next/types"; + +const description = + "Configure the watcher release advertised by the auto-update endpoint."; + +export const metadata: Metadata = { + title: "Watcher Release", + description, + openGraph: { title: "Watcher Release", description }, + twitter: { title: "Watcher Release", description }, +}; + +export default async function WatcherReleasePage() { + const session = await auth(); + if (!session?.user) { + return ( + + Sign in to manage the watcher release. + + ); + } + + // Page-level admin gate. Non-admins reach this URL via a stale link, + // bookmark, or by typing it in — render an explicit explanation rather + // than redirecting silently so the missing-permission failure mode is + // visible. The settings sidebar already hides this entry for non-admins. + if (!session.user.isAdmin) { + return ( +
+ +

+ Admins only +

+

+ You need workspace admin access to change the watcher release. Ask an + existing admin if you need to be promoted. +

+
+ ); + } + + // Reading directly via Drizzle (rather than round-tripping through the + // API) avoids an extra hop on the initial render. The singleton + // constraint means at most one row, so the left join on `user` for the + // "last updated by" line is a constant-cost query regardless of fleet + // size. Returns `undefined` when no admin has saved yet — the form + // renders blank defaults in that case. + const [row] = await db + .select({ + latestVersion: watcherReleaseConfig.latestVersion, + minSupportedVersion: watcherReleaseConfig.minSupportedVersion, + channel: watcherReleaseConfig.channel, + mandatory: watcherReleaseConfig.mandatory, + updatedAt: watcherReleaseConfig.updatedAt, + updatedByName: users.name, + updatedByEmail: users.email, + }) + .from(watcherReleaseConfig) + .leftJoin(users, eq(users.id, watcherReleaseConfig.updatedBy)); + + return ( +
+
+
+

+ Watcher Release +

+

+ Configure the release advertised by{" "} + + GET /api/v1/watchers/:id/update-check + + . Watchers compare their installed version against{" "} + + latest_version + {" "} + and self-upgrade when a newer release is offered. +

+
+
+ +
+ +
+
+ ); +} diff --git a/web/components/app-sidebar/settings-nav.tsx b/web/components/app-sidebar/settings-nav.tsx index afd31647..037d4f1b 100644 --- a/web/components/app-sidebar/settings-nav.tsx +++ b/web/components/app-sidebar/settings-nav.tsx @@ -23,6 +23,11 @@ type SettingsSection = { const SETTINGS_SECTIONS: SettingsSection[] = [ { href: "/settings/tokens", label: "Access Tokens" }, { href: "/settings/members", label: "Members", adminOnly: true }, + { + href: "/settings/watcher-release", + label: "Watcher Release", + adminOnly: true, + }, ]; // Single, predictable destination for the "leave settings" affordance. A diff --git a/web/components/watcher-release/watcher-release-form.tsx b/web/components/watcher-release/watcher-release-form.tsx new file mode 100644 index 00000000..0eae1da1 --- /dev/null +++ b/web/components/watcher-release/watcher-release-form.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { formatRelativeTime } from "@/lib/utils"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; + +type WatcherReleaseFormValues = { + latestVersion: string; + minSupportedVersion: string; + channel: string; + mandatory: boolean; +}; + +type LastUpdated = { + at: string; + byName: string | null; + byEmail: string | null; +}; + +type WatcherReleaseFormProps = { + initial: WatcherReleaseFormValues; + lastUpdated: LastUpdated | null; +}; + +export function WatcherReleaseForm({ + initial, + lastUpdated, +}: WatcherReleaseFormProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const [latestVersion, setLatestVersion] = useState(initial.latestVersion); + const [minSupportedVersion, setMinSupportedVersion] = useState( + initial.minSupportedVersion + ); + const [channel, setChannel] = useState(initial.channel); + const [mandatory, setMandatory] = useState(initial.mandatory); + + // "mandatory only takes effect with a latest_version" is enforced by the + // server (and by the watcher's own logic). Mirror it as a derived render + // expression here so the checkbox visibly disables when the user clears + // the version field — no useEffect needed to keep client state in sync + // with the rule. + const mandatoryDisabled = latestVersion.trim().length === 0; + const channelEmpty = channel.trim().length === 0; + const canSubmit = !isPending && !channelEmpty; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + startTransition(async () => { + const res = await fetch("/api/v1/watcher-release", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + // Server accepts string|null; sending the trimmed string + // (possibly empty) lets the server's normaliser convert ""→null + // so the wire contract stays "empty means unset" everywhere. + latest_version: latestVersion.trim() || null, + min_supported_version: minSupportedVersion.trim() || null, + channel: channel.trim(), + // The server collapses mandatory→false on read when latest_version + // is null, so we don't need to mirror that on write — keep the + // user's explicit choice in the row. + mandatory, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + toast.error(body?.error?.message ?? "Failed to save watcher release"); + return; + } + + toast.success("Watcher release saved"); + // router.refresh() re-runs the page's server component so the "last + // updated by" line and any other server-derived UI re-renders with + // the just-saved values — no need to thread the response back + // into client state. + router.refresh(); + }); + }; + + return ( +
+
+ + setLatestVersion(e.target.value)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> +

+ The release watchers will self-upgrade to. Leave blank to temporarily + disable self-updates (the endpoint returns{" "} + latest_version: null and clients + skip the upgrade). +

+
+ +
+ + setMinSupportedVersion(e.target.value)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> +

+ Optional floor surfaced in the response for future use. Not yet + enforced server-side. +

+
+ +
+ + setChannel(e.target.value)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> +

+ Defaults to stable. Surfaced in the + response and shown in self-update{" "} + output. +

+
+ +
+
+ +

+ Skip the watcher's activity-window guard so mid-acquisition PCs + upgrade immediately. Reserve for security fixes or wire-protocol + breaks. Has no effect when the latest version is blank. +

+
+ +
+ +
+ {lastUpdated ? ( +

+ Last updated{" "} + + {formatRelativeTime(lastUpdated.at)} + + {lastUpdated.byName || lastUpdated.byEmail ? ( + <> + {" by "} + + {lastUpdated.byName ?? lastUpdated.byEmail} + + + ) : null} + . +

+ ) : ( +

+ No release configured yet. Watchers will skip self-updates until you + save a version. +

+ )} + +
+
+ ); +} diff --git a/web/drizzle/0024_add_watcher_release_config.sql b/web/drizzle/0024_add_watcher_release_config.sql new file mode 100644 index 00000000..7e3b37a0 --- /dev/null +++ b/web/drizzle/0024_add_watcher_release_config.sql @@ -0,0 +1,12 @@ +CREATE TABLE "watcher_release_config" ( + "id" boolean PRIMARY KEY DEFAULT true NOT NULL, + "latest_version" text, + "min_supported_version" text, + "channel" text DEFAULT 'stable' NOT NULL, + "mandatory" boolean DEFAULT false NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_by" text, + CONSTRAINT "watcher_release_config_singleton" CHECK ("watcher_release_config"."id" = true) +); +--> statement-breakpoint +ALTER TABLE "watcher_release_config" ADD CONSTRAINT "watcher_release_config_updated_by_user_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/web/drizzle/meta/0024_snapshot.json b/web/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..dc10378c --- /dev/null +++ b/web/drizzle/meta/0024_snapshot.json @@ -0,0 +1,1797 @@ +{ + "id": "898e4cab-6b8f-40d9-9521-08574b076d5e", + "prevId": "224f790c-1d23-4d44-b581-99a8e9976228", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_accounts_user_id": { + "name": "idx_accounts_user_id", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.archive_jobs": { + "name": "archive_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instrument_run_id": { + "name": "instrument_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archive_bucket": { + "name": "archive_bucket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archive_key": { + "name": "archive_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "archive_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_archive_jobs_inflight": { + "name": "uq_archive_jobs_inflight", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"archive_jobs\".\"status\" in ('pending', 'building')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_archive_jobs_run_fingerprint_status": { + "name": "idx_archive_jobs_run_fingerprint_status", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "archive_jobs_instrument_run_id_instrument_runs_id_fk": { + "name": "archive_jobs_instrument_run_id_instrument_runs_id_fk", + "tableFrom": "archive_jobs", + "tableTo": "instrument_runs", + "columnsFrom": [ + "instrument_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "archive_jobs_created_by_user_id_fk": { + "name": "archive_jobs_created_by_user_id_fk", + "tableFrom": "archive_jobs", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "instrument_run_id": { + "name": "instrument_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "s3_bucket": { + "name": "s3_bucket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "file_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'raw'" + }, + "status": { + "name": "status", + "type": "file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'detected'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "upload_requested_at": { + "name": "upload_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "file_created_at": { + "name": "file_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_files_instrument_run_id_relative_path": { + "name": "uq_files_instrument_run_id_relative_path", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "relative_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"relative_path\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_files_active_instrument_run_id_filename": { + "name": "uq_files_active_instrument_run_id_filename", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_files_s3_key": { + "name": "uq_files_s3_key", + "columns": [ + { + "expression": "s3_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"files\".\"s3_key\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_instrument_run_id": { + "name": "idx_files_instrument_run_id", + "columns": [ + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_status_instrument_run_id": { + "name": "idx_files_status_instrument_run_id", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instrument_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_upload_queue": { + "name": "idx_files_upload_queue", + "columns": [ + { + "expression": "upload_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"files\".\"upload_requested_at\" is not null and \"files\".\"uploaded_at\" is null and \"files\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_metadata_gin": { + "name": "idx_files_metadata_gin", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "files_instrument_run_id_instrument_runs_id_fk": { + "name": "files_instrument_run_id_instrument_runs_id_fk", + "tableFrom": "files", + "tableTo": "instrument_runs", + "columnsFrom": [ + "instrument_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instrument_runs": { + "name": "instrument_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instrument_id": { + "name": "instrument_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "instrument_run_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'lambda'" + }, + "watcher_id": { + "name": "watcher_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_instrument_runs_instrument_id_created_at": { + "name": "idx_instrument_runs_instrument_id_created_at", + "columns": [ + { + "expression": "instrument_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_instrument_runs_active": { + "name": "idx_instrument_runs_active", + "columns": [ + { + "expression": "instrument_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"instrument_runs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_instrument_runs_active_acquired_at": { + "name": "idx_instrument_runs_active_acquired_at", + "columns": [ + { + "expression": "instrument_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"acquired_at\", \"created_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"instrument_runs\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_instrument_runs_metadata_gin": { + "name": "idx_instrument_runs_metadata_gin", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "instrument_runs_instrument_id_instruments_id_fk": { + "name": "instrument_runs_instrument_id_instruments_id_fk", + "tableFrom": "instrument_runs", + "tableTo": "instruments", + "columnsFrom": [ + "instrument_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "instrument_runs_watcher_id_watchers_id_fk": { + "name": "instrument_runs_watcher_id_watchers_id_fk", + "tableFrom": "instrument_runs", + "tableTo": "watchers", + "columnsFrom": [ + "watcher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_instrument_runs_instrument_id_run_id": { + "name": "uq_instrument_runs_instrument_id_run_id", + "nullsNotDistinct": false, + "columns": [ + "instrument_id", + "run_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instruments": { + "name": "instruments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "instrument_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "instrument_type": { + "name": "instrument_type", + "type": "instrument_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.personal_access_tokens": { + "name": "personal_access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY['*']::text[]" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_personal_access_tokens_user_id": { + "name": "idx_personal_access_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "personal_access_tokens_user_id_user_id_fk": { + "name": "personal_access_tokens_user_id_user_id_fk", + "tableFrom": "personal_access_tokens", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "personal_access_tokens_token_hash_unique": { + "name": "personal_access_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_attributions": { + "name": "run_attributions", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_run_attributions_run_id": { + "name": "idx_run_attributions_run_id", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_run_attributions_user_id": { + "name": "idx_run_attributions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_attributions_run_id_instrument_runs_id_fk": { + "name": "run_attributions_run_id_instrument_runs_id_fk", + "tableFrom": "run_attributions", + "tableTo": "instrument_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_attributions_user_id_user_id_fk": { + "name": "run_attributions_user_id_user_id_fk", + "tableFrom": "run_attributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "run_attributions_run_id_user_id_pk": { + "name": "run_attributions_run_id_user_id_pk", + "columns": [ + "run_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_comments": { + "name": "run_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_run_comments_run_id_created_at": { + "name": "idx_run_comments_run_id_created_at", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_run_comments_user_id": { + "name": "idx_run_comments_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_comments_run_id_instrument_runs_id_fk": { + "name": "run_comments_run_id_instrument_runs_id_fk", + "tableFrom": "run_comments", + "tableTo": "instrument_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_comments_user_id_user_id_fk": { + "name": "run_comments_user_id_user_id_fk", + "tableFrom": "run_comments", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_sessions_user_id": { + "name": "idx_sessions_user_id", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watcher_events": { + "name": "watcher_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "watcher_id": { + "name": "watcher_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "watcher_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_watcher_events_watcher_id_timestamp": { + "name": "idx_watcher_events_watcher_id_timestamp", + "columns": [ + { + "expression": "watcher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_watcher_events_watcher_id_event_type": { + "name": "idx_watcher_events_watcher_id_event_type", + "columns": [ + { + "expression": "watcher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watcher_events_watcher_id_watchers_id_fk": { + "name": "watcher_events_watcher_id_watchers_id_fk", + "tableFrom": "watcher_events", + "tableTo": "watchers", + "columnsFrom": [ + "watcher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watcher_heartbeats": { + "name": "watcher_heartbeats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "watcher_id": { + "name": "watcher_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upload_mode": { + "name": "upload_mode", + "type": "upload_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "files_uploaded_since_last": { + "name": "files_uploaded_since_last", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "runs_reported_since_last": { + "name": "runs_reported_since_last", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "errors_since_last": { + "name": "errors_since_last", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "uptime_seconds": { + "name": "uptime_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_watcher_heartbeats_watcher_id_timestamp": { + "name": "idx_watcher_heartbeats_watcher_id_timestamp", + "columns": [ + { + "expression": "watcher_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watcher_heartbeats_watcher_id_watchers_id_fk": { + "name": "watcher_heartbeats_watcher_id_watchers_id_fk", + "tableFrom": "watcher_heartbeats", + "tableTo": "watchers", + "columnsFrom": [ + "watcher_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.watcher_release_config": { + "name": "watcher_release_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "boolean", + "primaryKey": true, + "notNull": true, + "default": true + }, + "latest_version": { + "name": "latest_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_supported_version": { + "name": "min_supported_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stable'" + }, + "mandatory": { + "name": "mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "watcher_release_config_updated_by_user_id_fk": { + "name": "watcher_release_config_updated_by_user_id_fk", + "tableFrom": "watcher_release_config", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "watcher_release_config_singleton": { + "name": "watcher_release_config_singleton", + "value": "\"watcher_release_config\".\"id\" = true" + } + }, + "isRLSEnabled": false + }, + "public.watchers": { + "name": "watchers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instrument_id": { + "name": "instrument_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os_info": { + "name": "os_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "watcher_version": { + "name": "watcher_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_checksum": { + "name": "config_checksum", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config_yaml": { + "name": "config_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "watcher_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'registered'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uq_watchers_active_instrument_id": { + "name": "uq_watchers_active_instrument_id", + "columns": [ + { + "expression": "instrument_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"watchers\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "watchers_instrument_id_instruments_id_fk": { + "name": "watchers_instrument_id_instruments_id_fk", + "tableFrom": "watchers", + "tableTo": "instruments", + "columnsFrom": [ + "instrument_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.archive_job_status": { + "name": "archive_job_status", + "schema": "public", + "values": [ + "pending", + "building", + "ready", + "failed" + ] + }, + "public.file_category": { + "name": "file_category", + "schema": "public", + "values": [ + "raw", + "processed" + ] + }, + "public.file_status": { + "name": "file_status", + "schema": "public", + "values": [ + "detected", + "upload_requested", + "uploaded", + "processing", + "completed", + "failed" + ] + }, + "public.instrument_run_source": { + "name": "instrument_run_source", + "schema": "public", + "values": [ + "lambda", + "watcher" + ] + }, + "public.instrument_status": { + "name": "instrument_status", + "schema": "public", + "values": [ + "pending", + "active", + "inactive" + ] + }, + "public.instrument_type": { + "name": "instrument_type", + "schema": "public", + "values": [ + "generic", + "plate_reader", + "gel_doc", + "qpcr", + "tape_station", + "hina_microscope", + "epson_v700_scanner", + "instant_raman" + ] + }, + "public.upload_mode": { + "name": "upload_mode", + "schema": "public", + "values": [ + "auto", + "manual" + ] + }, + "public.watcher_event_type": { + "name": "watcher_event_type", + "schema": "public", + "values": [ + "watcher_started", + "watcher_stopped", + "file_uploaded", + "upload_failed", + "run_reported", + "config_synced", + "error", + "update_started", + "update_succeeded", + "update_failed" + ] + }, + "public.watcher_status": { + "name": "watcher_status", + "schema": "public", + "values": [ + "registered", + "watching", + "stopped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json index c6ef281f..3d546646 100644 --- a/web/drizzle/meta/_journal.json +++ b/web/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1778882678139, "tag": "0023_add_user_is_admin", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1778958471151, + "tag": "0024_add_watcher_release_config", + "breakpoints": true } ] } \ No newline at end of file diff --git a/web/lib/db/schema.ts b/web/lib/db/schema.ts index 1fac718f..374d9cfa 100644 --- a/web/lib/db/schema.ts +++ b/web/lib/db/schema.ts @@ -4,6 +4,7 @@ import { bigint, bigserial, boolean, + check, index, integer, jsonb, @@ -150,6 +151,57 @@ export const sessions = pgTable( (session) => [index("idx_sessions_user_id").on(session.userId)] ); +// Singleton row of server-advertised watcher release metadata, served by +// `GET /api/v1/watchers/:id/update-check` and edited via the admin-only +// `/settings/watcher-release` page. Previously sourced from the +// `WATCHER_LATEST_VERSION` / `WATCHER_MIN_SUPPORTED_VERSION` / +// `WATCHER_RELEASE_CHANNEL` / `WATCHER_MANDATORY_UPDATE` env vars. +// +// The `id boolean PRIMARY KEY DEFAULT true` + check constraint is the +// standard Postgres singleton trick — schema-level guarantee of at most +// one row, so callers don't need any `LIMIT 1` discipline and concurrent +// upserts collapse onto the same row via `ON CONFLICT (id) DO UPDATE`. +// +// When the table is empty (fresh deploy, before any admin has saved) the +// update-check endpoint returns `latest_version: null`, the same +// "no update info available" sentinel watchers already understand. +export const watcherReleaseConfig = pgTable( + "watcher_release_config", + { + id: boolean("id").primaryKey().default(true), + // Required to advertise a release. NULL → watchers skip the upgrade + // attempt. + latestVersion: text("latest_version"), + // Optional floor; surfaced in the response for future use, not yet + // enforced server-side. + minSupportedVersion: text("min_supported_version"), + // Defaults to "stable"; surfaced in the response and shown in + // `self-update` output. + channel: text("channel").notNull().default("stable"), + // When true, the release skips the watcher's activity-window guard so + // mid-acquisition PCs upgrade immediately. Has no effect when + // `latest_version` is NULL — `update-check` collapses it to false on + // read so the wire response stays self-consistent. + mandatory: boolean("mandatory").notNull().default(false), + updatedAt: timestamp("updated_at", { + withTimezone: true, + mode: "date", + }) + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + // The admin who last saved this row. NULL only after a deleted user + // cascade — the route always stamps the authenticated user id on + // write. + updatedBy: text("updated_by").references(() => users.id, { + onDelete: "set null", + }), + }, + (config) => [ + check("watcher_release_config_singleton", sql`${config.id} = true`), + ] +); + export const personalAccessTokens = pgTable( "personal_access_tokens", { diff --git a/web/tests/integration/global-setup.ts b/web/tests/integration/global-setup.ts index b1b63d12..1af6867b 100644 --- a/web/tests/integration/global-setup.ts +++ b/web/tests/integration/global-setup.ts @@ -116,6 +116,30 @@ export async function setup() { stdio: "pipe", }); + // 2a. Seed the singleton `watcher_release_config` row with stable + // defaults so the `update-check` endpoint returns deterministic + // values during integration tests. Individual tests assert against + // these exact strings (see `watchers.test.ts`). Previously this was + // done via WATCHER_* env vars; the source of truth is now the DB, + // edited via the admin-only /settings/watcher-release page. + const seedSql = postgres(databaseUrl); + try { + await seedSql` + INSERT INTO watcher_release_config + (id, latest_version, min_supported_version, channel, mandatory) + VALUES + (true, '9.9.9', '0.1.0', 'stable', false) + ON CONFLICT (id) DO UPDATE SET + latest_version = excluded.latest_version, + min_supported_version = excluded.min_supported_version, + channel = excluded.channel, + mandatory = excluded.mandatory, + updated_at = now() + `; + } finally { + await seedSql.end(); + } + // 3. Build and start the Next.js production server. We use a production // build (`next build` + `next start`) rather than `next dev` because: // - dev mode re-compiles on every request, making tests 5-10x slower @@ -141,14 +165,8 @@ export async function setup() { AWS_REGION: process.env.AWS_REGION ?? "us-east-1", S3_RAW_DATA_BUCKET: process.env.S3_RAW_DATA_BUCKET ?? "test-raw-data-bucket", - // Stable watcher release-info defaults so the `update-check` endpoint - // returns deterministic values during integration tests. Individual - // tests assert against these exact strings. - WATCHER_LATEST_VERSION: process.env.WATCHER_LATEST_VERSION ?? "9.9.9", - WATCHER_MIN_SUPPORTED_VERSION: - process.env.WATCHER_MIN_SUPPORTED_VERSION ?? "0.1.0", - WATCHER_RELEASE_CHANNEL: process.env.WATCHER_RELEASE_CHANNEL ?? "stable", - WATCHER_MANDATORY_UPDATE: process.env.WATCHER_MANDATORY_UPDATE ?? "false", + // Watcher release-info defaults are seeded into the + // `watcher_release_config` table above; the env-var fallback is gone. // Point Slack webhook calls at the in-process capture server defined // above so tests can assert on the messages without hitting Slack. SLACK_WEBHOOK_URL: `${slackCaptureBaseUrl}/webhook`, diff --git a/web/tests/integration/helpers.ts b/web/tests/integration/helpers.ts index dc56213f..504fd9e0 100644 --- a/web/tests/integration/helpers.ts +++ b/web/tests/integration/helpers.ts @@ -58,6 +58,28 @@ export async function resetDb() { >[0] ); } + + // Re-seed the `watcher_release_config` singleton with the same + // defaults the global setup uses (9.9.9 / 0.1.0 / stable / false). + // The TRUNCATE on "user" above cascades through the + // `updated_by → user.id` FK and wipes this row even though + // `watcher_release_config` is not in TRUNCATE_ORDER — TRUNCATE CASCADE + // ignores the ON DELETE SET NULL clause and unconditionally clears + // dependent rows. Re-seeding here keeps every test's update-check + // baseline identical to a fresh global setup. + await db.execute( + `INSERT INTO watcher_release_config + (id, latest_version, min_supported_version, channel, mandatory) + VALUES + (true, '9.9.9', '0.1.0', 'stable', false) + ON CONFLICT (id) DO UPDATE SET + latest_version = EXCLUDED.latest_version, + min_supported_version = EXCLUDED.min_supported_version, + channel = EXCLUDED.channel, + mandatory = EXCLUDED.mandatory, + updated_at = now(), + updated_by = NULL` as unknown as Parameters[0] + ); } // --------------------------------------------------------------------------- diff --git a/web/tests/integration/watcher-release.test.ts b/web/tests/integration/watcher-release.test.ts new file mode 100644 index 00000000..0434ec44 --- /dev/null +++ b/web/tests/integration/watcher-release.test.ts @@ -0,0 +1,248 @@ +import { instruments, watcherReleaseConfig } from "@/lib/db/schema"; +import { + api, + closeTestDb, + getTestDb, + resetDb, + seedTestUser, +} from "@/tests/integration/helpers"; +import { eq, sql } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; + +// The `/api/v1/watcher-release` surface is admin-only and session-only — +// PATs never pass the gate. Mirroring the `users.test.ts` pattern, the +// negative cases are locked down here; the session-authenticated happy +// path (admin saving via the UI) is covered by manual QA per the PR +// description. +// +// The end-to-end "settings page → update-check" wiring is verified by +// upserting the singleton row directly via Drizzle and asserting the +// public `update-check` endpoint reflects the change. This keeps the +// schema → route → wire-response chain exercised without depending on a +// session synthesised by the harness. + +describe("Watcher Release API admin gate", () => { + let token: string; + + beforeAll(async () => { + await resetDb(); + // Seeding an admin user doesn't help here — PATs never carry a + // session, and `requireAdmin()` only consults the NextAuth session. + // This intentionally makes "PAT tries to manage the release" a 401, + // not a 403, so the failure mode is clearly "session required". + ({ token } = await seedTestUser({ isAdmin: true })); + }); + + afterAll(async () => { + await closeTestDb(); + }); + + it("GET /api/v1/watcher-release rejects PAT auth (session required)", async () => { + const res = await api("/api/v1/watcher-release", { token }); + expect(res.status).toBe(401); + }); + + it("GET /api/v1/watcher-release rejects unauthenticated requests", async () => { + const res = await api("/api/v1/watcher-release"); + expect(res.status).toBe(401); + }); + + it("PUT /api/v1/watcher-release rejects PAT auth", async () => { + const res = await api("/api/v1/watcher-release", { + method: "PUT", + token, + body: { + latest_version: "1.0.0", + min_supported_version: null, + channel: "stable", + mandatory: false, + }, + }); + expect(res.status).toBe(401); + }); + + it("PUT /api/v1/watcher-release rejects unauthenticated requests", async () => { + const res = await api("/api/v1/watcher-release", { + method: "PUT", + body: { + latest_version: "1.0.0", + min_supported_version: null, + channel: "stable", + mandatory: false, + }, + }); + expect(res.status).toBe(401); + }); +}); + +// Direct DB write → public read. Bypassing the admin-only write endpoint +// here is intentional: it isolates the schema → /update-check chain from +// the (separately-tested) session auth gate, so a regression in either +// half points cleanly at its half. +describe("Watcher Release singleton flows through update-check", () => { + let token: string; + let watcherId: string; + const instrumentId = "watcher-release-test-instrument"; + + beforeAll(async () => { + await resetDb(); + ({ token } = await seedTestUser()); + + const db = getTestDb(); + await db.insert(instruments).values({ + id: instrumentId, + displayName: "Watcher Release Test Instrument", + status: "active", + }); + + // Register a watcher to call update-check against. Using the public + // route (rather than a direct insert) keeps the test honest about + // the active-watcher invariants update-check relies on. + const res = await api("/api/v1/watchers/register", { + method: "POST", + token, + body: { + instrument_id: instrumentId, + hostname: "release-test-lab-pc", + os_info: "Linux integration-test", + }, + }); + expect(res.status).toBe(201); + const data = await res.json(); + watcherId = data.watcher_id; + }); + + // After each test, restore the singleton to the global-setup defaults + // so the assertions in `watchers.test.ts` (which expects 9.9.9) stay + // independent of test ordering. + afterEach(async () => { + const db = getTestDb(); + await db + .insert(watcherReleaseConfig) + .values({ + id: true, + latestVersion: "9.9.9", + minSupportedVersion: "0.1.0", + channel: "stable", + mandatory: false, + }) + .onConflictDoUpdate({ + target: watcherReleaseConfig.id, + set: { + latestVersion: "9.9.9", + minSupportedVersion: "0.1.0", + channel: "stable", + mandatory: false, + }, + }); + }); + + afterAll(async () => { + await closeTestDb(); + }); + + it("update-check reflects a direct upsert into watcher_release_config", async () => { + const db = getTestDb(); + await db + .insert(watcherReleaseConfig) + .values({ + id: true, + latestVersion: "1.2.3", + minSupportedVersion: "1.0.0", + channel: "beta", + mandatory: true, + }) + .onConflictDoUpdate({ + target: watcherReleaseConfig.id, + set: { + latestVersion: "1.2.3", + minSupportedVersion: "1.0.0", + channel: "beta", + mandatory: true, + }, + }); + + const res = await api(`/api/v1/watchers/${watcherId}/update-check`, { + token, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + latest_version: "1.2.3", + min_supported_version: "1.0.0", + channel: "beta", + mandatory: true, + }); + }); + + it("update-check collapses mandatory→false when latest_version is null", async () => { + // Mirrors the invariant baked into `readReleaseInfo()`: advertising + // `mandatory: true` while no version is offered would be nonsensical + // on the wire, so the route forces it false on read. This guards + // against a future schema change accidentally leaking the raw value. + const db = getTestDb(); + await db + .insert(watcherReleaseConfig) + .values({ + id: true, + latestVersion: null, + minSupportedVersion: null, + channel: "stable", + mandatory: true, + }) + .onConflictDoUpdate({ + target: watcherReleaseConfig.id, + set: { + latestVersion: null, + minSupportedVersion: null, + channel: "stable", + mandatory: true, + }, + }); + + const res = await api(`/api/v1/watchers/${watcherId}/update-check`, { + token, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + latest_version: null, + min_supported_version: null, + channel: "stable", + mandatory: false, + }); + }); + + it("update-check falls back to defaults when the singleton row is missing", async () => { + // Fresh-deploy / pre-seed shape. Deleting and reading verifies the + // route's "no row" branch still returns 200 (rather than 500), so + // watchers don't log spurious errors before any admin has saved. + const db = getTestDb(); + await db + .delete(watcherReleaseConfig) + .where(eq(watcherReleaseConfig.id, true)); + + const res = await api(`/api/v1/watchers/${watcherId}/update-check`, { + token, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + latest_version: null, + min_supported_version: null, + channel: "stable", + mandatory: false, + }); + }); + + it("singleton check constraint rejects a second row", async () => { + // The `id boolean PRIMARY KEY` + `CHECK (id = true)` schema-level + // guarantee that exactly 0 or 1 rows can exist is the whole reason + // route handlers can skip LIMIT 1 / ORDER BY discipline. If a + // future migration relaxes this without updating the routes, both + // halves of the invariant should break together. + const db = getTestDb(); + await expect( + db.execute( + sql`INSERT INTO watcher_release_config (id, channel) VALUES (false, 'stable')` + ) + ).rejects.toThrow(); + }); +}); diff --git a/web/tests/integration/watchers.test.ts b/web/tests/integration/watchers.test.ts index 1d934f2b..771c98dd 100644 --- a/web/tests/integration/watchers.test.ts +++ b/web/tests/integration/watchers.test.ts @@ -388,9 +388,9 @@ describe("Watchers API", () => { // ------------------------------------------------------------------------- // Update-check is the foundation for the auto-update flow. The exact - // response values come from `WATCHER_*` env vars seeded in - // `tests/integration/global-setup.ts` so the assertions stay stable as - // the real release line moves on. + // response values come from the `watcher_release_config` singleton row + // seeded in `tests/integration/global-setup.ts` so the assertions stay + // stable as the real release line moves on. it("GET /api/v1/watchers/:id/update-check returns server-reported release info", async () => { const res = await api(`/api/v1/watchers/${watcherId}/update-check`, { token, From e1d181376c197e6c7ded606bf62e43f9a91eb8fa Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Mon, 18 May 2026 08:57:28 -0700 Subject: [PATCH 2/3] =?UTF-8?q?Web:=20Polish=20watcher=20release=20setting?= =?UTF-8?q?s=20=E2=80=94=20Zod=20validation,=20URL=20reorg,=20card=20layou?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups to the previous watcher release commit: * Replace the hand-rolled per-field validation in the PUT route with a Zod `strictObject` schema. Drops `normalizeOptionalString` and the bespoke `unknown_fields` detail in favor of zod issues, and unifies PUT semantics so all four fields take their defaults uniformly when omitted rather than silently preserving existing values for the two version columns. * Move the API surface from `/api/v1/watcher-release` to `/api/v1/settings/watcher-release` and the admin page from `/settings/watcher-release` to `/settings/watchers`. Reorder the settings sidebar to Access Tokens → Watchers → Members. Comment and doc references throughout (schema, env example, integration tests, shared testing helper, upgrade guide) updated to match. * UI polish: drop the duplicate "Settings" H1 from the settings layout (the sidebar already labels the section), rename the on-page heading to "Watcher Version", and wrap the entire form (heading, description, fields, footer) in a full-width Card. CardHeader, CardContent and the inner footer row are capped at max-w-2xl so the input rows stay at a readable measure while the card's top border and full-width footer separator span the page. Co-authored-by: Cursor --- docs/guides/upgrading-the-watcher.md | 14 +- .../shared/src/data_hub_shared/testing.py | 2 +- watcher/tests/test_updater.py | 2 +- web/.env.example | 4 +- .../{ => settings}/watcher-release/route.ts | 155 ++++------ .../[watcherId]/update-check/route.ts | 8 +- web/app/settings/layout.tsx | 6 +- .../{watcher-release => watchers}/page.tsx | 84 ++---- web/components/app-sidebar/settings-nav.tsx | 6 +- .../watcher-release/watcher-release-form.tsx | 272 +++++++++++------- web/lib/db/schema.ts | 2 +- web/tests/integration/global-setup.ts | 2 +- web/tests/integration/watcher-release.test.ts | 26 +- 13 files changed, 291 insertions(+), 292 deletions(-) rename web/app/api/v1/{ => settings}/watcher-release/route.ts (55%) rename web/app/settings/{watcher-release => watchers}/page.tsx (51%) diff --git a/docs/guides/upgrading-the-watcher.md b/docs/guides/upgrading-the-watcher.md index 6e139878..0628820d 100644 --- a/docs/guides/upgrading-the-watcher.md +++ b/docs/guides/upgrading-the-watcher.md @@ -64,7 +64,7 @@ If you want a specific PC to stay on a particular release rather than tracking t uv tool install data-hub-watcher== ``` -Run `data-hub-watcher self-update --check` afterwards to confirm what the server's target is. As long as the pinned version matches `latest_version`, the auto-update tick is a no-op. The moment the server's target moves past your pin, the next tick will try to upgrade past it again — pinning is per-machine state, not server-side state. To park a fleet on a given version intentionally, the right knob is the server-side **Latest version** field on the **Settings → Watcher Release** page in Data Hub (see [Cutting a new release](#cutting-a-new-release)). +Run `data-hub-watcher self-update --check` afterwards to confirm what the server's target is. As long as the pinned version matches `latest_version`, the auto-update tick is a no-op. The moment the server's target moves past your pin, the next tick will try to upgrade past it again — pinning is per-machine state, not server-side state. To park a fleet on a given version intentionally, the right knob is the server-side **Latest version** field on the **Settings → Watchers** page in Data Hub (see [Cutting a new release](#cutting-a-new-release)). ## Cutting a new release @@ -99,7 +99,7 @@ Manual dispatch from any branch other than `production` is refused by the workfl ### 4. Roll the release out -Once the new version is live on PyPI, open **Settings → Watcher Release** in Data Hub (admins only) and set **Latest version** to the new release. Save; the `/update-check` endpoint will start advertising the new target immediately. Lab PCs running an auto-update-capable build will pick it up on their next hourly tick. +Once the new version is live on PyPI, open **Settings → Watchers** in Data Hub (admins only) and set **Latest version** to the new release. Save; the `/update-check` endpoint will start advertising the new target immediately. Lab PCs running an auto-update-capable build will pick it up on their next hourly tick. The supported watcher release fields are: @@ -118,7 +118,7 @@ If the rollout needs to be paged through (e.g. you want only `staging` lab PCs t ## Mandatory updates -By default the activity-window guard means a watcher mid-acquisition won't auto-update — it'll wait for the next idle window. For releases that fix a security issue, a wire-protocol break, or any other case where running the known-bad version is worse than a brief outage, toggle **Mandatory update** on alongside the version bump on the **Watcher Release** settings page. Mandatory rollouts skip the activity-window guard and fire on the very next hourly check on every lab PC. +By default the activity-window guard means a watcher mid-acquisition won't auto-update — it'll wait for the next idle window. For releases that fix a security issue, a wire-protocol break, or any other case where running the known-bad version is worse than a brief outage, toggle **Mandatory update** on alongside the version bump on the **Settings → Watchers** page. Mandatory rollouts skip the activity-window guard and fire on the very next hourly check on every lab PC. Use this sparingly. The activity-window guard exists for a reason — a forced upgrade in the middle of a multi-hour microscopy run will lose data. Reserve it for cases where leaving the bad version in place is strictly worse than restarting the watcher in flight. @@ -128,7 +128,7 @@ Note also that mandatory rollouts are versioned, not absolute: the server compar Rollback is just another release. To revert the fleet from `0.3.0` to `0.2.5`: -1. Open **Settings → Watcher Release** in Data Hub and set **Latest version** to `0.2.5` for the affected environment(s). +1. Open **Settings → Watchers** in Data Hub and set **Latest version** to `0.2.5` for the affected environment(s). 2. Toggle **Mandatory update** on if you need the rollback to bypass the activity-window guard (most rollback scenarios qualify — you're rolling back precisely because the running version is misbehaving). 3. Wait for the next hourly tick on each PC. Lab PCs running an auto-update-capable build will downgrade themselves; PCs being upgraded manually need a `data-hub-watcher self-update` (or an `uv tool install data-hub-watcher==0.2.5` if `self-update` itself is what's broken). 4. Once the fleet has converged, toggle **Mandatory update** back off. @@ -149,7 +149,7 @@ The upgrade subprocess started but didn't end up running the new version on the When `details.attempted_subprocess` is `false`, the auto-updater never ran the upgrade command — it refused before starting one. The `details.reason` field tells you why: -- `**install method '' not eligible for auto-update**` — the watcher detected a development-style install (editable `uv sync`, or a distribution whose metadata couldn't be located) and refused so it wouldn't silently shadow the source tree with an index build. Resolve by switching the host to a PyPI install (`uv tool install data-hub-watcher`) or, on a developer machine, ignoring the event. To avoid spamming the events stream, the watcher emits this at most once per server target — saving a new **Latest version** on the **Watcher Release** settings page will trigger one fresh event per stuck PC. +- `**install method '' not eligible for auto-update**` — the watcher detected a development-style install (editable `uv sync`, or a distribution whose metadata couldn't be located) and refused so it wouldn't silently shadow the source tree with an index build. Resolve by switching the host to a PyPI install (`uv tool install data-hub-watcher`) or, on a developer machine, ignoring the event. To avoid spamming the events stream, the watcher emits this at most once per server target — saving a new **Latest version** on the **Settings → Watchers** page will trigger one fresh event per stuck PC. ### Auto-update never fires @@ -157,12 +157,12 @@ Check, in order: - The watcher is running as a service, not in a console window. Foreground `data-hub-watcher watch` does run the in-process updater, but on a developer-style install it'll typically be refused as editable. - The environment isn't `preview`. Auto-update is hard-disabled there. -- The activity-window guard isn't holding things up. The instrument has to have been quiet for several heartbeats; on a busy plate reader you may simply never hit the idle window. Use the CLI path (`data-hub-watcher self-update`) for an immediate upgrade, or toggle **Mandatory update** on the **Watcher Release** settings page if the release warrants it. +- The activity-window guard isn't holding things up. The instrument has to have been quiet for several heartbeats; on a busy plate reader you may simply never hit the idle window. Use the CLI path (`data-hub-watcher self-update`) for an immediate upgrade, or toggle **Mandatory update** on the **Settings → Watchers** page if the release warrants it. - The dashboard's **Last Heartbeat** is recent. If the watcher has gone stale, it's not ticking and won't auto-update. ### `data-hub-watcher self-update --check` says "(none configured)" -The **Watcher Release** settings page has a blank **Latest version** for that environment. Either you're running against a `preview` build that has never had a release saved, or an admin cleared the field in Data Hub. The CLI returns successfully and treats this as "no update available" — the same response code path used by an up-to-date watcher — so this is benign, just informational. +The **Settings → Watchers** page has a blank **Latest version** for that environment. Either you're running against a `preview` build that has never had a release saved, or an admin cleared the field in Data Hub. The CLI returns successfully and treats this as "no update available" — the same response code path used by an up-to-date watcher — so this is benign, just informational. ### "Refusing to self-update an editable / unknown install" diff --git a/packages/shared/src/data_hub_shared/testing.py b/packages/shared/src/data_hub_shared/testing.py index aa8f4f1e..903a3cd9 100644 --- a/packages/shared/src/data_hub_shared/testing.py +++ b/packages/shared/src/data_hub_shared/testing.py @@ -175,7 +175,7 @@ def seed_watcher_release( The ``update-check`` endpoint reads from this table; tests assert on the seeded values. Previously these were plumbed via ``WATCHER_*`` env vars; the source of truth is now the DB, edited via the - admin-only ``/settings/watcher-release`` page in the web app. + admin-only ``/settings/watchers`` page in the web app. """ conn = psycopg2.connect(dsn) conn.autocommit = True diff --git a/watcher/tests/test_updater.py b/watcher/tests/test_updater.py index 298758ea..8169b846 100644 --- a/watcher/tests/test_updater.py +++ b/watcher/tests/test_updater.py @@ -768,7 +768,7 @@ def test_editable_refusal_re_emits_when_target_advances( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: # When the server advertises a new release (via the admin-only - # /settings/watcher-release page), we *do* want to re-notify — + # /settings/watchers page), we *do* want to re-notify — # the dashboard event for # the old target doesn't tell the admin this PC is missing the # new one. Throttling is per-target, not "fire at most once diff --git a/web/.env.example b/web/.env.example index 71887a7f..244ff65c 100644 --- a/web/.env.example +++ b/web/.env.example @@ -29,7 +29,7 @@ LAMBDA_FUNCTION_URL= SLACK_WEBHOOK_URL= # Watcher release metadata is now stored in the `watcher_release_config` -# table and edited at /settings/watcher-release (admin-only). Until an -# admin saves a row, GET /api/v1/watchers/:id/update-check returns +# table and edited at /settings/watchers (admin-only). Until an admin +# saves a row, GET /api/v1/watchers/:id/update-check returns # latest_version: null and watchers skip the self-update — the same # behavior as leaving WATCHER_LATEST_VERSION unset used to be. diff --git a/web/app/api/v1/watcher-release/route.ts b/web/app/api/v1/settings/watcher-release/route.ts similarity index 55% rename from web/app/api/v1/watcher-release/route.ts rename to web/app/api/v1/settings/watcher-release/route.ts index aa557662..0c573212 100644 --- a/web/app/api/v1/watcher-release/route.ts +++ b/web/app/api/v1/settings/watcher-release/route.ts @@ -4,11 +4,12 @@ import { db } from "@/lib/db"; import { users, watcherReleaseConfig } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; +import { z } from "zod"; // Admin-only read/write of the singleton `watcher_release_config` row, -// edited via `/settings/watcher-release`. The `update-check` endpoint -// reads the same row but is open to any watcher-scoped PAT — this route -// is the privileged write path and is therefore session-only via +// edited via `/settings/watchers`. The `update-check` endpoint reads +// the same row but is open to any watcher-scoped PAT — this route is +// the privileged write path and is therefore session-only via // `requireAdmin()`, matching the `/api/v1/users/[userId]` PATCH model. // Loose PEP-440-style version match — covers the values we already @@ -18,12 +19,48 @@ import type { NextRequest } from "next/server"; // is the same failure mode they already debug today. const VERSION_REGEX = /^\d+\.\d+\.\d+([.-].+)?$/; -const ALLOWED_PUT_FIELDS = new Set([ - "latest_version", - "min_supported_version", - "channel", - "mandatory", -]); +// Trim and collapse `""` to `null` so the wire contract stays "empty +// means unset" everywhere — operators don't have to remember to send +// `null` instead of `""`. Shared by both version fields. +function normalizeVersionInput(v: string | null | undefined): string | null { + if (v == null) return null; + const trimmed = v.trim(); + return trimmed.length === 0 ? null : trimmed; +} + +// PUT semantics: missing fields take their defaults rather than +// silently preserving the existing row's value. The form always sends +// all four, so this only affects hand-crafted callers — for whom a +// uniform "replace with these (or defaults)" rule is far less surprising +// than the previous mix where `channel`/`mandatory` defaulted but +// `latest_version`/`min_supported_version` were preserved. +const PutBodySchema = z.strictObject({ + latest_version: z + .string() + .nullish() + .transform(normalizeVersionInput) + .refine((v) => v === null || VERSION_REGEX.test(v), { + message: "latest_version is not a valid PEP 440-style version", + }), + min_supported_version: z + .string() + .nullish() + .transform(normalizeVersionInput) + .refine((v) => v === null || VERSION_REGEX.test(v), { + message: "min_supported_version is not a valid PEP 440-style version", + }), + channel: z + .string() + .optional() + .transform((v) => (v ?? "stable").trim()) + .refine((v) => v.length > 0, { + message: "channel must be a non-empty string", + }), + mandatory: z + .boolean() + .optional() + .transform((v) => v ?? false), +}); type WatcherReleaseResponse = { latest_version: string | null; @@ -92,102 +129,34 @@ export async function GET() { return Response.json(await readCurrent()); } -// Normalises a string input from the form: trims whitespace and treats -// an empty string the same as an omitted/explicit-null value. Keeps the -// "version unset" semantics from the env-var era — operators don't have -// to remember to send `null` instead of `""`. -function normalizeOptionalString(value: unknown): string | null { - if (value === null) return null; - if (typeof value !== "string") return value as never; - const trimmed = value.trim(); - return trimmed.length === 0 ? null : trimmed; -} - export async function PUT(request: NextRequest) { const authResult = await requireAdmin(); if (authResult instanceof Response) return authResult; - let body: Record; + let rawBody: unknown; try { - body = await request.json(); + rawBody = await request.json(); } catch { return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); } - const unknownKeys = Object.keys(body).filter( - (k) => !ALLOWED_PUT_FIELDS.has(k) - ); - if (unknownKeys.length > 0) { - return apiError(400, VALIDATION_ERROR, "Unknown fields", { - unknown_fields: unknownKeys, - allowed_fields: [...ALLOWED_PUT_FIELDS], + const parsed = PutBodySchema.safeParse(rawBody); + if (!parsed.success) { + return apiError(400, VALIDATION_ERROR, "Invalid request body", { + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), }); } - // Type-check each field individually so the error messages name the - // offending property (rather than the all-or-nothing failure you'd get - // from a single zod parse). - if ( - body.latest_version !== undefined && - body.latest_version !== null && - typeof body.latest_version !== "string" - ) { - return apiError( - 400, - VALIDATION_ERROR, - "latest_version must be a string or null" - ); - } - if ( - body.min_supported_version !== undefined && - body.min_supported_version !== null && - typeof body.min_supported_version !== "string" - ) { - return apiError( - 400, - VALIDATION_ERROR, - "min_supported_version must be a string or null" - ); - } - if (body.channel !== undefined && typeof body.channel !== "string") { - return apiError(400, VALIDATION_ERROR, "channel must be a string"); - } - if (body.mandatory !== undefined && typeof body.mandatory !== "boolean") { - return apiError(400, VALIDATION_ERROR, "mandatory must be a boolean"); - } - - const latestVersion = normalizeOptionalString(body.latest_version); - const minSupportedVersion = normalizeOptionalString( - body.min_supported_version - ); - const channel = - typeof body.channel === "string" ? body.channel.trim() : "stable"; - const mandatory = body.mandatory === true; - - if (latestVersion !== null && !VERSION_REGEX.test(latestVersion)) { - return apiError( - 400, - VALIDATION_ERROR, - `latest_version '${latestVersion}' is not a valid PEP 440-style version` - ); - } - if ( - minSupportedVersion !== null && - !VERSION_REGEX.test(minSupportedVersion) - ) { - return apiError( - 400, - VALIDATION_ERROR, - `min_supported_version '${minSupportedVersion}' is not a valid PEP 440-style version` - ); - } - if (channel.length === 0) { - return apiError( - 400, - VALIDATION_ERROR, - "channel must be a non-empty string" - ); - } + const { + latest_version: latestVersion, + min_supported_version: minSupportedVersion, + channel, + mandatory, + } = parsed.data; const now = new Date(); // Singleton upsert: `id = true` is the primary key, so ON CONFLICT diff --git a/web/app/api/v1/watchers/[watcherId]/update-check/route.ts b/web/app/api/v1/watchers/[watcherId]/update-check/route.ts index ba3ab8d2..185c253c 100644 --- a/web/app/api/v1/watchers/[watcherId]/update-check/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/update-check/route.ts @@ -10,10 +10,10 @@ import type { NextRequest } from "next/server"; * Server-reported watcher release metadata. * * Source of truth is the `watcher_release_config` singleton row, edited - * by admins via `/settings/watcher-release`. Until that row exists - * (fresh deploy, before any admin save) the endpoint still returns 200 - * with `latest_version: null` so watchers don't log spurious 5xxs and - * the client treats it as "no update available". + * by admins via `/settings/watchers`. Until that row exists (fresh + * deploy, before any admin save) the endpoint still returns 200 with + * `latest_version: null` so watchers don't log spurious 5xxs and the + * client treats it as "no update available". */ type WatcherReleaseInfo = { latest_version: string | null; diff --git a/web/app/settings/layout.tsx b/web/app/settings/layout.tsx index c9614925..1523d671 100644 --- a/web/app/settings/layout.tsx +++ b/web/app/settings/layout.tsx @@ -34,10 +34,12 @@ export default async function SettingsLayout({ ); } + // No layout-level "Settings" title: the SettingsNav sidebar already + // labels this section, so rendering an H1 here was duplicative chrome + // on every settings page. Pages render their own H2 instead. return (
-

Settings

-
{children}
+ {children}
); } diff --git a/web/app/settings/watcher-release/page.tsx b/web/app/settings/watchers/page.tsx similarity index 51% rename from web/app/settings/watcher-release/page.tsx rename to web/app/settings/watchers/page.tsx index 14001f3b..c3f09b84 100644 --- a/web/app/settings/watcher-release/page.tsx +++ b/web/app/settings/watchers/page.tsx @@ -7,22 +7,18 @@ import { eq } from "drizzle-orm"; import { ShieldOff } from "lucide-react"; import type { Metadata } from "next/types"; -const description = - "Configure the watcher release advertised by the auto-update endpoint."; - export const metadata: Metadata = { - title: "Watcher Release", - description, - openGraph: { title: "Watcher Release", description }, - twitter: { title: "Watcher Release", description }, + title: "Watchers", + description: + "Configure the watcher release advertised by the auto-update endpoint.", }; -export default async function WatcherReleasePage() { +export default async function WatchersSettingsPage() { const session = await auth(); if (!session?.user) { return ( - - Sign in to manage the watcher release. + + Sign in to manage watcher settings. ); } @@ -39,7 +35,7 @@ export default async function WatcherReleasePage() { Admins only

- You need workspace admin access to change the watcher release. Ask an + You need workspace admin access to change watcher settings. Ask an existing admin if you need to be promoted.

@@ -65,49 +61,29 @@ export default async function WatcherReleasePage() { .from(watcherReleaseConfig) .leftJoin(users, eq(users.id, watcherReleaseConfig.updatedBy)); + // The form owns its own Card chrome (heading, description, fields, + // footer) so it renders as a self-contained settings panel. The page + // just wires server-fetched state into it. return ( -
-
-
-

- Watcher Release -

-

- Configure the release advertised by{" "} - - GET /api/v1/watchers/:id/update-check - - . Watchers compare their installed version against{" "} - - latest_version - {" "} - and self-upgrade when a newer release is offered. -

-
-
- -
- -
-
+ ); } diff --git a/web/components/app-sidebar/settings-nav.tsx b/web/components/app-sidebar/settings-nav.tsx index 037d4f1b..32ce0a0c 100644 --- a/web/components/app-sidebar/settings-nav.tsx +++ b/web/components/app-sidebar/settings-nav.tsx @@ -22,12 +22,8 @@ type SettingsSection = { const SETTINGS_SECTIONS: SettingsSection[] = [ { href: "/settings/tokens", label: "Access Tokens" }, + { href: "/settings/watchers", label: "Watchers", adminOnly: true }, { href: "/settings/members", label: "Members", adminOnly: true }, - { - href: "/settings/watcher-release", - label: "Watcher Release", - adminOnly: true, - }, ]; // Single, predictable destination for the "leave settings" affordance. A diff --git a/web/components/watcher-release/watcher-release-form.tsx b/web/components/watcher-release/watcher-release-form.tsx index 0eae1da1..55982d6b 100644 --- a/web/components/watcher-release/watcher-release-form.tsx +++ b/web/components/watcher-release/watcher-release-form.tsx @@ -1,6 +1,14 @@ "use client"; import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -54,7 +62,7 @@ export function WatcherReleaseForm({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); startTransition(async () => { - const res = await fetch("/api/v1/watcher-release", { + const res = await fetch("/api/v1/settings/watcher-release", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -87,114 +95,162 @@ export function WatcherReleaseForm({ }; return ( -
-
- - setLatestVersion(e.target.value)} - autoComplete="off" - spellCheck={false} - className="font-mono" - /> -

- The release watchers will self-upgrade to. Leave blank to temporarily - disable self-updates (the endpoint returns{" "} - latest_version: null and clients - skip the upgrade). -

-
- -
- - setMinSupportedVersion(e.target.value)} - autoComplete="off" - spellCheck={false} - className="font-mono" - /> -

- Optional floor surfaced in the response for future use. Not yet - enforced server-side. -

-
- -
- - setChannel(e.target.value)} - autoComplete="off" - spellCheck={false} - className="font-mono" - /> -

- Defaults to stable. Surfaced in the - response and shown in self-update{" "} - output. -

-
- -
-
- -

- Skip the watcher's activity-window guard so mid-acquisition PCs - upgrade immediately. Reserve for security fixes or wire-protocol - breaks. Has no effect when the latest version is blank. -

-
- -
- -
- {lastUpdated ? ( -

- Last updated{" "} - - {formatRelativeTime(lastUpdated.at)} - - {lastUpdated.byName || lastUpdated.byEmail ? ( - <> - {" by "} - - {lastUpdated.byName ?? lastUpdated.byEmail} + // The Card wrapper mirrors Vercel's project-settings panel pattern: + // heading + description, form fields, then a CardFooter holding the + // meta + Save action separated by a single border. The Card spans + // the full width of the page area; CardHeader/CardContent and the + // inner FooterRow are capped at `max-w-2xl` so input rows and body + // copy stay at a readable measure regardless of viewport. + // + // The fields stay in a single Card (rather than one-card-per-field) + // because they all configure the same logical resource — the + // singleton release row. + + + + + Watcher Version + + + Configure the release advertised by{" "} + + GET /api/v1/watchers/:id/update-check + + .
Watchers compare their installed version against{" "} + + latest_version + {" "} + and self-upgrade when a newer release is offered. +
+
+ + +

+ + setLatestVersion(e.target.value)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> +

+ The release watchers will self-upgrade to. Leave blank to + temporarily disable self-updates (the endpoint returns{" "} + latest_version: null and + clients skip the upgrade). +

+
+ +
+ + setMinSupportedVersion(e.target.value)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> +

+ Optional floor surfaced in the response for future use. Not yet + enforced server-side. +

+
+ +
+ + setChannel(e.target.value)} + autoComplete="off" + spellCheck={false} + className="font-mono" + /> +

+ Defaults to stable. Surfaced in + the response and shown in{" "} + self-update output. +

+
+ + {/* Plain inline row — no nested rounded-border panel — so the + switch sits flush with the input fields above and we don't + double up card styling inside the outer Card. */} +
+
+ +

+ Skip the watcher's activity-window guard so mid-acquisition + PCs upgrade immediately. Reserve for security fixes or + wire-protocol breaks. Has no effect when the latest version is + blank. +

+
+ +
+ + + {/* Footer keeps full card width so its top border runs + edge-to-edge; the inner row is capped at the same max-w-2xl + as the body so the action row visually aligns with the + field column above. */} + +
+ {lastUpdated ? ( +

+ Last updated{" "} + + {formatRelativeTime(lastUpdated.at)} - - ) : null} - . -

- ) : ( -

- No release configured yet. Watchers will skip self-updates until you - save a version. -

- )} - -
+ {lastUpdated.byName || lastUpdated.byEmail ? ( + <> + {" by "} + + {lastUpdated.byName ?? lastUpdated.byEmail} + + + ) : null} + . +

+ ) : ( +

+ No release configured yet. Watchers will skip self-updates until + you save a version. +

+ )} + +
+ +
); } diff --git a/web/lib/db/schema.ts b/web/lib/db/schema.ts index 374d9cfa..1962686a 100644 --- a/web/lib/db/schema.ts +++ b/web/lib/db/schema.ts @@ -153,7 +153,7 @@ export const sessions = pgTable( // Singleton row of server-advertised watcher release metadata, served by // `GET /api/v1/watchers/:id/update-check` and edited via the admin-only -// `/settings/watcher-release` page. Previously sourced from the +// `/settings/watchers` page. Previously sourced from the // `WATCHER_LATEST_VERSION` / `WATCHER_MIN_SUPPORTED_VERSION` / // `WATCHER_RELEASE_CHANNEL` / `WATCHER_MANDATORY_UPDATE` env vars. // diff --git a/web/tests/integration/global-setup.ts b/web/tests/integration/global-setup.ts index 1af6867b..84b4cef3 100644 --- a/web/tests/integration/global-setup.ts +++ b/web/tests/integration/global-setup.ts @@ -121,7 +121,7 @@ export async function setup() { // values during integration tests. Individual tests assert against // these exact strings (see `watchers.test.ts`). Previously this was // done via WATCHER_* env vars; the source of truth is now the DB, - // edited via the admin-only /settings/watcher-release page. + // edited via the admin-only /settings/watchers page. const seedSql = postgres(databaseUrl); try { await seedSql` diff --git a/web/tests/integration/watcher-release.test.ts b/web/tests/integration/watcher-release.test.ts index 0434ec44..9a297da9 100644 --- a/web/tests/integration/watcher-release.test.ts +++ b/web/tests/integration/watcher-release.test.ts @@ -9,11 +9,11 @@ import { import { eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -// The `/api/v1/watcher-release` surface is admin-only and session-only — -// PATs never pass the gate. Mirroring the `users.test.ts` pattern, the -// negative cases are locked down here; the session-authenticated happy -// path (admin saving via the UI) is covered by manual QA per the PR -// description. +// The `/api/v1/settings/watcher-release` surface is admin-only and +// session-only — PATs never pass the gate. Mirroring the +// `users.test.ts` pattern, the negative cases are locked down here; the +// session-authenticated happy path (admin saving via the UI) is covered +// by manual QA per the PR description. // // The end-to-end "settings page → update-check" wiring is verified by // upserting the singleton row directly via Drizzle and asserting the @@ -37,18 +37,18 @@ describe("Watcher Release API admin gate", () => { await closeTestDb(); }); - it("GET /api/v1/watcher-release rejects PAT auth (session required)", async () => { - const res = await api("/api/v1/watcher-release", { token }); + it("GET /api/v1/settings/watcher-release rejects PAT auth (session required)", async () => { + const res = await api("/api/v1/settings/watcher-release", { token }); expect(res.status).toBe(401); }); - it("GET /api/v1/watcher-release rejects unauthenticated requests", async () => { - const res = await api("/api/v1/watcher-release"); + it("GET /api/v1/settings/watcher-release rejects unauthenticated requests", async () => { + const res = await api("/api/v1/settings/watcher-release"); expect(res.status).toBe(401); }); - it("PUT /api/v1/watcher-release rejects PAT auth", async () => { - const res = await api("/api/v1/watcher-release", { + it("PUT /api/v1/settings/watcher-release rejects PAT auth", async () => { + const res = await api("/api/v1/settings/watcher-release", { method: "PUT", token, body: { @@ -61,8 +61,8 @@ describe("Watcher Release API admin gate", () => { expect(res.status).toBe(401); }); - it("PUT /api/v1/watcher-release rejects unauthenticated requests", async () => { - const res = await api("/api/v1/watcher-release", { + it("PUT /api/v1/settings/watcher-release rejects unauthenticated requests", async () => { + const res = await api("/api/v1/settings/watcher-release", { method: "PUT", body: { latest_version: "1.0.0", From e8eade020ed59600897cd8958ce3e4f3485f1e65 Mon Sep 17 00:00:00 2001 From: Wasim Sandhu Date: Mon, 18 May 2026 11:23:46 -0700 Subject: [PATCH 3/3] Web: Refactor watcher release form to use TanStack Form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces hand-rolled useState/useTransition wiring in the watcher release settings form with @tanstack/react-form. Field state, isSubmitting, and validation are now owned by the form library, and the same Zod schema is checked onBlur (live feedback after a field is touched) and onSubmit (final guard) — mirroring VERSION_REGEX from the server route so typos are caught locally instead of round-tripping to a 400. - Adds the shadcn Field primitive (Field, FieldGroup, FieldLabel, FieldDescription, FieldError, FieldContent) used by the docs' TanStack Form examples. - Scopes re-renders to small subtrees via form.Subscribe selectors for the mandatory-switch row and the Save button. - Preserves the existing UX (visually-off-when-disabled mandatory switch, "" → null wire normalisation, last-updated-by footer, router.refresh on success). Co-authored-by: Cursor --- web/components/ui/field.tsx | 248 ++++++++++++ .../watcher-release/watcher-release-form.tsx | 374 ++++++++++++------ web/package-lock.json | 104 ++++- web/package.json | 4 +- 4 files changed, 595 insertions(+), 135 deletions(-) create mode 100644 web/components/ui/field.tsx diff --git a/web/components/ui/field.tsx b/web/components/ui/field.tsx new file mode 100644 index 00000000..3bf77f8f --- /dev/null +++ b/web/components/ui/field.tsx @@ -0,0 +1,248 @@ +"use client"; + +import { cva, type VariantProps } from "class-variance-authority"; +import { useMemo } from "react"; + +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ); +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +); + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +