diff --git a/docs/guides/upgrading-the-watcher.md b/docs/guides/upgrading-the-watcher.md index 280e9fd3..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 `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 → Watchers** 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 → 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 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 **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,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 → 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, 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 **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 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 **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)" -`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 **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 10703d88..903a3cd9 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/watchers`` 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..8169b846 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/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 # ever". diff --git a/web/.env.example b/web/.env.example index 62585c7c..244ff65c 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/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/settings/watcher-release/route.ts b/web/app/api/v1/settings/watcher-release/route.ts new file mode 100644 index 00000000..0c573212 --- /dev/null +++ b/web/app/api/v1/settings/watcher-release/route.ts @@ -0,0 +1,189 @@ +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"; +import { z } from "zod"; + +// Admin-only read/write of the singleton `watcher_release_config` row, +// 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 +// 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+([.-].+)?$/; + +// 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; + 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()); +} + +export async function PUT(request: NextRequest) { + const authResult = await requireAdmin(); + if (authResult instanceof Response) return authResult; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return apiError(400, VALIDATION_ERROR, "Invalid JSON body"); + } + + 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, + })), + }); + } + + 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 + // 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..185c253c 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/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; @@ -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/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/watchers/page.tsx b/web/app/settings/watchers/page.tsx new file mode 100644 index 00000000..c3f09b84 --- /dev/null +++ b/web/app/settings/watchers/page.tsx @@ -0,0 +1,89 @@ +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"; + +export const metadata: Metadata = { + title: "Watchers", + description: + "Configure the watcher release advertised by the auto-update endpoint.", +}; + +export default async function WatchersSettingsPage() { + const session = await auth(); + if (!session?.user) { + return ( + + Sign in to manage watcher settings. + + ); + } + + // 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 watcher settings. 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)); + + // 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 ( + + ); +} diff --git a/web/components/app-sidebar/settings-nav.tsx b/web/components/app-sidebar/settings-nav.tsx index afd31647..32ce0a0c 100644 --- a/web/components/app-sidebar/settings-nav.tsx +++ b/web/components/app-sidebar/settings-nav.tsx @@ -22,6 +22,7 @@ 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 }, ]; 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 ( +