diff --git a/.github/workflows/typescript-lint.yml b/.github/workflows/typescript-lint.yml index 5ac21569..dba57aa6 100644 --- a/.github/workflows/typescript-lint.yml +++ b/.github/workflows/typescript-lint.yml @@ -34,11 +34,10 @@ jobs: - name: Install packages run: npm ci - - name: Check formatting - run: npm run format:check - - - name: Lint - run: npm run lint + # `ultracite check` runs Biome's combined formatter + linter check + # (read-only), replacing the separate Prettier `--check` + ESLint steps. + - name: Lint and format check + run: npm run lint:check - name: Type check run: npm run typecheck diff --git a/AGENTS.md b/AGENTS.md index d2ee0d52..e7afa513 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,4 +52,5 @@ recreate it from the "Minimal `.env`" block in `docs/local-development.md` (the writes to `web/.next` and **contends with a running `make dev`** (also using `.next`). Stop the dev server before running integration tests, then restart it afterward. - Lint/format/typecheck: `make check-all` (note: `py-format`/`fe-format` auto-rewrite files; use - `uv run ruff check .`, `npm run lint`, `npm run typecheck`, `npm run format:check` for read-only checks). + `uv run ruff check .`, `npm run lint:check` (Biome formatter + linter, read-only), and + `npm run typecheck` for read-only checks). diff --git a/Makefile b/Makefile index 0f70925f..e90b792a 100644 --- a/Makefile +++ b/Makefile @@ -49,11 +49,11 @@ py-check-watcher-version: # Web app. .PHONY: fe-format fe-format: - cd web && npm run format + cd web && npm run lint:fix .PHONY: fe-lint fe-lint: - cd web && npm run lint + cd web && npm run lint:check .PHONY: fe-typecheck fe-typecheck: diff --git a/docs/ci-and-deployment.md b/docs/ci-and-deployment.md index 6b627718..53c96381 100644 --- a/docs/ci-and-deployment.md +++ b/docs/ci-and-deployment.md @@ -19,9 +19,8 @@ Four workflows run on pushes to `staging`/`production` and on pull requests targ ### TypeScript lint and typecheck (`typescript-lint.yml`) 1. Install dependencies with `npm ci`. -2. `npm run format:check` — Prettier. -3. `npm run lint` — ESLint. -4. `npm run typecheck` — TypeScript compiler. +2. `npm run lint:check` — Biome (via Ultracite), combined formatter + linter check. +3. `npm run typecheck` — TypeScript compiler. ### TypeScript tests (`typescript-test.yml`) diff --git a/docs/conventions.md b/docs/conventions.md index 4247390b..a157000d 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -51,8 +51,7 @@ Environment-specific configuration is managed through environment variables, nev ### TypeScript / JavaScript -- Formatter: [Prettier](https://prettier.io/) -- Linter: [ESLint](https://eslint.org/) +- Formatter + linter: [Biome](https://biomejs.dev/) via [Ultracite](https://www.ultracite.ai/) (`npm run lint:check` / `lint:fix`) - Type checker: TypeScript compiler (`tsc`) ### Pre-commit diff --git a/docs/getting-started.md b/docs/getting-started.md index 5da1f351..c53f73dd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -122,8 +122,8 @@ This runs both Python and web app checks: | `make py-format` | Auto-fix with Ruff | | `make py-lint` | Ruff linter | | `make py-typecheck` | Pyright | -| `make fe-format` | Prettier | -| `make fe-lint` | ESLint | +| `make fe-format` | Biome format + safe lint fixes (`ultracite fix`) | +| `make fe-lint` | Biome format + lint check (`ultracite check`) | | `make fe-typecheck` | TypeScript compiler | ## Running tests diff --git a/web/.prettierignore b/web/.prettierignore deleted file mode 100644 index 461b0084..00000000 --- a/web/.prettierignore +++ /dev/null @@ -1,7 +0,0 @@ -dist/ -node_modules/ -.next/ -.turbo/ -coverage/ -pnpm-lock.yaml -.pnpm-store/ \ No newline at end of file diff --git a/web/.prettierrc b/web/.prettierrc deleted file mode 100644 index 031af2c9..00000000 --- a/web/.prettierrc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "endOfLine": "lf", - "semi": true, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 80, - "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-tailwindcss"], - "tailwindStylesheet": "app/globals.css", - "tailwindFunctions": ["cn", "cva"] -} diff --git a/web/.vscode/settings.json b/web/.vscode/settings.json new file mode 100644 index 00000000..4d00146a --- /dev/null +++ b/web/.vscode/settings.json @@ -0,0 +1,54 @@ +{ + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "emmet.showExpandedAbbreviation": "never", + "js/ts.tsdk.path": "node_modules/typescript/lib", + "js/ts.tsdk.promptToUseWorkspaceVersion": true, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[html]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[markdown]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[mdx]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[svelte]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[vue]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[yaml]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + } +} diff --git a/web/README.md b/web/README.md index a2ac2be4..3b116f84 100644 --- a/web/README.md +++ b/web/README.md @@ -53,11 +53,11 @@ This directory contains the source code for the Data Hub web application and API | `npm run dev` | Start dev server (Turbopack) | | `npm run build` | Production build | | `npm run start` | Start production server | -| `npm run lint` | Run ESLint | -| `npm run format` | Format code with Prettier | -| `npm run format:check` | Check formatting without writing | +| `npm run lint:check` | Check formatting + lint with Biome (read-only) | +| `npm run lint:fix` | Format and apply safe lint fixes with Biome | | `npm run typecheck` | Run the TypeScript compiler (no emit) | -| `npm run precommit` | Format + lint + typecheck (run before committing) | +| `npm run check` | Lint check + typecheck | +| `npm run precommit` | Lint fix + typecheck (run before committing) | | `npm run db:generate` | Generate Drizzle migration files | | `npm run db:migrate` | Apply pending migrations | | `npm run db:push` | Push schema to database (no migration files) | @@ -86,11 +86,10 @@ See the table below for a summary of environment variables configured for this a ## CI -A GitHub Actions workflow (`.github/workflows/typescript-lint.yml`) runs on every push to `staging` and `production`, as well as pull requests targeting both branches. It executes three checks: +A GitHub Actions workflow (`.github/workflows/typescript-lint.yml`) runs on every push to `staging` and `production`, as well as pull requests targeting both branches. It executes two checks: -1. **Format check** — `npm run format:check` (Prettier) -2. **Lint** — `npm run lint` (ESLint) -3. **Type check** — `npm run typecheck` (TypeScript compiler) +1. **Lint and format check** — `npm run lint:check` (Biome, via Ultracite — combined formatter + linter, read-only) +2. **Type check** — `npm run typecheck` (TypeScript compiler) Run `npm run precommit` locally before pushing to catch the same issues earlier. diff --git a/web/app/api/local-s3/[bucket]/[...key]/route.ts b/web/app/api/local-s3/[bucket]/[...key]/route.ts index db39add0..0c84dc45 100644 --- a/web/app/api/local-s3/[bucket]/[...key]/route.ts +++ b/web/app/api/local-s3/[bucket]/[...key]/route.ts @@ -13,28 +13,30 @@ // on the Edge runtime and there's no production deployment story for // this route anyway. -import { - getLocalMirrorRoot, - mimeFor, - resolveMirrorPath, -} from "@/lib/s3-local-mirror"; -import type { NextRequest } from "next/server"; import { createReadStream, createWriteStream } from "node:fs"; import { mkdir, stat } from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import type { ReadableStream as NodeWebReadableStream } from "node:stream/web"; +import type { NextRequest } from "next/server"; +import { + getLocalMirrorRoot, + mimeFor, + resolveMirrorPath, +} from "@/lib/s3-local-mirror"; -type RouteContext = { +interface RouteContext { params: Promise<{ bucket: string; key: string[] }>; -}; +} const NOT_FOUND_RESPONSE = () => new Response("Not Found", { status: 404 }); export async function GET(request: NextRequest, { params }: RouteContext) { const root = getLocalMirrorRoot(); - if (!root) return NOT_FOUND_RESPONSE(); + if (!root) { + return NOT_FOUND_RESPONSE(); + } const { bucket, key } = await params; const joinedKey = key.join("/"); @@ -50,7 +52,9 @@ export async function GET(request: NextRequest, { params }: RouteContext) { let fileSize: number; try { const s = await stat(filePath); - if (!s.isFile()) return NOT_FOUND_RESPONSE(); + if (!s.isFile()) { + return NOT_FOUND_RESPONSE(); + } fileSize = s.size; } catch { return NOT_FOUND_RESPONSE(); @@ -79,7 +83,9 @@ export async function GET(request: NextRequest, { params }: RouteContext) { export async function PUT(request: NextRequest, { params }: RouteContext) { const root = getLocalMirrorRoot(); - if (!root) return NOT_FOUND_RESPONSE(); + if (!root) { + return NOT_FOUND_RESPONSE(); + } const { bucket, key } = await params; const joinedKey = key.join("/"); diff --git a/web/app/api/v1/archive-jobs/[id]/route.ts b/web/app/api/v1/archive-jobs/[id]/route.ts index 632d5621..19d52539 100644 --- a/web/app/api/v1/archive-jobs/[id]/route.ts +++ b/web/app/api/v1/archive-jobs/[id]/route.ts @@ -1,14 +1,14 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { isValidUUID } from "@/lib/api/validators"; import { db } from "@/lib/db"; import { archiveJobs } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ id: string }>; -}; +} // --------------------------------------------------------------------------- // PATCH /api/v1/archive-jobs/:id @@ -31,17 +31,19 @@ type RouteContext = { const TERMINAL_STATUSES = new Set(["ready", "failed"]); -type PatchBody = { - status?: unknown; +interface PatchBody { archive_bucket?: unknown; archive_key?: unknown; - size_bytes?: unknown; error_message?: unknown; -}; + size_bytes?: unknown; + status?: unknown; +} export async function PATCH(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "archive-jobs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { id } = await params; if (!isValidUUID(id)) { @@ -68,17 +70,16 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { const status = body.status as "pending" | "building" | "ready" | "failed"; - if (status === "ready") { - if ( - typeof body.archive_bucket !== "string" || - typeof body.archive_key !== "string" - ) { - return apiError( - 400, - VALIDATION_ERROR, - "archive_bucket and archive_key are required when status is 'ready'" - ); - } + if ( + status === "ready" && + (typeof body.archive_bucket !== "string" || + typeof body.archive_key !== "string") + ) { + return apiError( + 400, + VALIDATION_ERROR, + "archive_bucket and archive_key are required when status is 'ready'" + ); } const update: Partial = { status }; diff --git a/web/app/api/v1/files/[fileId]/download/route.ts b/web/app/api/v1/files/[fileId]/download/route.ts index f1db206e..7a589861 100644 --- a/web/app/api/v1/files/[fileId]/download/route.ts +++ b/web/app/api/v1/files/[fileId]/download/route.ts @@ -1,14 +1,14 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; import { getPresignedDownloadUrl } from "@/lib/s3"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ fileId: string }>; -}; +} // --------------------------------------------------------------------------- // GET /api/v1/files/:fileId/download @@ -20,11 +20,13 @@ type RouteContext = { export async function GET(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "files:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { fileId } = await params; - const numericId = parseInt(fileId, 10); - if (isNaN(numericId)) { + const numericId = Number.parseInt(fileId, 10); + if (Number.isNaN(numericId)) { return apiError(400, VALIDATION_ERROR, "Invalid file ID"); } @@ -57,7 +59,7 @@ export async function GET(request: NextRequest, { params }: RouteContext) { return apiError(404, NOT_FOUND, `File '${fileId}' not found`); } - if (!file.s3Bucket || !file.s3Key) { + if (!(file.s3Bucket && file.s3Key)) { return apiError(404, NOT_FOUND, "File has not been uploaded to S3 yet"); } diff --git a/web/app/api/v1/files/[fileId]/reprocess/route.ts b/web/app/api/v1/files/[fileId]/reprocess/route.ts index 791a1c2b..665d9e41 100644 --- a/web/app/api/v1/files/[fileId]/reprocess/route.ts +++ b/web/app/api/v1/files/[fileId]/reprocess/route.ts @@ -1,11 +1,11 @@ +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, VALIDATION_ERROR } from "@/lib/api/errors"; import { reprocessFile } from "@/lib/api/file-reprocessing"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ fileId: string }>; -}; +} // --------------------------------------------------------------------------- // POST /api/v1/files/:fileId/reprocess @@ -18,11 +18,13 @@ type RouteContext = { export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "files:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { fileId } = await params; - const numericId = parseInt(fileId, 10); - if (isNaN(numericId)) { + const numericId = Number.parseInt(fileId, 10); + if (Number.isNaN(numericId)) { return apiError(400, VALIDATION_ERROR, "Invalid file ID"); } diff --git a/web/app/api/v1/files/[fileId]/route.ts b/web/app/api/v1/files/[fileId]/route.ts index 55eec1d4..5f3ce6ce 100644 --- a/web/app/api/v1/files/[fileId]/route.ts +++ b/web/app/api/v1/files/[fileId]/route.ts @@ -1,3 +1,5 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -7,12 +9,10 @@ import { } from "@/lib/api/errors"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ fileId: string }>; -}; +} // Enforced state machine for file status transitions: // Watcher flow: detected → [upload_requested →] uploaded → processing → completed|failed @@ -43,11 +43,13 @@ const VALID_TRANSITIONS: Record = { export async function PATCH(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "files:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { fileId } = await params; - const numericId = parseInt(fileId, 10); - if (isNaN(numericId)) { + const numericId = Number.parseInt(fileId, 10); + if (Number.isNaN(numericId)) { return apiError(400, VALIDATION_ERROR, "Invalid file ID"); } @@ -93,7 +95,7 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { // Status transition validation. if ("status" in body && typeof body.status === "string") { const allowed = VALID_TRANSITIONS[file.status]; - if (!allowed || !allowed.includes(body.status)) { + if (!allowed?.includes(body.status)) { return apiError( 409, CONFLICT, @@ -126,11 +128,18 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { } // S3 info — set when transitioning to "uploaded" (watcher path). - if (typeof body.s3_bucket === "string") updates.s3Bucket = body.s3_bucket; - if (typeof body.s3_key === "string") updates.s3Key = body.s3_key; - if (typeof body.content_type === "string") + if (typeof body.s3_bucket === "string") { + updates.s3Bucket = body.s3_bucket; + } + if (typeof body.s3_key === "string") { + updates.s3Key = body.s3_key; + } + if (typeof body.content_type === "string") { updates.contentType = body.content_type; - if (typeof body.size_bytes === "number") updates.sizeBytes = body.size_bytes; + } + if (typeof body.size_bytes === "number") { + updates.sizeBytes = body.size_bytes; + } // Metadata — flat JSON object set by the Lambda after processing. if ( @@ -190,11 +199,13 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { export async function DELETE(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "files:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { fileId } = await params; - const numericId = parseInt(fileId, 10); - if (isNaN(numericId)) { + const numericId = Number.parseInt(fileId, 10); + if (Number.isNaN(numericId)) { return apiError(400, VALIDATION_ERROR, "Invalid file ID"); } diff --git a/web/app/api/v1/instrument-runs/route.ts b/web/app/api/v1/instrument-runs/route.ts index 3f1dde3b..fcfee38a 100644 --- a/web/app/api/v1/instrument-runs/route.ts +++ b/web/app/api/v1/instrument-runs/route.ts @@ -1,7 +1,7 @@ +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { buildRunListQuery } from "@/lib/api/instrument-runs"; import { parseIntParam } from "@/lib/api/validators"; -import type { NextRequest } from "next/server"; // --------------------------------------------------------------------------- // GET /api/v1/instrument-runs @@ -13,7 +13,9 @@ import type { NextRequest } from "next/server"; export async function GET(request: NextRequest) { const authResult = await authorize(request, "runs:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { searchParams } = request.nextUrl; diff --git a/web/app/api/v1/instruments/[instrumentId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/route.ts index ef5305ee..4893c298 100644 --- a/web/app/api/v1/instruments/[instrumentId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/route.ts @@ -1,3 +1,5 @@ +import { and, count, eq, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize, requireAdminForSession } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { db } from "@/lib/db"; @@ -7,15 +9,15 @@ import { VALID_INSTRUMENT_TYPES, watchers, } from "@/lib/db/schema"; -import { and, count, eq, isNull } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function GET( request: NextRequest, { params }: { params: Promise<{ instrumentId: string }> } ) { const authResult = await authorize(request, "instruments:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId } = await params; @@ -71,14 +73,18 @@ export async function PATCH( { params }: { params: Promise<{ instrumentId: string }> } ) { const authResult = await authorize(request, "instruments:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } // Browser callers (the Edit dialog and the "Confirm pending" button on // `/instruments`) must additionally be admins. PAT callers — the watcher // CLI and Lambda — pass through purely on the `instruments:write` scope // so existing automation continues to work without rotation. const adminGate = await requireAdminForSession(authResult); - if (adminGate) return adminGate; + if (adminGate) { + return adminGate; + } const { instrumentId } = await params; @@ -135,9 +141,15 @@ export async function PATCH( } const updates: Record = {}; - if ("status" in body) updates.status = body.status; - if ("display_name" in body) updates.displayName = body.display_name; - if ("instrument_type" in body) updates.instrumentType = body.instrument_type; + if ("status" in body) { + updates.status = body.status; + } + if ("display_name" in body) { + updates.displayName = body.display_name; + } + if ("instrument_type" in body) { + updates.instrumentType = body.instrument_type; + } if (Object.keys(updates).length === 0) { return apiError(400, VALIDATION_ERROR, "No valid fields to update"); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/attributions/me/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/attributions/me/route.ts index 86ddb7d0..07351310 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/attributions/me/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/attributions/me/route.ts @@ -1,3 +1,5 @@ +import { and, eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND } from "@/lib/api/errors"; import { @@ -6,12 +8,10 @@ import { } from "@/lib/api/instrument-runs"; import { db } from "@/lib/db"; import { runAttributions } from "@/lib/db/schema"; -import { and, eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // --------------------------------------------------------------------------- // PUT /api/v1/instruments/:instrumentId/runs/:runId/attributions/me @@ -24,7 +24,9 @@ type RouteContext = { export async function PUT(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); @@ -58,7 +60,9 @@ export async function PUT(request: NextRequest, { params }: RouteContext) { export async function DELETE(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts index 1b1e01c1..5b132a45 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/[commentId]/route.ts @@ -1,3 +1,4 @@ +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -12,15 +13,14 @@ import { softDeleteComment, updateComment, } from "@/lib/api/run-comments"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string; commentId: string; }>; -}; +} const MAX_BODY_LENGTH = 10_000; @@ -100,7 +100,9 @@ async function preflight( export async function PATCH(request: NextRequest, { params }: RouteContext) { const pre = await preflight(request, params); - if (pre.kind === "error") return pre.response; + if (pre.kind === "error") { + return pre.response; + } let payload: Record; try { @@ -150,7 +152,9 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { export async function DELETE(request: NextRequest, { params }: RouteContext) { const pre = await preflight(request, params); - if (pre.kind === "error") return pre.response; + if (pre.kind === "error") { + return pre.response; + } await softDeleteComment({ commentId: pre.commentId, diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts index 0eb6e2d3..2c2e3e0b 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/comments/route.ts @@ -1,3 +1,5 @@ +import type { NextRequest } from "next/server"; +import { after } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -8,12 +10,10 @@ import { import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; import { notifyComment } from "@/lib/api/notifications"; import { createComment, listCommentsForRun } from "@/lib/api/run-comments"; -import type { NextRequest } from "next/server"; -import { after } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // Cap on the markdown source we accept. Generous for prose, well below any // jsonb / text limit. Bumping is a route-only change — no migration needed. @@ -28,7 +28,9 @@ const MAX_BODY_LENGTH = 10_000; export async function GET(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); @@ -54,7 +56,9 @@ export async function GET(request: NextRequest, { params }: RouteContext) { export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/download-archive/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/download-archive/route.ts index 2d1e451c..643b235d 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/download-archive/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/download-archive/route.ts @@ -1,3 +1,4 @@ +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, INTERNAL_ERROR, NOT_FOUND } from "@/lib/api/errors"; import { @@ -6,7 +7,6 @@ import { lookupRunByNaturalKey, } from "@/lib/api/instrument-runs"; import { prepareRunArchive } from "@/lib/api/run-archive"; -import type { NextRequest } from "next/server"; const FILES_STATUS_VALUES: ReadonlySet = new Set([ "all", @@ -23,12 +23,12 @@ function parseStatusParam(value: string | null): FilesStatusFilter | undefined { if (value && FILES_STATUS_VALUES.has(value as FilesStatusFilter)) { return value as FilesStatusFilter; } - return undefined; + return; } -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // The route itself returns its 202 response in a couple of round-trips // (cache HEAD, dedup INSERT), but the `after()` callback that POSTs the @@ -48,12 +48,16 @@ export const maxDuration = 300; // translates that into the same 404 a non-matching id list would. function parseFileIdsParam(searchParams: URLSearchParams): number[] | null { const raw = searchParams.getAll("file_ids"); - if (raw.length === 0) return null; + if (raw.length === 0) { + return null; + } const ids = new Set(); for (const entry of raw) { for (const part of entry.split(",")) { const n = Number.parseInt(part.trim(), 10); - if (Number.isInteger(n) && n > 0) ids.add(n); + if (Number.isInteger(n) && n > 0) { + ids.add(n); + } } } return Array.from(ids); @@ -71,7 +75,9 @@ async function resolveFileIdsFilter( runId: string ): Promise { const explicit = parseFileIdsParam(request.nextUrl.searchParams); - if (explicit !== null) return explicit; + if (explicit !== null) { + return explicit; + } const sp = request.nextUrl.searchParams; const search = sp.get("search")?.trim() || undefined; @@ -82,13 +88,17 @@ async function resolveFileIdsFilter( search !== undefined || (status !== undefined && status !== "all") || includeDismissed; - if (!hasFilter) return null; + if (!hasFilter) { + return null; + } // `lookupRunByNaturalKey` is request-cached, so this doesn't double the // lookup `prepareRunArchive` performs. A missing run falls through to the // 404 that helper raises. const run = await lookupRunByNaturalKey(instrumentId, runId); - if (!run) return null; + if (!run) { + return null; + } return getFilteredFileIds(run.id, { search, status, includeDismissed }); } @@ -122,7 +132,9 @@ async function resolveFileIdsFilter( // --------------------------------------------------------------------------- export async function GET(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "files:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts index d8e237ec..772c3633 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/files/route.ts @@ -1,3 +1,5 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -8,17 +10,15 @@ import { import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; import { db } from "@/lib/db"; import { files } from "@/lib/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; -import type { NextRequest } from "next/server"; // Statuses where a row is "pre-S3" — safe for the Lambda path to overwrite // when adopting a watcher-created row. Anything beyond uploaded is left // untouched so Lambda retries don't regress in-progress / completed work. const PRE_UPLOAD_STATUSES = new Set(["detected", "upload_requested"]); -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // --------------------------------------------------------------------------- // POST /api/v1/instruments/:instrumentId/runs/:runId/files @@ -33,7 +33,9 @@ type RouteContext = { export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "files:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); @@ -63,7 +65,7 @@ export async function POST(request: NextRequest, { params }: RouteContext) { const filename = typeof body.filename === "string" ? body.filename.trim() : ""; - if (!s3Bucket || !s3Key || !filename) { + if (!(s3Bucket && s3Key && filename)) { return apiError( 400, VALIDATION_ERROR, diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/reprocess/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/reprocess/route.ts index 632f735e..0dec286a 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/reprocess/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/reprocess/route.ts @@ -1,15 +1,15 @@ +import { and, eq, inArray, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, CONFLICT, NOT_FOUND } from "@/lib/api/errors"; import { reprocessFile } from "@/lib/api/file-reprocessing"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; import { db } from "@/lib/db"; import { files } from "@/lib/db/schema"; -import { and, eq, inArray, isNull } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} const REPROCESSABLE_STATUSES = ["completed", "failed"] as const; @@ -25,7 +25,9 @@ const REPROCESSABLE_STATUSES = ["completed", "failed"] as const; export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-all/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-all/route.ts index 5bc3495b..56130d57 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-all/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-all/route.ts @@ -1,3 +1,5 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -9,12 +11,10 @@ import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; import { instrumentHasOnlineWatcher } from "@/lib/api/instruments"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // --------------------------------------------------------------------------- // POST /api/v1/instruments/:instrumentId/runs/:runId/request-upload-all @@ -27,7 +27,9 @@ type RouteContext = { export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts index f73a1d39..97a99b04 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload-url/route.ts @@ -1,3 +1,5 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -10,12 +12,10 @@ import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; import { db } from "@/lib/db"; import { files } from "@/lib/db/schema"; import { getPresignedUploadUrl, getS3RawDataBucket } from "@/lib/s3"; -import { and, eq, isNull } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} const UPLOAD_URL_EXPIRY_SECONDS = 60 * 60; // 1 hour @@ -37,7 +37,9 @@ const UPLOADED_OR_LATER_STATUSES = new Set([ export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts index 7f3c880d..76720823 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/request-upload/route.ts @@ -1,3 +1,5 @@ +import { eq, inArray } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -10,12 +12,10 @@ import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; import { instrumentHasOnlineWatcher } from "@/lib/api/instruments"; import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; -import { eq, inArray } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // --------------------------------------------------------------------------- // POST /api/v1/instruments/:instrumentId/runs/:runId/request-upload @@ -29,7 +29,9 @@ type RouteContext = { export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); @@ -142,7 +144,7 @@ export async function POST(request: NextRequest, { params }: RouteContext) { // Only transition files that are still in "detected" — skip those already // in "upload_requested" to make the endpoint idempotent. const toTransition = fileIds.filter( - (fid: number) => requestedById.get(fid)!.status === "detected" + (fid: number) => requestedById.get(fid)?.status === "detected" ); if (toTransition.length > 0) { @@ -161,7 +163,10 @@ export async function POST(request: NextRequest, { params }: RouteContext) { // Build the response with the upload_requested_at for all files (both // newly transitioned and already-queued). const responseFiles = fileIds.map((fid: number) => { - const f = requestedById.get(fid)!; + const f = requestedById.get(fid); + if (!f) { + return apiError(400, VALIDATION_ERROR, `File ${fid} not found`); + } return { id: f.id, filename: f.filename, diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/restore/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/restore/route.ts index d451dab5..94648731 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/restore/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/restore/route.ts @@ -1,14 +1,14 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, CONFLICT, NOT_FOUND } from "@/lib/api/errors"; import { lookupRunByNaturalKey } from "@/lib/api/instrument-runs"; import { db } from "@/lib/db"; import { instrumentRuns } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // --------------------------------------------------------------------------- // POST /api/v1/instruments/:instrumentId/runs/:runId/restore @@ -20,7 +20,9 @@ type RouteContext = { export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts index 3573ec75..1060b967 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/[runId]/route.ts @@ -1,3 +1,5 @@ +import { eq, sql } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -12,12 +14,10 @@ import { import { db } from "@/lib/db"; import { files, instrumentRuns } from "@/lib/db/schema"; import { getPresignedDownloadUrl } from "@/lib/s3"; -import { eq, sql } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string; runId: string }>; -}; +} // --------------------------------------------------------------------------- // GET /api/v1/instruments/:instrumentId/runs/:runId @@ -28,7 +28,9 @@ type RouteContext = { export async function GET(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); @@ -102,7 +104,9 @@ export async function GET(request: NextRequest, { params }: RouteContext) { export async function PATCH(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); @@ -199,17 +203,17 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { const updated = await lookupRunByNaturalKey(instrumentId, runId); return Response.json({ - id: updated!.id, - instrument_id: updated!.instrumentId, - instrument_display_name: updated!.instrumentDisplayName, - run_id: updated!.runId, - source: updated!.source, - watcher_id: updated!.watcherId, - metadata: updated!.metadata, - created_at: updated!.createdAt, - acquired_at: updated!.acquiredAt, - updated_at: updated!.updatedAt, - deleted_at: updated!.deletedAt, + id: updated?.id, + instrument_id: updated?.instrumentId, + instrument_display_name: updated?.instrumentDisplayName, + run_id: updated?.runId, + source: updated?.source, + watcher_id: updated?.watcherId, + metadata: updated?.metadata, + created_at: updated?.createdAt, + acquired_at: updated?.acquiredAt, + updated_at: updated?.updatedAt, + deleted_at: updated?.deletedAt, }); } @@ -223,7 +227,9 @@ export async function PATCH(request: NextRequest, { params }: RouteContext) { export async function DELETE(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); diff --git a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts index c90588c8..43a16e35 100644 --- a/web/app/api/v1/instruments/[instrumentId]/runs/route.ts +++ b/web/app/api/v1/instruments/[instrumentId]/runs/route.ts @@ -1,3 +1,5 @@ +import { and, eq, isNull, sql } from "drizzle-orm"; +import { after, type NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { buildRunListQuery, parseAcquiredAt } from "@/lib/api/instrument-runs"; @@ -6,12 +8,10 @@ import { parseIntParam } from "@/lib/api/validators"; import { db } from "@/lib/db"; import { files, instrumentRuns, instruments, watchers } from "@/lib/db/schema"; import { sendSlackMessage } from "@/lib/slack"; -import { and, eq, isNull, sql } from "drizzle-orm"; -import { after, type NextRequest } from "next/server"; -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string }>; -}; +} // --------------------------------------------------------------------------- // POST /api/v1/instruments/:instrumentId/runs @@ -26,7 +26,9 @@ type RouteContext = { export async function POST(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId } = await params; @@ -216,7 +218,9 @@ export async function POST(request: NextRequest, { params }: RouteContext) { export async function GET(request: NextRequest, { params }: RouteContext) { const authResult = await authorize(request, "runs:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { instrumentId } = await params; diff --git a/web/app/api/v1/instruments/route.ts b/web/app/api/v1/instruments/route.ts index a40a8b80..fa21ea7b 100644 --- a/web/app/api/v1/instruments/route.ts +++ b/web/app/api/v1/instruments/route.ts @@ -1,3 +1,5 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, CONFLICT, VALIDATION_ERROR } from "@/lib/api/errors"; import { isValidKebabCase } from "@/lib/api/validators"; @@ -7,12 +9,12 @@ import { instruments, VALID_INSTRUMENT_TYPES, } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function GET(request: NextRequest) { const authResult = await authorize(request, "instruments:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const rows = await db .select({ @@ -28,7 +30,9 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { const authResult = await authorize(request, "instruments:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } let body: { id?: string; display_name?: string; instrument_type?: string }; try { diff --git a/web/app/api/v1/mcp/route.ts b/web/app/api/v1/mcp/route.ts index a8ce4913..ae7ca38f 100644 --- a/web/app/api/v1/mcp/route.ts +++ b/web/app/api/v1/mcp/route.ts @@ -1,9 +1,9 @@ +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import { createMcpHandler, withMcpAuth } from "mcp-handler"; import { authenticateWithToken } from "@/lib/api/auth"; import { registerPrompts } from "@/lib/mcp/prompts"; import { registerResources } from "@/lib/mcp/resources"; import { registerTools } from "@/lib/mcp/tools"; -import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; -import { createMcpHandler, withMcpAuth } from "mcp-handler"; const handler = createMcpHandler( (server) => { @@ -35,7 +35,9 @@ const verifyToken = async ( bearerToken?: string ): Promise => { const result = await authenticateWithToken(req); - if (!result) return undefined; + if (!result) { + return; + } return { token: bearerToken ?? "", diff --git a/web/app/api/v1/notifications/route.ts b/web/app/api/v1/notifications/route.ts index 0152165f..46a8ec4a 100644 --- a/web/app/api/v1/notifications/route.ts +++ b/web/app/api/v1/notifications/route.ts @@ -1,3 +1,5 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; import { requireSession } from "@/lib/api/auth"; import { apiError, UNAUTHORIZED, VALIDATION_ERROR } from "@/lib/api/errors"; import { @@ -5,8 +7,6 @@ import { listNotifications, markRead, } from "@/lib/api/notifications"; -import type { NextRequest } from "next/server"; -import { z } from "zod"; // Notification reads/writes are session-only — these are personal-UX // surfaces, never invoked by the watcher / Lambda PATs, so they don't @@ -26,7 +26,9 @@ const PostBodySchema = z.object({ export async function GET(request: NextRequest) { const auth = await requireSession(); - if (!auth) return apiError(401, UNAUTHORIZED, "Authentication required"); + if (!auth) { + return apiError(401, UNAUTHORIZED, "Authentication required"); + } const unreadOnly = request.nextUrl.searchParams.get("unread_only") === "true"; const [items, unreadCount] = await Promise.all([ @@ -64,7 +66,9 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { const auth = await requireSession(); - if (!auth) return apiError(401, UNAUTHORIZED, "Authentication required"); + if (!auth) { + return apiError(401, UNAUTHORIZED, "Authentication required"); + } let raw: unknown = {}; // Empty body is fine — it means "mark all". Only error on outright diff --git a/web/app/api/v1/settings/notifications/instruments/[instrumentId]/route.ts b/web/app/api/v1/settings/notifications/instruments/[instrumentId]/route.ts index ae4148d9..33d7845f 100644 --- a/web/app/api/v1/settings/notifications/instruments/[instrumentId]/route.ts +++ b/web/app/api/v1/settings/notifications/instruments/[instrumentId]/route.ts @@ -1,3 +1,6 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; import { requireSession } from "@/lib/api/auth"; import { apiError, @@ -8,15 +11,12 @@ import { import { setInstrumentSubscription } from "@/lib/api/notifications"; import { db } from "@/lib/db"; import { instruments } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; -import { z } from "zod"; const PutBodySchema = z.object({ enabled: z.boolean() }).strict(); -type RouteContext = { +interface RouteContext { params: Promise<{ instrumentId: string }>; -}; +} // --------------------------------------------------------------------------- // PUT /api/v1/settings/notifications/instruments/:instrumentId { enabled } @@ -29,7 +29,9 @@ type RouteContext = { export async function PUT(request: NextRequest, { params }: RouteContext) { const auth = await requireSession(); - if (!auth) return apiError(401, UNAUTHORIZED, "Authentication required"); + if (!auth) { + return apiError(401, UNAUTHORIZED, "Authentication required"); + } const { instrumentId } = await params; diff --git a/web/app/api/v1/settings/notifications/route.ts b/web/app/api/v1/settings/notifications/route.ts index fdda3040..0d55d9fe 100644 --- a/web/app/api/v1/settings/notifications/route.ts +++ b/web/app/api/v1/settings/notifications/route.ts @@ -1,3 +1,5 @@ +import type { NextRequest } from "next/server"; +import { z } from "zod"; import { requireSession } from "@/lib/api/auth"; import { apiError, UNAUTHORIZED, VALIDATION_ERROR } from "@/lib/api/errors"; import { @@ -5,8 +7,6 @@ import { listInstrumentSubscriptions, updatePreferences, } from "@/lib/api/notifications"; -import type { NextRequest } from "next/server"; -import { z } from "zod"; // PUT body is a partial: every key is optional and only present fields // are written. Defaults live on the column, so a missing key on a fresh @@ -30,7 +30,9 @@ const PutBodySchema = z export async function GET() { const auth = await requireSession(); - if (!auth) return apiError(401, UNAUTHORIZED, "Authentication required"); + if (!auth) { + return apiError(401, UNAUTHORIZED, "Authentication required"); + } const [prefs, subscriptions] = await Promise.all([ getPreferences(auth.userId), @@ -59,7 +61,9 @@ export async function GET() { export async function PUT(request: NextRequest) { const auth = await requireSession(); - if (!auth) return apiError(401, UNAUTHORIZED, "Authentication required"); + if (!auth) { + return apiError(401, UNAUTHORIZED, "Authentication required"); + } let raw: unknown; try { diff --git a/web/app/api/v1/settings/watcher-release/route.ts b/web/app/api/v1/settings/watcher-release/route.ts index 0c573212..23966e8b 100644 --- a/web/app/api/v1/settings/watcher-release/route.ts +++ b/web/app/api/v1/settings/watcher-release/route.ts @@ -1,10 +1,10 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; 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 @@ -23,7 +23,9 @@ const VERSION_REGEX = /^\d+\.\d+\.\d+([.-].+)?$/; // 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; + if (v == null) { + return null; + } const trimmed = v.trim(); return trimmed.length === 0 ? null : trimmed; } @@ -62,18 +64,18 @@ const PutBodySchema = z.strictObject({ .transform((v) => v ?? false), }); -type WatcherReleaseResponse = { - latest_version: string | null; - min_supported_version: string | null; +interface WatcherReleaseResponse { channel: string; + latest_version: string | null; mandatory: boolean; + min_supported_version: string | null; updated_at: string | null; updated_by: { id: string; name: string | null; email: string | null; } | null; -}; +} const EMPTY_RESPONSE: WatcherReleaseResponse = { latest_version: null, @@ -124,14 +126,18 @@ async function readCurrent(): Promise { export async function GET() { const authResult = await requireAdmin(); - if (authResult instanceof Response) return authResult; + 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; + if (authResult instanceof Response) { + return authResult; + } let rawBody: unknown; try { diff --git a/web/app/api/v1/tokens/[id]/route.ts b/web/app/api/v1/tokens/[id]/route.ts index 62995cc5..67d92b91 100644 --- a/web/app/api/v1/tokens/[id]/route.ts +++ b/web/app/api/v1/tokens/[id]/route.ts @@ -1,8 +1,8 @@ +import { eq } from "drizzle-orm"; import { requireAdmin } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { db } from "@/lib/db"; import { personalAccessTokens } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; export async function DELETE( _request: Request, @@ -13,7 +13,9 @@ export async function DELETE( // tokens during an audit. The previous owner-scoped delete made // multi-user revocation impossible from the UI. const authResult = await requireAdmin(); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { id } = await params; diff --git a/web/app/api/v1/tokens/route.ts b/web/app/api/v1/tokens/route.ts index 041ef6d4..a68c4f56 100644 --- a/web/app/api/v1/tokens/route.ts +++ b/web/app/api/v1/tokens/route.ts @@ -1,11 +1,11 @@ +import { desc, eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { requireAdmin, requireSession } from "@/lib/api/auth"; import { apiError, UNAUTHORIZED, VALIDATION_ERROR } from "@/lib/api/errors"; import { validateRequestedScopes } from "@/lib/api/scopes"; import { db } from "@/lib/db"; import { personalAccessTokens } from "@/lib/db/schema"; import { generateToken, getTokenPrefix, hashToken } from "@/lib/tokens"; -import { desc, eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function GET() { // Listing is open to any signed-in user — regular members see their own @@ -40,7 +40,9 @@ export async function POST(request: NextRequest) { // PAT list on `/settings/tokens` and call `GET` above, but only admins // can mint new credentials. const authResult = await requireAdmin(); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } let body: { name?: string; expires_at?: string; scopes?: unknown }; try { @@ -67,7 +69,7 @@ export async function POST(request: NextRequest) { let expiresAt: Date | null = null; if (body.expires_at) { expiresAt = new Date(body.expires_at); - if (isNaN(expiresAt.getTime())) { + if (Number.isNaN(expiresAt.getTime())) { return apiError( 400, VALIDATION_ERROR, diff --git a/web/app/api/v1/users/[userId]/route.ts b/web/app/api/v1/users/[userId]/route.ts index a89110f5..e8a86921 100644 --- a/web/app/api/v1/users/[userId]/route.ts +++ b/web/app/api/v1/users/[userId]/route.ts @@ -1,9 +1,9 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { requireAdmin } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; const ALLOWED_PATCH_FIELDS = new Set(["is_admin"]); @@ -20,7 +20,9 @@ export async function PATCH( { params }: { params: Promise<{ userId: string }> } ) { const authResult = await requireAdmin(); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { userId } = await params; diff --git a/web/app/api/v1/users/route.ts b/web/app/api/v1/users/route.ts index 5a86a510..a1dcdc57 100644 --- a/web/app/api/v1/users/route.ts +++ b/web/app/api/v1/users/route.ts @@ -1,7 +1,7 @@ +import { asc } from "drizzle-orm"; import { requireAdmin } from "@/lib/api/auth"; import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; -import { asc } from "drizzle-orm"; // Admin-only roster used by `/settings/members`. Returns every signed-in // user along with their workspace admin flag so the members page can @@ -11,7 +11,9 @@ import { asc } from "drizzle-orm"; // state. export async function GET() { const authResult = await requireAdmin(); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const rows = await db .select({ diff --git a/web/app/api/v1/watchers/[watcherId]/config-checksum/route.ts b/web/app/api/v1/watchers/[watcherId]/config-checksum/route.ts index 5a85ee46..e808ce84 100644 --- a/web/app/api/v1/watchers/[watcherId]/config-checksum/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/config-checksum/route.ts @@ -1,15 +1,17 @@ +import type { NextRequest } from "next/server"; 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 type { NextRequest } from "next/server"; export async function GET( request: NextRequest, { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { diff --git a/web/app/api/v1/watchers/[watcherId]/config/route.ts b/web/app/api/v1/watchers/[watcherId]/config/route.ts index 17b1db1d..88fc3d88 100644 --- a/web/app/api/v1/watchers/[watcherId]/config/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/config/route.ts @@ -1,3 +1,5 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { isValidUUID } from "@/lib/api/validators"; @@ -8,15 +10,15 @@ import { } from "@/lib/api/watchers"; import { db } from "@/lib/db"; import { watcherEvents, watchers } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function PUT( request: NextRequest, { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { diff --git a/web/app/api/v1/watchers/[watcherId]/events/route.ts b/web/app/api/v1/watchers/[watcherId]/events/route.ts index c4938260..edf829e5 100644 --- a/web/app/api/v1/watchers/[watcherId]/events/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/events/route.ts @@ -1,3 +1,5 @@ +import { and, desc, eq, gte, inArray } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { @@ -8,8 +10,6 @@ import { import { findActiveWatcher } from "@/lib/api/watchers"; import { db } from "@/lib/db"; import { watcherEvents, watcherEventTypeEnum } from "@/lib/db/schema"; -import { and, desc, eq, gte, inArray } from "drizzle-orm"; -import type { NextRequest } from "next/server"; // Derived from the Drizzle enum so adding a new event type is a one-line // schema change — historically this was a hand-maintained Set and drifted @@ -22,7 +22,9 @@ export async function POST( { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { @@ -53,17 +55,17 @@ export async function POST( return apiError(400, VALIDATION_ERROR, "Maximum 100 events per request"); } - type EventInput = { + interface EventInput { + details?: Record; event_type: string; - timestamp: string; message: string; - details?: Record; - }; + timestamp: string; + } - const values = []; + const values: (typeof watcherEvents.$inferInsert)[] = []; for (let i = 0; i < body.events.length; i++) { const evt = body.events[i] as EventInput; - if (!evt.event_type || !evt.timestamp || !evt.message) { + if (!(evt.event_type && evt.timestamp && evt.message)) { return apiError( 400, VALIDATION_ERROR, @@ -78,7 +80,7 @@ export async function POST( ); } const ts = new Date(evt.timestamp); - if (isNaN(ts.getTime())) { + if (Number.isNaN(ts.getTime())) { return apiError(400, VALIDATION_ERROR, `Invalid timestamp at index ${i}`); } values.push({ @@ -100,7 +102,9 @@ export async function GET( { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { diff --git a/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts b/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts index 9612d446..8143c88f 100644 --- a/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/heartbeat/route.ts @@ -1,3 +1,5 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -14,15 +16,15 @@ import { watcherReleaseConfig, watchers, } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function POST( request: NextRequest, { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { @@ -61,7 +63,7 @@ export async function POST( const timestamp = body.timestamp ? new Date(body.timestamp as string) : new Date(); - if (isNaN(timestamp.getTime())) { + if (Number.isNaN(timestamp.getTime())) { return apiError(400, VALIDATION_ERROR, "Invalid timestamp"); } diff --git a/web/app/api/v1/watchers/[watcherId]/heartbeats/route.ts b/web/app/api/v1/watchers/[watcherId]/heartbeats/route.ts index 7cfda44e..2885a9a7 100644 --- a/web/app/api/v1/watchers/[watcherId]/heartbeats/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/heartbeats/route.ts @@ -1,3 +1,5 @@ +import { and, desc, eq, gte } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, NOT_FOUND, VALIDATION_ERROR } from "@/lib/api/errors"; import { @@ -8,15 +10,15 @@ import { import { findActiveWatcher } from "@/lib/api/watchers"; import { db } from "@/lib/db"; import { watcherHeartbeats } from "@/lib/db/schema"; -import { and, desc, eq, gte } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function GET( request: NextRequest, { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { diff --git a/web/app/api/v1/watchers/[watcherId]/route.ts b/web/app/api/v1/watchers/[watcherId]/route.ts index d9db57ac..e76febaa 100644 --- a/web/app/api/v1/watchers/[watcherId]/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/route.ts @@ -1,3 +1,5 @@ +import { eq } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -9,15 +11,15 @@ import { isValidUUID } from "@/lib/api/validators"; import { computeEffectiveStatus, findActiveWatcher } from "@/lib/api/watchers"; import { db } from "@/lib/db"; import { instruments, watchers } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function GET( request: NextRequest, { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { @@ -55,7 +57,9 @@ export async function DELETE( { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { 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 185c253c..39fde22c 100644 --- a/web/app/api/v1/watchers/[watcherId]/update-check/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/update-check/route.ts @@ -1,10 +1,10 @@ +import type { NextRequest } from "next/server"; 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. @@ -15,12 +15,12 @@ import type { NextRequest } from "next/server"; * `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; - min_supported_version: string | null; +interface WatcherReleaseInfo { channel: string; + latest_version: string | null; mandatory: boolean; -}; + min_supported_version: string | null; +} async function readReleaseInfo(): Promise { // The singleton check constraint on `id` guarantees at most one row; @@ -52,7 +52,9 @@ export async function GET( { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { diff --git a/web/app/api/v1/watchers/[watcherId]/upload-queue/route.ts b/web/app/api/v1/watchers/[watcherId]/upload-queue/route.ts index 2fcbe703..9c125661 100644 --- a/web/app/api/v1/watchers/[watcherId]/upload-queue/route.ts +++ b/web/app/api/v1/watchers/[watcherId]/upload-queue/route.ts @@ -1,18 +1,20 @@ +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; 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 { files, instrumentRuns } from "@/lib/db/schema"; -import { and, eq, isNotNull, isNull } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function GET( request: NextRequest, { params }: { params: Promise<{ watcherId: string }> } ) { const authResult = await authorize(request, "watchers:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { watcherId } = await params; if (!isValidUUID(watcherId)) { diff --git a/web/app/api/v1/watchers/register/route.ts b/web/app/api/v1/watchers/register/route.ts index e12831c7..1b2b14df 100644 --- a/web/app/api/v1/watchers/register/route.ts +++ b/web/app/api/v1/watchers/register/route.ts @@ -1,3 +1,5 @@ +import { and, eq, isNull } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { apiError, @@ -7,12 +9,12 @@ import { } from "@/lib/api/errors"; import { db } from "@/lib/db"; import { instruments, watchers } from "@/lib/db/schema"; -import { and, eq, isNull } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function POST(request: NextRequest) { const authResult = await authorize(request, "watchers:write"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } let body: { instrument_id?: string; diff --git a/web/app/api/v1/watchers/route.ts b/web/app/api/v1/watchers/route.ts index 09f9bc9b..6df9e055 100644 --- a/web/app/api/v1/watchers/route.ts +++ b/web/app/api/v1/watchers/route.ts @@ -1,20 +1,22 @@ +import { and, eq, isNull, type SQL, sql } from "drizzle-orm"; +import type { NextRequest } from "next/server"; import { authorize } from "@/lib/api/auth"; import { computeEffectiveStatus, STALE_THRESHOLD_MS } from "@/lib/api/watchers"; import { db } from "@/lib/db"; import { instruments, watchers } from "@/lib/db/schema"; -import { and, eq, isNull, sql } from "drizzle-orm"; -import type { NextRequest } from "next/server"; export async function GET(request: NextRequest) { const authResult = await authorize(request, "watchers:read"); - if (authResult instanceof Response) return authResult; + if (authResult instanceof Response) { + return authResult; + } const { searchParams } = request.nextUrl; const instrumentIdFilter = searchParams.get("instrument_id"); const statusFilter = searchParams.get("status"); const includeDeleted = searchParams.get("include_deleted") === "true"; - const conditions = []; + const conditions: SQL[] = []; if (!includeDeleted) { conditions.push(isNull(watchers.deletedAt)); diff --git a/web/app/globals.css b/web/app/globals.css index 39a44ba7..1383e5e9 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -5,120 +5,124 @@ @custom-variant dark (&:is(.dark *)); @theme inline { - --font-heading: var(--font-sans); - --font-sans: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --color-foreground: var(--foreground); - --color-background: var(--background); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: #F7F7F7; - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: #F3F3F3; - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.91 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: #f7f7f7; + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: #f3f3f3; + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.91 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.21 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.21 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.2 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.2 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } @keyframes table-pending-slide { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(400%); } + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400%); + } } html .shiki, @@ -136,12 +140,12 @@ html.dark .shiki span { @layer base { * { @apply border-border outline-ring/50; - } + } html, body { @apply bg-muted text-foreground dark:bg-background; - } + } html { @apply font-sans; - } -} \ No newline at end of file + } +} diff --git a/web/app/instruments/[instrumentId]/page.tsx b/web/app/instruments/[instrumentId]/page.tsx index 3a291af7..d1ba98d2 100644 --- a/web/app/instruments/[instrumentId]/page.tsx +++ b/web/app/instruments/[instrumentId]/page.tsx @@ -1,3 +1,5 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { InstrumentHeader } from "@/components/instruments/instrument-header"; import { InstrumentRunsToolbar } from "@/components/instruments/instrument-runs-toolbar"; @@ -32,13 +34,11 @@ import { } from "@/lib/api/notifications"; import { auth } from "@/lib/auth"; import { instrumentDetailParamsCache } from "@/lib/search-params"; -import { notFound } from "next/navigation"; -import type { Metadata } from "next/types"; -type Props = { +interface Props { params: Promise<{ instrumentId: string }>; searchParams: Promise>; -}; +} export async function generateMetadata({ params }: Props): Promise { const { instrumentId } = await params; @@ -74,8 +74,8 @@ function renderRunsTableVariant( return ( ); @@ -83,8 +83,8 @@ function renderRunsTableVariant( return ( ); @@ -92,8 +92,8 @@ function renderRunsTableVariant( return ( ); @@ -101,8 +101,8 @@ function renderRunsTableVariant( return ( ); @@ -110,12 +110,12 @@ function renderRunsTableVariant( return ( ); - case "default": + default: return ( +
{renderRunsTableVariant( filterOptions, @@ -274,8 +276,8 @@ export default async function InstrumentDetailPage({ diff --git a/web/app/instruments/[instrumentId]/runs/[runId]/page.tsx b/web/app/instruments/[instrumentId]/runs/[runId]/page.tsx index 0b32f881..cf657bae 100644 --- a/web/app/instruments/[instrumentId]/runs/[runId]/page.tsx +++ b/web/app/instruments/[instrumentId]/runs/[runId]/page.tsx @@ -1,3 +1,5 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { RunAttributionsSection } from "@/components/runs/run-attributions-section"; import { RunCommentsSection } from "@/components/runs/run-comments-section"; @@ -15,20 +17,20 @@ import { listCommentsForRun } from "@/lib/api/run-comments"; import { auth } from "@/lib/auth"; import { formatDate } from "@/lib/date"; import { runDetailParamsCache } from "@/lib/search-params"; -import { notFound } from "next/navigation"; -import type { Metadata } from "next/types"; const FILES_PER_PAGE = 10; -type Props = { +interface Props { params: Promise<{ instrumentId: string; runId: string }>; searchParams: Promise>; -}; +} export async function generateMetadata({ params }: Props): Promise { const { instrumentId, runId } = await params; const run = await lookupRunByNaturalKey(instrumentId, runId); - if (!run) return { title: "Run Not Found" }; + if (!run) { + return { title: "Run Not Found" }; + } const title = `${run.runId} | ${run.instrumentDisplayName}`; @@ -74,7 +76,9 @@ export default async function RunDetailPage({ params, searchParams }: Props) { } const run = await lookupRunByNaturalKey(instrumentId, runId); - if (!run) notFound(); + if (!run) { + notFound(); + } const [filesPage, fileStats, reportFiles, instrument, comments] = await Promise.all([ @@ -100,26 +104,26 @@ export default async function RunDetailPage({ params, searchParams }: Props) {
} + fileStats={fileStats} + files={filesPage.data} + filesPagination={filesPage.pagination} + instrumentId={instrumentId} + reportFiles={reportFiles} + run={run} + runId={runId} + wellData={wellData} />
diff --git a/web/app/instruments/page.tsx b/web/app/instruments/page.tsx index e17fd0c0..2ada32c1 100644 --- a/web/app/instruments/page.tsx +++ b/web/app/instruments/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { InstrumentRowManagementActions, @@ -9,7 +10,6 @@ import { listInstrumentSubscriptions, } from "@/lib/api/notifications"; import { auth } from "@/lib/auth"; -import type { Metadata } from "next/types"; const description = "Instruments connected to Data Hub."; @@ -37,8 +37,8 @@ export default async function InstrumentsPage() { // lookup inside the table. const [instruments, subscriptions, prefs] = await Promise.all([ getInstrumentListWithCounts(), - listInstrumentSubscriptions(session.user.id!), - getPreferences(session.user.id!), + listInstrumentSubscriptions(session.user.id), + getPreferences(session.user.id), ]); // Composition: the management actions cell (Edit dialog + Confirm @@ -55,15 +55,15 @@ export default async function InstrumentsPage() { return (
-

Instruments

+

Instruments

); diff --git a/web/app/layout.tsx b/web/app/layout.tsx index ead4906f..eae943ef 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,6 +1,10 @@ import { Geist, Geist_Mono } from "next/font/google"; import "@/app/globals.css"; +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { SessionProvider } from "next-auth/react"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; import { AppSidebar } from "@/components/app-sidebar"; import { NotificationBell } from "@/components/notifications/notification-bell"; import { NotificationsProvider } from "@/components/notifications/notifications-provider"; @@ -18,10 +22,6 @@ import { countUnread } from "@/lib/api/notifications"; import { getSidebarInstruments, getSidebarWatchers } from "@/lib/api/sidebar"; import { auth, signOut } from "@/lib/auth"; import { cn } from "@/lib/utils"; -import type { Metadata } from "next"; -import { SessionProvider } from "next-auth/react"; -import { cookies } from "next/headers"; -import { NuqsAdapter } from "nuqs/adapters/next/app"; const fontSans = Geist({ subsets: ["latin"], variable: "--font-sans" }); @@ -100,7 +100,7 @@ export default async function RootLayout({ ? await Promise.all([ getSidebarInstruments(), getSidebarWatchers(), - countUnread(session.user.id!), + countUnread(session.user.id), ]) : [[], [], 0]; @@ -112,14 +112,14 @@ export default async function RootLayout({ return ( @@ -133,13 +133,13 @@ export default async function RootLayout({ { "use server"; await signOut({ redirectTo: "/login" }); }} + watchers={watchers} />
diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index 381572e2..f2c9a912 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -1,7 +1,7 @@ +import type { Metadata } from "next/types"; import { DevSignInForm } from "@/components/auth/dev-sign-in-form"; import { Button } from "@/components/ui/button"; import { isDevAuthEnabled, signIn } from "@/lib/auth"; -import { Metadata } from "next/types"; export const metadata: Metadata = { title: "Login", @@ -12,7 +12,7 @@ export default function LoginPage() {
-

+

Welcome to Data Hub

diff --git a/web/app/page.tsx b/web/app/page.tsx index 4ddd6765..1a3c2020 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,3 +1,6 @@ +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { DashboardStatsCards } from "@/components/dashboard/dashboard-stats"; import { RunsTable } from "@/components/dashboard/runs-table"; @@ -15,9 +18,6 @@ import { buildRunListQuery } from "@/lib/api/instrument-runs"; import { getRecentActiveInstrumentsForDashboard } from "@/lib/api/instruments"; import { auth } from "@/lib/auth"; import { dashboardParamsCache, hasActiveFilters } from "@/lib/search-params"; -import { ArrowRight } from "lucide-react"; -import Link from "next/link"; -import type { Metadata } from "next/types"; // `default: "Data Hub"` on the root metadata template already renders // `Data Hub` here, so we skip an explicit `title` and @@ -112,13 +112,13 @@ export default async function DashboardPage({

-

Instruments

+

Instruments

View all {totalActiveInstruments} instruments @@ -128,7 +128,7 @@ export default async function DashboardPage({
-

Recent runs

+

Recent runs

@@ -137,16 +137,16 @@ export default async function DashboardPage({ diff --git a/web/app/settings/layout.tsx b/web/app/settings/layout.tsx index 8391badc..7951ba74 100644 --- a/web/app/settings/layout.tsx +++ b/web/app/settings/layout.tsx @@ -1,6 +1,6 @@ +import type { Metadata } from "next"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { auth } from "@/lib/auth"; -import type { Metadata } from "next"; export const metadata: Metadata = { title: { diff --git a/web/app/settings/members/page.tsx b/web/app/settings/members/page.tsx index 96bbccd5..9d95ee12 100644 --- a/web/app/settings/members/page.tsx +++ b/web/app/settings/members/page.tsx @@ -1,11 +1,11 @@ +import { asc } from "drizzle-orm"; +import { ShieldOff } from "lucide-react"; +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { MembersTable } from "@/components/members/members-table"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { users } from "@/lib/db/schema"; -import { asc } from "drizzle-orm"; -import { ShieldOff } from "lucide-react"; -import type { Metadata } from "next/types"; const description = "Manage workspace members and admin access."; @@ -34,10 +34,10 @@ export default async function MembersPage() { return (
-

+

Admins only

-

+

You need workspace admin access to view or change member roles. Ask an existing admin if you need to be promoted.

@@ -60,15 +60,15 @@ export default async function MembersPage() {
-

Members

-

+

Members

+

Grant or revoke admin access for teammates signed in to Data Hub.

- +
); diff --git a/web/app/settings/notifications/page.tsx b/web/app/settings/notifications/page.tsx index 0650a243..b92180e8 100644 --- a/web/app/settings/notifications/page.tsx +++ b/web/app/settings/notifications/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { NotificationsSettingsForm } from "@/components/notifications/notifications-settings-form"; import { @@ -5,7 +6,6 @@ import { listInstrumentSubscriptions, } from "@/lib/api/notifications"; import { auth } from "@/lib/auth"; -import type { Metadata } from "next/types"; const description = "Choose which Data Hub events to be notified about."; @@ -31,15 +31,15 @@ export default async function NotificationsSettingsPage() { // schema-side defaults when no row exists yet, so the form always // renders with concrete values — no need for nullable form state. const [prefs, subscriptions] = await Promise.all([ - getPreferences(session.user.id!), - listInstrumentSubscriptions(session.user.id!), + getPreferences(session.user.id), + listInstrumentSubscriptions(session.user.id), ]); return (
-

Notifications

-

+

Notifications

+

Choose which Data Hub events should produce an in-app notification. Per-instrument subscriptions opt you in to new run{" "} notifications; comment notifications fire when someone replies on a @@ -49,16 +49,16 @@ export default async function NotificationsSettingsPage() {

({ instrumentId: s.instrumentId, displayName: s.displayName, enabled: s.enabled, }))} + initialPreferences={{ + runsAllMuted: prefs.runsAllMuted, + commentsAttributedEnabled: prefs.commentsAttributedEnabled, + commentsParticipatedEnabled: prefs.commentsParticipatedEnabled, + }} />
diff --git a/web/app/settings/page.tsx b/web/app/settings/page.tsx index c099ab66..797fa6a8 100644 --- a/web/app/settings/page.tsx +++ b/web/app/settings/page.tsx @@ -1,6 +1,6 @@ +import { redirect } from "next/navigation"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { auth } from "@/lib/auth"; -import { redirect } from "next/navigation"; export default async function SettingsPage() { // Self-defending auth gate; mirrors `tokens/page.tsx`. Without this an diff --git a/web/app/settings/tokens/page.tsx b/web/app/settings/tokens/page.tsx index 8b5b67e1..0be0c86d 100644 --- a/web/app/settings/tokens/page.tsx +++ b/web/app/settings/tokens/page.tsx @@ -1,3 +1,6 @@ +import { desc, eq } from "drizzle-orm"; +import { KeyRound } from "lucide-react"; +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { CreateTokenDialog } from "@/components/tokens/create-token-dialog"; import { DeleteTokenDialog } from "@/components/tokens/delete-token-dialog"; @@ -21,9 +24,6 @@ import { avatarColor, toInitials } from "@/lib/avatar-color"; import { db } from "@/lib/db"; import { personalAccessTokens, users } from "@/lib/db/schema"; import { formatRelativeTime } from "@/lib/utils"; -import { desc, eq } from "drizzle-orm"; -import { KeyRound } from "lucide-react"; -import type { Metadata } from "next/types"; const description = "Personal access tokens for the Data Hub API."; @@ -52,8 +52,8 @@ function TokenScopeBadges({ scopes }: { scopes: string[] }) { if (scopes.length === 0) { return ( No scopes @@ -62,7 +62,7 @@ function TokenScopeBadges({ scopes }: { scopes: string[] }) { if (scopes.length === 1 && scopes[0] === "*") { return ( - + Full access ); @@ -75,7 +75,7 @@ function TokenScopeBadges({ scopes }: { scopes: string[] }) { if (sorted.length === 1) { return ( - + {sorted[0]} ); @@ -86,10 +86,10 @@ function TokenScopeBadges({ scopes }: { scopes: string[] }) {
- + {first} - + +{rest.length}
@@ -157,10 +157,10 @@ export default async function TokensPage() {
-

+

Access Tokens

-

+

{isAdmin ? "Manage personal access tokens for API authentication." : "View personal access tokens for API authentication."} @@ -173,10 +173,10 @@ export default async function TokensPage() { {tokens.length === 0 ? (

-

+

No access tokens yet

-

+

{isAdmin ? "Create a token to authenticate with the API." : "Ask an admin to create a token for you."} @@ -212,8 +212,8 @@ export default async function TokensPage() { {token.user.image ? ( ) : null} {token.tokenPrefix}… diff --git a/web/app/settings/watchers/page.tsx b/web/app/settings/watchers/page.tsx index 51da4e54..513eeb9d 100644 --- a/web/app/settings/watchers/page.tsx +++ b/web/app/settings/watchers/page.tsx @@ -1,11 +1,11 @@ +import { eq } from "drizzle-orm"; +import { ShieldOff } from "lucide-react"; +import type { Metadata } from "next/types"; 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", @@ -31,10 +31,10 @@ export default async function WatchersSettingsPage() { return (

-

+

Admins only

-

+

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

@@ -70,10 +70,10 @@ export default async function WatchersSettingsPage() {
-

+

Watcher Version

-

+

Configure the release advertised by{" "} GET /api/v1/watchers/:id/update-check diff --git a/web/app/watchers/[watcherId]/page.tsx b/web/app/watchers/[watcherId]/page.tsx index caddacb0..0c9d4c2a 100644 --- a/web/app/watchers/[watcherId]/page.tsx +++ b/web/app/watchers/[watcherId]/page.tsx @@ -1,22 +1,22 @@ +import { notFound, redirect } from "next/navigation"; +import type { Metadata } from "next/types"; import { WatcherConfig } from "@/components/watchers/watcher-config"; import { WatcherDetailTabs } from "@/components/watchers/watcher-detail-tabs"; import { WatcherHeader } from "@/components/watchers/watcher-header"; import { - WATCHER_PAGE_SIZE, getAllWatcherHeartbeats, getWatcherById, getWatcherEvents, + WATCHER_PAGE_SIZE, } from "@/lib/api/watchers"; import { auth } from "@/lib/auth"; import { todayDateString } from "@/lib/date"; import { watcherDetailParamsCache } from "@/lib/search-params"; -import { notFound, redirect } from "next/navigation"; -import type { Metadata } from "next/types"; -type Props = { +interface Props { params: Promise<{ watcherId: string }>; searchParams: Promise>; -}; +} export async function generateMetadata({ params }: Props): Promise { const { watcherId } = await params; @@ -31,7 +31,9 @@ export default async function WatcherDetailPage({ searchParams, }: Props) { const session = await auth(); - if (!session) redirect("/login"); + if (!session) { + redirect("/login"); + } const { watcherId } = await params; const filters = watcherDetailParamsCache.parse(await searchParams); @@ -43,7 +45,7 @@ export default async function WatcherDetailPage({ // clips the chart precisely on the client, and the event log // already orders by timestamp so a slightly wider window is harmless. function toTzSafeSince(dateString: string): Date { - const d = new Date(dateString + "T00:00:00Z"); + const d = new Date(`${dateString}T00:00:00Z`); d.setUTCDate(d.getUTCDate() - 1); return d; } @@ -65,7 +67,9 @@ export default async function WatcherDetailPage({ }), ]); - if (!watcher) notFound(); + if (!watcher) { + notFound(); + } const logsTotalPages = Math.ceil(eventResult.total / WATCHER_PAGE_SIZE); @@ -74,11 +78,11 @@ export default async function WatcherDetailPage({ } - heartbeats={heartbeats} events={eventResult.rows} - eventsTotal={eventResult.total} eventsPage={filters.logs_page} + eventsTotal={eventResult.total} eventsTotalPages={logsTotalPages} + heartbeats={heartbeats} />

); diff --git a/web/app/watchers/page.tsx b/web/app/watchers/page.tsx index 517f65a8..7e518a3b 100644 --- a/web/app/watchers/page.tsx +++ b/web/app/watchers/page.tsx @@ -1,8 +1,8 @@ +import type { Metadata } from "next/types"; import { SignInRequired } from "@/components/auth/sign-in-required"; import { WatchersView } from "@/components/watchers/watchers-view"; import { getWatcherList } from "@/lib/api/watchers"; import { auth } from "@/lib/auth"; -import type { Metadata } from "next/types"; const description = "Watcher agents reporting into Data Hub."; @@ -34,7 +34,7 @@ export default async function WatchersPage() { return (
-

Watchers

+

Watchers

diff --git a/web/biome.jsonc b/web/biome.jsonc new file mode 100644 index 00000000..f4e400b1 --- /dev/null +++ b/web/biome.jsonc @@ -0,0 +1,43 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": [ + "ultracite/biome/core", + "ultracite/biome/react", + "ultracite/biome/next" + ], + "linter": { + "rules": { + "a11y": { + "noNoninteractiveElementInteractions": "off", + "noNoninteractiveTabindex": "off", + "noStaticElementInteractions": "off", + "useKeyWithClickEvents": "off", + "useSemanticElements": "off" + }, + "complexity": { + "noExcessiveCognitiveComplexity": "off", + "noVoid": "off" + }, + "performance": { + "useTopLevelRegex": "off" + }, + "style": { + "noNestedTernary": "off" + }, + // TODO: replace index-based React keys in skeleton rows, pagination, and + // static lists with stable identifiers, then re-enable this rule. + "suspicious": { + "noArrayIndexKey": "off", + "noControlCharactersInRegex": "off" + } + } + }, + "overrides": [ + { + "includes": ["components/ui/**"], + "linter": { + "enabled": false + } + } + ] +} diff --git a/web/components/app-sidebar/app-sidebar-content.tsx b/web/components/app-sidebar/app-sidebar-content.tsx index 65fbb3f1..e252e0b0 100644 --- a/web/components/app-sidebar/app-sidebar-content.tsx +++ b/web/components/app-sidebar/app-sidebar-content.tsx @@ -1,16 +1,16 @@ "use client"; +import { usePathname } from "next/navigation"; import { MainNav } from "@/components/app-sidebar/main-nav"; import { SettingsNav } from "@/components/app-sidebar/settings-nav"; import { SidebarContent } from "@/components/ui/sidebar"; import type { SidebarInstrument, SidebarWatcher } from "@/lib/api/sidebar"; -import { usePathname } from "next/navigation"; -type AppSidebarContentProps = { +interface AppSidebarContentProps { instruments: SidebarInstrument[]; - watchers: SidebarWatcher[]; isAdmin: boolean; -}; + watchers: SidebarWatcher[]; +} // Centralizes the "main vs. settings" mode toggle so the rest of the sidebar // doesn't need to know about pathname-based switching. When the user diff --git a/web/components/app-sidebar/index.tsx b/web/components/app-sidebar/index.tsx index bf95934d..876c2e45 100644 --- a/web/components/app-sidebar/index.tsx +++ b/web/components/app-sidebar/index.tsx @@ -1,3 +1,6 @@ +import Image from "next/image"; +import Link from "next/link"; +import type { Session } from "next-auth"; import { AppSidebarContent } from "@/components/app-sidebar/app-sidebar-content"; import { UserMenuFooter } from "@/components/app-sidebar/user-menu-footer"; import { @@ -10,16 +13,13 @@ import { SidebarRail, } from "@/components/ui/sidebar"; import type { SidebarInstrument, SidebarWatcher } from "@/lib/api/sidebar"; -import type { Session } from "next-auth"; -import Image from "next/image"; -import Link from "next/link"; -type AppSidebarProps = { - session: Session; +interface AppSidebarProps { instruments: SidebarInstrument[]; - watchers: SidebarWatcher[]; + session: Session; signOutAction: () => Promise; -}; + watchers: SidebarWatcher[]; +} export function AppSidebar({ session, @@ -34,19 +34,19 @@ export function AppSidebar({ Data Hub - Data Hub + Data Hub @@ -54,11 +54,11 @@ export function AppSidebar({ - + diff --git a/web/components/app-sidebar/main-nav.tsx b/web/components/app-sidebar/main-nav.tsx index ca397e3b..e514c40c 100644 --- a/web/components/app-sidebar/main-nav.tsx +++ b/web/components/app-sidebar/main-nav.tsx @@ -1,5 +1,8 @@ "use client"; +import { ChevronRight, Cpu, Home, type LucideIcon, Radio } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; import { Collapsible, CollapsibleContent, @@ -17,82 +20,77 @@ import { SidebarMenuSubItem, } from "@/components/ui/sidebar"; import type { SidebarInstrument, SidebarWatcher } from "@/lib/api/sidebar"; -import { ChevronRight, Cpu, Home, Radio, type LucideIcon } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -type MainNavProps = { +interface MainNavProps { instruments: SidebarInstrument[]; watchers: SidebarWatcher[]; -}; +} export function MainNav({ instruments, watchers }: MainNavProps) { const pathname = usePathname(); return ( - <> - - Navigation - - - - - - - Home - - - + + Navigation + + + + + + + Home + + + - ({ - key: instrument.id, - href: `/instruments/${instrument.id}`, - label: instrument.displayName, - }))} - currentPath={pathname} - /> + ({ + key: instrument.id, + href: `/instruments/${instrument.id}`, + label: instrument.displayName, + }))} + label="Instruments" + viewAllHref="/instruments" + /> - ({ - key: watcher.id, - href: `/watchers/${watcher.id}`, - // Hostname is the canonical identifier for a watcher in the - // table view; fall back to the short id when missing so the - // row never collapses to an empty label. - label: watcher.hostname ?? `${watcher.id.slice(0, 8)}…`, - }))} - currentPath={pathname} - /> - - - - + ({ + key: watcher.id, + href: `/watchers/${watcher.id}`, + // Hostname is the canonical identifier for a watcher in the + // table view; fall back to the short id when missing so the + // row never collapses to an empty label. + label: watcher.hostname ?? `${watcher.id.slice(0, 8)}…`, + }))} + label="Watchers" + viewAllHref="/watchers" + /> + + + ); } -type CollapsibleNavSectionProps = { - icon: LucideIcon; - label: string; +interface CollapsibleNavSectionProps { /** Pathname prefix used to determine the active/expanded state. */ basePath: string; + currentPath: string; + icon: LucideIcon; + items: Array<{ key: string; href: string; label: string }>; + label: string; /** Href for the trailing "View all" sub-item that opens the full list. */ viewAllHref: string; - items: Array<{ key: string; href: string; label: string }>; - currentPath: string; -}; +} function CollapsibleNavSection({ icon: Icon, @@ -110,8 +108,8 @@ function CollapsibleNavSection({ return ( @@ -138,8 +136,8 @@ function CollapsibleNavSection({ View all diff --git a/web/components/app-sidebar/settings-nav.tsx b/web/components/app-sidebar/settings-nav.tsx index 570e7685..c3050203 100644 --- a/web/components/app-sidebar/settings-nav.tsx +++ b/web/components/app-sidebar/settings-nav.tsx @@ -1,5 +1,8 @@ "use client"; +import { ChevronLeft } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; import { SidebarGroup, SidebarGroupContent, @@ -7,18 +10,15 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; -import { ChevronLeft } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -type SettingsSection = { - href: string; - label: string; +interface SettingsSection { // Admin-only entries are mounted into the nav only when the viewer is // an admin. Using composition here (filter by predicate, then render) // keeps the SettingsNav body free of per-item `isAdmin && …` branches. adminOnly?: boolean; -}; + href: string; + label: string; +} const SETTINGS_SECTIONS: SettingsSection[] = [ { href: "/settings/notifications", label: "Notifications" }, @@ -51,8 +51,8 @@ export function SettingsNav({ isAdmin }: { isAdmin: boolean }) { diff --git a/web/components/app-sidebar/user-menu-footer.tsx b/web/components/app-sidebar/user-menu-footer.tsx index 9a5838d5..8cd7d14e 100644 --- a/web/components/app-sidebar/user-menu-footer.tsx +++ b/web/components/app-sidebar/user-menu-footer.tsx @@ -1,5 +1,8 @@ "use client"; +import { ChevronsUpDown, LogOut, Settings } from "lucide-react"; +import Link from "next/link"; +import { useTransition } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, @@ -14,18 +17,15 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; -import { ChevronsUpDown, LogOut, Settings } from "lucide-react"; -import Link from "next/link"; -import { useTransition } from "react"; -type UserMenuFooterProps = { +interface UserMenuFooterProps { + signOutAction: () => Promise; user: { name?: string | null; email?: string | null; image?: string | null; }; - signOutAction: () => Promise; -}; +} function getInitials(name?: string | null, email?: string | null): string { if (name) { @@ -54,12 +54,12 @@ export function UserMenuFooter({ user, signOutAction }: UserMenuFooterProps) { {user.image && ( - + )} {initials} @@ -70,7 +70,7 @@ export function UserMenuFooter({ user, signOutAction }: UserMenuFooterProps) { {user.name ?? "User"} {user.email && ( - + {user.email} )} @@ -79,9 +79,9 @@ export function UserMenuFooter({ user, signOutAction }: UserMenuFooterProps) { diff --git a/web/components/auth/dev-sign-in-form.tsx b/web/components/auth/dev-sign-in-form.tsx index 7ba593bb..eaef31b6 100644 --- a/web/components/auth/dev-sign-in-form.tsx +++ b/web/components/auth/dev-sign-in-form.tsx @@ -21,8 +21,8 @@ export function DevSignInForm({ inputId?: string; }) { return ( -
-

+

+

Local development

diff --git a/web/components/auth/sign-in-required.tsx b/web/components/auth/sign-in-required.tsx index 69538dfe..3a4b01ad 100644 --- a/web/components/auth/sign-in-required.tsx +++ b/web/components/auth/sign-in-required.tsx @@ -2,7 +2,7 @@ import { DevSignInForm } from "@/components/auth/dev-sign-in-form"; import { Button } from "@/components/ui/button"; import { isDevAuthEnabled, signIn } from "@/lib/auth"; -type SignInRequiredProps = { +interface SignInRequiredProps { /** * URL the user should land on after a successful sign-in. The unfurler * arrives here without a session, so `callbackUrl` lets us return them to @@ -15,7 +15,7 @@ type SignInRequiredProps = { * inline formatting without us inventing per-case prop variants. */ children?: React.ReactNode; -}; +} /** * Rendered in place of a signed-in page's body when there's no session. @@ -35,7 +35,7 @@ export function SignInRequired({ callbackUrl, children }: SignInRequiredProps) {
-

+

Sign in to Data Hub

{children ? ( diff --git a/web/components/code-block.tsx b/web/components/code-block.tsx index 64df7841..a031efc1 100644 --- a/web/components/code-block.tsx +++ b/web/components/code-block.tsx @@ -1,9 +1,9 @@ import { codeToHtml } from "shiki"; interface CodeBlockProps { + className?: string; code: string; lang: string; - className?: string; } export async function CodeBlock({ code, lang, className }: CodeBlockProps) { @@ -17,6 +17,7 @@ export async function CodeBlock({ code, lang, className }: CodeBlockProps) { }); return ( + // biome-ignore lint/security/noDangerouslySetInnerHtml: shiki emits syntax-highlighting markup, not raw user HTML
); } diff --git a/web/components/copy-button.tsx b/web/components/copy-button.tsx index b3de0155..c0ee2ca0 100644 --- a/web/components/copy-button.tsx +++ b/web/components/copy-button.tsx @@ -1,15 +1,15 @@ "use client"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import { Check, Copy } from "lucide-react"; import { useCallback, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; interface CopyButtonProps { - value: string; className?: string; - variant?: React.ComponentProps["variant"]; size?: React.ComponentProps["size"]; + value: string; + variant?: React.ComponentProps["variant"]; } export function CopyButton({ @@ -28,11 +28,11 @@ export function CopyButton({ return ( diff --git a/web/components/dashboard/dashboard-stats.tsx b/web/components/dashboard/dashboard-stats.tsx index dd90dd06..04126ae8 100644 --- a/web/components/dashboard/dashboard-stats.tsx +++ b/web/components/dashboard/dashboard-stats.tsx @@ -1,7 +1,7 @@ +import type { ReactNode } from "react"; import { Card } from "@/components/ui/card"; import type { DashboardStats } from "@/lib/api/dashboard"; import { cn, formatBytes } from "@/lib/utils"; -import type { ReactNode } from "react"; const numberFormatter = new Intl.NumberFormat("en-US"); @@ -21,18 +21,18 @@ function StatCard({ valueClassName?: string; }) { return ( - +
-

{label}

+

{label}

{value}

-

{subline}

+

{subline}

); @@ -48,11 +48,7 @@ function DataGeneratedSubline({ if (bytes === 0) { return {emptyLabel}; } - return ( - <> - {formatBytes(bytes)} generated - - ); + return {formatBytes(bytes)} generated; } export function DashboardStatsCards({ stats }: { stats: DashboardStats }) { @@ -66,42 +62,42 @@ export function DashboardStatsCards({ stats }: { stats: DashboardStats }) {
} + value={formatNumber(runsLast24Hours.total)} /> } + value={formatNumber(runsThisWeek.total)} /> 0 ? `${formatBytes(pendingUploads.totalBytes)} queued` : "Upload queue is clear" } + value={formatNumber(pendingUploads.count)} + valueClassName={pendingHasBacklog ? "text-destructive" : undefined} /> 0 ? `${formatNumber(runsThisWeek.unattributed)} unattributed` : "All runs attributed" } + value={formatNumber(runsThisWeek.mine)} />
); diff --git a/web/components/dashboard/relative-time.tsx b/web/components/dashboard/relative-time.tsx index fb49b8be..2b95683f 100644 --- a/web/components/dashboard/relative-time.tsx +++ b/web/components/dashboard/relative-time.tsx @@ -15,8 +15,8 @@ export function RelativeTime({ date }: { date: string }) { diff --git a/web/components/dashboard/runs-table.tsx b/web/components/dashboard/runs-table.tsx index f92ae621..898eac85 100644 --- a/web/components/dashboard/runs-table.tsx +++ b/web/components/dashboard/runs-table.tsx @@ -1,3 +1,4 @@ +import { SearchX } from "lucide-react"; import { RelativeTime } from "@/components/dashboard/relative-time"; import { AcquiredColumnHeader } from "@/components/instruments/runs-table/acquired-column-header"; import { RanByCell } from "@/components/instruments/runs-table/ran-by-cell"; @@ -24,7 +25,6 @@ import { import type { RunListRow } from "@/lib/api/instrument-runs"; import { runRowToRef } from "@/lib/runs/row-actions"; import { cn, formatBytes } from "@/lib/utils"; -import { SearchX } from "lucide-react"; export function RunsTable({ data, @@ -45,7 +45,7 @@ export function RunsTable({ return (
-

+

{hasFilters ? "No runs match your filters." : "No instrument runs yet."} @@ -87,38 +87,38 @@ export function RunsTable({ const href = `/instruments/${row.instrument_id}/runs/${encodeURIComponent(row.run_id)}`; return ( -

+
{row.instrument_display_name}
{isDeleted ? ( deleted @@ -133,9 +133,9 @@ export function RunsTable({ @@ -154,11 +154,11 @@ export function RunsTable({
); diff --git a/web/components/dashboard/runs-toolbar.tsx b/web/components/dashboard/runs-toolbar.tsx index 08838b10..a86536c5 100644 --- a/web/components/dashboard/runs-toolbar.tsx +++ b/web/components/dashboard/runs-toolbar.tsx @@ -1,5 +1,8 @@ "use client"; +import { Check, ChevronsUpDown, Search, X } from "lucide-react"; +import { useQueryStates } from "nuqs"; +import { useState } from "react"; import { RunFiltersCombobox } from "@/components/runs/run-filters-combobox"; import { RunsDateFilter } from "@/components/runs/runs-date-filter"; import { useTablePending } from "@/components/table-pending"; @@ -21,14 +24,11 @@ import { } from "@/components/ui/popover"; import { dashboardSearchParams, hasActiveFilters } from "@/lib/search-params"; import { cn } from "@/lib/utils"; -import { Check, ChevronsUpDown, Search, X } from "lucide-react"; -import { useQueryStates } from "nuqs"; -import { useState } from "react"; -type Instrument = { - id: string; +interface Instrument { displayName: string; -}; + id: string; +} export function RunsToolbar({ instruments }: { instruments: Instrument[] }) { const { startTransition } = useTablePending(); @@ -69,36 +69,36 @@ export function RunsToolbar({ instruments }: { instruments: Instrument[] }) {
setFilters({ search: e.target.value, page: 1 })} placeholder="Search runs..." value={filters.search} - onChange={(e) => setFilters({ search: e.target.value, page: 1 })} - className="pl-9" />
{/* Instrument multi-select */} - + - + @@ -109,8 +109,8 @@ export function RunsToolbar({ instruments }: { instruments: Instrument[] }) { return ( toggleInstrument(inst.id)} + value={inst.displayName} > Clear @@ -144,7 +144,8 @@ export function RunsToolbar({ instruments }: { instruments: Instrument[] }) { )} setFilters({ date_from: range.from, @@ -152,15 +153,14 @@ export function RunsToolbar({ instruments }: { instruments: Instrument[] }) { page: 1, }) } - align="end" - defaultPreset="24h" + value={{ from: filters.date_from, to: filters.date_to }} /> setFilters({ include_deleted: includeDeleted, page: 1 }) } + values={{ includeDeleted: filters.include_deleted }} />
diff --git a/web/components/instruments/edit-instrument-dialog.tsx b/web/components/instruments/edit-instrument-dialog.tsx index d227f441..fed3a168 100644 --- a/web/components/instruments/edit-instrument-dialog.tsx +++ b/web/components/instruments/edit-instrument-dialog.tsx @@ -1,5 +1,9 @@ "use client"; +import { Loader2, Pencil } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -20,10 +24,6 @@ import { SelectValue, } from "@/components/ui/select"; import { VALID_INSTRUMENT_TYPES } from "@/lib/db/schema"; -import { Loader2, Pencil } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; const TYPE_LABELS: Record = { generic: "Generic", @@ -81,7 +81,6 @@ export function EditInstrumentDialog({ return ( { setOpen(value); // Re-sync form state from props on open so the dialog reflects any @@ -91,9 +90,10 @@ export function EditInstrumentDialog({ setType(instrumentType); } }} + open={open} > - @@ -110,17 +110,17 @@ export function EditInstrumentDialog({
setName(e.target.value)} maxLength={100} - autoFocus + onChange={(e) => setName(e.target.value)} + value={name} />
- + @@ -131,13 +131,13 @@ export function EditInstrumentDialog({ ))} -

+

Controls the run detail page layout.

-
diff --git a/web/components/instruments/instruments-table.tsx b/web/components/instruments/instruments-table.tsx index 4e6ef0e2..02261dc8 100644 --- a/web/components/instruments/instruments-table.tsx +++ b/web/components/instruments/instruments-table.tsx @@ -1,3 +1,5 @@ +import { SearchX } from "lucide-react"; +import type { ReactNode } from "react"; import { RelativeTime } from "@/components/dashboard/relative-time"; import { EditInstrumentDialog } from "@/components/instruments/edit-instrument-dialog"; import { RowActionsCell } from "@/components/instruments/row-actions-cell"; @@ -16,8 +18,6 @@ import { import { getWatcherOnlineStatus } from "@/components/watchers/watcher-online-status"; import { WatcherStatusBadge } from "@/components/watchers/watcher-status-badge"; import type { InstrumentListItem } from "@/lib/api/instruments"; -import { SearchX } from "lucide-react"; -import type { ReactNode } from "react"; /** * Default row actions used by the management page: an approval action for @@ -31,8 +31,8 @@ export function InstrumentRowManagementActions(row: InstrumentListItem) {
{row.status === "pending" && }
@@ -72,7 +72,7 @@ export function InstrumentsTable({ return (
-

+

No instruments configured yet.

@@ -100,20 +100,20 @@ export function InstrumentsTable({ const watcherStatus = getWatcherOnlineStatus(row); return ( {row.displayName} - + {row.runCount} total {row.runCount === 1 ? "run" : "runs"} @@ -121,9 +121,9 @@ export function InstrumentsTable({
{row.filePatterns.map((p) => ( {p} @@ -143,10 +143,10 @@ export function InstrumentsTable({ {notifications ? ( ) : null} diff --git a/web/components/instruments/row-actions-cell.tsx b/web/components/instruments/row-actions-cell.tsx index 6fed95b3..4a4df585 100644 --- a/web/components/instruments/row-actions-cell.tsx +++ b/web/components/instruments/row-actions-cell.tsx @@ -1,7 +1,7 @@ "use client"; -import { TableCell } from "@/components/ui/table"; import type { ReactNode } from "react"; +import { TableCell } from "@/components/ui/table"; /** * Wraps an `` actions cell so clicks inside it (edit diff --git a/web/components/instruments/runs-table/acquired-column-header.tsx b/web/components/instruments/runs-table/acquired-column-header.tsx index 2dabc613..96ababfc 100644 --- a/web/components/instruments/runs-table/acquired-column-header.tsx +++ b/web/components/instruments/runs-table/acquired-column-header.tsx @@ -16,14 +16,14 @@ export function AcquiredColumnHeader() { return ( Acquired diff --git a/web/components/instruments/runs-table/clickable-row.tsx b/web/components/instruments/runs-table/clickable-row.tsx index 53f12006..9d6710ee 100644 --- a/web/components/instruments/runs-table/clickable-row.tsx +++ b/web/components/instruments/runs-table/clickable-row.tsx @@ -1,7 +1,7 @@ "use client"; -import { TableRow } from "@/components/ui/table"; import { useRouter } from "next/navigation"; +import { TableRow } from "@/components/ui/table"; export function ClickableRow({ href, diff --git a/web/components/instruments/runs-table/default-runs-table.tsx b/web/components/instruments/runs-table/default-runs-table.tsx index acd591f7..8c2c6401 100644 --- a/web/components/instruments/runs-table/default-runs-table.tsx +++ b/web/components/instruments/runs-table/default-runs-table.tsx @@ -46,8 +46,8 @@ export function DefaultRunsTable({ @@ -63,8 +63,8 @@ export function DefaultRunsTable({ const isDeleted = row.deleted_at !== null; return ( @@ -72,21 +72,21 @@ export function DefaultRunsTable({
{isDeleted && ( - + deleted )} @@ -100,9 +100,9 @@ export function DefaultRunsTable({ diff --git a/web/components/instruments/runs-table/epson-scanner-runs-table.tsx b/web/components/instruments/runs-table/epson-scanner-runs-table.tsx index bc2848b7..a8acfb6a 100644 --- a/web/components/instruments/runs-table/epson-scanner-runs-table.tsx +++ b/web/components/instruments/runs-table/epson-scanner-runs-table.tsx @@ -1,4 +1,8 @@ import { RelativeTime } from "@/components/dashboard/relative-time"; +import { + getMetadataField, + MetadataFieldBadge, +} from "@/components/runs/metadata-badges"; import { Badge } from "@/components/ui/badge"; import { Table, @@ -16,11 +20,9 @@ import { } from "@/lib/instrument-colors"; import { runRowToRef } from "@/lib/runs/row-actions"; import { cn, formatBytes } from "@/lib/utils"; - import type { RunRow } from "."; import { AcquiredColumnHeader } from "./acquired-column-header"; import { FilterableColumnHeader } from "./filterable-column-header"; -import { MetadataFieldBadge, getMetadataField } from "./metadata-utils"; import { RanByCell } from "./ran-by-cell"; import { RawFileColumnHeader } from "./raw-file-column-header"; import { RunIdLabel } from "./run-id-label"; @@ -71,22 +73,22 @@ export function EpsonScannerRunsTable({ @@ -104,8 +106,8 @@ export function EpsonScannerRunsTable({ const colorMode = getMetadataField(row.metadata, "color_mode"); return ( @@ -113,21 +115,21 @@ export function EpsonScannerRunsTable({
{isDeleted && ( - + deleted )} @@ -141,23 +143,23 @@ export function EpsonScannerRunsTable({ diff --git a/web/components/instruments/runs-table/filterable-column-header.tsx b/web/components/instruments/runs-table/filterable-column-header.tsx index 558245d4..2241898d 100644 --- a/web/components/instruments/runs-table/filterable-column-header.tsx +++ b/web/components/instruments/runs-table/filterable-column-header.tsx @@ -1,5 +1,8 @@ "use client"; +import { ChevronsUpDown, ListFilter } from "lucide-react"; +import { useQueryStates } from "nuqs"; +import type { inferParserType } from "nuqs/server"; import { useTablePending } from "@/components/table-pending"; import { Button } from "@/components/ui/button"; import { @@ -12,9 +15,6 @@ import { } from "@/components/ui/dropdown-menu"; import { instrumentDetailSearchParams } from "@/lib/search-params"; import { cn } from "@/lib/utils"; -import { ChevronsUpDown, ListFilter } from "lucide-react"; -import { useQueryStates } from "nuqs"; -import type { inferParserType } from "nuqs/server"; type InstrumentDetailFilters = inferParserType< typeof instrumentDetailSearchParams @@ -68,12 +68,12 @@ export function FilterableColumnHeader({ @@ -154,7 +169,7 @@ export function RanByCell({ - @@ -171,17 +186,17 @@ export function RanByCell({ {attribution.avatarUrl ? ( ) : null} @@ -198,11 +213,6 @@ export function RanByCell({ @@ -220,13 +235,13 @@ export function RanByCell({ diff --git a/web/components/instruments/runs-table/raw-file-column-header.tsx b/web/components/instruments/runs-table/raw-file-column-header.tsx index e4cf3ade..e3824156 100644 --- a/web/components/instruments/runs-table/raw-file-column-header.tsx +++ b/web/components/instruments/runs-table/raw-file-column-header.tsx @@ -14,14 +14,14 @@ export function RawFileColumnHeader({ label }: { label: string }) { return ( {label} diff --git a/web/components/instruments/runs-table/run-bulk-action-bar.tsx b/web/components/instruments/runs-table/run-bulk-action-bar.tsx index 0b76d423..6f6b0c77 100644 --- a/web/components/instruments/runs-table/run-bulk-action-bar.tsx +++ b/web/components/instruments/runs-table/run-bulk-action-bar.tsx @@ -1,5 +1,9 @@ "use client"; +import { ArrowDownToLine, ArrowUpToLine, RotateCw, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; import { DeleteRunsDialog } from "@/components/runs/delete-runs-dialog"; import { ReprocessRunsDialog } from "@/components/runs/reprocess-runs-dialog"; import { Button } from "@/components/ui/button"; @@ -11,12 +15,8 @@ import { } from "@/components/ui/tooltip"; import { useArchiveDownload } from "@/hooks/use-archive-download"; import { cn } from "@/lib/utils"; -import { ArrowDownToLine, ArrowUpToLine, RotateCw, Trash2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; -import { useRunSelection, type RunRef } from "./run-selection-provider"; +import { type RunRef, useRunSelection } from "./run-selection-provider"; // --------------------------------------------------------------------------- // Bulk action bar shown as a floating card pinned to the center bottom of @@ -64,7 +64,9 @@ async function fanOutUpload( ref.runId )}/request-upload-all`; const res = await fetch(url, { method: "POST" }); - if (!res.ok) throw new Error(await res.text()); + if (!res.ok) { + throw new Error(await res.text()); + } const body = (await res.json()) as { files_queued?: number }; return body.files_queued ?? 0; }) @@ -92,7 +94,9 @@ export function RunBulkActionBar() { const [reprocessOpen, setReprocessOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); - if (meta.count === 0) return null; + if (meta.count === 0) { + return null; + } const refs = Array.from(state.selected.values()); @@ -189,12 +193,12 @@ export function RunBulkActionBar() { return ( <>
{meta.count}{" "} @@ -204,20 +208,20 @@ export function RunBulkActionBar() {
@@ -226,12 +230,12 @@ export function RunBulkActionBar() { @@ -300,16 +304,16 @@ export function RunBulkActionBar() {
actions.clear()} onOpenChange={setReprocessOpen} + open={reprocessOpen} runs={reprocessTargets} - onComplete={() => actions.clear()} /> actions.clear()} onOpenChange={setDeleteOpen} + open={deleteOpen} runs={deleteTargets} - onComplete={() => actions.clear()} /> ); diff --git a/web/components/instruments/runs-table/run-id-label.tsx b/web/components/instruments/runs-table/run-id-label.tsx index 46222691..bc9b67f0 100644 --- a/web/components/instruments/runs-table/run-id-label.tsx +++ b/web/components/instruments/runs-table/run-id-label.tsx @@ -1,12 +1,12 @@ "use client"; +import Link from "next/link"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import Link from "next/link"; const MAX_RUN_ID_LENGTH = 32; @@ -33,19 +33,21 @@ export function RunIdLabel({ ); const label = href ? ( - + {display} ) : ( {display} ); - if (!isTruncated) return label; + if (!isTruncated) { + return label; + } return ( {label} - + {runId} diff --git a/web/components/instruments/runs-table/run-row-actions.tsx b/web/components/instruments/runs-table/run-row-actions.tsx index 26de6b8c..43c99263 100644 --- a/web/components/instruments/runs-table/run-row-actions.tsx +++ b/web/components/instruments/runs-table/run-row-actions.tsx @@ -1,5 +1,18 @@ "use client"; +import { + ArrowDownToLine, + ArrowUpToLine, + Loader2, + MoreHorizontal, + RotateCw, + Trash2, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type MouseEvent, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { DeleteRunsDialog } from "@/components/runs/delete-runs-dialog"; +import { ReprocessRunsDialog } from "@/components/runs/reprocess-runs-dialog"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -16,20 +29,6 @@ import { import { useArchiveDownload } from "@/hooks/use-archive-download"; import { computeRunCaps } from "@/lib/runs/row-actions"; import { cn } from "@/lib/utils"; -import { - ArrowDownToLine, - ArrowUpToLine, - Loader2, - MoreHorizontal, - RotateCw, - Trash2, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useTransition, type MouseEvent } from "react"; -import { toast } from "sonner"; - -import { DeleteRunsDialog } from "@/components/runs/delete-runs-dialog"; -import { ReprocessRunsDialog } from "@/components/runs/reprocess-runs-dialog"; import type { RunRow } from "."; // Actions cell shown inline at the end of every runs-table row. The strip is @@ -54,7 +53,9 @@ export function RunRowActions({ row }: { row: RunRow }) { const [deleteOpen, setDeleteOpen] = useState(false); // Deleted rows get no actions — the row is read-only. - if (row.deleted_at !== null) return null; + if (row.deleted_at !== null) { + return null; + } const baseUrl = `/api/v1/instruments/${row.instrument_id}/runs/${encodeURIComponent( row.run_id @@ -109,13 +110,13 @@ export function RunRowActions({ row }: { row: RunRow }) { @@ -146,15 +147,15 @@ export function RunRowActions({ row }: { row: RunRow }) { )} - + @@ -175,12 +176,12 @@ export function RunRowActions({ row }: { row: RunRow }) { {caps.reprocess && caps.delete && } {caps.delete && ( { e.preventDefault(); setMenuOpen(false); setDeleteOpen(true); }} + variant="destructive" > Delete run @@ -190,8 +191,8 @@ export function RunRowActions({ row }: { row: RunRow }) { +
+
0 && meta.allSelected(refs)} diff --git a/web/components/instruments/runs-table/run-selection-provider.tsx b/web/components/instruments/runs-table/run-selection-provider.tsx index e0145039..8d934cff 100644 --- a/web/components/instruments/runs-table/run-selection-provider.tsx +++ b/web/components/instruments/runs-table/run-selection-provider.tsx @@ -2,35 +2,34 @@ import { createContext, use, useCallback, useMemo, useState } from "react"; -export type RunCaps = { - upload: boolean; +export interface RunCaps { + delete: boolean; download: boolean; reprocess: boolean; - delete: boolean; -}; + upload: boolean; +} // Minimal per-row counts the bulk bar needs to populate confirmation // dialogs without refetching — covers the "soft-delete 4 runs and their // 44 files" and "reprocess 12 eligible files" messaging. -export type RunStats = { +export interface RunStats { fileCount: number; filesCompleted: number; filesFailed: number; -}; +} -export type RunRef = { +export interface RunRef { + caps: RunCaps; id: string; instrumentId: string; runId: string; - caps: RunCaps; stats: RunStats; -}; +} // Explicit context interface: state / actions / meta. Consumers never touch // the underlying useState — we can swap implementations (e.g. move to URL // state, redux, etc.) without changing any consumer. -type RunSelectionContextValue = { - state: { selected: ReadonlyMap }; +interface RunSelectionContextValue { actions: { toggle: (ref: RunRef) => void; selectMany: (refs: RunRef[]) => void; @@ -48,7 +47,8 @@ type RunSelectionContextValue = { allCanReprocess: boolean; allCanDelete: boolean; }; -}; + state: { selected: ReadonlyMap }; +} const RunSelectionContext = createContext( null @@ -80,9 +80,13 @@ export function RunSelectionProvider({ const next = new Map(prev); const alreadyAll = refs.every((r) => next.has(r.id)); if (alreadyAll) { - for (const r of refs) next.delete(r.id); + for (const r of refs) { + next.delete(r.id); + } } else { - for (const r of refs) next.set(r.id, r); + for (const r of refs) { + next.set(r.id, r); + } } return next; }); diff --git a/web/components/instruments/runs-table/run-status-icon.tsx b/web/components/instruments/runs-table/run-status-icon.tsx index 009cfc5f..7a6c5a16 100644 --- a/web/components/instruments/runs-table/run-status-icon.tsx +++ b/web/components/instruments/runs-table/run-status-icon.tsx @@ -1,11 +1,5 @@ "use client"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { cn } from "@/lib/utils"; import { CircleCheck, CircleDashed, @@ -13,6 +7,12 @@ import { Clock, LoaderCircle, } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; export function RunStatusIcon({ fileCount, @@ -91,11 +91,11 @@ export function RunStatusIcon({ {icon} {lines.length === 1 ? ( lines[0] diff --git a/web/components/instruments/runs-table/runs-table-footer.tsx b/web/components/instruments/runs-table/runs-table-footer.tsx index 51ec01d6..b5a52efb 100644 --- a/web/components/instruments/runs-table/runs-table-footer.tsx +++ b/web/components/instruments/runs-table/runs-table-footer.tsx @@ -12,7 +12,7 @@ export function RunsTableFooter({ ranByYouCount: number; }) { return ( -
+

Showing {shownCount} of{" "} {totalCount} diff --git a/web/components/instruments/status-actions.tsx b/web/components/instruments/status-actions.tsx index 1f7fd05d..ef9d0e9a 100644 --- a/web/components/instruments/status-actions.tsx +++ b/web/components/instruments/status-actions.tsx @@ -1,10 +1,10 @@ "use client"; -import { Button } from "@/components/ui/button"; import { Check, Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; // Instruments registered by a watcher start as "pending" and require admin // approval. This button transitions them to "active" via the PATCH API. @@ -33,11 +33,11 @@ export function StatusActions({ instrumentId }: { instrumentId: string }) { return (

); - if (!isSelf) return control; + if (!isSelf) { + return control; + } return ( diff --git a/web/components/members/members-table.tsx b/web/components/members/members-table.tsx index 46d8a6ac..b81d02f0 100644 --- a/web/components/members/members-table.tsx +++ b/web/components/members/members-table.tsx @@ -11,22 +11,22 @@ import { } from "@/components/ui/table"; import { avatarColor, toInitials } from "@/lib/avatar-color"; -export type MemberRow = { - id: string; - name: string | null; +export interface MemberRow { email: string | null; + id: string; image: string | null; isAdmin: boolean; -}; + name: string | null; +} -type MembersTableProps = { - data: MemberRow[]; +interface MembersTableProps { /** * The signed-in admin viewing the table. Used to flag the self row so * the toggle is disabled (server also rejects self-demotion). */ currentUserId: string; -}; + data: MemberRow[]; +} export function MembersTable({ data, currentUserId }: MembersTableProps) { return ( @@ -50,7 +50,7 @@ export function MembersTable({ data, currentUserId }: MembersTableProps) {
{member.image ? ( - + ) : null} {toInitials(displayName)} @@ -60,7 +60,7 @@ export function MembersTable({ data, currentUserId }: MembersTableProps) { {displayName} {isSelf ? ( - + (you) ) : null} @@ -75,7 +75,7 @@ export function MembersTable({ data, currentUserId }: MembersTableProps) { {member.isAdmin ? ( Admin ) : ( - + Member )} @@ -83,10 +83,10 @@ export function MembersTable({ data, currentUserId }: MembersTableProps) {
diff --git a/web/components/notifications/instrument-notification-switch.tsx b/web/components/notifications/instrument-notification-switch.tsx index b87c5422..98cd34e9 100644 --- a/web/components/notifications/instrument-notification-switch.tsx +++ b/web/components/notifications/instrument-notification-switch.tsx @@ -1,13 +1,13 @@ "use client"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; // Composition over a `tooltip` boolean prop: the Tooltip is wrapped here // so callers don't have to know about the active vs muted copy. Three @@ -55,7 +55,9 @@ export function InstrumentNotificationSwitch({ body: JSON.stringify({ enabled: next }), } ); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } } catch (err) { setEnabled(previous); toast.error("Couldn't update instrument notifications", { @@ -81,11 +83,11 @@ export function InstrumentNotificationSwitch({ pointer events otherwise. */} diff --git a/web/components/notifications/instrument-notifications-cell.tsx b/web/components/notifications/instrument-notifications-cell.tsx index 28d647a1..8a0e8b66 100644 --- a/web/components/notifications/instrument-notifications-cell.tsx +++ b/web/components/notifications/instrument-notifications-cell.tsx @@ -22,8 +22,8 @@ export function InstrumentNotificationsCell({ return ( e.stopPropagation()}> diff --git a/web/components/notifications/notification-bell-content.tsx b/web/components/notifications/notification-bell-content.tsx index 5c574ec8..62901822 100644 --- a/web/components/notifications/notification-bell-content.tsx +++ b/web/components/notifications/notification-bell-content.tsx @@ -1,30 +1,30 @@ "use client"; -import { - useNotifications, - type NotificationItem, -} from "@/components/notifications/notifications-provider"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { avatarColor } from "@/lib/avatar-color"; -import type { InstrumentType } from "@/lib/db/schema"; -import { cn, formatRelativeTime } from "@/lib/utils"; import { Activity, BellOff, ChevronDown, FlaskConical, Image as ImageIcon, + type LucideIcon, Microscope, Radar, ScanLine, Settings, TestTube, - type LucideIcon, } from "lucide-react"; import Link from "next/link"; import { useMemo, useState } from "react"; +import { + type NotificationItem, + useNotifications, +} from "@/components/notifications/notifications-provider"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { avatarColor } from "@/lib/avatar-color"; +import type { InstrumentType } from "@/lib/db/schema"; +import { cn, formatRelativeTime } from "@/lib/utils"; // --------------------------------------------------------------------------- // Bell popover content. The provider is the single source of truth for @@ -61,21 +61,21 @@ const BUCKET_LABEL: Record = { earlier: "Earlier", }; -type CommentEntry = { - kind: "comment"; +interface CommentEntry { id: string; + kind: "comment"; notification: NotificationItem; -}; +} -type RunGroupEntry = { - kind: "run_group"; +interface RunGroupEntry { id: string; + instrumentDisplayName: string; instrumentId: string; instrumentType: InstrumentType; - instrumentDisplayName: string; - runs: NotificationItem[]; + kind: "run_group"; latestCreatedAt: string; -}; + runs: NotificationItem[]; +} type Entry = CommentEntry | RunGroupEntry; type BucketedEntries = Record; @@ -102,6 +102,8 @@ function commentActionLabel(n: NotificationItem): string { // Unreachable — `run_created` never flows into the comment row // renderer — but exhaustive switches keep TS honest. return `${actor} created`; + default: + return `${actor} commented on`; } } @@ -112,10 +114,14 @@ function bucketOf(createdAt: string, now: Date): Bucket { now.getMonth(), now.getDate() ); - if (created >= startOfToday) return "today"; + if (created >= startOfToday) { + return "today"; + } const startOfYesterday = new Date(startOfToday); startOfYesterday.setDate(startOfYesterday.getDate() - 1); - if (created >= startOfYesterday) return "yesterday"; + if (created >= startOfYesterday) { + return "yesterday"; + } return "earlier"; } @@ -165,8 +171,8 @@ function buildEntries(items: NotificationItem[], now: Date): BucketedEntries { const EMPTY_STATE = (
-

You're all caught up.

-

+

You're all caught up.

+

New runs and replies will show up here when you have something subscribed.

@@ -192,12 +198,12 @@ export function NotificationBellContent({ return (
{ void markAllRead(); }} onNavigate={onNavigate} + unreadCount={unreadCount} /> {isEmpty ? ( EMPTY_STATE @@ -205,7 +211,9 @@ export function NotificationBellContent({
{BUCKET_ORDER.map((bucket) => { const entries = buckets[bucket]; - if (entries.length === 0) return null; + if (entries.length === 0) { + return null; + } return ( {entries.map((entry) => @@ -222,8 +230,8 @@ export function NotificationBellContent({ /> ) : ( { void markOneRead(notificationId); }} @@ -260,12 +268,12 @@ function NotificationsHeader({ return (
-

Notifications

+

Notifications

{hasUnread ? ( {unreadCount > 99 ? "99+" : unreadCount} new @@ -273,21 +281,21 @@ function NotificationsHeader({
@@ -311,7 +319,7 @@ function NotificationSection({ }) { return (
-
+
{label}
    {children}
@@ -365,14 +373,14 @@ function CommentNotificationRow({ return ( {n.actor ? ( {n.actor.avatarUrl ? ( - + ) : null} {n.actor.initials} @@ -391,12 +399,12 @@ function CommentNotificationRow({

{n.commentBody ? ( -

+

“{n.commentBody}”

) : null}

{formatRelativeTime(n.createdAt)} @@ -458,12 +466,14 @@ function RunGroupNotificationRow({ return ( { - if (onlyRun.readAt === null) onActivate(onlyRun.id); + if (onlyRun.readAt === null) { + onActivate(onlyRun.id); + } onNavigate?.(); }} - className="flex cursor-pointer items-start gap-3 px-4 py-3 outline-none focus-visible:ring-2 focus-visible:ring-ring/50" > {iconBlock}

@@ -471,11 +481,11 @@ function RunGroupNotificationRow({ 1 new run on{" "} {group.instrumentDisplayName}

-

+

{onlyRun.runDisplayId}

{formatRelativeTime(group.latestCreatedAt)} @@ -489,15 +499,15 @@ function RunGroupNotificationRow({ return ( @@ -127,12 +135,14 @@ export function ArchiveDownloadDialog({ diff --git a/web/components/runs/archive-download-provider.tsx b/web/components/runs/archive-download-provider.tsx index 42adda97..aa94eecb 100644 --- a/web/components/runs/archive-download-provider.tsx +++ b/web/components/runs/archive-download-provider.tsx @@ -2,12 +2,12 @@ import { createContext, + type ReactNode, useCallback, useEffect, useMemo, useRef, useState, - type ReactNode, } from "react"; import { toast } from "sonner"; @@ -23,44 +23,44 @@ import { ArchiveDownloadDialog } from "./archive-download-dialog"; // 200s on a hit. That makes the artifact in S3 — not the row's status — // the source of truth for "ready", so we recover automatically even if // the Lambda's PATCH callback to flip the row to `ready` never lands. -export type ArchiveDownloadJob = { - id: string; - runId: string; +export interface ArchiveDownloadJob { archiveUrl: string; defaultFilename: string; + downloadUrl?: string; + errorMessage?: string; + id: string; // `job_id` returned by the very first 202. Subsequent polls compare // their own `job_id` against this; if it changes, the route's dedup // INSERT created a new row, which only happens after the previous // attempt was marked failed (or expired by the stuck-row sweep). We // surface that as a failure rather than silently chase a fresh build. initialJobId?: string; - status: "pending" | "building" | "ready" | "failed"; - errorMessage?: string; - downloadUrl?: string; + runId: string; sizeBytes?: number | null; startedAt: number; -}; + status: "pending" | "building" | "ready" | "failed"; +} -type StartArchiveDownloadInput = { +interface StartArchiveDownloadInput { archiveUrl: string; - runId: string; defaultFilename?: string; -}; + runId: string; +} -export type ArchiveDownloadActions = { - start: (input: StartArchiveDownloadInput) => Promise; +export interface ArchiveDownloadActions { dismiss: (id: string) => void; -}; + start: (input: StartArchiveDownloadInput) => Promise; +} -type ArchiveDownloadContextValue = { - jobs: ArchiveDownloadJob[]; +interface ArchiveDownloadContextValue { actions: ArchiveDownloadActions; -}; + jobs: ArchiveDownloadJob[]; +} export const ArchiveDownloadContext = createContext(null); -const POLL_INTERVAL_MS = 2_000; +const POLL_INTERVAL_MS = 2000; // Stop polling after a generous amount of time so a stuck Lambda doesn't // leave the dialog spinning forever. Lambda Function URLs cap at 15 minutes; // we double that as a hard ceiling. @@ -203,7 +203,7 @@ export function ArchiveDownloadProvider({ children }: { children: ReactNode }) { triggerDownload(downloadUrl, filename); // Auto-dismiss the row a moment later so the dialog doesn't linger // when the build was actually a cache hit. - window.setTimeout(() => dismiss(id), 1_500); + window.setTimeout(() => dismiss(id), 1500); }, [dismiss, updateJob] ); @@ -246,6 +246,8 @@ export function ArchiveDownloadProvider({ children }: { children: ReactNode }) { initialJobId: result.jobId, }); return; + default: + return; } }, [completeReady, dismiss, updateJob] @@ -259,11 +261,15 @@ export function ArchiveDownloadProvider({ children }: { children: ReactNode }) { // don't spin up N timers. useEffect(() => { const building = jobs.filter((j) => j.status === "building"); - if (building.length === 0) return; + if (building.length === 0) { + return; + } const interval = window.setInterval(async () => { for (const job of jobsRef.current) { - if (job.status !== "building") continue; + if (job.status !== "building") { + continue; + } if (Date.now() - job.startedAt > POLL_TIMEOUT_MS) { updateJob(job.id, { status: "failed", @@ -311,6 +317,8 @@ export function ArchiveDownloadProvider({ children }: { children: ReactNode }) { // Try again on the next interval — by then the route will // hopefully be honoring the JSON Accept header again. break; + default: + break; } } }, POLL_INTERVAL_MS); diff --git a/web/components/runs/colony-data-table.tsx b/web/components/runs/colony-data-table.tsx index 2a0d72ab..bc47f2f0 100644 --- a/web/components/runs/colony-data-table.tsx +++ b/web/components/runs/colony-data-table.tsx @@ -1,5 +1,13 @@ "use client"; +import { parse } from "csv-parse/browser/esm/sync"; +import { + AlertTriangle, + ChevronLeft, + ChevronRight, + ExternalLink, +} from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -12,14 +20,6 @@ import { } from "@/components/ui/table"; import type { RunFile } from "@/lib/api/instrument-runs"; import { cn } from "@/lib/utils"; -import { parse } from "csv-parse/browser/esm/sync"; -import { - AlertTriangle, - ChevronLeft, - ChevronRight, - ExternalLink, -} from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; const PAGE_SIZE = 10; @@ -65,29 +65,35 @@ export function ColonyDataTable({ file }: { file: RunFile }) { const state: LoadState = useMemo(() => { const cached = cacheRef.current.get(fileId); - if (cached) return { status: "ready", rows: cached }; + if (cached) { + return { status: "ready", rows: cached }; + } if (asyncResult && asyncResult.fileId === fileId) { return asyncResult.status === "ready" ? { status: "ready", rows: asyncResult.rows } : { status: "error", message: asyncResult.message }; } return { status: "loading" }; - // retryNonce participates so a retry that clears the cache entry forces - // a fresh derivation back to "loading" before the next fetch resolves. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fileId, asyncResult, retryNonce]); + }, [fileId, asyncResult]); useEffect(() => { - if (cacheRef.current.has(fileId)) return; + void retryNonce; + if (cacheRef.current.has(fileId)) { + return; + } let cancelled = false; fetchCsvRows(fileId) .then((rows) => { cacheRef.current.set(fileId, rows); - if (cancelled) return; + if (cancelled) { + return; + } setAsyncResult({ fileId, status: "ready", rows }); }) .catch((err: unknown) => { - if (cancelled) return; + if (cancelled) { + return; + } const message = err instanceof Error ? err.message : "Failed to load CSV"; setAsyncResult({ fileId, status: "error", message }); @@ -106,31 +112,31 @@ export function ColonyDataTable({ file }: { file: RunFile }) { return (

{state.status === "loading" && ( - + )} {state.status === "error" && (
- -

{state.message}

-
)} {state.status === "ready" && ( )}
@@ -155,11 +161,15 @@ function ColonyDataTableView({ // Computed once per row set so per-cell rendering stays cheap. const numericColumns = useMemo>(() => { const out = new Set(); - if (rows.length === 0) return out; + if (rows.length === 0) { + return out; + } for (const col of columns) { for (const row of rows) { const v = row[col]; - if (v === undefined || v === "") continue; + if (v === undefined || v === "") { + continue; + } if (!Number.isNaN(Number(v)) && Number.isFinite(Number(v))) { out.add(col); } @@ -180,7 +190,7 @@ function ColonyDataTableView({ if (total === 0) { return ( -
+
CSV is empty.
); @@ -197,11 +207,11 @@ function ColonyDataTableView({ {columns.map((col) => ( {col} @@ -209,15 +219,17 @@ function ColonyDataTableView({ - {pageRows.map((row, idx) => ( - + {pageRows.map((row) => ( + `${col}:${row[col] ?? ""}`).join("|")} + > {columns.map((col) => ( {row[col] ?? ""} @@ -227,7 +239,7 @@ function ColonyDataTableView({
-
+
Showing {start + 1}{end} of{" "} @@ -239,20 +251,20 @@ function ColonyDataTableView({
diff --git a/web/components/runs/comment-markdown.tsx b/web/components/runs/comment-markdown.tsx index 855ee272..b543f736 100644 --- a/web/components/runs/comment-markdown.tsx +++ b/web/components/runs/comment-markdown.tsx @@ -12,17 +12,16 @@ import remarkGfm from "remark-gfm"; // react-markdown bundle (~30 KB) only ships when there's a comment to render. export function CommentMarkdown({ body }: { body: string }) { return ( -
+

, a: (props) => ( ), ul: (props) => ( @@ -61,18 +60,18 @@ export function CommentMarkdown({ body }: { body: string }) { ), blockquote: (props) => (

), h1: (props) => ( -

+

), h2: (props) => ( -

+

), h3: (props) => ( -

+

), hr: () =>
, table: (props) => ( @@ -82,14 +81,15 @@ export function CommentMarkdown({ body }: { body: string }) { ), th: (props) => ( ), td: (props) => ( - + ), }} + remarkPlugins={[remarkGfm]} > {body} diff --git a/web/components/runs/delete-run-dialog.tsx b/web/components/runs/delete-run-dialog.tsx index bd498b17..b8948e76 100644 --- a/web/components/runs/delete-run-dialog.tsx +++ b/web/components/runs/delete-run-dialog.tsx @@ -1,5 +1,9 @@ "use client"; +import { Loader2, Trash2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, @@ -14,10 +18,6 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Loader2, Trash2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; -import { toast } from "sonner"; export function DeleteRunDialog({ instrumentId, @@ -61,12 +61,12 @@ export function DeleteRunDialog({ } return ( - + diff --git a/web/components/runs/file-selection-provider.tsx b/web/components/runs/file-selection-provider.tsx index 6c3e2548..308646c4 100644 --- a/web/components/runs/file-selection-provider.tsx +++ b/web/components/runs/file-selection-provider.tsx @@ -1,7 +1,7 @@ "use client"; -import type { RunFile } from "@/lib/api/instrument-runs"; import { createContext, use, useCallback, useMemo, useState } from "react"; +import type { RunFile } from "@/lib/api/instrument-runs"; // --------------------------------------------------------------------------- // File selection provider for the run files table. Mirrors RunSelectionProvider @@ -20,29 +20,33 @@ const DOWNLOADABLE_STATUSES = new Set([ const REPROCESSABLE_STATUSES = new Set(["completed", "failed"]); -export type FileCaps = { - upload: boolean; +export interface FileCaps { dismiss: boolean; - reprocess: boolean; download: boolean; -}; + reprocess: boolean; + upload: boolean; +} -export type FileRef = { - id: number; - filename: string; +export interface FileRef { caps: FileCaps; -}; + filename: string; + id: number; +} // Returns null for rows that should not participate in selection at all // (dismissed files, transient `upload_requested` rows). Caller treats null // the same as "no checkbox in this row". export function buildFileRef(file: RunFile): FileRef | null { - if (file.deletedAt !== null) return null; + if (file.deletedAt !== null) { + return null; + } const isDetected = file.status === "detected"; const canDownload = DOWNLOADABLE_STATUSES.has(file.status); const canReprocess = REPROCESSABLE_STATUSES.has(file.status) && file.s3Key !== null; - if (!isDetected && !canDownload) return null; + if (!(isDetected || canDownload)) { + return null; + } return { id: file.id, filename: file.filename, @@ -55,8 +59,7 @@ export function buildFileRef(file: RunFile): FileRef | null { }; } -type FileSelectionContextValue = { - state: { selected: ReadonlyMap }; +interface FileSelectionContextValue { actions: { toggle: (ref: FileRef) => void; selectMany: (refs: FileRef[]) => void; @@ -72,7 +75,8 @@ type FileSelectionContextValue = { allCanReprocess: boolean; allCanDownload: boolean; }; -}; + state: { selected: ReadonlyMap }; +} const FileSelectionContext = createContext( null @@ -107,9 +111,13 @@ export function FileSelectionProvider({ const next = new Map(prev); const alreadyAll = refs.length > 0 && refs.every((r) => next.has(r.id)); if (alreadyAll) { - for (const r of refs) next.delete(r.id); + for (const r of refs) { + next.delete(r.id); + } } else { - for (const r of refs) next.set(r.id, r); + for (const r of refs) { + next.set(r.id, r); + } } return next; }); diff --git a/web/components/runs/file-status-badge.tsx b/web/components/runs/file-status-badge.tsx index f8a37153..fdebc72d 100644 --- a/web/components/runs/file-status-badge.tsx +++ b/web/components/runs/file-status-badge.tsx @@ -30,7 +30,7 @@ export function FileStatusBadge({ status }: { status: string }) { }; return ( - + {config.label} ); diff --git a/web/components/runs/hina-report-section.tsx b/web/components/runs/hina-report-section.tsx index 09045c0f..830b4b33 100644 --- a/web/components/runs/hina-report-section.tsx +++ b/web/components/runs/hina-report-section.tsx @@ -1,18 +1,18 @@ "use client"; +import { ExternalLink } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { - type CarouselApi, Carousel, + type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@/components/ui/carousel"; import type { RunFile } from "@/lib/api/instrument-runs"; -import { ExternalLink } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|webp|tiff?)$/i; @@ -43,7 +43,9 @@ export function HinaReportSection({ files }: { files: RunFile[] }) { const [currentIndex, setCurrentIndex] = useState(0); useEffect(() => { - if (!api) return; + if (!api) { + return; + } // Subscribe to Embla's own "select" and "reInit" events — no synchronous // state sync needed on mount since Embla defaults to snap 0 which matches // our initial state. `reInit` covers the case where the carousel recalcs @@ -60,10 +62,10 @@ export function HinaReportSection({ files }: { files: RunFile[] }) { if (processedImages.length === 0) { return (
-

Report Data

+

Report Data

-

+

No report data has been generated for this run.

@@ -78,9 +80,9 @@ export function HinaReportSection({ files }: { files: RunFile[] }) { return (
-

+

Report Data{" "} - + {processedImages.length} image(s)

@@ -88,42 +90,44 @@ export function HinaReportSection({ files }: { files: RunFile[] }) {
- + {processedImages.map((file, i) => { const url = `/api/v1/files/${file.id}/download`; return (
- {/* eslint-disable-next-line @next/next/no-img-element */} + {/* biome-ignore lint/performance/noImgElement: auth-gated download URLs are not next/image candidates */} {file.filename}
diff --git a/web/components/runs/metadata-badges.tsx b/web/components/runs/metadata-badges.tsx index e02d0ec2..3b5ce461 100644 --- a/web/components/runs/metadata-badges.tsx +++ b/web/components/runs/metadata-badges.tsx @@ -10,16 +10,24 @@ export function getMetadataField( metadata: unknown, key: string ): string | null { - if (!metadata || typeof metadata !== "object") return null; + if (!metadata || typeof metadata !== "object") { + return null; + } const value = (metadata as Record)[key]; - return value != null ? String(value) : null; + return value == null ? null : String(value); } export function getMetadataArray(metadata: unknown, key: string): string[] { - if (!metadata || typeof metadata !== "object") return []; + if (!metadata || typeof metadata !== "object") { + return []; + } const value = (metadata as Record)[key]; - if (Array.isArray(value)) return value.map(String); - if (value != null) return [String(value)]; + if (Array.isArray(value)) { + return value.map(String); + } + if (value != null) { + return [String(value)]; + } return []; } @@ -27,7 +35,9 @@ export function getMetadataRecord( metadata: unknown, key: string ): Record | null { - if (!metadata || typeof metadata !== "object") return null; + if (!metadata || typeof metadata !== "object") { + return null; + } const value = (metadata as Record)[key]; if (value && typeof value === "object" && !Array.isArray(value)) { return value as Record; @@ -39,9 +49,13 @@ export function getMetadataObjectArray( metadata: unknown, key: string ): Record[] { - if (!metadata || typeof metadata !== "object") return []; + if (!metadata || typeof metadata !== "object") { + return []; + } const value = (metadata as Record)[key]; - if (!Array.isArray(value)) return []; + if (!Array.isArray(value)) { + return []; + } return value.filter( (v): v is Record => v !== null && typeof v === "object" && !Array.isArray(v) @@ -59,9 +73,15 @@ export function sortWavelengths(wavelengths: string[]): string[] { const nb = Number(b); const aNum = Number.isFinite(na); const bNum = Number.isFinite(nb); - if (aNum && bNum) return na - nb; - if (aNum) return -1; - if (bNum) return 1; + if (aNum && bNum) { + return na - nb; + } + if (aNum) { + return -1; + } + if (bNum) { + return 1; + } return a.localeCompare(b); }); } @@ -73,9 +93,11 @@ export function MetadataFieldBadge({ value: string | null; colorClass?: string; }) { - if (!value) return ; + if (!value) { + return ; + } return ( - + {value} ); @@ -94,9 +116,9 @@ function BadgeRow({
{values.map((v) => ( {v} @@ -112,9 +134,10 @@ export function MetadataArrayBadges({ values: string[]; colorMap?: Record; }) { - if (values.length === 0) + if (values.length === 0) { return ; - return ; + } + return ; } /** @@ -132,11 +155,12 @@ export function TruncatedBadges({ colorMap?: Record; maxVisible?: number; }) { - if (values.length === 0) + if (values.length === 0) { return ; + } if (values.length <= maxVisible) { - return ; + return ; } const visible = values.slice(0, maxVisible); @@ -148,24 +172,24 @@ export function TruncatedBadges({
{visible.map((v) => ( {v} ))} +{hiddenCount}
- + ); diff --git a/web/components/runs/plate-map-grid.tsx b/web/components/runs/plate-map-grid.tsx index 2f8e527d..640e8bc5 100644 --- a/web/components/runs/plate-map-grid.tsx +++ b/web/components/runs/plate-map-grid.tsx @@ -1,26 +1,33 @@ "use client"; +import { Fragment, useMemo, useState } from "react"; import { Slider } from "@/components/ui/slider"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { Fragment, useMemo, useState } from "react"; -export type PlateWellData = { well: string; value: unknown }; +export interface PlateWellData { + value: unknown; + well: string; +} function parseWell(well: string): { row: number; col: number } | null { const match = well.match(/^([A-P])(\d{1,2})$/i); - if (!match) return null; + if (!match) { + return null; + } return { row: match[1].toUpperCase().charCodeAt(0) - 65, - col: parseInt(match[2], 10) - 1, + col: Number.parseInt(match[2], 10) - 1, }; } function formatCellValue(value: unknown): string { - if (value === null || value === undefined) return ""; + if (value === null || value === undefined) { + return ""; + } if (typeof value === "number") { return Number.isInteger(value) ? String(value) @@ -76,14 +83,14 @@ function heatmapColor( return [`rgb(${r},${g},${b})`, fg]; } -type PlateMapGridProps = { +interface PlateMapGridProps { data: unknown; heatmap?: boolean; /** When heatmap is on, use this scale instead of inferring min/max from `data`. */ heatmapRange?: { min: number; max: number }; plateName?: string; wavelength?: string; -}; +} export function PlateMapGrid({ data, @@ -92,10 +99,14 @@ export function PlateMapGrid({ plateName, wavelength, }: PlateMapGridProps) { - if (!Array.isArray(data)) return null; + if (!Array.isArray(data)) { + return null; + } const wells = data as PlateWellData[]; - if (wells.length === 0) return null; + if (wells.length === 0) { + return null; + } let maxRow = 0; let maxCol = 0; @@ -103,17 +114,23 @@ export function PlateMapGrid({ for (const w of wells) { const pos = parseWell(w.well); - if (!pos) continue; - if (pos.row > maxRow) maxRow = pos.row; - if (pos.col > maxCol) maxCol = pos.col; + if (!pos) { + continue; + } + if (pos.row > maxRow) { + maxRow = pos.row; + } + if (pos.col > maxCol) { + maxCol = pos.col; + } cellMap.set(`${pos.row}-${pos.col}`, w.value); } const rows = maxRow + 1; const cols = maxCol + 1; - let vMin = Infinity; - let vMax = -Infinity; + let vMin = Number.POSITIVE_INFINITY; + let vMax = Number.NEGATIVE_INFINITY; if (heatmap) { if (heatmapRange) { vMin = heatmapRange.min; @@ -121,13 +138,17 @@ export function PlateMapGrid({ } else { for (const v of cellMap.values()) { if (typeof v === "number") { - if (v < vMin) vMin = v; - if (v > vMax) vMax = v; + if (v < vMin) { + vMin = v; + } + if (v > vMax) { + vMax = v; + } } } } } - const hasRange = isFinite(vMin) && isFinite(vMax); + const hasRange = Number.isFinite(vMin) && Number.isFinite(vMax); const rowLabels = Array.from({ length: rows }, (_, i) => String.fromCharCode(65 + i) @@ -138,11 +159,11 @@ export function PlateMapGrid({
{(plateName || wavelength) && (
-

+

{plateName}

{wavelength && ( - + {wavelength} nm )} @@ -158,8 +179,8 @@ export function PlateMapGrid({ {/* Column headers */} {colLabels.map((c, ci) => (
{c} @@ -170,7 +191,7 @@ export function PlateMapGrid({ {rowLabels.map((rowLabel, ri) => (
{rowLabel} @@ -228,7 +249,7 @@ export function PlateMapGrid({
- {heatmap && hasRange && } + {heatmap && hasRange && }
); } @@ -252,27 +273,33 @@ function PlasmaColorBar({ min, max }: { min: number; max: number }) { function computeGlobalHeatmapRange( frames: PlateWellData[][] ): { min: number; max: number } | undefined { - let min = Infinity; - let max = -Infinity; + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; for (const frame of frames) { for (const w of frame) { if (typeof w.value === "number") { - if (w.value < min) min = w.value; - if (w.value > max) max = w.value; + if (w.value < min) { + min = w.value; + } + if (w.value > max) { + max = w.value; + } } } } - if (!isFinite(min) || !isFinite(max)) return undefined; + if (!(Number.isFinite(min) && Number.isFinite(max))) { + return; + } return { min, max }; } -type KineticPlateMapWithTimeSliderProps = { - timeLabels: string[]; +interface KineticPlateMapWithTimeSliderProps { frames: PlateWellData[][]; heatmap: boolean; plateName?: string; + timeLabels: string[]; wavelength?: string; -}; +} /** * Plate map with a time index slider (for kinetic absorbance series). @@ -294,7 +321,9 @@ export function KineticPlateMapWithTimeSlider({ [heatmap, frames] ); - if (frames.length === 0) return null; + if (frames.length === 0) { + return null; + } return (
@@ -307,7 +336,7 @@ export function KineticPlateMapWithTimeSlider({ /> {frames.length > 1 && (
-
+
Time setIndex(v[0] ?? 0)} step={1} value={[selectedIndex]} - onValueChange={(v) => setIndex(v[0] ?? 0)} - aria-label="Select measurement time" /> -

Report Data

+

Report Data

-

+

No report data has been generated for this run.

@@ -28,9 +28,9 @@ export function RamanReportSection({ return (
-

+

Report Data{" "} - + {spectra.length} {spectra.length === 1 ? "spectrum" : "spectra"}

diff --git a/web/components/runs/raman-spectrum-viewer.tsx b/web/components/runs/raman-spectrum-viewer.tsx index 6d6c126e..a7c72321 100644 --- a/web/components/runs/raman-spectrum-viewer.tsx +++ b/web/components/runs/raman-spectrum-viewer.tsx @@ -1,5 +1,15 @@ "use client"; +import { parse } from "csv-parse/browser/esm/sync"; +import { + AlertTriangle, + Check, + ChevronLeft, + ChevronRight, + ChevronsUpDown, +} from "lucide-react"; +import { startTransition, useEffect, useMemo, useRef, useState } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import type { RamanSpectrumFileRef } from "@/components/runs/raman-report-section"; import { Button } from "@/components/ui/button"; import { @@ -24,22 +34,12 @@ import { import { Skeleton } from "@/components/ui/skeleton"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { cn } from "@/lib/utils"; -import { parse } from "csv-parse/browser/esm/sync"; -import { - AlertTriangle, - Check, - ChevronLeft, - ChevronRight, - ChevronsUpDown, -} from "lucide-react"; -import { startTransition, useEffect, useMemo, useRef, useState } from "react"; -import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; -type SpectrumPoint = { - wavenumber: number; +interface SpectrumPoint { intensity: number; intensityDarkSubtracted: number; -}; + wavenumber: number; +} type Series = "intensity" | "intensityDarkSubtracted"; @@ -84,8 +84,10 @@ async function fetchSpectrum(fileId: number): Promise { } const first = rows[0]; if ( - !(CSV_HEADER_WAVENUMBER in first) || - !(CSV_HEADER_INTENSITY in first || CSV_HEADER_DARK in first) + !( + CSV_HEADER_WAVENUMBER in first && + (CSV_HEADER_INTENSITY in first || CSV_HEADER_DARK in first) + ) ) { throw new Error( `CSV is missing expected columns (${CSV_HEADER_WAVENUMBER} + ${CSV_HEADER_INTENSITY}/${CSV_HEADER_DARK})` @@ -114,13 +116,13 @@ function SpectrumPicker({ const selected = spectra.find((s) => s.fileId === selectedId); return ( - + @@ -142,11 +144,11 @@ function SpectrumPicker({ return ( { onSelect(s.fileId); setOpen(false); }} + value={s.filename} > { // Don't let the user deselect every series — that would leave an // empty chart with no obvious way to recover. - if (next.length === 0) return; + if (next.length === 0) { + return; + } onChange(next as Series[]); }} - aria-label="Series visibility" + size="sm" + type="multiple" + value={visible} > {ALL_SERIES.map((key) => ( - + + - + v.toFixed(0)} label={{ value: "Wavenumber (cm\u207B\u00B9)", position: "insideBottom", @@ -241,15 +241,19 @@ function SpectrumChart({ fill: "var(--color-muted-foreground)", }, }} + tickFormatter={(v: number) => v.toFixed(0)} + tickLine={false} + tickMargin={8} + type="number" /> Math.abs(v) >= 1000 ? `${(v / 1000).toFixed(1)}k` : v.toFixed(0) } + tickLine={false} + tickMargin={8} + width={64} /> {showIntensity && ( )} {showDark && ( )} @@ -328,11 +332,15 @@ export function RamanSpectrumViewer({ const canGoNext = currentIndex >= 0 && currentIndex < spectra.length - 1; function goPrev() { - if (!canGoPrev) return; + if (!canGoPrev) { + return; + } setSelectedId(spectra[currentIndex - 1].fileId); } function goNext() { - if (!canGoNext) return; + if (!canGoNext) { + return; + } setSelectedId(spectra[currentIndex + 1].fileId); } @@ -340,30 +348,39 @@ export function RamanSpectrumViewer({ // and never re-hits S3. const cacheRef = useRef>(new Map()); + // biome-ignore lint/correctness/useExhaustiveDependencies: retryNonce retriggers loading state after cache clear on retry const state: LoadState = useMemo(() => { - if (selectedId == null) return { status: "idle" }; + if (selectedId == null) { + return { status: "idle" }; + } const cached = cacheRef.current.get(selectedId); - if (cached) return { status: "ready", points: cached }; + if (cached) { + return { status: "ready", points: cached }; + } if (asyncResult && asyncResult.fileId === selectedId) { return asyncResult.status === "ready" ? { status: "ready", points: asyncResult.points } : { status: "error", message: asyncResult.message }; } return { status: "loading" }; - // `retryNonce` participates so a retry that clears the cache entry forces - // a fresh derivation back to "loading" before the next fetch resolves. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, asyncResult, retryNonce]); + // biome-ignore lint/correctness/useExhaustiveDependencies: retryNonce retriggers fetch when the user retries after an error useEffect(() => { - if (selectedId == null) return; - if (cacheRef.current.has(selectedId)) return; + if (selectedId == null) { + return; + } + if (cacheRef.current.has(selectedId)) { + return; + } let cancelled = false; fetchSpectrum(selectedId) .then((points) => { cacheRef.current.set(selectedId, points); - if (cancelled) return; + if (cancelled) { + return; + } // Rendering ~2k points to recharts is the heavy part of this update; // a transition lets the picker close stay snappy. startTransition(() => { @@ -371,7 +388,9 @@ export function RamanSpectrumViewer({ }); }) .catch((err: unknown) => { - if (cancelled) return; + if (cancelled) { + return; + } const message = err instanceof Error ? err.message : "Failed to load spectrum"; setAsyncResult({ fileId: selectedId, status: "error", message }); @@ -383,7 +402,9 @@ export function RamanSpectrumViewer({ }, [selectedId, retryNonce]); function handleRetry() { - if (selectedId == null) return; + if (selectedId == null) { + return; + } cacheRef.current.delete(selectedId); setAsyncResult(null); setRetryNonce((n) => n + 1); @@ -393,42 +414,42 @@ export function RamanSpectrumViewer({
- +
{state.status === "loading" && ( - + )} {state.status === "error" && (
- -

{state.message}

-
diff --git a/web/components/runs/reprocess-runs-dialog.tsx b/web/components/runs/reprocess-runs-dialog.tsx index 9fe7b386..ee787fc5 100644 --- a/web/components/runs/reprocess-runs-dialog.tsx +++ b/web/components/runs/reprocess-runs-dialog.tsx @@ -1,5 +1,9 @@ "use client"; +import { Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, @@ -10,17 +14,13 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Loader2 } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useTransition } from "react"; -import { toast } from "sonner"; -export type ReprocessRunTarget = { - instrumentId: string; - runId: string; +export interface ReprocessRunTarget { filesCompleted: number; filesFailed: number; -}; + instrumentId: string; + runId: string; +} // --------------------------------------------------------------------------- // Controlled reprocess dialog used by both the per-row "..." menu (single @@ -39,7 +39,9 @@ async function fanOut( r.runId )}/reprocess`; const res = await fetch(url, { method: "POST" }); - if (!res.ok) throw new Error(await res.text()); + if (!res.ok) { + throw new Error(await res.text()); + } const body = (await res.json()) as { files_queued?: number }; return body.files_queued ?? 0; }) @@ -101,11 +103,11 @@ export function ReprocessRunsDialog({ const title = runCount === 1 - ? `Reprocess run ${runs[0]!.runId}?` + ? `Reprocess run ${runs[0]?.runId}?` : `Reprocess ${runCount} runs?`; return ( - + {title} @@ -119,7 +121,7 @@ export function ReprocessRunsDialog({ Cancel - + {isPending && } Reprocess diff --git a/web/components/runs/restore-run-button.tsx b/web/components/runs/restore-run-button.tsx index ba9467a3..f1ea8259 100644 --- a/web/components/runs/restore-run-button.tsx +++ b/web/components/runs/restore-run-button.tsx @@ -1,10 +1,10 @@ "use client"; -import { Button } from "@/components/ui/button"; import { Loader2, RotateCcw } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTransition } from "react"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; export function RestoreRunButton({ instrumentId, @@ -36,11 +36,11 @@ export function RestoreRunButton({ return (