diff --git a/.changeset/fair-queue-concurrency-slot-leak.md b/.changeset/fair-queue-concurrency-slot-leak.md deleted file mode 100644 index 41f41e7e3d2..00000000000 --- a/.changeset/fair-queue-concurrency-slot-leak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/redis-worker": patch ---- - -Fair queue tenants can no longer get permanently stuck behind leaked concurrency slots. Slots are now freed on every path that finishes a message, a failed release no longer causes a message to run twice or lose its retry, and a background sweep frees any slot that does leak, so a tenant's queues recover on their own instead of needing manual cleanup. diff --git a/.changeset/lucky-pillows-invite.md b/.changeset/lucky-pillows-invite.md deleted file mode 100644 index 2f43f51ff9e..00000000000 --- a/.changeset/lucky-pillows-invite.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Task metrics no longer go missing for projects that configure their own `metricExporters` or `metricReaders`, and the flush error that came with it is gone. diff --git a/.changeset/prebuilt-base-images.md b/.changeset/prebuilt-base-images.md deleted file mode 100644 index 4f3f0be0945..00000000000 --- a/.changeset/prebuilt-base-images.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Deployment builds now use custom base layer images and no longer install system packages during every build. This improves layer caching resulting in both faster deployments and faster image pulls on the worker cluster side. diff --git a/.changeset/smooth-schedule-windows.md b/.changeset/smooth-schedule-windows.md deleted file mode 100644 index 6e7bba2eb79..00000000000 --- a/.changeset/smooth-schedule-windows.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@trigger.dev/core": patch -"@trigger.dev/sdk": patch ---- - -Define stable execution windows on declarative scheduled tasks. Schedule API responses now expose both the nominal CRON time and its assigned time, while the dashboard shows configured windows and upcoming assignments. diff --git a/.env.example b/.env.example index 901fbd7b3e0..70558bbacd0 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,8 @@ NODE_ENV=development CLICKHOUSE_URL=http://default:password@localhost:8123 RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123 RUN_REPLICATION_ENABLED=1 +# LOGS_SEARCH_PROJECTOR_ENABLED=1 +# LOGS_SEARCH_PROJECTOR_PREVIEW_ENABLED=1 # Store task run spans/traces in ClickHouse so the dashboard trace view is # populated in local dev. The local stack is ClickHouse-backed (see above), so # leaving this unset falls back to the "postgres" store and dev run traces show diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 612a028bb98..92fd331f2d8 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -29,4 +29,5 @@ Leafgard Rohan170603 NERLOE Jakub-Vacek -gtremper \ No newline at end of file +gtremper +wuweiweiwu \ No newline at end of file diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 48575827b39..7499aac2e75 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -36,3 +36,6 @@ jobs: - name: ๐Ÿ”Ž Lint run: pnpm exec oxlint . + + - name: โœ‚๏ธ Check unused code and dependencies + run: pnpm run knip diff --git a/.github/workflows/dashboard-agent-deploy.yml b/.github/workflows/dashboard-agent-deploy.yml index e5ea856069b..ef8c1a9cb99 100644 --- a/.github/workflows/dashboard-agent-deploy.yml +++ b/.github/workflows/dashboard-agent-deploy.yml @@ -3,9 +3,19 @@ name: "๐Ÿค– Deploy dashboard agent" # Deploys the @internal/dashboard-agent chat.agent to its Trigger.dev project # with --skip-promotion, so a deploy never becomes "current" on its own. The # consuming app cuts over by pinning DASHBOARD_AGENT_VERSION to the new version. -# Runs a leg per environment (staging + prod), each gated by its own environment; -# a push to main that touches the agent or its store triggers both. Version -# numbers are per-environment, so pin each environment to its own leg's version. +# Runs a leg per environment (staging + prod); a push to main that touches the +# agent or its store deploys both. Version numbers are per-environment, so pin +# each environment to its own leg's version. +# +# The deploy lands dormant, so it doesn't need a reviewer gate: nothing goes live +# until DASHBOARD_AGENT_VERSION is flipped. The `environment:` below is kept only +# to scope the deploy token per environment; its required-reviewers rule is +# removed in repo settings so pushes deploy unattended. workflow_dispatch takes an +# optional ref (SHA, branch, or tag) to deploy a specific commit instead of head. +# +# The deployed ref must be an ancestor of main, so only reviewed, merged code ever +# runs with the deploy token (the checked-out build + trigger.config.ts execute +# with it). A push is always on main; a dispatched ref is checked before deploy. on: push: @@ -14,6 +24,11 @@ on: - "internal-packages/dashboard-agent/**" - "internal-packages/dashboard-agent-db/**" workflow_dispatch: + inputs: + ref: + description: "Commit SHA, branch, or tag to deploy. Defaults to the ref the workflow runs from." + required: false + type: string permissions: {} @@ -27,9 +42,15 @@ jobs: max-parallel: 1 matrix: environment: [staging, prod] - # Per-environment reviewer gate + source of the scoped deploy PAT. + # Kept to scope the deploy token per environment. The required-reviewers rule + # on these environments is removed in repo settings, so this no longer gates. environment: dashboard-agent-${{ matrix.environment }} concurrency: + # Queue a superseding deploy behind an in-flight one; do NOT cancel it. + # Cancelling the runner wouldn't stop the remote build (it finishes + # server-side), and a second concurrent deploy of the same project would + # race the indexer. Deploys are short now the gate is gone, so a brief queue + # is fine and can't pile up. group: dashboard-agent-deploy-${{ matrix.environment }} cancel-in-progress: false permissions: @@ -41,8 +62,34 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: + # push: the pushed commit. workflow_dispatch: the input ref if given, + # otherwise the head of the ref the run was launched from. + ref: ${{ github.event.inputs.ref || github.sha }} + # Full history so the ancestor-of-main check below can find a merge base. + fetch-depth: 0 persist-credentials: false + - name: Require the ref to be an ancestor of main + # The deploy token runs the checked-out code, so refuse anything that + # hasn't landed on main. A push is main's tip (ancestor of itself); this + # only ever rejects a dispatched, unmerged ref. + # + # NOTE: this in-file check only constrains WHICH commit is deployed. It + # can't protect the token on its own, because workflow_dispatch runs the + # workflow file from the selected ref. The real guard is the deployment + # branch policy on the dashboard-agent-* environments (main only), set in + # repo settings, which GitHub enforces server-side against GITHUB_REF. + run: | + set -euo pipefail + # An explicit `ref:` checkout doesn't create remote-tracking branches, + # so fetch main before comparing against it. + git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::Refusing to deploy $(git rev-parse HEAD): not an ancestor of origin/main. Only merged code can be deployed." + exit 1 + fi + echo "$(git rev-parse --short HEAD) is an ancestor of origin/main" + - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 with: diff --git a/.github/workflows/e2e-webapp-auth-full.yml b/.github/workflows/e2e-webapp-auth-full.yml index e88429a3443..3b1c510107b 100644 --- a/.github/workflows/e2e-webapp-auth-full.yml +++ b/.github/workflows/e2e-webapp-auth-full.yml @@ -99,11 +99,10 @@ jobs: run: echo "DockerHub login skipped because secrets are not available." - name: ๐Ÿณ Pre-pull testcontainer images - if: ${{ env.DOCKERHUB_USERNAME }} run: | docker pull postgres:14 docker pull redis:7.2 - docker pull testcontainers/ryuk:0.11.0 + docker pull testcontainers/ryuk:0.14.0 - name: ๐Ÿ“ฅ Download deps run: pnpm install --frozen-lockfile diff --git a/.github/workflows/e2e-webapp.yml b/.github/workflows/e2e-webapp.yml index 1c1525f31bc..e8f778d75b9 100644 --- a/.github/workflows/e2e-webapp.yml +++ b/.github/workflows/e2e-webapp.yml @@ -16,8 +16,15 @@ jobs: name: "๐Ÿงช E2E Tests: Webapp" runs-on: warp-ubuntu-latest-x64-16x timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2] + shardTotal: [2] env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + SHARD_INDEX: ${{ matrix.shardIndex }} + SHARD_TOTAL: ${{ matrix.shardTotal }} steps: - name: ๐Ÿ”ง Disable IPv6 run: | @@ -57,7 +64,7 @@ jobs: version: 10.33.2 - name: โŽ” Setup node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6 with: node-version: 24.18.0 cache: "pnpm" @@ -73,19 +80,52 @@ jobs: if: ${{ !env.DOCKERHUB_USERNAME }} run: echo "DockerHub login skipped because secrets are not available." - - name: ๐Ÿณ Pre-pull testcontainer images - if: ${{ env.DOCKERHUB_USERNAME }} + - name: ๐Ÿ“ฅ Prepare deps and testcontainer images run: | - echo "Pre-pulling Docker images with authenticated session..." - docker pull postgres:14 - docker pull redis:7.2 - docker pull testcontainers/ryuk:0.11.0 - docker pull ghcr.io/s2-streamstore/s2:0.40.0@sha256:b26249e2ede0949755f5af8028185dc2bcfc3aa2db21eb9610543d144eb6ee9d - docker pull minio/minio:latest - echo "Image pre-pull complete" + # Pull images concurrently with dependency installation. Retry each pull because + # registry timeouts are a recurring transient CI flake. + pull() { + for attempt in 1 2 3; do + docker pull "$1" && return 0 + echo "::warning::docker pull $1 failed (attempt ${attempt}/3); retrying in 10s" + sleep 10 + done + echo "::error::docker pull $1 failed after 3 attempts" + return 1 + } + + pull_images() { + local pids=() + local failed=0 + for image in \ + postgres:14 \ + redis:7.2 \ + testcontainers/ryuk:0.14.0 \ + ghcr.io/s2-streamstore/s2:0.40.0@sha256:b26249e2ede0949755f5af8028185dc2bcfc3aa2db21eb9610543d144eb6ee9d \ + minio/minio:latest + do + pull "$image" & + pids+=("$!") + done + for pid in "${pids[@]}"; do + if ! wait "$pid"; then + failed=1 + fi + done + return "$failed" + } - - name: ๐Ÿ“ฅ Download deps - run: pnpm install --frozen-lockfile + echo "Installing dependencies and pre-pulling Docker images..." + pull_images & + pull_pid=$! + install_status=0 + pnpm install --frozen-lockfile || install_status=$? + pull_status=0 + wait "$pull_pid" || pull_status=$? + if (( install_status != 0 || pull_status != 0 )); then + exit 1 + fi + echo "Dependency install and image pre-pull complete" - name: ๐Ÿ“€ Generate Prisma Client run: pnpm run generate @@ -97,6 +137,6 @@ jobs: run: cd apps/webapp && pnpm exec playwright install chromium - name: ๐Ÿงช Run Webapp E2E Tests - run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default + run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} env: WEBAPP_TEST_VERBOSE: "1" diff --git a/.github/workflows/unit-tests-internal.yml b/.github/workflows/unit-tests-internal.yml index 1a398af9c44..9d606bb98fa 100644 --- a/.github/workflows/unit-tests-internal.yml +++ b/.github/workflows/unit-tests-internal.yml @@ -78,7 +78,6 @@ jobs: run: echo "DockerHub login skipped because secrets are not available." - name: ๐Ÿณ Pre-pull testcontainer images - if: ${{ env.DOCKERHUB_USERNAME }} run: | # Retry each pull - DockerHub registry timeouts are a recurring transient CI flake. pull() { @@ -96,7 +95,6 @@ jobs: pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 pull redis:7.2 pull testcontainers/ryuk:0.14.0 - pull electricsql/electric:1.2.4 echo "Image pre-pull complete" - name: ๐Ÿ“ฅ Download deps diff --git a/.github/workflows/unit-tests-packages.yml b/.github/workflows/unit-tests-packages.yml index ceb0b7b49da..ebca33d50b2 100644 --- a/.github/workflows/unit-tests-packages.yml +++ b/.github/workflows/unit-tests-packages.yml @@ -81,7 +81,6 @@ jobs: run: echo "DockerHub login skipped because secrets are not available." - name: ๐Ÿณ Pre-pull testcontainer images - if: ${{ env.DOCKERHUB_USERNAME }} run: | # Retry each pull - DockerHub registry timeouts are a recurring transient CI flake. pull() { @@ -98,7 +97,6 @@ jobs: pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 pull redis:7.2 pull testcontainers/ryuk:0.14.0 - pull electricsql/electric:1.2.4 pull otel/opentelemetry-collector-k8s:0.158.0@sha256:c09130a633196a5becee164411473a0932ecf223f94fda6dab5f22798ff9f376 echo "Image pre-pull complete" diff --git a/.github/workflows/unit-tests-webapp.yml b/.github/workflows/unit-tests-webapp.yml index 680eafd9aa1..0a3fa28b68c 100644 --- a/.github/workflows/unit-tests-webapp.yml +++ b/.github/workflows/unit-tests-webapp.yml @@ -14,18 +14,18 @@ on: jobs: unitTests: name: "๐Ÿงช Unit Tests: Webapp" - # 10 shards on 16x machines: webapp test throughput is limited per-machine (one - # docker daemon + disk absorbing all the per-file Postgres/ClickHouse container - # spin-up), so many machines beats few big ones - fewer/bigger (3x32) measured - # SLOWER than 10x8. The 16x (vs 8x) gives the fork pool the CPU headroom the 8x - # runners lacked. Setup overhead per machine is ~1 min on warm runners. + # Webapp test throughput is limited per-machine (one docker daemon + disk absorbing + # all the per-file Postgres/ClickHouse container spin-up), so many machines beats + # few big ones - fewer/bigger (3x32) measured slower than 10x8. The 16x (vs 8x) + # gives the fork pool the CPU headroom the 8x runners lacked. runs-on: warp-ubuntu-latest-x64-16x strategy: # one flaky shard shouldn't cancel its siblings - lets us re-run only the failed shard fail-fast: false matrix: - shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] - shardTotal: [12] + shardIndex: + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24] + shardTotal: [24] env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} SHARD_INDEX: ${{ matrix.shardIndex }} @@ -69,7 +69,7 @@ jobs: version: 10.33.2 - name: โŽ” Setup node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + uses: WarpBuilds/setup-node@bc639b444d583175926b588962199c247d23e8d3 # v6 with: node-version: 24.18.0 cache: "pnpm" @@ -85,10 +85,10 @@ jobs: if: ${{ !env.DOCKERHUB_USERNAME }} run: echo "DockerHub login skipped because secrets are not available." - - name: ๐Ÿณ Pre-pull testcontainer images - if: ${{ env.DOCKERHUB_USERNAME }} + - name: ๐Ÿ“ฅ Prepare deps and testcontainer images run: | - # Retry each pull - DockerHub registry timeouts are a recurring transient CI flake. + # Pull images concurrently with dependency installation. Retry each pull because + # DockerHub registry timeouts are a recurring transient CI flake. pull() { for attempt in 1 2 3; do docker pull "$1" && return 0 @@ -98,17 +98,41 @@ jobs: echo "::error::docker pull $1 failed after 3 attempts" return 1 } - echo "Pre-pulling Docker images with authenticated session..." - pull postgres:14 - pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 - pull redis:7.2 - pull testcontainers/ryuk:0.14.0 - pull electricsql/electric:1.2.4 - pull minio/minio:latest - echo "Image pre-pull complete" - - - name: ๐Ÿ“ฅ Download deps - run: pnpm install --frozen-lockfile + + pull_images() { + local pids=() + local failed=0 + for image in \ + postgres:14 \ + postgres:17 \ + clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 \ + redis:7.2 \ + testcontainers/ryuk:0.14.0 \ + electricsql/electric:1.2.4@sha256:20da3d0b0e74926c5623392db67fd56698b9e374c4aeb6cb5cadeb8fea171c36 \ + minio/minio:latest + do + pull "$image" & + pids+=("$!") + done + for pid in "${pids[@]}"; do + if ! wait "$pid"; then + failed=1 + fi + done + return "$failed" + } + + echo "Installing dependencies and pre-pulling Docker images..." + pull_images & + pull_pid=$! + install_status=0 + pnpm install --frozen-lockfile || install_status=$? + pull_status=0 + wait "$pull_pid" || pull_status=$? + if (( install_status != 0 || pull_status != 0 )); then + exit 1 + fi + echo "Dependency install and image pre-pull complete" - name: ๐Ÿ“€ Generate Prisma Client run: pnpm run generate diff --git a/.gitignore b/.gitignore index b11dded2b02..3f49db35ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ ailogger-output.log observability-map.json .claude/worktrees/ + +# CPU benchmark artifacts (profiles + summaries) +.bench/ diff --git a/.oxlintrc.json b/.oxlintrc.json index c51e04730cf..7d863dcbe73 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -3,7 +3,7 @@ "categories": { "correctness": "error" }, - "plugins": ["typescript", "import", "react"], + "plugins": ["typescript", "import", "react", "jsx-a11y"], "jsPlugins": [ "./oxlint-plugins/no-thrown-unawaited-redirect.mjs", "./oxlint-plugins/runops-residency.mjs", @@ -34,7 +34,7 @@ ], "no-empty-pattern": "off", "no-control-regex": "off", - "typescript/no-non-null-asserted-optional-chain": "off", + "typescript/no-non-null-asserted-optional-chain": "error", "no-unused-expressions": [ "error", { @@ -45,8 +45,87 @@ "typescript/consistent-type-imports": "error", "import/no-duplicates": "error", "import/namespace": "off", - "react-hooks/exhaustive-deps": "off", - "react-hooks/rules-of-hooks": "off", + "react/exhaustive-deps": "error", + "react/rules-of-hooks": "off", + "guard-for-in": "error", + "symbol-description": "error", + "no-unneeded-ternary": "error", + "prefer-object-has-own": "error", + "no-redeclare": "error", + "no-multi-assign": "error", + "prefer-object-spread": "error", + "react/jsx-no-target-blank": "error", + "react/jsx-fragments": "error", + "react/self-closing-comp": "error", + "react/jsx-no-constructed-context-values": "error", + "react/no-children-prop": "error", + "react/no-danger-with-children": "error", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "error", + "react/no-is-mounted": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/no-unsafe": "error", + "react/no-will-update-set-state": "error", + "react/require-render-return": "error", + "react/style-prop-object": "error", + "react/void-dom-elements-no-children": "error", + "react/error-boundaries": "off", + "react/globals": "off", + "react/immutability": "off", + "react/incompatible-library": "off", + "react/preserve-manual-memoization": "off", + "react/purity": "off", + "react/refs": "off", + "react/set-state-in-effect": "off", + "react/set-state-in-render": "off", + "react/static-components": "off", + "react/unsupported-syntax": "off", + "react/use-memo": "off", + "react/void-use-memo": "off", + "react/checked-requires-onchange-or-readonly": "error", + "react/forward-ref-uses-ref": "error", + "react/iframe-missing-sandbox": "error", + "react/no-unknown-property": "error", + "jsx-a11y/alt-text": "error", + "jsx-a11y/aria-role": "error", + "jsx-a11y/click-events-have-key-events": "error", + "jsx-a11y/control-has-associated-label": [ + "error", + { + "depth": 4, + "ignoreElements": ["audio", "canvas", "embed", "input", "textarea", "tr", "td", "video"] + } + ], + "jsx-a11y/label-has-associated-control": "error", + "jsx-a11y/no-autofocus": "off", + "jsx-a11y/no-noninteractive-element-interactions": "error", + "jsx-a11y/no-static-element-interactions": "error", + "jsx-a11y/prefer-tag-over-role": "off", + "jsx-a11y/anchor-ambiguous-text": "error", + "jsx-a11y/anchor-has-content": "error", + "jsx-a11y/anchor-is-valid": "error", + "jsx-a11y/aria-activedescendant-has-tabindex": "error", + "jsx-a11y/aria-props": "error", + "jsx-a11y/aria-proptypes": "error", + "jsx-a11y/aria-unsupported-elements": "error", + "jsx-a11y/autocomplete-valid": "error", + "jsx-a11y/heading-has-content": "error", + "jsx-a11y/html-has-lang": "error", + "jsx-a11y/iframe-has-title": "error", + "jsx-a11y/img-redundant-alt": "error", + "jsx-a11y/media-has-caption": "error", + "jsx-a11y/no-access-key": "error", + "jsx-a11y/no-aria-hidden-on-focusable": "error", + "jsx-a11y/no-distracting-elements": "error", + "jsx-a11y/no-redundant-roles": "error", + "jsx-a11y/role-has-required-aria-props": "error", + "jsx-a11y/role-supports-aria-props": "error", + "jsx-a11y/scope": "error", + "jsx-a11y/tabindex-no-positive": "error", + "no-lone-blocks": "error", + "typescript/prefer-function-type": "error", + "typescript/prefer-for-of": "error", "trigger/no-thrown-unawaited-redirect": "error", "trigger-prisma/no-unbounded-list-filter": "error", "trigger-prisma/no-unbounded-list-filter-in-args-helper": "error" @@ -55,10 +134,42 @@ { "files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"], "rules": { + "react/button-has-type": "error", + "react/jsx-no-useless-fragment": "error", + "react/no-unstable-nested-components": "error", + "react/error-boundaries": "error", + "react/globals": "error", + "react/hooks": "error", + "react/immutability": "error", + "react/incompatible-library": "error", + "react/memo-dependencies": "error", + "react/no-deriving-state-in-effects": "error", + "react/preserve-manual-memoization": "error", + "react/purity": "error", + "react/refs": "error", + "react/set-state-in-effect": "error", + "react/set-state-in-render": "error", + "react/static-components": "error", + "react/unsupported-syntax": "error", + "react/use-memo": "error", + "react/void-use-memo": "error", + "react/rules-of-hooks": "error", "trigger-runops/no-control-plane-run-graph-access": "error", "trigger-runops/no-control-plane-in-runops-slot": "error" } }, + { + "files": ["packages/react-hooks/src/**/*.ts", "packages/react-hooks/src/**/*.tsx"], + "rules": { + "react/rules-of-hooks": "error" + } + }, + { + "files": ["**/*.ts", "**/*.tsx"], + "rules": { + "no-redeclare": "off" + } + }, { "files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"], "rules": { @@ -72,6 +183,21 @@ "trigger-prisma/no-unbounded-list-filter": "off", "trigger-prisma/no-unbounded-list-filter-in-args-helper": "off" } + }, + { + "files": ["internal-packages/tsql/**"], + "rules": { + "prefer-object-has-own": "off" + } + }, + { + "files": [ + "apps/webapp/app/components/primitives/charts/Chart.tsx", + "apps/webapp/app/components/primitives/Timeline.tsx" + ], + "rules": { + "react/jsx-no-constructed-context-values": "off" + } } ] } diff --git a/.server-changes/appearance-themes-and-options.md b/.server-changes/appearance-themes-and-options.md new file mode 100644 index 00000000000..9f4637763fd --- /dev/null +++ b/.server-changes/appearance-themes-and-options.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +The dashboard has two new themes, Black and White, plus appearance options for stronger colors and underlined links. diff --git a/.server-changes/bound-env-layout-environment-load.md b/.server-changes/bound-env-layout-environment-load.md deleted file mode 100644 index 5c658a99e56..00000000000 --- a/.server-changes/bound-env-layout-environment-load.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Dashboard pages load faster on projects with many preview branches by no longer loading every environment on each page. diff --git a/.server-changes/dequeue-worker-version-freshness.md b/.server-changes/dequeue-worker-version-freshness.md deleted file mode 100644 index d584ef2c56b..00000000000 --- a/.server-changes/dequeue-worker-version-freshness.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Fixed a brief window after promoting or rolling back a deployment where newly triggered runs could still execute on the previous version. New runs now pick up the current version immediately. diff --git a/.server-changes/hide-root-api-key-creation-date.md b/.server-changes/hide-root-api-key-creation-date.md deleted file mode 100644 index 311febd2fbe..00000000000 --- a/.server-changes/hide-root-api-key-creation-date.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Root API keys no longer show an environment creation timestamp as their creation date. diff --git a/.server-changes/org-settings-app-version.md b/.server-changes/org-settings-app-version.md deleted file mode 100644 index 43ae2cc5c34..00000000000 --- a/.server-changes/org-settings-app-version.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -The app version shown on the organization settings page now reports the real version instead of v0.0.0. diff --git a/.server-changes/paused-environment-stays-paused-after-deploy.md b/.server-changes/paused-environment-stays-paused-after-deploy.md deleted file mode 100644 index 4c35b6e2152..00000000000 --- a/.server-changes/paused-environment-stays-paused-after-deploy.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Fix paused environments starting to run work again after a deploy: a paused environment now stays paused until you resume it. diff --git a/.server-changes/reduce-webapp-cpu-on-worker-routes.md b/.server-changes/reduce-webapp-cpu-on-worker-routes.md new file mode 100644 index 00000000000..95a2ce05c30 --- /dev/null +++ b/.server-changes/reduce-webapp-cpu-on-worker-routes.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Cut webapp CPU usage by about a quarter on the routes that workers call most, freeing headroom at the same request rate. Detailed event-loop blocking traces are no longer recorded by default, because producing them was itself a large part of that cost. diff --git a/.server-changes/runs-list-column-customization.md b/.server-changes/runs-list-column-customization.md new file mode 100644 index 00000000000..400b1eec2f8 --- /dev/null +++ b/.server-changes/runs-list-column-customization.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Customize the runs list: show, hide, and reorder columns, and add smart columns that pull a value straight out of a run's payload, metadata, or output. Your column choices are saved in the page URL, so you can share a view, bookmark it, or save it straight to your favorites. diff --git a/.server-changes/settings-back-to-app-org.md b/.server-changes/settings-back-to-app-org.md deleted file mode 100644 index 64464505eea..00000000000 --- a/.server-changes/settings-back-to-app-org.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -The "Back to app" button in organization settings now returns you to that organization instead of your most recently used one. diff --git a/.server-changes/transaction-resilience-during-db-interruptions.md b/.server-changes/transaction-resilience-during-db-interruptions.md deleted file mode 100644 index c05bfd5f22b..00000000000 --- a/.server-changes/transaction-resilience-during-db-interruptions.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: improvement ---- - -Triggering tasks is now more resilient to brief, transient service interruptions, so short stalls are less likely to surface as errors. diff --git a/.server-changes/vercel-version-skew-protection.md b/.server-changes/vercel-version-skew-protection.md new file mode 100644 index 00000000000..08c67b61ab5 --- /dev/null +++ b/.server-changes/vercel-version-skew-protection.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +New Vercel connections now get version skew protection turned on automatically, so each run uses the task version its deployment shipped with. Automatic atomic deployments are deprecated and no longer offered when you connect a project, but stay available in your Vercel integration settings. diff --git a/AGENTS.md b/AGENTS.md index 2a2dea78b55..20de6b692e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,18 @@ pnpm run lint:fix # oxlint โ€” auto-fixes lint violations pnpm run lint # oxlint โ€” check only (no fixes) ``` +### Dead code + +We use knip to control unused dependencies and code. It is enforced by CI `code-quality`. + +Scan your code before pushing with: + +```bash +pnpm run knip +``` + +If there are false positives, edit ./knip.json so that it passes. + ### Imports **Prefer static imports over dynamic imports.** Only use dynamic `import()` when: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 591828c1f79..a7fae66cde1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -238,10 +238,11 @@ This never affects correctness โ€” CI enforces the same checks on every PR; the 1. **Always open your PR in draft status first.** Do not mark it as "Ready for Review" until the steps below are complete. 2. **Run format and lint locally before pushing:** ```bash - pnpm run format # auto-fixes formatting (oxfmt) - pnpm run lint:fix # auto-fixes lint violations (oxlint) + pnpm run format + pnpm run lint + pnpm run knip ``` - Both are enforced by CI โ€” the `code-quality` check will fail if either produces a diff or errors. + These are enforced by CI โ€” the `code-quality` check will fail if either produces a diff or errors. 3. **Address all CodeRabbit code review comments.** Our CI runs an automated code review via CodeRabbit. Go through each comment and either fix the issue or resolve it with a comment explaining why no change is needed. 4. **Wait for all CI checks to pass.** Do not mark the PR as "Ready for Review" until every check is green. 5. **Then mark the PR as "Ready for Review"** so a maintainer can take a look. diff --git a/apps/supervisor/src/backpressure/backpressureMonitor.ts b/apps/supervisor/src/backpressure/backpressureMonitor.ts index aa16fdeaa60..b41601f76fa 100644 --- a/apps/supervisor/src/backpressure/backpressureMonitor.ts +++ b/apps/supervisor/src/backpressure/backpressureMonitor.ts @@ -1,6 +1,6 @@ import type { BackpressureMetrics } from "./backpressureMetrics.js"; -export interface BackpressureLogger { +interface BackpressureLogger { info(message: string, meta?: Record): void; error(message: string, meta?: Record): void; } diff --git a/apps/supervisor/src/clients/kubernetes.ts b/apps/supervisor/src/clients/kubernetes.ts index 1e511a68e6c..7ffb4fec204 100644 --- a/apps/supervisor/src/clients/kubernetes.ts +++ b/apps/supervisor/src/clients/kubernetes.ts @@ -3,7 +3,7 @@ import type { Informer, KubernetesObject, ListPromise } from "@kubernetes/client import { assertExhaustive } from "@trigger.dev/core/utils"; import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; -export const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local"; +const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local"; const logger = new SimpleStructuredLogger("kubernetes-client"); diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 6830d5b8642..f15ef766753 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -1,7 +1,13 @@ import { randomUUID } from "crypto"; import { env as stdEnv } from "std-env"; import { z } from "zod"; -import { AdditionalEnvVars, BoolEnv, NodeLabelValue, Tolerations } from "./envUtil.js"; +import { + AdditionalEnvVars, + BoolEnv, + NodeLabelValue, + OrgPlacementOverrides, + Tolerations, +} from "./envUtil.js"; export const Env = z .object({ @@ -179,6 +185,9 @@ export const Env = z KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"), KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"), KUBERNETES_STRIP_IMAGE_DIGEST: BoolEnv.default(false), + KUBERNETES_IMAGE_REGISTRY_REWRITE_FROM: z.string().optional(), + KUBERNETES_IMAGE_REGISTRY_REWRITE_TO: z.string().optional(), + KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME: z.string().optional(), KUBERNETES_CPU_REQUEST_MIN_CORES: z.coerce.number().min(0).default(0), KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0), @@ -204,6 +213,16 @@ export const Env = z KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods + KUBERNETES_RUNNER_SECCOMP_PROFILE_PATH: z + .string() + .trim() + .min(1) + .default("profiles/block-io-uring.json"), + KUBERNETES_RUNNER_SECCOMP_PROFILE_RUNTIMES: z + .enum(["none", "node-24-plus", "all"]) + .default("node-24-plus"), + KUBERNETES_RUNNER_SECURITY_CONTEXT: z.enum(["off", "baseline", "restricted"]).default("off"), + KUBERNETES_RUNNER_RUN_AS_USER: z.coerce.number().int().min(1).default(1000), // Pod DNS config โ€” override the cluster default ndots to `KUBERNETES_POD_DNS_NDOTS`. // Default k8s ndots is 5: any name with fewer than 5 dots (e.g. `api.example.com`, 2 dots) is first walked @@ -260,6 +279,11 @@ export const Env = z KUBERNETES_RUNNER_TOLERATIONS: Tolerations.optional(), // every run pod KUBERNETES_SCHEDULED_RUN_TOLERATIONS: Tolerations.optional(), // schedule-tree runs only + // Per-org placement overrides, JSON keyed by the internal org id + // (the `org` label on run pods): + // {"": {"nodeSelector": {"": ""}, "tolerations": ""}} + KUBERNETES_ORG_PLACEMENT_OVERRIDES: OrgPlacementOverrides, + // Placement tags settings PLACEMENT_TAGS_ENABLED: BoolEnv.default(false), PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"), @@ -305,6 +329,22 @@ export const Env = z path: ["TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE"], }); } + if (data.KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED && data.KUBERNETES_ORG_PLACEMENT_OVERRIDES) { + // Non-large presets carry a hard NotIn on the large-machine pool, so an org + // pinned to that pool could never schedule its non-large runs. + for (const [orgId, override] of Object.entries(data.KUBERNETES_ORG_PLACEMENT_OVERRIDES)) { + const pinnedPool = + override.nodeSelector?.[data.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY]; + + if (pinnedPool === data.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Org "${orgId}" pins run pods to the large-machine pool, but non-large presets are required to stay off it, so those runs would never schedule. Use a different pool or disable KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED.`, + path: ["KUBERNETES_ORG_PLACEMENT_OVERRIDES"], + }); + } + } + } if (data.COMPUTE_SNAPSHOTS_ENABLED && !data.TRIGGER_METADATA_URL) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/apps/supervisor/src/envUtil.test.ts b/apps/supervisor/src/envUtil.test.ts index 378830f8ab0..9231925303f 100644 --- a/apps/supervisor/src/envUtil.test.ts +++ b/apps/supervisor/src/envUtil.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { BoolEnv, AdditionalEnvVars, NodeLabelValue, Tolerations } from "./envUtil.js"; +import { + BoolEnv, + AdditionalEnvVars, + NodeLabelValue, + OrgPlacementOverrides, + Tolerations, +} from "./envUtil.js"; describe("BoolEnv", () => { it("should parse string 'true' as true", () => { @@ -203,3 +209,125 @@ describe("Tolerations", () => { expect(Tolerations.safeParse("dedicated=runs:NoSchedule:NoExecute").success).toBe(false); }); }); + +describe("OrgPlacementOverrides", () => { + it("should parse a full override with nodeSelector and tolerations", () => { + expect( + OrgPlacementOverrides.parse( + JSON.stringify({ + org_123: { + nodeSelector: { "node.cluster.x-k8s.io/machinepool": "dedicated-pool" }, + tolerations: "dedicated=pool:NoSchedule", + }, + }) + ) + ).toEqual({ + org_123: { + nodeSelector: { "node.cluster.x-k8s.io/machinepool": "dedicated-pool" }, + tolerations: [{ key: "dedicated", operator: "Equal", value: "pool", effect: "NoSchedule" }], + }, + }); + }); + + it("should allow either half to be omitted", () => { + expect( + OrgPlacementOverrides.parse(JSON.stringify({ org_123: { nodeSelector: { pool: "a" } } })) + ).toEqual({ org_123: { nodeSelector: { pool: "a" } } }); + + expect( + OrgPlacementOverrides.parse(JSON.stringify({ org_123: { tolerations: "spot:NoExecute" } })) + ).toEqual({ + org_123: { tolerations: [{ key: "spot", operator: "Exists", effect: "NoExecute" }] }, + }); + + expect(OrgPlacementOverrides.parse(JSON.stringify({ org_123: {} }))).toEqual({ org_123: {} }); + }); + + it("should reject invalid JSON at startup rather than silently skipping the override", () => { + for (const invalid of ["not json", "[]", '"org_123"', "{"]) { + expect(OrgPlacementOverrides.safeParse(invalid).success).toBe(false); + } + }); + + it("should treat a blank or missing value as no overrides, like the sibling settings", () => { + expect(OrgPlacementOverrides.parse(undefined)).toBeUndefined(); + expect(OrgPlacementOverrides.parse("")).toBeUndefined(); + expect(OrgPlacementOverrides.parse(" ")).toBeUndefined(); + }); + + it("should accept tolerations as an array of entries, matching the Helm list shape", () => { + expect( + OrgPlacementOverrides.parse( + JSON.stringify({ + org_123: { tolerations: ["dedicated=pool:NoSchedule", "spot:NoExecute"] }, + }) + ) + ).toEqual({ + org_123: { + tolerations: [ + { key: "dedicated", operator: "Equal", value: "pool", effect: "NoSchedule" }, + { key: "spot", operator: "Exists", effect: "NoExecute" }, + ], + }, + }); + }); + + it("should coerce scalar node selector values to strings, as Kubernetes labels are", () => { + expect( + OrgPlacementOverrides.parse( + JSON.stringify({ org_123: { nodeSelector: { paid: true, replicas: 3 } } }) + ) + ).toEqual({ org_123: { nodeSelector: { paid: "true", replicas: "3" } } }); + }); + + it("should trim whitespace around node selector keys and values", () => { + expect( + OrgPlacementOverrides.parse( + JSON.stringify({ org_123: { nodeSelector: { " pool ": " a " } } }) + ) + ).toEqual({ org_123: { nodeSelector: { pool: "a" } } }); + }); + + it("should reject blank or padded org keys, since the lookup is exact", () => { + for (const key of [" ", " org_123", "org_123 "]) { + expect(OrgPlacementOverrides.safeParse(JSON.stringify({ [key]: {} })).success).toBe(false); + } + }); + + it("should reject an empty node selector value instead of pinning the org to nothing", () => { + for (const value of ["", " "]) { + expect( + OrgPlacementOverrides.safeParse( + JSON.stringify({ org_123: { nodeSelector: { pool: value } } }) + ).success + ).toBe(false); + } + }); + + it("should reject an unknown field, so a typo cannot silently drop an override", () => { + expect( + OrgPlacementOverrides.safeParse( + JSON.stringify({ org_123: { toleration: "dedicated=pool:NoSchedule" } }) + ).success + ).toBe(false); + }); + + it("should reject a node selector key or value Kubernetes would reject", () => { + for (const invalid of [ + { org_123: { nodeSelector: { "bad key": "a" } } }, + { org_123: { nodeSelector: { pool: "bad value" } } }, + { org_123: { nodeSelector: { "a/b/c": "a" } } }, + { org_123: { nodeSelector: { pool: "v".repeat(64) } } }, + ]) { + expect(OrgPlacementOverrides.safeParse(JSON.stringify(invalid)).success).toBe(false); + } + }); + + it("should reject an invalid toleration inside an override", () => { + expect( + OrgPlacementOverrides.safeParse( + JSON.stringify({ org_123: { tolerations: "dedicated=pool:Nope" } }) + ).success + ).toBe(false); + }); +}); diff --git a/apps/supervisor/src/envUtil.ts b/apps/supervisor/src/envUtil.ts index 67811f76fcb..e905141a43e 100644 --- a/apps/supervisor/src/envUtil.ts +++ b/apps/supervisor/src/envUtil.ts @@ -146,6 +146,102 @@ export const Tolerations = z.string().transform((val, ctx) => { }); }); +/** + * Scalar values are coerced: YAML/JSON easily produce `true` or `3` where a label + * value is meant, and Kubernetes label values are always strings. An empty value + * is rejected rather than passed through - as a selector it matches only nodes + * carrying a literal empty-valued label, which pins the org to nothing. + */ +const NodeSelector = z + .record(z.string(), z.union([z.string(), z.number(), z.boolean()])) + .transform((selector, ctx) => { + const result: Record = {}; + + for (const [rawKey, rawValue] of Object.entries(selector)) { + const key = rawKey.trim(); + const value = String(rawValue).trim(); + + if (!isQualifiedName(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid node selector key "${rawKey}". Must be a Kubernetes label key, optionally prefixed with a DNS subdomain.`, + }); + continue; + } + + if (!value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Empty node selector value for key "${key}". Remove the key instead of blanking the value.`, + }); + continue; + } + + if (!isLabelValue(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid node selector value "${value}" for key "${key}". Must be a Kubernetes label value: alphanumeric, with dashes, underscores and dots inside, at most 63 characters.`, + }); + continue; + } + + result[key] = value; + } + + return result; + }); + +/** + * Per-organization placement overrides for run pods, as JSON keyed by the + * internal org id (the `org` label on run pods): + * `{"": {"nodeSelector": {"": ""}, "tolerations": ""}}`. + * Tolerations use the same CSV format as `Tolerations`, or an array of such + * entries. Everything is validated at startup for the same reason as + * tolerations above: a typo would otherwise reject every pod create for that + * org, with the cause buried in API errors. A blank value means no overrides. + */ +export const OrgPlacementOverrides = z + .string() + .optional() + .transform((val, ctx) => { + if (val === undefined || val.trim() === "") { + return undefined; + } + + try { + return JSON.parse(val) as unknown; + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Invalid org placement overrides: not valid JSON", + }); + return z.NEVER; + } + }) + .pipe( + z + .record( + z + .string() + .min(1) + .refine((key) => key === key.trim() && key.trim().length > 0, { + message: + "Org override keys must not be blank or padded with whitespace; the lookup is exact", + }), + z + .object({ + nodeSelector: NodeSelector.optional(), + tolerations: z + .union([z.string(), z.array(z.string())]) + .transform((val) => (Array.isArray(val) ? val.join(",") : val)) + .pipe(Tolerations) + .optional(), + }) + .strict() + ) + .optional() + ); + export const AdditionalEnvVars = z.preprocess((val) => { if (typeof val !== "string") { return val; diff --git a/apps/supervisor/src/wideEvents/index.ts b/apps/supervisor/src/wideEvents/index.ts index 6e61d85896f..e736c4753d2 100644 --- a/apps/supervisor/src/wideEvents/index.ts +++ b/apps/supervisor/src/wideEvents/index.ts @@ -7,18 +7,14 @@ * Off by default behind a kill switch - the dispatch hotpath runs at high * QPS, so logging pressure must be cleanly removable. */ -export { type Env, isValidRequestId, newState, type NewStateOptions } from "./new.js"; -export { emit, EmitMessage } from "./emit.js"; -export { parseTraceId } from "./traceparent.js"; -export { fromContext, wideEventStorage } from "./context.js"; -export { type PhaseOpt, recordPhase, recordPhaseSince, timePhase } from "./record.js"; +export { fromContext } from "./context.js"; +export { recordPhaseSince } from "./record.js"; export { emitOneShot, runWideEvent, setExtra, setMeta, - type WideEventLifecycleOptions, type WideEventOptions, } from "./middleware.js"; -export type { ErrorInfo, PhaseRecord, State } from "./state.js"; +export type { State } from "./state.js"; export { encodeBaggage } from "./baggage.js"; diff --git a/apps/supervisor/src/wideEvents/state.ts b/apps/supervisor/src/wideEvents/state.ts index dece3a3f5fd..f310921aa51 100644 --- a/apps/supervisor/src/wideEvents/state.ts +++ b/apps/supervisor/src/wideEvents/state.ts @@ -76,7 +76,7 @@ export type PhaseRecord = { }; /** Top-level error summary for a failed operation. */ -export type ErrorInfo = { +type ErrorInfo = { code: string; message: string; /** Coarse classification - "client" | "upstream" | "internal" | "timeout". */ diff --git a/apps/supervisor/src/workloadManager/imageRegistry.test.ts b/apps/supervisor/src/workloadManager/imageRegistry.test.ts new file mode 100644 index 00000000000..66f829564c3 --- /dev/null +++ b/apps/supervisor/src/workloadManager/imageRegistry.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { rewriteImageRegistry } from "./imageRegistry.js"; + +const FROM = "123456789012.dkr.ecr.us-east-1.amazonaws.com"; +const TO = "123456789012.dkr.ecr.eu-central-1.amazonaws.com"; + +describe("rewriteImageRegistry", () => { + it("rewrites the registry host and keeps the rest of the reference", () => { + expect(rewriteImageRegistry(`${FROM}/deployments/proj_abc:20260818.1`, FROM, TO)).toBe( + `${TO}/deployments/proj_abc:20260818.1` + ); + }); + + it("preserves a digest", () => { + expect(rewriteImageRegistry(`${FROM}/deployments/proj_abc@sha256:abc123`, FROM, TO)).toBe( + `${TO}/deployments/proj_abc@sha256:abc123` + ); + }); + + it("is a no-op unless both ends are configured", () => { + const ref = `${FROM}/deployments/proj_abc:tag`; + + expect(rewriteImageRegistry(ref, undefined, TO)).toBe(ref); + expect(rewriteImageRegistry(ref, FROM, undefined)).toBe(ref); + expect(rewriteImageRegistry(ref, undefined, undefined)).toBe(ref); + }); + + it("leaves other registries alone", () => { + const ref = "ghcr.io/triggerdotdev/something:tag"; + expect(rewriteImageRegistry(ref, FROM, TO)).toBe(ref); + }); + + it("only matches on a host boundary", () => { + const lookalike = `${FROM}.evil.example.com/deployments/proj_abc:tag`; + expect(rewriteImageRegistry(lookalike, FROM, TO)).toBe(lookalike); + }); + + it("does not rewrite a host that merely contains the source", () => { + const ref = `registry.example.com/${FROM}/proj_abc:tag`; + expect(rewriteImageRegistry(ref, FROM, TO)).toBe(ref); + }); +}); diff --git a/apps/supervisor/src/workloadManager/imageRegistry.ts b/apps/supervisor/src/workloadManager/imageRegistry.ts new file mode 100644 index 00000000000..a49da7f85f3 --- /dev/null +++ b/apps/supervisor/src/workloadManager/imageRegistry.ts @@ -0,0 +1,15 @@ +export function rewriteImageRegistry( + imageRef: string, + from: string | undefined, + to: string | undefined +): string { + if (!from || !to) { + return imageRef; + } + + if (!imageRef.startsWith(`${from}/`)) { + return imageRef; + } + + return `${to}${imageRef.slice(from.length)}`; +} diff --git a/apps/supervisor/src/workloadManager/kubernetes.test.ts b/apps/supervisor/src/workloadManager/kubernetes.test.ts index bb15c23e9f4..e99e292aca1 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.test.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import { - BLOCK_IO_URING_SECCOMP_PROFILE, nodetypeNodeSelector, runPodTolerations, - withBlockIoUringSeccompProfile, + runnerSecurityContext, + withRunnerSeccompProfile, + withNodeSelector, } from "./kubernetesPodSpec.js"; const basePodSpec = { @@ -54,29 +55,134 @@ describe("runPodTolerations", () => { expect(runPodTolerations(worker, [], true)).toEqual(worker); expect(runPodTolerations(worker, scheduled, true)).toEqual([...worker, ...scheduled]); }); + + it("appends the org tolerations regardless of run type", () => { + const org = [{ key: "dedicated", operator: "Equal", value: "org-pool", effect: "NoSchedule" }]; + + expect(runPodTolerations(undefined, undefined, false, org)).toEqual(org); + expect(runPodTolerations(worker, undefined, false, org)).toEqual([...worker, ...org]); + expect(runPodTolerations(worker, scheduled, true, org)).toEqual([ + ...worker, + ...scheduled, + ...org, + ]); + expect(runPodTolerations(undefined, undefined, false, [])).toBeUndefined(); + }); +}); + +describe("withNodeSelector", () => { + const podSpec = { ...basePodSpec, nodeSelector: { nodetype: "v4-worker", paid: "true" } }; + + it("returns the pod spec untouched when there is nothing to merge", () => { + expect(withNodeSelector(podSpec, undefined)).toBe(podSpec); + expect(withNodeSelector(podSpec, {})).toBe(podSpec); + }); + + it("merges extra entries with existing ones", () => { + expect(withNodeSelector(podSpec, { machinepool: "dedicated-pool" })).toEqual({ + ...podSpec, + nodeSelector: { nodetype: "v4-worker", paid: "true", machinepool: "dedicated-pool" }, + }); + }); + + it("lets the extra entries win on key collision", () => { + expect(withNodeSelector(podSpec, { nodetype: "other" }).nodeSelector).toEqual({ + nodetype: "other", + paid: "true", + }); + }); + + it("adds a nodeSelector to a spec that had none", () => { + expect(withNodeSelector(basePodSpec, { machinepool: "dedicated-pool" })).toEqual({ + ...basePodSpec, + nodeSelector: { machinepool: "dedicated-pool" }, + }); + }); }); -describe("withBlockIoUringSeccompProfile", () => { - it("adds the Localhost io_uring profile for node-24 and above, preserving pod security defaults", () => { +describe("withRunnerSeccompProfile", () => { + const base = { + profilePath: "profiles/example.json", + runtimes: "node-24-plus" as const, + runtime: "node-24", + checkpointsEnabled: true, + }; + + const withProfile = { + ...basePodSpec, + securityContext: { + ...basePodSpec.securityContext, + seccompProfile: { type: "Localhost", localhostProfile: "profiles/example.json" }, + }, + }; + + it("applies the profile to node-24 and above under the default scope", () => { for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) { - const podSpec = withBlockIoUringSeccompProfile(basePodSpec, runtime); - - expect(podSpec).toMatchObject({ - ...basePodSpec, - securityContext: { - ...basePodSpec.securityContext, - seccompProfile: { - type: "Localhost", - localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, - }, - }, - }); + expect(withRunnerSeccompProfile(basePodSpec, { ...base, runtime })).toMatchObject( + withProfile + ); } }); - it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => { + it("skips older runtimes under the default scope", () => { for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) { - expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec); + expect(withRunnerSeccompProfile(basePodSpec, { ...base, runtime })).toBe(basePodSpec); + } + }); + + it("applies the profile to every runtime under the all scope", () => { + for (const runtime of ["node", "node-22", "bun", "node-24", undefined]) { + expect( + withRunnerSeccompProfile(basePodSpec, { ...base, runtimes: "all", runtime }) + ).toMatchObject(withProfile); + } + }); + + it("applies nothing under the none scope, whatever the runtime", () => { + for (const runtime of ["node-24", "bun", "node-22"]) { + expect(withRunnerSeccompProfile(basePodSpec, { ...base, runtimes: "none", runtime })).toBe( + basePodSpec + ); + } + }); + + it("applies nothing when checkpoints are disabled", () => { + for (const runtimes of ["none", "node-24-plus", "all"] as const) { + expect( + withRunnerSeccompProfile(basePodSpec, { ...base, runtimes, checkpointsEnabled: false }) + ).toBe(basePodSpec); + } + }); +}); + +describe("runnerSecurityContext", () => { + it("sets nothing when off", () => { + expect(runnerSecurityContext("off", 1000, "node-24")).toBeUndefined(); + }); + + it("drops all capabilities and blocks escalation at baseline", () => { + expect(runnerSecurityContext("baseline", 1000, "node-24")).toEqual({ + allowPrivilegeEscalation: false, + capabilities: { drop: ["ALL"] }, + }); + }); + + it("pins the configured uid when restricted", () => { + expect(runnerSecurityContext("restricted", 1000, "node-24")).toEqual({ + allowPrivilegeEscalation: false, + capabilities: { drop: ["ALL"] }, + runAsNonRoot: true, + runAsUser: 1000, + }); + }); + + it("pins bun's own uid, which differs from node's", () => { + expect(runnerSecurityContext("restricted", 1000, "bun")?.runAsUser).toBe(1001); + }); + + it("falls back to the configured uid when the runtime is unknown", () => { + for (const runtime of [undefined, null, "", "node", "node-22", "node-26"]) { + expect(runnerSecurityContext("restricted", 1000, runtime)?.runAsUser).toBe(1000); } }); }); diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 1b88bafbc28..0394a8181e2 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -17,8 +17,11 @@ import { getRunnerId } from "../util.js"; import { nodetypeNodeSelector, runPodTolerations, - withBlockIoUringSeccompProfile, + runnerSecurityContext, + withRunnerSeccompProfile, + withNodeSelector, } from "./kubernetesPodSpec.js"; +import { rewriteImageRegistry } from "./imageRegistry.js"; type ResourceQuantities = { [K in "cpu" | "memory" | "ephemeral-storage"]?: string; @@ -69,6 +72,12 @@ export class KubernetesWorkloadManager implements WorkloadManager { domain: opts.workloadApiDomain, }); } + + if (env.KUBERNETES_ORG_PLACEMENT_OVERRIDES) { + this.logger.info("[KubernetesWorkloadManager] Org placement overrides enabled", { + orgIds: Object.keys(env.KUBERNETES_ORG_PLACEMENT_OVERRIDES), + }); + } } private addPlacementTags( @@ -110,10 +119,30 @@ export class KubernetesWorkloadManager implements WorkloadManager { const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); try { - const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags); - const podSpec = this.opts.checkpointsEnabled - ? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime) - : basePodSpec; + const orgOverride = env.KUBERNETES_ORG_PLACEMENT_OVERRIDES?.[opts.orgId]; + const taggedPodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags); + const basePodSpec = withNodeSelector(taggedPodSpec, orgOverride?.nodeSelector); + + if (orgOverride?.nodeSelector) { + const replacedKeys = Object.keys(orgOverride.nodeSelector).filter( + (key) => + taggedPodSpec.nodeSelector?.[key] !== undefined && + taggedPodSpec.nodeSelector[key] !== orgOverride.nodeSelector?.[key] + ); + + if (replacedKeys.length > 0) { + this.logger.warn( + "[KubernetesWorkloadManager] Org placement override replaces node selector keys", + { orgId: opts.orgId, replacedKeys } + ); + } + } + const podSpec = withRunnerSeccompProfile(basePodSpec, { + profilePath: env.KUBERNETES_RUNNER_SECCOMP_PROFILE_PATH, + runtimes: env.KUBERNETES_RUNNER_SECCOMP_PROFILE_RUNTIMES, + runtime: opts.runtime, + checkpointsEnabled: this.opts.checkpointsEnabled, + }); await this.k8s.core.createNamespacedPod({ namespace: this.namespace, @@ -131,18 +160,27 @@ export class KubernetesWorkloadManager implements WorkloadManager { spec: { ...podSpec, affinity: this.#getAffinity(opts), - tolerations: this.#getTolerations(this.#isScheduledRun(opts)), + tolerations: this.#getTolerations(this.#isScheduledRun(opts), orgOverride?.tolerations), terminationGracePeriodSeconds: 60 * 60, containers: [ { name: "run-controller", - image: this.stripImageDigest(opts.image), + image: rewriteImageRegistry( + this.stripImageDigest(opts.image), + env.KUBERNETES_IMAGE_REGISTRY_REWRITE_FROM, + env.KUBERNETES_IMAGE_REGISTRY_REWRITE_TO + ), ports: [ { containerPort: 8000, }, ], resources: this.#getResourcesForMachine(opts.machine), + securityContext: runnerSecurityContext( + env.KUBERNETES_RUNNER_SECURITY_CONTEXT, + env.KUBERNETES_RUNNER_RUN_AS_USER, + opts.runtime + ), env: [ { name: "TRIGGER_DEQUEUED_AT_MS", @@ -333,6 +371,11 @@ export class KubernetesWorkloadManager implements WorkloadManager { schedulerName: env.KUBERNETES_SCHEDULER_NAME, } : {}), + ...(env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME + ? { + priorityClassName: env.KUBERNETES_RUN_POD_PRIORITY_CLASS_NAME, + } + : {}), ...nodetypeNodeSelector(env.KUBERNETES_WORKER_NODETYPE_LABEL), ...(env.KUBERNETES_POD_DNS_NDOTS_OVERRIDE_ENABLED ? { @@ -555,11 +598,15 @@ export class KubernetesWorkloadManager implements WorkloadManager { }; } - #getTolerations(isScheduledRun: boolean): k8s.V1Toleration[] | undefined { + #getTolerations( + isScheduledRun: boolean, + orgTolerations?: k8s.V1Toleration[] + ): k8s.V1Toleration[] | undefined { return runPodTolerations( env.KUBERNETES_RUNNER_TOLERATIONS, env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS, - isScheduledRun + isScheduledRun, + orgTolerations ); } diff --git a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts index ba15e563f6d..ddd336d2f2e 100644 --- a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts +++ b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts @@ -1,11 +1,5 @@ import type { k8s } from "../clients/kubernetes.js"; -/** - * Relative path (kubelet seccomp root) of the profile blocking only io_uring - * syscalls. Must match the profile deployed to worker nodes. - */ -export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json"; - /** * An empty label is the documented off-switch, leaving the pod unpinned. The Helm * chart ships an empty value, so don't collapse this into a fallback default - @@ -19,45 +13,116 @@ export function nodetypeNodeSelector( /** * Tolerations for a run pod: the cluster-wide set, plus the scheduled-run set when the - * run came from a schedule tree. Not reconciled - Kubernetes matches tolerations as an - * any-match set, so a broad entry in one set can subsume a narrower one in the other. + * run came from a schedule tree, plus the org's own set when a placement override + * matches. Not reconciled - Kubernetes matches tolerations as an any-match set, so a + * broad entry in one set can subsume a narrower one in another. * Returns undefined rather than an empty array to leave the field unset. */ export function runPodTolerations( runnerTolerations: k8s.V1Toleration[] | undefined, scheduledRunTolerations: k8s.V1Toleration[] | undefined, - isScheduledRun: boolean + isScheduledRun: boolean, + orgTolerations?: k8s.V1Toleration[] ): k8s.V1Toleration[] | undefined { const tolerations = [ ...(runnerTolerations ?? []), ...(isScheduledRun ? (scheduledRunTolerations ?? []) : []), + ...(orgTolerations ?? []), ]; return tolerations.length > 0 ? tolerations : undefined; } /** - * Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking - * io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this, - * so the profile is only applied for node-24+. Tolerates an "experimental-" prefix. + * Merges extra node selector entries into a pod spec. Later entries win on key + * collision, so an override can retarget a key set by an earlier stage. */ -export function withBlockIoUringSeccompProfile( +export function withNodeSelector( podSpec: Omit, - runtime: string | null | undefined + nodeSelector: Record | undefined +): Omit { + if (!nodeSelector || Object.keys(nodeSelector).length === 0) { + return podSpec; + } + + return { + ...podSpec, + nodeSelector: { + ...podSpec.nodeSelector, + ...nodeSelector, + }, + }; +} + +export type RunnerSeccompProfileOptions = { + profilePath: string; + runtimes: "none" | "node-24-plus" | "all"; + runtime: string | null | undefined; + checkpointsEnabled: boolean | undefined; +}; + +/** + * Applies the runner seccomp profile, which is a node-local file installed outside + * this repo - pointing a pod at a profile its node doesn't have fails pod creation, + * so every condition for skipping it lives here. + * + * "node-24-plus" matches the original rollout: node >= 24 always creates io_uring + * fds, which can't be checkpointed, and blocking io_uring_setup makes libuv fall + * back to epoll. Tolerates an "experimental-" prefix. "bun" matches only under "all". + */ +export function withRunnerSeccompProfile( + podSpec: Omit, + options: RunnerSeccompProfileOptions ): Omit { - const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null; - if (!match || Number(match[1]) < 24) { + if (!options.checkpointsEnabled || options.runtimes === "none") { return podSpec; } + if (options.runtimes === "node-24-plus") { + const match = options.runtime ? /^(?:experimental-)?node-(\d+)$/.exec(options.runtime) : null; + if (!match || Number(match[1]) < 24) { + return podSpec; + } + } + return { ...podSpec, securityContext: { ...podSpec.securityContext, seccompProfile: { type: "Localhost", - localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, + localhostProfile: options.profilePath, }, }, }; } + +const BUN_RUN_AS_USER = 1001; + +/** + * runnerSecurityContext maps a configured level onto the run container's security + * context. "baseline" drops the capability bounding set and blocks setuid + * escalation; "restricted" additionally pins the container to a non-root uid. + * + * The uid is set explicitly rather than read from the image: the kubelet cannot + * verify `runAsNonRoot` against an image that declares a named user, and fails + * the container instead. Bun images carry their user at a different uid to + * node's, so the runtime selects which uid is pinned. + */ +export function runnerSecurityContext( + level: "off" | "baseline" | "restricted", + runAsUser: number, + runtime: string | null | undefined +): k8s.V1SecurityContext | undefined { + if (level === "off") { + return undefined; + } + + return { + allowPrivilegeEscalation: false, + capabilities: { drop: ["ALL"] }, + ...(level === "restricted" + ? { runAsNonRoot: true, runAsUser: runtime === "bun" ? BUN_RUN_AS_USER : runAsUser } + : {}), + }; +} diff --git a/apps/supervisor/src/workloadToken.ts b/apps/supervisor/src/workloadToken.ts index d28a6150744..dfac2fbd2e5 100644 --- a/apps/supervisor/src/workloadToken.ts +++ b/apps/supervisor/src/workloadToken.ts @@ -28,7 +28,6 @@ const mintCounter = new Counter({ }); export type WorkloadAuthTransport = "http" | "ws"; -export type WorkloadAuthOutcome = "jwt_valid" | "jwt_invalid" | "legacy_bare" | "token_absent"; const verifyCounter = new Counter({ name: "workload_auth_verify_total", diff --git a/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx b/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx index 3c94426fa03..95a16889e90 100644 --- a/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx +++ b/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx @@ -1,6 +1,6 @@ import { useAnimate } from "framer-motion"; import { HourglassIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; export function AnimatedHourglassIcon({ className, @@ -10,18 +10,21 @@ export function AnimatedHourglassIcon({ delay?: number; }) { const [scope, animate] = useAnimate(); + const initialDelay = useRef(delay); useEffect(() => { - animate( + const controls = animate( [ [scope.current, { rotate: 0 }, { duration: 0.7 }], [scope.current, { rotate: 180 }, { duration: 0.3 }], [scope.current, { rotate: 180 }, { duration: 0.7 }], [scope.current, { rotate: 360 }, { duration: 0.3 }], ], - { repeat: Infinity, delay } + { repeat: Infinity, delay: initialDelay.current } ); - }, []); + + return () => controls.stop(); + }, [animate, scope]); return ; } diff --git a/apps/webapp/app/assets/icons/CircleFilledIcon.tsx b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx new file mode 100644 index 00000000000..a6d10485bee --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx @@ -0,0 +1,16 @@ +/** Solid circle. Paired with {@link CircleOutlineIcon} by the Black and White + * theme options โ€” the filled disc reads as the opposite of the active theme. */ +export function CircleFilledIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx new file mode 100644 index 00000000000..e60de21907f --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx @@ -0,0 +1,16 @@ +/** Hollow circle. Paired with {@link CircleFilledIcon} by the Black and White + * theme options, which show the active theme's background through the ring. */ +export function CircleOutlineIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ColumnsIcon.tsx b/apps/webapp/app/assets/icons/ColumnsIcon.tsx new file mode 100644 index 00000000000..7632be1e780 --- /dev/null +++ b/apps/webapp/app/assets/icons/ColumnsIcon.tsx @@ -0,0 +1,9 @@ +export function ColumnsIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EditPencilIcon.tsx b/apps/webapp/app/assets/icons/EditPencilIcon.tsx new file mode 100644 index 00000000000..18728c03e90 --- /dev/null +++ b/apps/webapp/app/assets/icons/EditPencilIcon.tsx @@ -0,0 +1,22 @@ +/** Pencil over a couple of text lines โ€” editing a value in place. */ +export function EditPencilIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MonitorIcon.tsx b/apps/webapp/app/assets/icons/MonitorIcon.tsx new file mode 100644 index 00000000000..09aae279843 --- /dev/null +++ b/apps/webapp/app/assets/icons/MonitorIcon.tsx @@ -0,0 +1,21 @@ +/** Monitor on a stand โ€” the System theme, which follows the OS appearance. */ +export function MonitorIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoonIcon.tsx b/apps/webapp/app/assets/icons/MoonIcon.tsx new file mode 100644 index 00000000000..f3e20e27f2c --- /dev/null +++ b/apps/webapp/app/assets/icons/MoonIcon.tsx @@ -0,0 +1,21 @@ +/** Crescent moon โ€” the dark theme. */ +export function MoonIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ResetIcon.tsx b/apps/webapp/app/assets/icons/ResetIcon.tsx new file mode 100644 index 00000000000..4fb2f6d7abb --- /dev/null +++ b/apps/webapp/app/assets/icons/ResetIcon.tsx @@ -0,0 +1,20 @@ +export function ResetIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SmartColumnIcon.tsx b/apps/webapp/app/assets/icons/SmartColumnIcon.tsx new file mode 100644 index 00000000000..83a8770acf4 --- /dev/null +++ b/apps/webapp/app/assets/icons/SmartColumnIcon.tsx @@ -0,0 +1,13 @@ +/** Marks a smart column: in the runs table header, the Columns popover, and the dialog preview. */ +export function SmartColumnIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SunIcon.tsx b/apps/webapp/app/assets/icons/SunIcon.tsx new file mode 100644 index 00000000000..1601722dd16 --- /dev/null +++ b/apps/webapp/app/assets/icons/SunIcon.tsx @@ -0,0 +1,29 @@ +export function SunIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx new file mode 100644 index 00000000000..51b8136e1dc --- /dev/null +++ b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx @@ -0,0 +1,23 @@ +/** Toggle switch, knob to the left. */ +export function ToggleSwitchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/images/producthunt.png b/apps/webapp/app/assets/images/producthunt.png deleted file mode 100644 index e27a96f6976..00000000000 Binary files a/apps/webapp/app/assets/images/producthunt.png and /dev/null differ diff --git a/apps/webapp/app/assets/logos/ATAndTLogo.tsx b/apps/webapp/app/assets/logos/ATAndTLogo.tsx deleted file mode 100644 index 505294d3440..00000000000 --- a/apps/webapp/app/assets/logos/ATAndTLogo.tsx +++ /dev/null @@ -1,21 +0,0 @@ -export function ATAndTLogo({ className }: { className?: string }) { - return ( - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/AstroLogo.tsx b/apps/webapp/app/assets/logos/AstroLogo.tsx deleted file mode 100644 index fb51a8f422b..00000000000 --- a/apps/webapp/app/assets/logos/AstroLogo.tsx +++ /dev/null @@ -1,57 +0,0 @@ -export function AstroLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/ExpressLogo.tsx b/apps/webapp/app/assets/logos/ExpressLogo.tsx deleted file mode 100644 index 974e93a718a..00000000000 --- a/apps/webapp/app/assets/logos/ExpressLogo.tsx +++ /dev/null @@ -1,15 +0,0 @@ -export function ExpressLogo({ className }: { className?: string }) { - return ( - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/FastifyLogo.tsx b/apps/webapp/app/assets/logos/FastifyLogo.tsx deleted file mode 100644 index 928df8d6a64..00000000000 --- a/apps/webapp/app/assets/logos/FastifyLogo.tsx +++ /dev/null @@ -1,45 +0,0 @@ -export function FastifyLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/NestjsLogo.tsx b/apps/webapp/app/assets/logos/NestjsLogo.tsx deleted file mode 100644 index d908241d092..00000000000 --- a/apps/webapp/app/assets/logos/NestjsLogo.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export function NestjsLogo({ className }: { className?: string }) { - return ( - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/NextjsLogo.tsx b/apps/webapp/app/assets/logos/NextjsLogo.tsx deleted file mode 100644 index 9e5fa09cc0d..00000000000 --- a/apps/webapp/app/assets/logos/NextjsLogo.tsx +++ /dev/null @@ -1,47 +0,0 @@ -export function NextjsLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/NuxtLogo.tsx b/apps/webapp/app/assets/logos/NuxtLogo.tsx deleted file mode 100644 index e4fe0295bc0..00000000000 --- a/apps/webapp/app/assets/logos/NuxtLogo.tsx +++ /dev/null @@ -1,21 +0,0 @@ -export function NuxtLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/RedwoodLogo.tsx b/apps/webapp/app/assets/logos/RedwoodLogo.tsx deleted file mode 100644 index 6dd0e386ee5..00000000000 --- a/apps/webapp/app/assets/logos/RedwoodLogo.tsx +++ /dev/null @@ -1,42 +0,0 @@ -export function RedwoodLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/RemixLogo.tsx b/apps/webapp/app/assets/logos/RemixLogo.tsx deleted file mode 100644 index be9a10fdaec..00000000000 --- a/apps/webapp/app/assets/logos/RemixLogo.tsx +++ /dev/null @@ -1,199 +0,0 @@ -export function RemixLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/ShopifyLogo.tsx b/apps/webapp/app/assets/logos/ShopifyLogo.tsx deleted file mode 100644 index 86c71de7cfa..00000000000 --- a/apps/webapp/app/assets/logos/ShopifyLogo.tsx +++ /dev/null @@ -1,39 +0,0 @@ -export function ShopifyLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/SveltekitLogo.tsx b/apps/webapp/app/assets/logos/SveltekitLogo.tsx deleted file mode 100644 index 70875e2c58c..00000000000 --- a/apps/webapp/app/assets/logos/SveltekitLogo.tsx +++ /dev/null @@ -1,29 +0,0 @@ -export function SvelteKitLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/assets/logos/VerizonLogo.tsx b/apps/webapp/app/assets/logos/VerizonLogo.tsx deleted file mode 100644 index 908dcb4968c..00000000000 --- a/apps/webapp/app/assets/logos/VerizonLogo.tsx +++ /dev/null @@ -1,33 +0,0 @@ -export function VerizonLogo({ className }: { className?: string }) { - return ( - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index bc64a7e0c42..1bbfab0f0df 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -76,7 +76,7 @@ function useAskAIState() { next.delete(ASK_AI_DEEP_LINK_PARAM); setSearchParams(next); } - }, [searchParams, openAskAI]); + }, [searchParams, setSearchParams, openAskAI]); return { isOpen, setIsOpen, initialQuery, openAskAI, closeAskAI }; } @@ -273,6 +273,7 @@ function ChatMessages({ // Reset feedback state when conversation is reset useEffect(() => { if (conversation.length === 0) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setFeedbackGivenForQAs(new Set()); } }, [conversation.length]); @@ -543,8 +544,12 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) { /> {isGeneratingAnswer ? ( stopGeneration()} className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center" > @@ -553,7 +558,7 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) { className="absolute inset-0 animate-spin" hoverEffect /> - + } content="Stop generating" /> diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index adb5661a4b8..d2d1a4e88b0 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -3,14 +3,11 @@ import { BellAlertIcon, BookOpenIcon, ChatBubbleLeftRightIcon, - ClockIcon, PlusIcon, QuestionMarkCircleIcon, - RectangleGroupIcon, SparklesIcon, Squares2X2Icon, } from "@heroicons/react/20/solid"; -import { useLocation } from "react-use"; import { AIChatIcon } from "~/assets/icons/AIChatIcon"; import { AIPenIcon } from "~/assets/icons/AIPenIcon"; import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; @@ -32,7 +29,6 @@ import { v3CreateBulkActionPath, v3EnvironmentPath, v3NewProjectAlertPath, - v3NewSchedulePath, } from "~/utils/pathBuilder"; import { AskAgentButton } from "./dashboard-agent/AskAgentButton"; import { CodeBlock } from "./code/CodeBlock"; @@ -212,71 +208,6 @@ export function HasNoTasksDeployed({ environment }: { environment: MinimumEnviro return ; } -export function SchedulesNoPossibleTaskPanel() { - return ( - - How to schedule tasks - - } - > - - You have no scheduled tasks in your project. Before you can schedule a task you need to - create a schedules.task. - - - ); -} - -export function SchedulesNoneAttached() { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const location = useLocation(); - - return ( - - - Scheduled tasks will only run automatically if you connect a schedule to them, you can do - this in the dashboard or using the SDK. - -
- - Use the dashboard - - - Use the SDK - -
-
- ); -} - export function BatchesNone() { return ( { // If disabled or no events if (!enabled || streamedEvents === null) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setIsConnected(undefined); return; } @@ -80,7 +81,7 @@ export function DevPresenceProvider({ children, enabled = true }: DevPresencePro // Calculate isConnected and memoize the context value const contextValue = useMemo(() => { return { isConnected }; - }, [isConnected, enabled]); + }, [isConnected]); return {children}; } @@ -113,6 +114,7 @@ export function useCrossEngineIsConnected({ useEffect(() => { if (project.engine === "V2") { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setCrossEngineIsConnected(isConnected); return; } diff --git a/apps/webapp/app/components/ErrorDisplay.tsx b/apps/webapp/app/components/ErrorDisplay.tsx index 4a1e2804807..d0f60840f35 100644 --- a/apps/webapp/app/components/ErrorDisplay.tsx +++ b/apps/webapp/app/components/ErrorDisplay.tsx @@ -34,22 +34,16 @@ export function RouteErrorDisplay(options?: ErrorDisplayOptions) { ); } - return ( - <> - {isRouteErrorResponse(error) ? ( - - ) : error instanceof Error ? ( - - ) : ( - - )} - + return isRouteErrorResponse(error) ? ( + + ) : error instanceof Error ? ( + + ) : ( + ); } @@ -58,7 +52,7 @@ type DisplayOptionsProps = { message?: ReactNode; } & ErrorDisplayOptions; -export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) { +function ErrorDisplay({ title, message, button }: DisplayOptionsProps) { return ( // The backdrop stays dark in every theme (the rotating-logo animation is // dark artwork), so the text pins to the dark-theme colors on light too. diff --git a/apps/webapp/app/components/FeatureBadges.tsx b/apps/webapp/app/components/FeatureBadges.tsx index 706719dc9e7..fe0700e8008 100644 --- a/apps/webapp/app/components/FeatureBadges.tsx +++ b/apps/webapp/app/components/FeatureBadges.tsx @@ -29,15 +29,6 @@ export function AlphaBadge({ ); } -export function AlphaTitle({ children }: { children: React.ReactNode }) { - return ( - <> - {children} - - - ); -} - export function BetaBadge({ inline = false, className }: { inline?: boolean; className?: string }) { return ( - {children} - - - ); -} - export function NewBadge({ inline = false, className }: { inline?: boolean; className?: string }) { return ( { const open = searchParams.get("feedbackPanel"); if (open) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setType(open as FeedbackType); setOpen(true); // Clone instead of mutating in place @@ -83,7 +84,7 @@ export function Feedback({ next.delete("feedbackPanel"); setSearchParams(next); } - }, [searchParams]); + }, [searchParams, setOpen, setSearchParams]); // Reset the topic to the default once the dialog closes, so reopening always starts fresh. The // dialog is now persistently mounted (hosted outside the popover), so without this it would keep diff --git a/apps/webapp/app/components/GitHubLoginButton.tsx b/apps/webapp/app/components/GitHubLoginButton.tsx index 76a494927cd..531cbd8f594 100644 --- a/apps/webapp/app/components/GitHubLoginButton.tsx +++ b/apps/webapp/app/components/GitHubLoginButton.tsx @@ -1,30 +1,3 @@ -import { cn } from "~/utils/cn"; - -type GitHubLoginButtonProps = { - label?: string; - className?: string; - onClick?: () => void; -}; - -export function GitHubLoginButton({ - label = "Continue with GitHub", - className, - onClick, -}: GitHubLoginButtonProps) { - return ( - - ); -} - export function OctoKitty({ className }: { className?: string }) { return ( ; -}) { +function GitMetadataBranch({ git }: { git: Pick }) { return ( ; @@ -62,7 +58,7 @@ export function GitMetadataCommit({ ); } -export function GitMetadataPullRequest({ +function GitMetadataPullRequest({ git, }: { git: Pick; diff --git a/apps/webapp/app/components/LoginPageLayout.tsx b/apps/webapp/app/components/LoginPageLayout.tsx index 1db614eb926..a4f2d197517 100644 --- a/apps/webapp/app/components/LoginPageLayout.tsx +++ b/apps/webapp/app/components/LoginPageLayout.tsx @@ -47,6 +47,7 @@ export function LoginPageLayout({ const [randomQuote, setRandomQuote] = useState(null); useEffect(() => { const randomIndex = Math.floor(Math.random() * quotes.length); + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setRandomQuote(quotes[randomIndex]); }, []); diff --git a/apps/webapp/app/components/MachineLabelCombo.tsx b/apps/webapp/app/components/MachineLabelCombo.tsx index 485f6094cf0..29ce5e399c2 100644 --- a/apps/webapp/app/components/MachineLabelCombo.tsx +++ b/apps/webapp/app/components/MachineLabelCombo.tsx @@ -23,7 +23,7 @@ export function MachineLabelCombo({ ); } -export function MachineLabel({ +function MachineLabel({ preset, className, }: { diff --git a/apps/webapp/app/components/ProductHuntBanner.tsx b/apps/webapp/app/components/ProductHuntBanner.tsx deleted file mode 100644 index abb5a146355..00000000000 --- a/apps/webapp/app/components/ProductHuntBanner.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import productHuntLogo from "../assets/images/producthunt.png"; -import { ArrowRightIcon } from "@heroicons/react/20/solid"; -import { Paragraph } from "./primitives/Paragraph"; -import { LinkButton } from "./primitives/Buttons"; - -export function ProductHuntBanner() { - return ( -
- - We're live on{" "} - - Product Hunt - - - Vote for us today only! - -
- ); -} diff --git a/apps/webapp/app/components/SetupCommands.tsx b/apps/webapp/app/components/SetupCommands.tsx index 9b13a506deb..1e9cda1255e 100644 --- a/apps/webapp/app/components/SetupCommands.tsx +++ b/apps/webapp/app/components/SetupCommands.tsx @@ -1,5 +1,5 @@ import { CheckIcon, SparklesIcon } from "@heroicons/react/20/solid"; -import { createContext, useContext, useRef, useState } from "react"; +import { createContext, useContext, useMemo, useRef, useState } from "react"; import { useAppOrigin } from "~/hooks/useAppOrigin"; import { useProject } from "~/hooks/useProject"; import { useTriggerCliTag } from "~/hooks/useTriggerCliTag"; @@ -24,10 +24,13 @@ const PackageManagerContext = createContext ({ activePackageManager, setActivePackageManager }), + [activePackageManager] + ); + return ( - - {children} - + {children} ); } @@ -54,7 +57,7 @@ function useApiUrl() { } } -function getApiUrlArg() { +function useApiUrlArg() { const apiUrl = useApiUrl(); return apiUrl ? `-a ${apiUrl}` : undefined; } @@ -67,7 +70,7 @@ type TabsProps = { export function InitCommandV3({ title }: TabsProps) { const project = useProject(); const projectRef = project.externalRef; - const apiUrlArg = getApiUrlArg(); + const apiUrlArg = useApiUrlArg(); const triggerCliTag = useTriggerCliTag(); const initCommandParts = [`trigger.dev@${triggerCliTag}`, "init", `-p ${projectRef}`, apiUrlArg]; @@ -243,52 +246,6 @@ export function TriggerDevStepV3({ title }: TabsProps) { ); } -export function TriggerLoginStepV3({ title }: TabsProps) { - const triggerCliTag = useTriggerCliTag(); - const { activePackageManager, setActivePackageManager } = usePackageManager(); - - return ( - -
- {title && {title}} - - npm - pnpm - yarn - -
- - - - - - - - - -
- ); -} - export function TriggerDeployStep({ title, environment, diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 35147565d94..29fc0601fea 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -6,6 +6,7 @@ import { ASK_AI_SHORTCUT, askAiCanOpen } from "~/components/dashboard-agent/ask- import { useDashboardAgentAvailable } from "~/components/dashboard-agent/dashboardAgentOpenRequest"; import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader"; import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher"; +import { COLUMNS_SHORTCUT } from "~/components/runs/v3/RunsDisplayOptions"; import { useAskAiAvailability } from "~/hooks/useAskAiAvailability"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; import { Header3 } from "./primitives/Headers"; @@ -142,6 +143,9 @@ function ShortcutContent() { )}
Runs page + + + diff --git a/apps/webapp/app/components/TriggerRotatingLogo.tsx b/apps/webapp/app/components/TriggerRotatingLogo.tsx index 878c203a3ca..e82389f49ba 100644 --- a/apps/webapp/app/components/TriggerRotatingLogo.tsx +++ b/apps/webapp/app/components/TriggerRotatingLogo.tsx @@ -25,6 +25,7 @@ export function TriggerRotatingLogo() { useEffect(() => { // Already registered from a previous render if (customElements.get("spline-viewer")) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setIsSplineReady(true); return; } diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx index 88710370082..25d030f6c48 100644 --- a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -1,5 +1,5 @@ import { useFetcher } from "@remix-run/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import stableStringify from "json-stable-stringify"; import { Dialog, @@ -54,6 +54,10 @@ export function FeatureFlagsDialog({ }: FeatureFlagsDialogProps) { const loadFetcher = useFetcher(); const saveFetcher = useFetcher(); + const loadFeatureFlags = loadFetcher.load; + const onOpenChangeRef = useRef(onOpenChange); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. + onOpenChangeRef.current = onOpenChange; const [overrides, setOverrides] = useState>({}); const [initialOverrides, setInitialOverrides] = useState>({}); @@ -64,16 +68,18 @@ export function FeatureFlagsDialog({ useEffect(() => { if (open && orgId) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setSaveError(null); setOverrides({}); setInitialOverrides({}); - loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`); + loadFeatureFlags(`/admin/api/v2/orgs/${orgId}/feature-flags`); } - }, [open, orgId]); + }, [loadFeatureFlags, open, orgId]); useEffect(() => { if (loadFetcher.data) { const loaded = loadFetcher.data.orgFlags ?? {}; + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setOverrides({ ...loaded }); setInitialOverrides({ ...loaded }); } @@ -81,8 +87,9 @@ export function FeatureFlagsDialog({ useEffect(() => { if (saveFetcher.data?.success) { - onOpenChange(false); + onOpenChangeRef.current(false); } else if (saveFetcher.data?.error) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setSaveError(saveFetcher.data.error); } }, [saveFetcher.data]); diff --git a/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts index 7f137c61d0c..b22def305aa 100644 --- a/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts +++ b/apps/webapp/app/components/admin/backOffice/ApiRateLimitSection.server.ts @@ -12,7 +12,7 @@ import { } from "./RateLimitSection.server"; import type { EffectiveRateLimit } from "./RateLimitSection"; -export const apiRateLimitDomain: RateLimitDomain = { +const apiRateLimitDomain: RateLimitDomain = { intent: API_RATE_LIMIT_INTENT, systemDefault: () => ({ type: "tokenBucket", diff --git a/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts b/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts index 4614c5b2893..af05ace3978 100644 --- a/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts +++ b/apps/webapp/app/components/admin/backOffice/BatchRateLimitSection.server.ts @@ -12,7 +12,7 @@ import { } from "./RateLimitSection.server"; import type { EffectiveRateLimit } from "./RateLimitSection"; -export const batchRateLimitDomain: RateLimitDomain = { +const batchRateLimitDomain: RateLimitDomain = { intent: BATCH_RATE_LIMIT_INTENT, systemDefault: () => ({ type: "tokenBucket", diff --git a/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx index 6b0185e33a1..f1fb574baa2 100644 --- a/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx +++ b/apps/webapp/app/components/admin/backOffice/MaxProjectsSection.tsx @@ -34,10 +34,12 @@ export function MaxProjectsSection({ const [value, setValue] = useState(String(maximumProjectCount)); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. if (hasFieldErrors) setIsEditing(true); }, [hasFieldErrors]); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. if (savedJustNow && !hasFieldErrors) setIsEditing(false); }, [savedJustNow, hasFieldErrors]); diff --git a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx index 09d51e69fa3..9e84e40c110 100644 --- a/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx +++ b/apps/webapp/app/components/admin/backOffice/RateLimitSection.tsx @@ -12,7 +12,7 @@ import * as Property from "~/components/primitives/PropertyTable"; // view. Decoupled from the .server module so the component stays client-safe. // Duration fields are always suffixed strings โ€” the server's DurationSchema // rejects anything else, so non-string overrides fall back to the default. -export type RateLimitConfig = +type RateLimitConfig = | { type: "tokenBucket"; refillRate: number; @@ -30,7 +30,7 @@ export type EffectiveRateLimit = { config: RateLimitConfig; }; -export type FieldErrors = Record | null; +type FieldErrors = Record | null; // Props shared by every per-domain wrapper (Api / Batch / future ones). export type RateLimitWrapperProps = { @@ -65,10 +65,12 @@ export function RateLimitSection({ const [maxTokens, setMaxTokens] = useState(current ? String(current.maxTokens) : ""); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. if (hasFieldErrors) setIsEditing(true); }, [hasFieldErrors]); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. if (savedJustNow && !hasFieldErrors) setIsEditing(false); }, [savedJustNow, hasFieldErrors]); diff --git a/apps/webapp/app/components/admin/debugRun.tsx b/apps/webapp/app/components/admin/debugRun.tsx index 049c5cd08c3..6e8d4b79573 100644 --- a/apps/webapp/app/components/admin/debugRun.tsx +++ b/apps/webapp/app/components/admin/debugRun.tsx @@ -31,7 +31,7 @@ export function AdminDebugRun({ friendlyId }: { friendlyId: string }) { ); } -export function DebugRunDialog({ friendlyId }: { friendlyId: string }) { +function DebugRunDialog({ friendlyId }: { friendlyId: string }) { return ( (); const isLoading = fetcher.state === "loading"; + const load = fetcher.load; useEffect(() => { - fetcher.load(`/resources/taskruns/${friendlyId}/debug`); - }, [friendlyId]); + load(`/resources/taskruns/${friendlyId}/debug`); + }, [friendlyId, load]); return ( <> diff --git a/apps/webapp/app/components/billing/BillingAlertsSection.tsx b/apps/webapp/app/components/billing/BillingAlertsSection.tsx index ce2f5e5e0fc..47930cb54f2 100644 --- a/apps/webapp/app/components/billing/BillingAlertsSection.tsx +++ b/apps/webapp/app/components/billing/BillingAlertsSection.tsx @@ -119,6 +119,7 @@ export function BillingAlertsSection({ return; } + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setShowResetBanner(true); if (searchParams.get("alertsReset") !== "1") { @@ -140,14 +141,18 @@ export function BillingAlertsSection({ ); const maxAlerts = isPercentageMode ? MAX_PERCENTAGE_ALERTS : MAX_ABSOLUTE_ALERTS; + /* oxlint-disable react/preserve-manual-memoization -- Stable derived thresholds prevent the synchronization effect from resetting local edits. */ const savedThresholds = useMemo( () => storedAlertsToThresholds(alerts, billingLimitMode, effectiveLimitCents, planLimitCents), [alerts, billingLimitMode, effectiveLimitCents, planLimitCents] ); + /* oxlint-enable react/preserve-manual-memoization */ const savedEmails = useMemo(() => alerts.emails, [alerts.emails]); - const hasLegacySpikes = useMemo( - () => hasLegacySpikeAlertLevels(alerts, billingLimitMode, effectiveLimitCents, planLimitCents), - [alerts, billingLimitMode, effectiveLimitCents, planLimitCents] + const hasLegacySpikes = hasLegacySpikeAlertLevels( + alerts, + billingLimitMode, + effectiveLimitCents, + planLimitCents ); const nextThresholdIdRef = useRef(savedThresholds.length); @@ -185,6 +190,7 @@ export function BillingAlertsSection({ useEffect(() => { nextThresholdIdRef.current = savedThresholds.length; + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setThresholdRows(toThresholdRows(savedThresholds)); setEmailValues(savedEmails.length > 0 ? [...savedEmails, ""] : [""]); }, [savedThresholds, savedEmails]); diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index e6362d4cb1d..3aa23807620 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -20,6 +20,7 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; import type { BillingLimitResult } from "~/services/billingLimit.schemas"; import { formatCurrency } from "~/utils/numberFormatter"; +import { TextLink } from "~/components/primitives/TextLink"; export const billingLimitFormSchema = z.discriminatedUnion("mode", [ z.object({ @@ -126,6 +127,7 @@ export function BillingLimitConfigSection({ const formRef = useRef(null); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setMode(resetMode); setCustomAmount(savedCustomAmount); setCancelInProgressRuns(savedCancelInProgressRuns); @@ -338,10 +340,7 @@ function LimitReachedCalloutContent({ When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers will be rejected until you increase or remove the limit. Limits are enforced with a short delay, so spend may briefly exceed the limit before grace begins. See our{" "} - - terms - {" "} - for refund policy details. + terms for refund policy details. {cancelInProgressRuns ? ( <> In-progress runs will be cancelled when the limit is hit. ) : null} diff --git a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx index 1dfcdd73707..2ed56fd26e4 100644 --- a/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx +++ b/apps/webapp/app/components/billing/BillingLimitRecoveryPanel.tsx @@ -63,6 +63,7 @@ export function BillingLimitRecoveryPanel({ const formRef = useRef(null); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect, react/no-deriving-state-in-effects -- A refreshed server recommendation intentionally resets this editable amount draft. setNewAmount(String(suggestedNewLimitDollars)); }, [suggestedNewLimitDollars]); diff --git a/apps/webapp/app/components/billing/FreePlanUsage.tsx b/apps/webapp/app/components/billing/FreePlanUsage.tsx index d1f0716567c..a73e3a8a411 100644 --- a/apps/webapp/app/components/billing/FreePlanUsage.tsx +++ b/apps/webapp/app/components/billing/FreePlanUsage.tsx @@ -1,6 +1,7 @@ import { ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { Link } from "@remix-run/react"; import { motion, useMotionValue, useTransform } from "framer-motion"; +import { textLinkClassName } from "~/components/primitives/TextLink"; import { useThemeColor } from "~/hooks/useThemeColor"; import { cn } from "~/utils/cn"; @@ -31,7 +32,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb Free Plan
- + Upgrade diff --git a/apps/webapp/app/components/billing/OrgBanner.tsx b/apps/webapp/app/components/billing/OrgBanner.tsx index 03214bab7ec..acf10f2469d 100644 --- a/apps/webapp/app/components/billing/OrgBanner.tsx +++ b/apps/webapp/app/components/billing/OrgBanner.tsx @@ -157,14 +157,14 @@ function NoLimitConfiguredBanner() { to={v3BillingLimitsPath(organization)} > - Configure billing limit + Billing limit settings ) : undefined } > {canManageBillingLimits - ? "Protect your organization from unexpected usage spikes." + ? "Add a billing limit to your account to prevent overspending" : "Billing limits are not configured for this organization. Contact an organization administrator to configure them."} ); diff --git a/apps/webapp/app/components/billing/UsageBar.tsx b/apps/webapp/app/components/billing/UsageBar.tsx index fcb6377757c..6cc5f6f93de 100644 --- a/apps/webapp/app/components/billing/UsageBar.tsx +++ b/apps/webapp/app/components/billing/UsageBar.tsx @@ -106,7 +106,7 @@ type LegendProps = { function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) { const flipLegendPositionValue = 80; - const flipLegendPosition = percentage > flipLegendPositionValue ? true : false; + const flipLegendPosition = percentage > flipLegendPositionValue; return (
0; } -export function hasSavedAlertThresholds(alerts: BillingAlertsFormData): boolean { - return alerts.alertLevels.length > 0; -} - /** Saved thresholds that would be cleared when the billing limit alert format changes. */ export function hadSavedAlertsToClearOnLimitChange( alerts: BillingAlertsFormData, @@ -80,7 +65,7 @@ export function hadSavedAlertsToClearOnLimitChange( return hasConfiguredAlerts(alerts, billingLimit, planLimitCents); } -export function normalizeThresholdValues(values: number[]): number[] { +function normalizeThresholdValues(values: number[]): number[] { return [...values].sort((a, b) => a - b); } @@ -89,7 +74,7 @@ export function thresholdValuesAreUnique(values: number[]): boolean { return new Set(normalized).size === normalized.length; } -export function normalizeEmailValues(values: string[]): string[] { +function normalizeEmailValues(values: string[]): string[] { return values.map((value) => value.trim()).filter(Boolean); } @@ -235,33 +220,6 @@ export function isLegacyDollarAmountField( return rawAmount === planDollars || rawAmount === effectiveDollars; } -export function isAbsoluteSavedAlerts(alerts: BillingAlertsFormData): boolean { - return getSavedAlertAmountCents(alerts) === ABSOLUTE_ALERT_BASE_CENTS; -} - -/** Build a cleaned alerts payload when saving billing limits in the same alert format. */ -export function buildCleanedAlertsPayloadForLimitSave( - alerts: BillingAlertsFormData, - nextMode: BillingLimitMode, - effectiveLimitCents: number, - planLimitCents: number -): { amount: number; alertLevels: number[]; emails: string[] } | null { - if (alerts.alertLevels.length === 0) { - return null; - } - - const thresholds = storedAlertsToThresholds( - alerts, - nextMode, - effectiveLimitCents, - planLimitCents - ); - - return { - emails: alerts.emails, - ...thresholdsToAlertPayload(thresholds, nextMode, effectiveLimitCents), - }; -} /** Convert stored percentage alert levels to UI percent values (10, 50, 80). */ export function percentageAlertLevelsToUiThresholds(levels: number[]): number[] { @@ -386,10 +344,6 @@ export function thresholdsToAlertPayload( }; } -export function isEmptyThreshold(value: number): boolean { - return !Number.isFinite(value) || value <= 0; -} - export function previewDollarAmountForPercent( percent: number, effectiveLimitCents: number diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx index f9ceb3384ab..0670dc7c8a2 100644 --- a/apps/webapp/app/components/code/AIQueryInput.tsx +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -61,10 +61,44 @@ export function AIQueryInput({ // If mode is edit but there's no current query, switch to new useEffect(() => { if (mode === "edit" && !canEdit) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setMode("new"); } }, [mode, canEdit]); + const processStreamEvent = useCallback( + (event: StreamEventType) => { + switch (event.type) { + case "thinking": + setThinking((prev) => prev + event.content); + break; + case "tool_call": + // Tool calls are handled silently โ€” no UI text needed + break; + case "time_filter": + // Apply time filter immediately when the AI sets it + onTimeFilterChange?.(event.filter); + break; + case "result": + if (event.success) { + // Apply time filter if included in result (backup in case time_filter event was missed) + if (event.timeFilter) { + onTimeFilterChange?.(event.timeFilter); + } + onQueryGenerated(event.query); + setPrompt(""); + setLastResult("success"); + // Keep thinking visible to show what happened + } else { + setError(event.error); + setLastResult("error"); + } + break; + } + }, + [onQueryGenerated, onTimeFilterChange] + ); + const submitQuery = useCallback( async (queryPrompt: string, submitMode: AIQueryMode = mode) => { if (!queryPrompt.trim() || isLoading) return; @@ -158,40 +192,7 @@ export function AIQueryInput({ setIsLoading(false); } }, - [isLoading, resourcePath, mode, getCurrentQuery] - ); - - const processStreamEvent = useCallback( - (event: StreamEventType) => { - switch (event.type) { - case "thinking": - setThinking((prev) => prev + event.content); - break; - case "tool_call": - // Tool calls are handled silently โ€” no UI text needed - break; - case "time_filter": - // Apply time filter immediately when the AI sets it - onTimeFilterChange?.(event.filter); - break; - case "result": - if (event.success) { - // Apply time filter if included in result (backup in case time_filter event was missed) - if (event.timeFilter) { - onTimeFilterChange?.(event.timeFilter); - } - onQueryGenerated(event.query); - setPrompt(""); - setLastResult("success"); - // Keep thinking visible to show what happened - } else { - setError(event.error); - setLastResult("error"); - } - break; - } - }, - [onQueryGenerated, onTimeFilterChange] + [getCurrentQuery, isLoading, mode, processStreamEvent, resourcePath] ); const handleSubmit = useCallback( diff --git a/apps/webapp/app/components/code/ChartConfigPanel.tsx b/apps/webapp/app/components/code/ChartConfigPanel.tsx index 7711f063d55..c1d67312ee7 100644 --- a/apps/webapp/app/components/code/ChartConfigPanel.tsx +++ b/apps/webapp/app/components/code/ChartConfigPanel.tsx @@ -568,6 +568,7 @@ function SeriesColorPicker({
)} @@ -400,7 +400,7 @@ export const CodeBlock = forwardRef( {shouldHighlight ? ( ( className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control" >
-                  {highlightSearchText(code, searchTerm)}
+                  {highlightSearchText(normalizedCode, searchTerm)}
                 
)} @@ -439,12 +439,12 @@ function Chrome({ title }: { title?: string }) {
{title}
-
+
); } -export function TitleRow({ title }: { title: ReactNode }) { +function TitleRow({ title }: { title: ReactNode }) { return (
diff --git a/apps/webapp/app/components/code/InstallPackages.tsx b/apps/webapp/app/components/code/InstallPackages.tsx deleted file mode 100644 index 791d101daa9..00000000000 --- a/apps/webapp/app/components/code/InstallPackages.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - ClientTabs, - ClientTabsList, - ClientTabsTrigger, - ClientTabsContent, -} from "../primitives/ClientTabs"; -import { ClipboardField } from "../primitives/ClipboardField"; - -type InstallPackagesProps = { - packages: string[]; -}; - -export function InstallPackages({ packages }: InstallPackagesProps) { - return ( - - - npm - pnpm - yarn - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/components/code/JSONEditor.tsx b/apps/webapp/app/components/code/JSONEditor.tsx index b4c3f7a6ed3..7f13c14ca4b 100644 --- a/apps/webapp/app/components/code/JSONEditor.tsx +++ b/apps/webapp/app/components/code/JSONEditor.tsx @@ -94,6 +94,7 @@ export function JSONEditor(opts: JSONEditorProps) { const editor = useRef(null); const settings: Omit = { ...opts, + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. container: editor.current, extensions, editable: !readOnly, diff --git a/apps/webapp/app/components/code/TSQLEditor.tsx b/apps/webapp/app/components/code/TSQLEditor.tsx index 372c2be129c..03af976265d 100644 --- a/apps/webapp/app/components/code/TSQLEditor.tsx +++ b/apps/webapp/app/components/code/TSQLEditor.tsx @@ -196,6 +196,7 @@ export function TSQLEditor(opts: TSQLEditorProps) { const settings: Omit = { ...opts, + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. container: editor.current, extensions, editable: !readOnly, @@ -264,6 +265,7 @@ export function TSQLEditor(opts: TSQLEditorProps) { const showButtons = showClearButton || showCopyButton || showFormatButton || additionalActions; + /* oxlint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- The CodeMirror mount forwards pointer focus to CodeMirror's own keyboard-accessible editor. */ return (
); } +/* oxlint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ // SQL keywords that legitimately appear before parentheses with a space const SQL_KEYWORDS_BEFORE_PAREN = new Set([ diff --git a/apps/webapp/app/components/code/TSQLResultsTable.tsx b/apps/webapp/app/components/code/TSQLResultsTable.tsx index 87d2003d385..b5e9fc80c91 100644 --- a/apps/webapp/app/components/code/TSQLResultsTable.tsx +++ b/apps/webapp/app/components/code/TSQLResultsTable.tsx @@ -14,6 +14,7 @@ import { type ColumnFiltersState, type ColumnResizeMode, type FilterFn, + type Header, type SortDirection, type SortingState, } from "@tanstack/react-table"; @@ -223,6 +224,7 @@ const DebouncedInput = forwardRef< const [value, setValue] = useState(initialValue); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect, react/no-deriving-state-in-effects -- Programmatic filter changes intentionally reset the debounced input draft. setValue(initialValue); }, [initialValue]); @@ -241,6 +243,7 @@ const DebouncedInput = forwardRef< interface ColumnMeta { outputColumn: OutputColumnMetadata; alignment: "left" | "right"; + prettyFormatting: boolean; } /** @@ -489,6 +492,19 @@ function CellValueWrapper({ /** * Render a cell value based on its type and optional customRenderType */ +function TSQLResultsCell(info: CellContext) { + const meta = info.column.columnDef.meta as ColumnMeta; + + return ( + + ); +} + function CellValue({ value, column, @@ -829,6 +845,37 @@ function CopyableCell({ const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(value); + // The button (with its aria-label) always sits in the same position in the tree, wrapped by + // the same SimpleTooltip, so it is never unmounted/remounted on hover (which would drop + // keyboard focus). The tooltip is left uncontrolled so Radix opens it only when the pointer or + // keyboard focus is actually on the button, not whenever the pointer is anywhere in this + // virtualized grid's cell. `focus-visible:` (not `focus:`) ensures keyboard focus reveals the + // button without leaving it visible after a mouse click moves outside the cell. + const copyButton = ( + + ); + return (
setIsHovered(false)} > {children} - {isHovered && ( - { - e.stopPropagation(); - e.preventDefault(); - copy(); - }} - className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer" - > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - disableHoverableContent - /> - - )} +
); } @@ -906,6 +929,8 @@ function HeaderCellContent({ const sortHighlighted = isCellHovered && !isFilterHovered; + /* oxlint-disable jsx-a11y/click-events-have-key-events -- The sortable header contains separate tooltip and filter controls that cannot be nested in a button. */ + /* oxlint-disable jsx-a11y/no-static-element-interactions -- Preserve the existing full-header pointer target rather than nesting its child controls. */ return (
{children} - + event.stopPropagation()}> {children} )} - {/* Sort indicator */} + {/* The full header remains a pointer target, while this dedicated control makes sorting keyboard-accessible without nesting the tooltip or filter controls. */} {canSort && ( - { + event.stopPropagation(); + onSortClick?.(event); + }} className={cn( - "shrink-0 transition-colors", + "shrink-0 rounded transition-colors focus-custom", sortHighlighted ? "text-text-bright" : "text-text-dimmed" )} > @@ -952,10 +983,11 @@ function HeaderCellContent({ ) : ( )} - + )} {onFilterClick && (
); } +/* oxlint-enable jsx-a11y/click-events-have-key-events */ +/* oxlint-enable jsx-a11y/no-static-element-interactions */ /** * Filter input cell for the filter row @@ -1013,6 +1047,24 @@ function FilterCell({ ); } +/* oxlint-disable jsx-a11y/no-static-element-interactions -- Column resizing is a pointer-drag interaction provided by TanStack Table. */ +function ColumnResizeHandle({ header }: { header: Header }) { + return ( +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cn( + "absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none", + "opacity-0 group-hover/header:opacity-100", + "bg-surface-control hover:bg-indigo-500", + header.column.getIsResizing() && "bg-indigo-500 opacity-100" + )} + /> + ); +} +/* oxlint-enable jsx-a11y/no-static-element-interactions */ + export const TSQLResultsTable = memo(function TSQLResultsTable({ rows, columns, @@ -1053,17 +1105,11 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ id: col.name, accessorKey: col.name, header: () => col.name, - cell: (info: CellContext) => ( - - ), + cell: TSQLResultsCell, meta: { outputColumn: col, alignment: isRightAlignedColumn(col) ? "right" : "left", + prettyFormatting, } as ColumnMeta, size: calculateColumnWidth(col.name, rows, col), filterFn: fuzzyFilter, @@ -1075,6 +1121,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ // Column resize mode: 'onChange' for real-time feedback, 'onEnd' for performance const columnResizeMode: ColumnResizeMode = "onChange"; + // oxlint-disable-next-line react/incompatible-library -- TanStack Table is not compatible with compiler memoization. const table = useReactTable({ data: rows, columns: columnDefs, @@ -1211,18 +1258,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ > {flexRender(header.column.columnDef.header, header.getContext())} - {/* Column resizer */} -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={cn( - "absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none", - "opacity-0 group-hover/header:opacity-100", - "bg-surface-control hover:bg-indigo-500", - header.column.getIsResizing() && "bg-indigo-500 opacity-100" - )} - /> + ); })} diff --git a/apps/webapp/app/components/code/TextEditor.tsx b/apps/webapp/app/components/code/TextEditor.tsx index db16b996462..b86d3aacedf 100644 --- a/apps/webapp/app/components/code/TextEditor.tsx +++ b/apps/webapp/app/components/code/TextEditor.tsx @@ -48,6 +48,7 @@ export function TextEditor(opts: TextEditorProps) { const editor = useRef(null); const settings: Omit = { ...opts, + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. container: editor.current, extensions, editable: !readOnly, diff --git a/apps/webapp/app/components/code/tsql/index.ts b/apps/webapp/app/components/code/tsql/index.ts deleted file mode 100644 index 71c543161d8..00000000000 --- a/apps/webapp/app/components/code/tsql/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// TSQL CodeMirror support -// Provides syntax highlighting, autocompletion, and linting for TSQL queries - -export { createTSQLCompletion } from "./tsqlCompletion"; -export { - createTSQLLinter, - isValidTSQLQuery, - getTSQLError, - type TSQLLinterConfig, -} from "./tsqlLinter"; diff --git a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx index dda01b7f192..88becb33418 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentChart.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentChart.tsx @@ -93,6 +93,7 @@ export function AgentChart({ // The block can render before `query` has streamed in; an empty query 400s. if (!block.query) return; if (!organizationId || !projectId || !environmentId) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setState({ status: "error", error: "No environment context to run the query." }); return; } diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx index 70a5da7b826..82558fbdafb 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx @@ -1,6 +1,7 @@ import { Link } from "@remix-run/react"; import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix"; import { LinkButton } from "~/components/primitives/Buttons"; +import { textLinkClassName } from "~/components/primitives/TextLink"; import { useOrganization } from "~/hooks/useOrganizations"; import { v3BillingPath } from "~/utils/pathBuilder"; import { ASK_AGENT_LABEL } from "./agent-identity"; @@ -48,10 +49,7 @@ export function AgentQuotaNotice({ remaining, limit }: { remaining: number; limi {remaining} of {limit} free messages left ยท - + Upgrade
diff --git a/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx b/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx index f5acc37bb38..602a96e123b 100644 --- a/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx +++ b/apps/webapp/app/components/dashboard-agent/AskAgentButton.tsx @@ -21,7 +21,7 @@ export function AskAgentButton({ fallback?: React.ReactNode; }) { const available = useDashboardAgentAvailable(); - if (!available) return <>{fallback}; + if (!available) return fallback; const button = ( @@ -109,7 +110,7 @@ function getMarkdownComponents(onLinkClick?: () => void) { href={href} target="_blank" rel="noopener noreferrer" - className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300" + className={cn(textLinkClassName(), "relative z-20")} onClick={(e) => { e.stopPropagation(); onLinkClick?.(); diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx index 78cb52ca591..20c2b589703 100644 --- a/apps/webapp/app/components/navigation/NotificationPanel.tsx +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -42,60 +42,70 @@ export function NotificationPanel({ notifications: Notification[]; }; const [dismissedIds, setDismissedIds] = useState>(new Set()); - const dismissFetcher = useFetcher(); + const { submit: submitDismiss } = useFetcher(); const seenIdsRef = useRef>(new Set()); - const seenFetcher = useFetcher(); + const { submit: submitSeen } = useFetcher(); const clickedIdsRef = useRef>(new Set()); - const clickFetcher = useFetcher(); + const { submit: submitClick } = useFetcher(); const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); const notification = visibleNotifications[0] ?? null; + const notificationId = notification?.id; - const handleDismiss = useCallback((id: string) => { - setDismissedIds((prev) => new Set(prev).add(id)); + const handleDismiss = useCallback( + (id: string) => { + setDismissedIds((prev) => new Set(prev).add(id)); - dismissFetcher.submit( - {}, - { - method: "POST", - action: `/resources/platform-notifications/${id}/dismiss`, - } - ); - }, []); + submitDismiss( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/dismiss`, + } + ); + }, + [submitDismiss] + ); - const fireClickBeacon = useCallback((id: string) => { - if (clickedIdsRef.current.has(id)) return; - clickedIdsRef.current.add(id); + const fireClickBeacon = useCallback( + (id: string) => { + if (clickedIdsRef.current.has(id)) return; + clickedIdsRef.current.add(id); - clickFetcher.submit( - {}, - { - method: "POST", - action: `/resources/platform-notifications/${id}/clicked`, - } - ); - }, []); + submitClick( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/clicked`, + } + ); + }, + [submitClick] + ); // Fire seen beacon - const fireSeenBeacon = useCallback((n: Notification) => { - if (seenIdsRef.current.has(n.id)) return; - seenIdsRef.current.add(n.id); + const fireSeenBeacon = useCallback( + (id: string) => { + if (seenIdsRef.current.has(id)) return; + seenIdsRef.current.add(id); - seenFetcher.submit( - {}, - { - method: "POST", - action: `/resources/platform-notifications/${n.id}/seen`, - } - ); - }, []); + submitSeen( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/seen`, + } + ); + }, + [submitSeen] + ); // Beacon current notification on mount useEffect(() => { - if (notification && !hasIncident) { - fireSeenBeacon(notification); + if (notificationId && !hasIncident) { + fireSeenBeacon(notificationId); } - }, [notification?.id, hasIncident]); + }, [notificationId, hasIncident, fireSeenBeacon]); if (!notification) { return null; diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index 465346ad150..3087896019c 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -2,6 +2,7 @@ import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { BellIcon } from "~/assets/icons/BellIcon"; import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; import { PadlockIcon } from "~/assets/icons/PadlockIcon"; import { UsageIcon } from "~/assets/icons/UsageIcon"; import { RolesIcon } from "~/assets/icons/RolesIcon"; @@ -15,6 +16,7 @@ import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; import { organizationPath, + organizationProjectsPath, organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, @@ -49,11 +51,13 @@ export function OrganizationSettingsSideMenu({ buildInfo, isUsingPlugin, isSsoUsingPlugin, + hasProjectRuntimeUpdate, }: { organization: MatchedOrganization; buildInfo: BuildInfo; isUsingPlugin: boolean; isSsoUsingPlugin: boolean; + hasProjectRuntimeUpdate: boolean; }) { const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); @@ -127,6 +131,22 @@ export function OrganizationSettingsSideMenu({ ) : null} )} + + + Runtime update available. + + ) : undefined + } + />
@@ -374,6 +381,7 @@ type ButtonPropsType = Pick< > & React.ComponentProps; +/* oxlint-disable react/button-has-type -- Callers can select button, reset, or submit semantics. */ export const Button = forwardRef( ({ type, disabled, autoFocus, onClick, "aria-label": ariaLabel, ...props }, ref) => { const innerRef = useRef(null); @@ -435,6 +443,7 @@ export const Button = forwardRef( return buttonElement; } ); +/* oxlint-enable react/button-has-type */ type LinkPropsType = Pick< LinkProps, @@ -520,24 +529,6 @@ export const LinkButton = ({ } }; -type NavLinkPropsType = Pick & - Omit, "className"> & { - className?: (props: { isActive: boolean; isPending: boolean }) => string | undefined; - }; -export const NavLinkButton = ({ to, className, target, ...props }: NavLinkPropsType) => { - return ( - - {({ isActive, isPending }) => ( - - )} - - ); -}; - type ExtLinkProps = JSX.IntrinsicElements["a"] & { children: React.ReactNode; className?: string; diff --git a/apps/webapp/app/components/primitives/Calendar.tsx b/apps/webapp/app/components/primitives/Calendar.tsx index 22ddc7d4149..7d96a370527 100644 --- a/apps/webapp/app/components/primitives/Calendar.tsx +++ b/apps/webapp/app/components/primitives/Calendar.tsx @@ -103,8 +103,10 @@ export function Calendar({ ), range_start: "day-range-start rounded-l-md", range_end: "day-range-end rounded-r-md", + // White rather than text-text-bright: that token is near-black on the + // light themes, which put the selected day at 2.66:1 on indigo-600. selected: - "bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md", + "bg-indigo-600 text-white hover:bg-indigo-600 hover:text-white focus:bg-indigo-600 focus:text-white rounded-md", today: "bg-background-raised text-text-bright rounded-md", outside: "day-outside text-text-dimmed opacity-50 aria-selected:bg-background-raised/50 aria-selected:text-text-dimmed aria-selected:opacity-30", diff --git a/apps/webapp/app/components/primitives/Callout.tsx b/apps/webapp/app/components/primitives/Callout.tsx index 6c772fb6e52..6bcd16de0a1 100644 --- a/apps/webapp/app/components/primitives/Callout.tsx +++ b/apps/webapp/app/components/primitives/Callout.tsx @@ -92,6 +92,7 @@ export function Callout({ { const [isChecked, setIsChecked] = useState(defaultChecked ?? false); - const [isDisabled, setIsDisabled] = useState(disabled ?? false); + const isDisabled = disabled ?? false; + const onChangeRef = React.useRef(onChange); + const generatedId = React.useId(); + const inputId = id ?? generatedId; + const labelId = `${inputId}-label`; + const descriptionId = `${inputId}-description`; + const ariaLabelledBy = + props["aria-label"] || props["aria-labelledby"] ? props["aria-labelledby"] : labelId; const buttonClassName = variants[variant].button; const labelClassName = variants[variant].label; @@ -95,21 +103,20 @@ export const CheckboxWithLabel = React.forwardRef { - setIsDisabled(disabled ?? false); - }, [disabled]); + onChangeRef.current = onChange; + }, [onChange]); useEffect(() => { - if (props.onChange) { - props.onChange(isChecked); - } + onChangeRef.current?.(isChecked); }, [isChecked]); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setIsChecked(defaultChecked ?? false); }, [defaultChecked]); return ( -
{ - //returning false is not setting the state to false, it stops the event from bubbling up - if (isDisabled || props.readOnly === true) return false; - setIsChecked((c) => !c); - }} > { - //returning false is not setting the state to false, it stops the event from bubbling up - if (isDisabled || props.readOnly === true) return false; - setIsChecked(!isChecked); + if (isDisabled || props.readOnly === true) return; + setIsChecked(e.target.checked); }} disabled={isDisabled} className={cn( @@ -145,22 +151,21 @@ export const CheckboxWithLabel = React.forwardRef
- + {badges && ( {badges.map((badge) => ( @@ -170,12 +175,16 @@ export const CheckboxWithLabel = React.forwardRef {variant === "description" && ( - + {description} )}
-
+ ); } ); diff --git a/apps/webapp/app/components/primitives/ClientTabs.tsx b/apps/webapp/app/components/primitives/ClientTabs.tsx index 48757676d61..459a3e87637 100644 --- a/apps/webapp/app/components/primitives/ClientTabs.tsx +++ b/apps/webapp/app/components/primitives/ClientTabs.tsx @@ -20,18 +20,13 @@ const ClientTabs = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ onValueChange, value: valueProp, defaultValue, ...props }, ref) => { - const [value, setValue] = React.useState(valueProp ?? defaultValue); - - React.useEffect(() => { - if (valueProp !== undefined) { - setValue(valueProp); - } - }, [valueProp]); + const [internalValue, setInternalValue] = React.useState(defaultValue); + const value = valueProp ?? internalValue; const handleValueChange = React.useCallback( (nextValue: string) => { if (valueProp === undefined) { - setValue(nextValue); + setInternalValue(nextValue); } onValueChange?.(nextValue); }, @@ -199,15 +194,4 @@ const ClientTabsContent = React.forwardRef< )); ClientTabsContent.displayName = TabsPrimitive.Content.displayName; -export type TabsProps = { - tabs: { - label: string; - value: string; - }[]; - currentValue: string; - className?: string; - layoutId: string; - variant?: Variants; -}; - export { ClientTabs, ClientTabsContent, ClientTabsList, ClientTabsTrigger }; diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index ca5df717d34..c953add06cc 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useState } from "react"; import { cn } from "~/utils/cn"; import { CopyButton } from "./CopyButton"; @@ -116,10 +116,11 @@ export function ClipboardField({ fullWidth = true, }: ClipboardFieldProps) { const [isSecure, setIsSecure] = useState(secure !== undefined && secure); - const inputIcon = useRef(null); + const inputId = useId(); const { container, input, buttonVariant, button, size } = variants[variant]; useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setIsSecure(secure !== undefined && secure); }, [secure]); @@ -128,21 +129,19 @@ export function ClipboardField({ return ( {icon && ( - inputIcon.current && inputIcon.current.focus()} - className="flex items-center pl-1" - > + + )} { diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index 91d47c29668..566609a853d 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -44,58 +44,72 @@ export function CopyButton({ const { icon: iconSize, button: buttonSize } = sizes[size]; - const button = - variant === "icon" ? ( - - {copied ? ( - - ) : ( - - )} + if (variant === "button") { + return ( + + - ) : ( - ); + } - if (!showTooltip) return {button}; + const iconButton = ( + + ); + + if (!showTooltip) return {iconButton}; return ( ; without asChild the tooltip + // trigger wraps it in its own, and the browser parser splits the nested + // buttons apart, which React then fails to hydrate. + asChild + tabbable + button={iconButton} content={copied ? "Copied!" : "Copy"} className="font-sans" disableHoverableContent diff --git a/apps/webapp/app/components/primitives/CopyableText.tsx b/apps/webapp/app/components/primitives/CopyableText.tsx index bf5898390a3..60d4799b201 100644 --- a/apps/webapp/app/components/primitives/CopyableText.tsx +++ b/apps/webapp/app/components/primitives/CopyableText.tsx @@ -39,7 +39,11 @@ export function CopyableText({ if (resolvedVariant === "icon-right") { const iconButton = ( - e.stopPropagation()} className={cn( "ml-1 flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover", asChild && "p-1", @@ -53,7 +57,7 @@ export function CopyableText({ ) : ( )} - + ); return ( @@ -72,24 +76,23 @@ export function CopyableText({ {value} e.stopPropagation()} className={cn( - "absolute top-0 z-10 size-6 font-sans", + "absolute top-0 z-10 flex size-6 font-sans transition-opacity has-focus-visible:pointer-events-auto has-focus-visible:opacity-100", // Truncated values reserve a right gutter, so the button sits inside it truncate ? "right-0" : "-right-6", - isHovered ? "flex" : "hidden" + isHovered ? "opacity-100" : "pointer-events-none opacity-0" )} > {hideTooltip ? ( iconButton ) : ( )} diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index a68616fc1b1..98addd0065a 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -80,20 +80,26 @@ export function DateField({ }, }); - //if the passed in value changes, we should update the date + const stateValueRef = useRef(state.value); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. + stateValueRef.current = state.value; + + // Sync only when the passed value or timezone mode changes. Depending on state.value directly + // would reset partially edited segments back to the default after every keystroke. useEffect(() => { - if (state.value === undefined && defaultValue === undefined) return; + const stateValue = stateValueRef.current; + if (stateValue === undefined && defaultValue === undefined) return; const calendarDate = utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue); - //unchanged - if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) { + // unchanged + if (stateValue?.toDate(utc ? "utc" : deviceTimezone).getTime() === defaultValue?.getTime()) { return; } setValue(calendarDate); - }, [defaultValue]); + }, [defaultValue, utc]); const ref = useRef(null); const { labelProps: _labelProps, fieldProps } = useDateField( diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index 3c1227e0c9a..a3e75543a07 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -38,7 +38,7 @@ function getServerTimeZoneSnapshot(): string { * Uses useSyncExternalStore for SSR compatibility - returns "UTC" on server, * actual timezone on client. The timezone is cached and only resolved once. */ -export function useLocalTimeZone(): string { +function useLocalTimeZone(): string { return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot); } @@ -47,7 +47,7 @@ export function useLocalTimeZone(): string { * Returns the timezone stored in the user's preferences cookie (from root loader), * falling back to the browser's local timezone if not set. */ -export function useUserTimeZone(): string { +function useUserTimeZone(): string { const rootData = useRouteLoaderData("root") as { timezone?: string } | undefined; const localTimeZone = useLocalTimeZone(); // Use stored timezone from cookie, or fall back to browser's local timezone @@ -204,32 +204,6 @@ export function formatUtcOffset(date: Date, timeZone: string): string { return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`; } -// New component that only shows date when it changes -export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => { - const locales = useLocales(); - const userTimeZone = useUserTimeZone(); - const realDate = typeof date === "string" ? new Date(date) : date; - const realPrevDate = previousDate - ? typeof previousDate === "string" - ? new Date(previousDate) - : previousDate - : null; - - // Check if we should show the date - const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate); - - // Format with appropriate function - const formattedDateTime = showDatePart - ? formatSmartDateTime(realDate, userTimeZone, locales, hour12) - : formatTimeOnly(realDate, userTimeZone, locales, hour12); - - return ( - - {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} - - ); -}; - // Helper function to check if two dates are on the same day function isSameDay(date1: Date, date2: Date): boolean { return ( @@ -239,26 +213,6 @@ function isSameDay(date1: Date, date2: Date): boolean { ); } -// Format with date and time -function formatSmartDateTime( - date: Date, - timeZone: string, - locales: string[], - hour12: boolean = true -): string { - return new Intl.DateTimeFormat(locales, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "numeric", - second: "numeric", - timeZone, - // @ts-ignore fractionalSecondDigits works in most modern browsers - fractionalSecondDigits: 3, - hour12, - }).format(date); -} - // Format time only function formatTimeOnly( date: Date, @@ -289,12 +243,16 @@ const DateTimeAccurateInner = ({ const userTimeZone = useUserTimeZone(); // Use provided timeZone prop if available, otherwise fall back to user's preferred timezone const displayTimeZone = timeZone ?? userTimeZone; - const realDate = typeof date === "string" ? new Date(date) : date; - const realPrevDate = previousDate - ? typeof previousDate === "string" - ? new Date(previousDate) - : previousDate - : null; + const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]); + const realPrevDate = useMemo( + () => + previousDate + ? typeof previousDate === "string" + ? new Date(previousDate) + : previousDate + : null, + [previousDate] + ); // Smart formatting based on whether date changed const formattedDateTime = useMemo(() => { @@ -305,7 +263,7 @@ const DateTimeAccurateInner = ({ ? formatTimeOnly(realDate, displayTimeZone, locales, hour12) : formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12) : formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12); - }, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]); + }, [realDate, realPrevDate, displayTimeZone, locales, hour12, hideDate]); if (!showTooltip) return ( @@ -414,6 +372,7 @@ export const RelativeDateTime = ({ date, timeZone, capitalize = true }: Relative // On first render useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect, react/no-deriving-state-in-effects -- A changed date intentionally resets the timer-backed relative text. setRelativeText(getRelativeText(realDate, capitalize)); }, [realDate, capitalize]); diff --git a/apps/webapp/app/components/primitives/Dialog.tsx b/apps/webapp/app/components/primitives/Dialog.tsx index b62bb01f22f..5c8934b2cf8 100644 --- a/apps/webapp/app/components/primitives/Dialog.tsx +++ b/apps/webapp/app/components/primitives/Dialog.tsx @@ -112,6 +112,4 @@ export { DialogFooter, DialogTitle, DialogDescription, - DialogPortal, - DialogOverlay, }; diff --git a/apps/webapp/app/components/primitives/DurationPicker.tsx b/apps/webapp/app/components/primitives/DurationPicker.tsx index e4f5af6520d..cc548de2349 100644 --- a/apps/webapp/app/components/primitives/DurationPicker.tsx +++ b/apps/webapp/app/components/primitives/DurationPicker.tsx @@ -42,15 +42,16 @@ export function DurationPicker({ // Sync internal state with external value changes useEffect(() => { - if (controlledValue !== undefined && controlledValue !== totalSeconds) { - const newHours = Math.floor(controlledValue / 3600); - const newMinutes = Math.floor((controlledValue % 3600) / 60); - const newSeconds = controlledValue % 60; - - setHours(newHours); - setMinutes(newMinutes); - setSeconds(newSeconds); - } + if (controlledValue === undefined) return; + + const newHours = Math.floor(controlledValue / 3600); + const newMinutes = Math.floor((controlledValue % 3600) / 60); + const newSeconds = controlledValue % 60; + + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. + setHours(newHours); + setMinutes(newMinutes); + setSeconds(newSeconds); }, [controlledValue]); useEffect(() => { diff --git a/apps/webapp/app/components/primitives/FormError.tsx b/apps/webapp/app/components/primitives/FormError.tsx index 218d8449984..e9793158752 100644 --- a/apps/webapp/app/components/primitives/FormError.tsx +++ b/apps/webapp/app/components/primitives/FormError.tsx @@ -1,4 +1,3 @@ -import type { z } from "zod"; import { Paragraph } from "./Paragraph"; import { motion } from "framer-motion"; import { cn } from "~/utils/cn"; @@ -13,43 +12,17 @@ export function FormError({ id?: string; className?: string; }) { - return ( - <> - {children && ( - - - - {children} - - - )} - - ); -} - -export function ZodFormErrors({ errors, path }: { errors: z.ZodIssue[]; path: string[] }) { - if (errors.length === 0) { - return null; - } - - const relevantErrors = errors.filter((error) => { - return error.path.join(".") === path.join("."); - }); - - if (relevantErrors.length === 0) { - return null; - } - - return ( -
- {relevantErrors.map((error, index) => ( - {error.message} - ))} -
- ); + return children ? ( + + + + {children} + + + ) : null; } diff --git a/apps/webapp/app/components/primitives/Headers.tsx b/apps/webapp/app/components/primitives/Headers.tsx index 5cd3ec84559..2432987fbbe 100644 --- a/apps/webapp/app/components/primitives/Headers.tsx +++ b/apps/webapp/app/components/primitives/Headers.tsx @@ -20,8 +20,6 @@ const textColorVariants = { dimmed: "text-text-dimmed", }; -export type HeaderVariant = keyof typeof headerVariants; - type HeaderProps = { className?: string; children: React.ReactNode; diff --git a/apps/webapp/app/components/primitives/Icon.tsx b/apps/webapp/app/components/primitives/Icon.tsx index 4b8197f93ce..2f2ff761240 100644 --- a/apps/webapp/app/components/primitives/Icon.tsx +++ b/apps/webapp/app/components/primitives/Icon.tsx @@ -18,7 +18,7 @@ export function Icon(props: IconProps) { } if (React.isValidElement(props.icon)) { - return <>{props.icon}; + return props.icon; } if ( diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index 5c3235a66c9..9cb2fccda21 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -64,7 +64,7 @@ const variants = { }, }; -export type InputProps = React.InputHTMLAttributes & { +type InputProps = React.InputHTMLAttributes & { variant?: keyof typeof variants; icon?: RenderIcon; iconClassName?: string; @@ -95,6 +95,7 @@ const Input = React.forwardRef( const inputClassName = variants[variant].input; const variantIconClassName = variants[variant].iconSize; + /* oxlint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- The wrapper only forwards pointer focus to its nested input. */ return (
( ); } ); +/* oxlint-enable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ Input.displayName = "Input"; export { Input }; diff --git a/apps/webapp/app/components/primitives/InputOTP.tsx b/apps/webapp/app/components/primitives/InputOTP.tsx index 50b14f43c1a..ab24818f815 100644 --- a/apps/webapp/app/components/primitives/InputOTP.tsx +++ b/apps/webapp/app/components/primitives/InputOTP.tsx @@ -2,7 +2,6 @@ import * as React from "react"; import { OTPInput, OTPInputContext } from "input-otp"; -import { MinusIcon } from "lucide-react"; import { cn } from "~/utils/cn"; @@ -99,12 +98,4 @@ function InputOTPSlot({ ); } -function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { - return ( -
- -
- ); -} - -export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; +export { InputOTP, InputOTPGroup, InputOTPSlot }; diff --git a/apps/webapp/app/components/primitives/Label.tsx b/apps/webapp/app/components/primitives/Label.tsx index 7213d52205c..da27dee7eab 100644 --- a/apps/webapp/app/components/primitives/Label.tsx +++ b/apps/webapp/app/components/primitives/Label.tsx @@ -2,7 +2,8 @@ import * as React from "react"; import { cn } from "~/utils/cn"; import { InfoIconTooltip } from "./Tooltip"; -const variants = { +// Non-form labels (e.g. settings row titles) share this typography, so it is exported. +export const labelVariants = { small: { text: "font-sans text-[0.8125rem] font-normal text-text-bright leading-tight flex items-center gap-1", }, @@ -17,7 +18,7 @@ const variants = { type LabelProps = React.AllHTMLAttributes & { className?: string; children: React.ReactNode; - variant?: keyof typeof variants; + variant?: keyof typeof labelVariants; required?: boolean; tooltip?: React.ReactNode; }; @@ -30,7 +31,7 @@ export function Label({ tooltip, ...props }: LabelProps) { - const variation = variants[variant]; + const variation = labelVariants[variant]; return (
- {value} - - - - } - content={href} - /> - ); -} diff --git a/apps/webapp/app/components/primitives/LoadingBarDivider.tsx b/apps/webapp/app/components/primitives/LoadingBarDivider.tsx index 713240177a5..08e6a126df0 100644 --- a/apps/webapp/app/components/primitives/LoadingBarDivider.tsx +++ b/apps/webapp/app/components/primitives/LoadingBarDivider.tsx @@ -15,7 +15,7 @@ export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerPro ); } -export function AnimationDivider({ isLoading }: LoadingBarDividerProps) { +function AnimationDivider({ isLoading }: LoadingBarDividerProps) { const [scope, animate] = useAnimate(); const [isPresent, safeToRemove] = usePresence(); @@ -39,7 +39,7 @@ export function AnimationDivider({ isLoading }: LoadingBarDividerProps) { exitAnimation(); } - }, [isPresent, isLoading]); + }, [animate, isPresent, isLoading, safeToRemove, scope]); return ( diff --git a/apps/webapp/app/components/primitives/LocaleProvider.tsx b/apps/webapp/app/components/primitives/LocaleProvider.tsx index cb27a03a9f8..c55e6170630 100644 --- a/apps/webapp/app/components/primitives/LocaleProvider.tsx +++ b/apps/webapp/app/components/primitives/LocaleProvider.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useMemo } from "react"; type LocaleContext = { locales: string[]; @@ -13,7 +13,7 @@ type LocaleContextProviderProps = { const Context = createContext(null); export const LocaleContextProvider = ({ locales, children }: LocaleContextProviderProps) => { - const value = { locales }; + const value = useMemo(() => ({ locales }), [locales]); return {children}; }; diff --git a/apps/webapp/app/components/primitives/MiddleTruncate.tsx b/apps/webapp/app/components/primitives/MiddleTruncate.tsx index 45915c2521d..b39f28270b5 100644 --- a/apps/webapp/app/components/primitives/MiddleTruncate.tsx +++ b/apps/webapp/app/components/primitives/MiddleTruncate.tsx @@ -5,19 +5,44 @@ import { SimpleTooltip } from "./Tooltip"; type MiddleTruncateProps = { text: string; className?: string; + /** Hover delay before the full-text tooltip opens. Defaults to the tooltip default (0). */ + tooltipDelay?: number; + /** Merged onto the tooltip body, for callers whose text needs a bigger or scrollable box. */ + tooltipContentClassName?: string; + /** + * Roughly how many characters fit, used only for the very first render. Truncation needs + * layout, so the server (and the pre-hydration client) can only render the full string -- + * long values visibly snapped shorter once React hydrated. Seeding from a character count + * is deterministic, so it matches on both sides and the measured pass just refines it. + */ + initialCharBudget?: number; }; +/** Deterministic, layout-free middle truncation used to seed the first render. */ +function seedTruncation(text: string, budget: number | undefined): string { + if (budget === undefined || text.length <= budget) return text; + const keep = Math.max(1, Math.floor((budget - 1) / 2)); + return `${text.slice(0, keep)}โ€ฆ${text.slice(-keep)}`; +} + /** * A component that truncates text in the middle, showing the beginning and end. * Shows the full text in a tooltip on hover when truncated. * * Example: "namespace:category:subcategory:task-name" becomes "namespace:catโ€ฆtask-name" */ -export function MiddleTruncate({ text, className }: MiddleTruncateProps) { +export function MiddleTruncate({ + text, + className, + tooltipDelay, + tooltipContentClassName, + initialCharBudget, +}: MiddleTruncateProps) { + const seed = seedTruncation(text, initialCharBudget); const containerRef = useRef(null); const measureRef = useRef(null); - const [displayText, setDisplayText] = useState(text); - const [isTruncated, setIsTruncated] = useState(false); + const [displayText, setDisplayText] = useState(seed); + const [isTruncated, setIsTruncated] = useState(seed !== text); const calculateTruncation = useCallback(() => { const container = containerRef.current; @@ -117,6 +142,7 @@ export function MiddleTruncate({ text, className }: MiddleTruncateProps) { }, [text]); useLayoutEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. calculateTruncation(); // Recalculate on resize (guard for jsdom/older browsers) @@ -150,9 +176,14 @@ export function MiddleTruncate({ text, className }: MiddleTruncateProps) { return ( {text}} + content={ + + {text} + + } side="top" asChild + delayDuration={tooltipDelay} /> ); } diff --git a/apps/webapp/app/components/primitives/OperatingSystemProvider.tsx b/apps/webapp/app/components/primitives/OperatingSystemProvider.tsx index 7f7f8fc57b6..a50ac25d69e 100644 --- a/apps/webapp/app/components/primitives/OperatingSystemProvider.tsx +++ b/apps/webapp/app/components/primitives/OperatingSystemProvider.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useMemo } from "react"; export type OperatingSystemPlatform = "mac" | "windows"; @@ -18,7 +18,9 @@ export const OperatingSystemContextProvider = ({ platform, children, }: OperatingSystemContextProviderProps) => { - return {children}; + const value = useMemo(() => ({ platform }), [platform]); + + return {children}; }; const throwIfNoProvider = () => { diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index e0442b915fc..0f1a82df3a6 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -6,12 +6,10 @@ import * as PopoverPrimitive from "@radix-ui/react-popover"; import { Link } from "@remix-run/react"; import * as React from "react"; import { DropdownIcon } from "~/assets/icons/DropdownIcon"; -import * as useShortcutKeys from "~/hooks/useShortcutKeys"; import { cn } from "~/utils/cn"; import { ButtonContent, type ButtonContentPropsType } from "./Buttons"; import { type RenderIcon } from "./Icon"; import { Paragraph, type ParagraphVariant } from "./Paragraph"; -import { ShortcutKey } from "./ShortcutKey"; const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; @@ -53,6 +51,7 @@ function PopoverSectionHeader({ ); } +/* oxlint-disable react/button-has-type -- The trigger supports form button semantics. */ const PopoverMenuItem = React.forwardRef< HTMLButtonElement | HTMLAnchorElement, { @@ -143,6 +142,7 @@ const PopoverMenuItem = React.forwardRef< } ); PopoverMenuItem.displayName = "PopoverMenuItem"; +/* oxlint-enable react/button-has-type */ function PopoverCustomTrigger({ isOpen, @@ -163,48 +163,6 @@ function PopoverCustomTrigger({ ); } -function PopoverSideMenuTrigger({ - isOpen, - children, - className, - shortcut, - hideShortcutKey = false, - ...props -}: { - isOpen?: boolean; - shortcut?: useShortcutKeys.ShortcutDefinition; - hideShortcutKey?: boolean; -} & React.ComponentPropsWithoutRef) { - const ref = React.useRef(null); - useShortcutKeys.useShortcutKeys({ - shortcut: shortcut, - action: (e) => { - e.preventDefault(); - e.stopPropagation(); - if (ref.current) { - ref.current.click(); - } - }, - }); - - return ( - - {children} - {shortcut && !hideShortcutKey && ( - - )} - - ); -} - const popoverArrowTriggerVariants = { minimal: { trigger: "text-text-dimmed hover:bg-background-raised hover:text-text-bright", @@ -328,9 +286,6 @@ export { PopoverMenuItem, PopoverSectionHeader, PopoverEllipseTrigger, - PopoverSideMenuTrigger, PopoverTrigger, PopoverVerticalEllipseTrigger, }; - -export type { PopoverArrowTriggerVariant }; diff --git a/apps/webapp/app/components/primitives/PrettyDuration.tsx b/apps/webapp/app/components/primitives/PrettyDuration.tsx deleted file mode 100644 index b4f8a094d7f..00000000000 --- a/apps/webapp/app/components/primitives/PrettyDuration.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// Formats duration in a human readable way, some examples: -// 1h 30m -// 1m 30s -// 1h -// Uses built-in plain Date object, so it's not timezone aware -export function PrettyDuration({ - startAt, - endAt, - fallback, -}: { - startAt?: Date | null; - endAt?: Date | null; - fallback?: string; -}) { - if (!startAt || !endAt) { - return <>{fallback ?? "-"}; - } - - const duration = Math.abs(endAt.getTime() - startAt.getTime()); - - const hours = Math.floor(duration / (1000 * 60 * 60)); - const minutes = Math.floor((duration / (1000 * 60)) % 60); - const seconds = Math.floor((duration / 1000) % 60); - - const durationParts = []; - - if (hours > 0) { - durationParts.push(`${hours}h`); - } - - if (minutes > 0) { - durationParts.push(`${minutes}m`); - } - - if (seconds > 0) { - durationParts.push(`${seconds}s`); - } - - return <>{durationParts.join(" ")}; -} diff --git a/apps/webapp/app/components/primitives/PulsingDot.tsx b/apps/webapp/app/components/primitives/PulsingDot.tsx index 97c2a109306..7a59df7bc18 100644 --- a/apps/webapp/app/components/primitives/PulsingDot.tsx +++ b/apps/webapp/app/components/primitives/PulsingDot.tsx @@ -9,15 +9,17 @@ export function PulsingDot({ ringClassName?: string; dotClassName?: string; }) { + /* The dot fills the container, so resizing the whole thing scales the dot and + the ping ring together. */ return ( - + - + ); } diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index 0bd4f8e86d9..6eb91412b11 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -100,7 +100,9 @@ function collapsibleHandleClassName(show: boolean) { function useFrozenValue(value: T | null | undefined): T | null | undefined { const ref = useRef(value); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. if (value != null) ref.current = value; + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. return ref.current; } diff --git a/apps/webapp/app/components/primitives/SearchInput.tsx b/apps/webapp/app/components/primitives/SearchInput.tsx index 0c46a3d0286..c6693d40b85 100644 --- a/apps/webapp/app/components/primitives/SearchInput.tsx +++ b/apps/webapp/app/components/primitives/SearchInput.tsx @@ -14,6 +14,9 @@ export type SearchInputProps = { /** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */ resetParams?: string[]; autoFocus?: boolean; + minLength?: number; + /** Normalize the submitted value before applying minLength validation. */ + normalizeForValidation?: (value: string) => string; /** * Controlled value. When provided alongside `onValueChange`, the input * skips URL params entirely and acts as a controlled component โ€” useful @@ -34,6 +37,8 @@ export function SearchInput({ paramName = "search", resetParams = ["cursor", "direction"], autoFocus, + minLength, + normalizeForValidation, value: controlledValue, onValueChange, }: SearchInputProps) { @@ -65,11 +70,13 @@ export function SearchInput({ // Only mark synced once we actually apply it, so a URL change during focus still syncs on blur. if (!isFocused) { lastSyncedRef.current = urlSearch; + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setText(urlSearch); } }, [isControlled, controlledValue, value, isFocused, paramName]); const updateText = (next: string) => { + inputRef.current?.setCustomValidity(""); setText(next); if (isControlled) { onValueChange?.(next); @@ -77,13 +84,25 @@ export function SearchInput({ }; const handleSubmit = () => { + const trimmedText = text.trim(); + const validationText = normalizeForValidation?.(trimmedText) ?? trimmedText; + if ( + minLength !== undefined && + trimmedText.length > 0 && + [...validationText].length < minLength + ) { + inputRef.current?.setCustomValidity(`Enter at least ${minLength} characters`); + inputRef.current?.reportValidity(); + return; + } + inputRef.current?.setCustomValidity(""); if (isControlled) { // Live updates already fired through onValueChange; submit is a no-op. return; } const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined])); - if (text.trim()) { - replace({ [paramName]: text.trim(), ...resetValues }); + if (trimmedText) { + replace({ [paramName]: trimmedText, ...resetValues }); } else { del([paramName, ...resetParams]); } @@ -116,7 +135,10 @@ export function SearchInput({ variant="secondary-small" placeholder={placeholder} value={text} - onChange={(e) => updateText(e.target.value)} + onChange={(e) => { + e.currentTarget.setCustomValidity(""); + updateText(e.target.value); + }} fullWidth autoFocus={autoFocus} className={cn("", isFocused && "placeholder:text-text-dimmed/70")} diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 0fdccdbbc42..eca5438c34a 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -24,7 +24,7 @@ const theme = { selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary", }, secondary: { - base: "bg-transparent dark:bg-background-raised/50", + base: "bg-segmented-track", active: "text-text-bright", inactive: "text-text-dimmed transition hover:text-text-bright", selected: @@ -129,25 +129,23 @@ export default function SegmentedControl({ } > {({ checked }) => ( - <> -
-
- {option.label} -
- {checked && ( - - )} +
+
+ {option.label}
- + {checked && ( + + )} +
)} ))} diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 31921ef9854..466dcc5c82e 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -1,6 +1,7 @@ import * as Ariakit from "@ariakit/react"; import { type SelectProps as AriaSelectProps } from "@ariakit/react"; import { SelectValue } from "@ariakit/react-core/select/select-value"; +import { useStoreState } from "@ariakit/react-core/utils/store"; import { Link } from "@remix-run/react"; import * as React from "react"; import { Fragment, useMemo, useState } from "react"; @@ -29,8 +30,9 @@ const style = { "bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none", }, secondary: { + // Matches the secondary button's hover. button: - "bg-secondary focus-custom border border-border-bright/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised", + "bg-secondary focus-custom border border-border-bright/50 shadow-xs text-text-bright hover:bg-background-raised dark:hover:bg-surface-control", }, }; @@ -190,7 +192,7 @@ export function Select({ } return matchSorter(items, searchValue, filter); - }, [searchValue, items]); + }, [searchValue, items, filter]); const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length; @@ -225,7 +227,7 @@ export function Select({ {...props} /> - {!searchable && showHeading && heading && {heading}} />} + {!searchable && showHeading && heading && {heading}} />} {searchable && } @@ -313,22 +315,20 @@ export function SelectTrigger({ content = children; } else if (text !== undefined) { if (typeof text === "function") { - content = {(value) => <>{text(value) ?? placeholder}}; + content = {(value) => text(value) ?? placeholder}; } else { content = text; } } else { content = ( - {(value) => ( - <> - {typeof value === "string" - ? (value ?? placeholder) - : value.length === 0 - ? placeholder - : value.join(", ")} - - )} + {(value) => + typeof value === "string" + ? (value ?? placeholder) + : value.length === 0 + ? placeholder + : value.join(", ") + } ); } @@ -356,8 +356,9 @@ export function SelectTrigger({
{dropdownIcon === true ? ( ) : !dropdownIcon ? null : ( @@ -414,19 +415,20 @@ function SelectGroupedRenderer({ ) => React.ReactNode; enableItemShortcuts: boolean; }) { - let count = 0; return ( <> {items.map((section, index) => { - const previousItem = items.at(index - 1); - count += previousItem ? previousItem.items.length : 0; + const startIndex = items + .slice(0, index) + .reduce((count, previousSection) => count + previousSection.items.length, 0); + return ( {children(section.items as ItemFromSection[], { shortcutsEnabled: enableItemShortcuts, section: { title: section.title, - startIndex: count - 1, + startIndex, count: section.items.length, }, })} @@ -486,7 +488,7 @@ export function SelectItem({ const render = combobox ? : props.render; const ref = React.useRef(null); const select = Ariakit.useSelectContext(); - const selectValue = select?.useState("value"); + const selectValue = useStoreState(select, "value"); const isChecked = React.useMemo(() => { if (!props.value || selectValue == null) return false; @@ -574,16 +576,20 @@ export interface SelectButtonItemProps extends Omit["onClick"]; } export function SelectButtonItem({ checkIcon = , + accessibleLabel, onClick, ...props }: SelectButtonItemProps) { const render = (
) : (
) : ( - <>{children} + children )} ); @@ -468,6 +478,37 @@ export const CopyableTableCell = forwardRef { + e.stopPropagation(); + e.preventDefault(); + copy(); + }} + className={cn( + "absolute -right-2 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100", + isHovered ? "opacity-100" : "pointer-events-none opacity-0", + copied + ? "text-green-500" + : "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright" + )} + > + {copied ? ( + + ) : ( + + )} + + ); + return (
setIsHovered(false)} > {children} - {isHovered && ( - { - e.stopPropagation(); - e.preventDefault(); - copy(); - }} - className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer" - > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - disableHoverableContent - /> - - )} +
); } ); -export const TableCellChevron = forwardRef< - HTMLTableCellElement, - { - className?: string; - to?: string; - children?: ReactNode; - isSticky?: boolean; - onClick?: (event: React.MouseEvent) => void; - } ->(({ className, to, children, isSticky, onClick }, ref) => { - return ( - - {children} - - - ); -}); - export const TableCellMenu = forwardRef< HTMLTableCellElement, TableCellProps & { diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index 569e271434f..aa7631f4682 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -15,7 +15,7 @@ export const TITLE_BAR_CHROME = "flex h-10 shrink-0 gap-x-6 border-b border-grid const titleTabLabel = cn(headerVariants.header2.text, "transition duration-200"); const titleTabIndicator = "h-0.5 w-full bg-indigo-500"; const titleTabIndicatorIdle = - "h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100"; + "h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover/tab:opacity-100"; export type TabsProps = { tabs: { @@ -81,7 +81,7 @@ export function TabContainer({ return
{children}
; } -export function TabLink({ +function TabLink({ to, children, layoutId, @@ -98,7 +98,7 @@ export function TabLink({ return ( {({ isActive, isPending }) => { @@ -111,7 +111,7 @@ export function TabLink({ "text-sm transition duration-200", active ? "text-text-bright" - : "text-text-dimmed transition group-hover:text-text-bright" + : "text-text-dimmed transition group-hover/tab:text-text-bright" )} > {children} @@ -133,7 +133,7 @@ export function TabLink({ if (variant === "title") { return ( - + {({ isActive, isPending }) => { const active = isActive || isPending; return ( @@ -142,7 +142,9 @@ export function TabLink({ {children} @@ -190,7 +192,7 @@ export function TabLink({ // underline variant (default) return ( - + {({ isActive, isPending }) => { return ( <> @@ -211,7 +213,7 @@ export function TabLink({ className="mt-1 h-0.5 w-full bg-indigo-500" /> ) : ( -
+
)} ); @@ -225,33 +227,34 @@ export function TabButton({ layoutId, shortcut, variant = "underline", + size = "base", ...props }: { isActive: boolean; shortcut?: ShortcutDefinition; layoutId: string; variant?: Variants; + /** `"small"` drops the title variant's label to body size. Layout is unchanged. */ + size?: "base" | "small"; } & React.ButtonHTMLAttributes) { const ref = useRef(null); - if (shortcut) { - useShortcutKeys({ - shortcut: shortcut, - action: () => { - if (ref.current) { - ref.current.click(); - } - }, - disabled: props.disabled, - }); - } + useShortcutKeys({ + shortcut, + action: () => { + if (ref.current) { + ref.current.click(); + } + }, + disabled: props.disabled, + }); const title = variant === "title"; return ( ); } diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index 5f32fcdf850..8582b5dbbd6 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -6,15 +6,26 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey import { ShortcutKey } from "./ShortcutKey"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; +/* Hover shifts colour, never adds an underline: that's reserved for the + "Underline links" preference, so its presence always means the preference. */ +const colors = { + primary: "text-text-link transition hover:text-text-link-hover", + secondary: "text-text-dimmed transition hover:text-text-bright", +} as const; + +const layout = "inline-flex gap-0.5 items-center group"; + +/** + * A link's colour plus the `inline-text-link` marker, without the layout - for + * links that must stay in the inline flow, and triggers that aren't anchors. + */ +export function textLinkClassName(variant: keyof typeof colors = "primary") { + return cn("inline-text-link focus-visible:focus-custom", colors[variant]); +} + const variations = { - primary: - "text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom", - secondary: - "text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom", - // The theme-remapped link token, for links inside themed surfaces where the - // raw indigo of `primary` is dark-theme only. - token: - "text-text-link transition hover:underline inline-flex gap-0.5 items-center group focus-visible:focus-custom", + primary: cn(textLinkClassName("primary"), layout), + secondary: cn(textLinkClassName("secondary"), layout), } as const; type TextLinkProps = { @@ -28,6 +39,8 @@ type TextLinkProps = { shortcut?: ShortcutDefinition; hideShortcutKey?: boolean; tooltip?: React.ReactNode; + /** Forwarded to `Link`: forces a full document load rather than a client nav. */ + reloadDocument?: boolean; } & React.AnchorHTMLAttributes; export function TextLink({ @@ -41,21 +54,20 @@ export function TextLink({ shortcut, hideShortcutKey, tooltip, + reloadDocument, ...props }: TextLinkProps) { const innerRef = useRef(null); const classes = variations[variant]; - if (shortcut) { - useShortcutKeys({ - shortcut: shortcut, - action: () => { - if (innerRef.current) { - innerRef.current.click(); - } - }, - }); - } + useShortcutKeys({ + shortcut, + action: () => { + if (innerRef.current) { + innerRef.current.click(); + } + }, + }); const renderShortcutKey = () => shortcut && @@ -70,7 +82,13 @@ export function TextLink({ ); const linkElement = to ? ( - + {linkContent} ) : href ? ( diff --git a/apps/webapp/app/components/primitives/Timeline.tsx b/apps/webapp/app/components/primitives/Timeline.tsx index a5164b47b2b..b4562c3df4a 100644 --- a/apps/webapp/app/components/primitives/Timeline.tsx +++ b/apps/webapp/app/components/primitives/Timeline.tsx @@ -1,5 +1,5 @@ import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { Fragment, createContext, useCallback, useContext, useRef, useState } from "react"; +import { Fragment, createContext, useContext, useRef, useState } from "react"; import { inverseLerp, lerp } from "~/utils/lerp"; interface MousePosition { @@ -7,30 +7,27 @@ interface MousePosition { y: number; } const MousePositionContext = createContext(undefined); -export function MousePositionProvider({ children }: { children: ReactNode }) { +function MousePositionProvider({ children }: { children: ReactNode }) { const ref = useRef(null); const [position, setPosition] = useState(undefined); - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - if (!ref.current) { - setPosition(undefined); - return; - } - - const { top, left, width, height } = ref.current.getBoundingClientRect(); - const x = (e.clientX - left) / width; - const y = (e.clientY - top) / height; - - if (x < 0 || x > 1 || y < 0 || y > 1) { - setPosition(undefined); - return; - } - - setPosition({ x, y }); - }, - [ref.current] - ); + const handleMouseMove = (e: React.MouseEvent) => { + if (!ref.current) { + setPosition(undefined); + return; + } + + const { top, left, width, height } = ref.current.getBoundingClientRect(); + const x = (e.clientX - left) / width; + const y = (e.clientY - top) / height; + + if (x < 0 || x > 1 || y < 0 || y > 1) { + setPosition(undefined); + return; + } + + setPosition({ x, y }); + }; return (
); } -export const useMousePosition = () => { +const useMousePosition = () => { return useContext(MousePositionContext); }; diff --git a/apps/webapp/app/components/primitives/Toast.tsx b/apps/webapp/app/components/primitives/Toast.tsx index 2dfdbbe2162..9547d7f0b62 100644 --- a/apps/webapp/app/components/primitives/Toast.tsx +++ b/apps/webapp/app/components/primitives/Toast.tsx @@ -128,6 +128,7 @@ export function ToastUI({ {actionNode}
); } diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index 000d74b91c0..5275c2d1393 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -168,7 +168,7 @@ function ReferenceLineLabel({ y={viewBox.y} dominantBaseline="middle" textAnchor="start" - fill="#878C99" + className="fill-text-dimmed" fontSize={REFERENCE_LABEL_FONT_SIZE} > {value} @@ -180,7 +180,7 @@ function ReferenceLineLabel({ x={viewBox.x + viewBox.width - 4} y={viewBox.y + 12} textAnchor="end" - fill="#878C99" + className="fill-text-dimmed" fontSize={REFERENCE_LABEL_FONT_SIZE} > {value} @@ -458,7 +458,7 @@ export function ChartLineRenderer({ ( + ? // oxlint-disable-next-line react/no-unstable-nested-components -- Recharts invokes this renderer with hover coordinates; an element would rely on cloneElement prop injection. + (props: ActiveDotProps) => ( { - const numPoints = 10; - const points = []; - let lastY = startY; - - for (let i = 0; i < numPoints; i++) { - // Calculate x value that spreads points across the full width - const x = i * (9 / (numPoints - 1)); +type ChartPoint = { x: number; y: number }; - // Create less extreme variations that move smoothly - const change = Math.random() * 6 - 3; // Range from -3 to +3 - const y = Math.max(minY, Math.min(maxY, lastY + change)); // Apply constraints +function generateLinePoints(startY: number, minY: number, maxY: number): ChartPoint[] { + const numPoints = 10; + const points = []; + let lastY = startY; - points.push({ x, y }); - lastY = y; - } + for (let i = 0; i < numPoints; i++) { + const x = i * (9 / (numPoints - 1)); + const change = Math.random() * 6 - 3; + const y = Math.max(minY, Math.min(maxY, lastY + change)); - return points; - }; + points.push({ x, y }); + lastY = y; + } - // Generate points for both lines - const points = useMemo(() => generateLinePoints(30, 10, 90), []); - const secondPoints = useMemo(() => generateLinePoints(40, 30, 90), []); - - const generateSmoothPath = (points: Array<{ x: number; y: number }>) => { - if (points.length < 2) return ""; + return points; +} - let path = `M0,${50 - points[0].y}`; +function generateSmoothPath(points: ChartPoint[]) { + if (points.length < 2) return ""; - // Use curve command for smooth lines - for (let i = 0; i < points.length - 1; i++) { - const x1 = points[i].x; - const y1 = 50 - points[i].y; - const x2 = points[i + 1].x; - const y2 = 50 - points[i + 1].y; + let path = `M0,${50 - points[0].y}`; - // Bezier control points (create smooth curve) - const cx1 = (x1 + x2) / 2; - const cy1 = y1; - const cx2 = (x1 + x2) / 2; - const cy2 = y2; + for (let i = 0; i < points.length - 1; i++) { + const x1 = points[i].x; + const y1 = 50 - points[i].y; + const x2 = points[i + 1].x; + const y2 = 50 - points[i + 1].y; + const cx1 = (x1 + x2) / 2; + const cy1 = y1; + const cx2 = (x1 + x2) / 2; + const cy2 = y2; - path += ` C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`; - } + path += ` C${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`; + } - return path; - }; + return path; +} - const generateAreaPath = (points: Array<{ x: number; y: number }>) => { - const curvePath = generateSmoothPath(points); - const lastX = 9; - return `${curvePath} L${lastX},50 L0,50 Z`; - }; +function generateAreaPath(points: ChartPoint[]) { + return `${generateSmoothPath(points)} L9,50 L0,50 Z`; +} - // Component to render a line with area fill and animation - const AnimatedLine = ({ - points, - gradientId, - delay = 0, - }: { - points: Array<{ x: number; y: number }>; - gradientId: string; - delay?: number; - }) => ( +function AnimatedLine({ + points, + gradientId, + delay = 0, +}: { + points: ChartPoint[]; + gradientId: string; + delay?: number; +}) { + return ( <> ); +} + +function ChartLineLoadingBackground() { + const points = useMemo(() => generateLinePoints(30, 10, 90), []); + const secondPoints = useMemo(() => generateLinePoints(40, 30, 90), []); return ( (defaultStartISO); const [endDate, setEndDate] = useState(defaultEndISO); - const setDateRange = (start: string, end: string) => { + const setDateRange = useCallback((start: string, end: string) => { setStartDate(start); setEndDate(end); - }; + }, []); - const resetDateRange = () => { + const resetDateRange = useCallback(() => { setStartDate(defaultStartISO); setEndDate(defaultEndISO); - }; - - return ( - - {children} - + }, [defaultEndISO, defaultStartISO]); + + const contextValue = useMemo( + () => ({ startDate, endDate, setDateRange, resetDateRange }), + [startDate, endDate, setDateRange, resetDateRange] ); + + return {children}; } export function useDateRange(): DateRangeContextType | null { diff --git a/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts b/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts index 02baac0ca8f..c73137484dc 100644 --- a/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts +++ b/apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from "react"; -export type HighlightState = { +type HighlightState = { /** The currently highlighted series key (e.g., "completed", "failed") */ activeBarKey: string | null; /** The index of the specific data point being hovered (null when hovering legend) */ @@ -9,7 +9,7 @@ export type HighlightState = { tooltipActive: boolean; }; -export type HighlightActions = { +type HighlightActions = { /** Set the hovered bar (specific data point) */ setHoveredBar: (key: string, index: number) => void; /** Set the hovered legend item (highlights all bars of that type) */ @@ -75,32 +75,3 @@ export function useHighlightState(): UseHighlightStateReturn { [state, setHoveredBar, setHoveredLegendItem, setTooltipActive, reset] ); } - -/** - * Calculate the opacity for a bar based on highlight state. - * @param key - The series key of this bar - * @param dataIndex - The data point index of this bar - * @param highlight - The current highlight state - * @param dimmedOpacity - The opacity to use for dimmed bars (default 0.2) - */ -export function getBarOpacity( - key: string, - dataIndex: number, - highlight: HighlightState, - dimmedOpacity = 0.2 -): number { - const { activeBarKey, activeDataPointIndex } = highlight; - - // No highlight active - full opacity - if (activeBarKey === null) { - return 1; - } - - // Hovering a specific bar (from chart) - if (activeDataPointIndex !== null) { - return key === activeBarKey && dataIndex === activeDataPointIndex ? 1 : dimmedOpacity; - } - - // Hovering a legend item (all bars of this type) - return key === activeBarKey ? 1 : dimmedOpacity; -} diff --git a/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts b/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts index 1d7110fda8f..92e784bcec4 100644 --- a/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts +++ b/apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts @@ -5,7 +5,7 @@ export type ZoomRange = { end: string; }; -export type ZoomSelectionState = { +type ZoomSelectionState = { /** Starting point of drag selection (x-axis value) */ refAreaLeft: string | null; /** Ending point of drag selection (x-axis value) */ @@ -18,7 +18,7 @@ export type ZoomSelectionState = { inspectionLine: string | null; }; -export type ZoomSelectionActions = { +type ZoomSelectionActions = { /** Start a new selection at the given x-axis value */ startSelection: (label: string) => void; /** Update the selection as the user drags */ @@ -55,6 +55,7 @@ export function useZoomSelection(): UseZoomSelectionReturn { const stateRef = useRef(state); // Keep ref in sync with state + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. stateRef.current = state; const startSelection = useCallback((label: string) => { diff --git a/apps/webapp/app/components/primitives/charts/statusColors.ts b/apps/webapp/app/components/primitives/charts/statusColors.ts index 8439b4d3285..8fae83125fd 100644 --- a/apps/webapp/app/components/primitives/charts/statusColors.ts +++ b/apps/webapp/app/components/primitives/charts/statusColors.ts @@ -1,6 +1,6 @@ /** Shared status โ†’ color map for the task/agent activity charts. * Values are CSS variables so they follow the theme; CSS contexts only. */ -export const STATUS_COLOR: Record = { +const STATUS_COLOR: Record = { // Run-status groups COMPLETED: "var(--color-success)", RUNNING: "var(--color-pending)", @@ -12,7 +12,7 @@ export const STATUS_COLOR: Record = { EXPIRED: "var(--color-text-dimmed)", }; -export const STATUS_COLOR_FALLBACK = "var(--color-text-dimmed)"; +const STATUS_COLOR_FALLBACK = "var(--color-text-dimmed)"; export function statusColor(status: string): string { return STATUS_COLOR[status] ?? STATUS_COLOR_FALLBACK; diff --git a/apps/webapp/app/components/primitives/useTableSort.ts b/apps/webapp/app/components/primitives/useTableSort.ts index 5bb5f600431..991fb20897f 100644 --- a/apps/webapp/app/components/primitives/useTableSort.ts +++ b/apps/webapp/app/components/primitives/useTableSort.ts @@ -1,12 +1,5 @@ -import { useCallback, useMemo, useState } from "react"; - export type SortDirection = "asc" | "desc"; -export type SortState = { - key: K; - direction: SortDirection; -}; - /** * A sortable column definition for {@link useTableSort}. * @@ -24,12 +17,6 @@ export type SortColumn = | { key: K; type: "alpha"; value: (row: T) => string | null | undefined } | { key: K; type: "custom"; compare: (a: T, b: T) => number }; -/** Presentational props to spread onto a `` for a given column. */ -export type TableSortHeaderProps = { - sortDirection: SortDirection | null; - onSort: () => void; -}; - export function compareColumn( column: SortColumn, a: T, @@ -83,48 +70,3 @@ export function sortRows( }) .map((entry) => entry.row); } - -/** - * Client-side, header-click column sorting for tables of any row shape. - * - * Clicking a column cycles asc -> desc -> cleared (back to the original row order), so the - * incoming order (e.g. a server default) is always reachable without a reload. Returns the - * sorted rows plus a `getSortProps(key)` helper whose result spreads straight onto - * ``. - */ -export function useTableSort( - rows: T[], - columns: ReadonlyArray> -) { - const [sort, setSort] = useState | null>(null); - - const columnsByKey = useMemo(() => { - const map = new Map>(); - for (const column of columns) { - map.set(column.key, column); - } - return map; - }, [columns]); - - const sortedRows = useMemo(() => { - if (!sort) return rows; - const column = columnsByKey.get(sort.key); - if (!column) return rows; - return sortRows(rows, column, sort.direction); - }, [rows, sort, columnsByKey]); - - const getSortProps = useCallback( - (key: K): TableSortHeaderProps => ({ - sortDirection: sort?.key === key ? sort.direction : null, - onSort: () => - setSort((current) => { - if (!current || current.key !== key) return { key, direction: "asc" }; - if (current.direction === "asc") return { key, direction: "desc" }; - return null; - }), - }), - [sort] - ); - - return { sortedRows, getSortProps, sort }; -} diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 1b616059231..68ba3c8ccbb 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -99,7 +99,7 @@ type QueryActionResponse = { maxQueryPeriod?: number; }; -export type QueryEditorMode = +type QueryEditorMode = | { type: "standalone" } | { type: "dashboard-add"; dashboardId: string; dashboardName: string } | { @@ -377,22 +377,25 @@ export function QueryEditor({ // Use defaultData as initial results, then switch to fetcher data once a query is run const fetcherResults = fetcher.data; - const results = - fetcherResults ?? - (defaultData - ? { - error: null, - rows: defaultData.rows, - columns: defaultData.columns, - stats: null, - hiddenColumns: null, - reachedMaxRows: false, - explainOutput: null, - generatedSql: null, - queryId: null, - periodClipped: null, - } - : null); + const results = useMemo( + () => + fetcherResults ?? + (defaultData + ? { + error: null, + rows: defaultData.rows, + columns: defaultData.columns, + stats: null, + hiddenColumns: null, + reachedMaxRows: false, + explainOutput: null, + generatedSql: null, + queryId: null, + periodClipped: null, + } + : null), + [defaultData, fetcherResults] + ); const organization = useOrganization(); const project = useProject(); @@ -502,6 +505,7 @@ export function QueryEditor({ // Use a ref so the effect can read chartConfig without re-firing on every config tweak const chartConfigRef = useRef(chartConfig); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. chartConfigRef.current = chartConfig; // Reset chart config only when a column referenced by the current config is no @@ -559,6 +563,7 @@ export function QueryEditor({ }, []); // Compute current save data for the save render prop + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. const currentQuery = editorRef.current?.getQuery() ?? ""; const saveData: QueryEditorSaveData = { title: queryTitle ?? "Untitled Query", @@ -787,6 +792,7 @@ export function QueryEditor({ onRename={handleRenameTitle} /> } + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. query={editorRef.current?.getQuery() ?? defaultQuery} data={{ rows: results.rows, @@ -841,6 +847,7 @@ export function QueryEditor({ { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setRenameValue(title ?? ""); }, [title]); @@ -1187,38 +1197,36 @@ function ResultsChart({ accessory?: ReactNode; }) { return ( - <> - - -
- - } - query={query} - data={{ - rows, - columns, - }} - config={{ - type: "chart", - ...chartConfig, - }} - accessory={accessory} - /> -
-
- - - - -
- + + +
+ + } + query={query} + data={{ + rows, + columns, + }} + config={{ + type: "chart", + ...chartConfig, + }} + accessory={accessory} + /> +
+
+ + + + +
); } @@ -1257,51 +1265,49 @@ function ResultsBigNumber({ accessory?: ReactNode; }) { // Auto-select first numeric column if none selected - const numericColumns = columns.filter((c) => isNumericColumnType(c.type)); + const firstNumericColumn = columns.find((column) => isNumericColumnType(column.type)); useEffect(() => { - if (!bigNumberConfig.column && numericColumns.length > 0) { - onBigNumberConfigChange({ ...bigNumberConfig, column: numericColumns[0].name }); + if (!bigNumberConfig.column && firstNumericColumn) { + onBigNumberConfigChange({ ...bigNumberConfig, column: firstNumericColumn.name }); } - }, [columns]); + }, [bigNumberConfig, firstNumericColumn, onBigNumberConfigChange]); return ( - <> - - -
- - } - query={query} - data={{ - rows, - columns, - }} - config={{ - type: "bignumber", - ...bigNumberConfig, - }} - accessory={accessory} - /> -
-
- - - + +
+ + } + query={query} + data={{ + rows, + columns, + }} + config={{ + type: "bignumber", + ...bigNumberConfig, + }} + accessory={accessory} /> - - - +
+
+ + + + +
); } diff --git a/apps/webapp/app/components/queues/QueueControls.tsx b/apps/webapp/app/components/queues/QueueControls.tsx index 7817749b0cd..b2499bb6d0e 100644 --- a/apps/webapp/app/components/queues/QueueControls.tsx +++ b/apps/webapp/app/components/queues/QueueControls.tsx @@ -183,6 +183,7 @@ export function QueueOverrideConcurrencyButton({ useEffect(() => { if (navigation.state === "loading" || navigation.state === "idle") { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setIsOpen(false); } }, [navigation.state]); diff --git a/apps/webapp/app/components/queues/QueueMetricCards.tsx b/apps/webapp/app/components/queues/QueueMetricCards.tsx index 294a94b8650..7c87a1e2d02 100644 --- a/apps/webapp/app/components/queues/QueueMetricCards.tsx +++ b/apps/webapp/app/components/queues/QueueMetricCards.tsx @@ -6,18 +6,14 @@ import { type ChartState, } from "~/components/primitives/charts/ChartCompound"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; -import { MiniLineChart } from "~/components/metrics/MiniLineChart"; import { useMetricResourceQuery, type MetricResourceTimeRange, } from "~/hooks/useMetricResourceQuery"; -import { Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { InfoIconTooltip } from "~/components/primitives/Tooltip"; import { useSearchParams } from "~/hooks/useSearchParam"; import { QUEUE_METRICS_DEFAULT_PERIOD } from "~/components/queues/queueMetricsPeriod"; -import { cn } from "~/utils/cn"; -import { formatNumberCompact } from "~/utils/numberFormatter"; // Shared building blocks for queue-metric UI (queue detail page, task detail page, // run inspector). All CH-derived data is fetched client-side through useQueueMetric @@ -86,7 +82,7 @@ export function formatWaitMs(ms: number): string { return `${(ms / 3_600_000).toFixed(1)}h`; } -export type QueueMetricSeriesConfig = { key: string; label: string; color: string }; +type QueueMetricSeriesConfig = { key: string; label: string; color: string }; type QueueMetricChartProps = { query: string; @@ -367,115 +363,3 @@ export function QueueSidebarStats({ // A compact stat card with a recent trend sparkline underneath, for the run inspector. // The headline is a live "now" value from the loader; the sparkline pulls its own series. -const SPARKLINE_PERIOD = "30m"; - -export function QueueSparklineStat({ - title, - info, - query, - color, - ids, - queueName, - formatPeak, - unitLabel, - chartHeight, -}: { - title: string; - /** Tooltip text under the info icon next to the title (matches the queue page copy). */ - info?: ReactNode; - query: string; - color: string; - ids: QueueMetricIds; - queueName: string; - formatPeak?: (peak: number) => string; - /** Unit shown in the per-bucket hover tooltip (e.g. queued, ms). */ - unitLabel?: { singular: string; plural: string }; - /** Plot height in px. Defaults to the shared mini-chart height. */ - chartHeight?: number; -}) { - const timeRange: QueueMetricTimeRange = { period: SPARKLINE_PERIOD, from: null, to: null }; - const { rows } = useQueueMetric(query, { - ids, - timeRange, - queueName, - fillGaps: true, - defaultPeriod: SPARKLINE_PERIOD, - }); - - const { data, throttled, bucketStartMs, bucketIntervalMs, peak } = useMemo(() => { - const points = rows - .map((r) => ({ - bucket: clickhouseTimeToMs(r.t), - v: toNumber(r.v), - // Present only when the query selects it (Backlog); 0 elsewhere so no overlay draws. - throttled: toNumber(r.throttled), - })) - .filter((p) => Number.isFinite(p.bucket)) - .sort((a, b) => a.bucket - b.bucket); - return { - data: points.map((p) => p.v), - throttled: points.map((p) => p.throttled), - bucketStartMs: points[0]?.bucket, - bucketIntervalMs: points.length > 1 ? points[1]!.bucket - points[0]!.bucket : undefined, - peak: points.reduce((m, p) => Math.max(m, p.v), 0), - }; - }, [rows]); - - return ( -
-
- {title} - {info || (data.length > 0 && peak > 0) ? ( - - {info ? {info} : null} - {data.length > 0 && peak > 0 ? ( - - Peak {formatPeak ? formatPeak(peak) : formatNumberCompact(peak)} - - ) : null} -
- } - contentClassName="max-w-[230px]" - disableHoverableContent - /> - ) : null} -
- -
- ); -} - -export function QueueMetricStat({ - label, - value, - className, - loading, -}: { - label: string; - value: string; - className?: string; - loading?: boolean; -}) { - return ( -
-
{label}
- {loading ? ( -
- ) : ( -
{value}
- )} -
- ); -} diff --git a/apps/webapp/app/components/run/RunTimeline.tsx b/apps/webapp/app/components/run/RunTimeline.tsx index edc576980c6..6fabadaf8a1 100644 --- a/apps/webapp/app/components/run/RunTimeline.tsx +++ b/apps/webapp/app/components/run/RunTimeline.tsx @@ -26,7 +26,7 @@ export type TimelineEventVariant = | "end-cap"; // Timeline item type definitions -export type TimelineEventDefinition = { +type TimelineEventDefinition = { type: "event"; id: string; title: string; @@ -38,7 +38,7 @@ export type TimelineEventDefinition = { helpText?: string; }; -export type TimelineLineDefinition = { +type TimelineLineDefinition = { type: "line"; id: string; title: React.ReactNode; @@ -581,8 +581,6 @@ export type SpanTimelineProps = { style?: TimelineStyle; }; -export type SpanTimelineState = "error" | "pending" | "complete"; - export function SpanTimeline({ startTime, duration, @@ -596,86 +594,84 @@ export function SpanTimeline({ const visibleEvents = events ?? []; return ( - <> -
- {visibleEvents.map((event, index) => { - // Store previous date to compare - const prevDate = index === 0 ? null : visibleEvents[index - 1].timestamp; +
+ {visibleEvents.map((event, index) => { + // Store previous date to compare + const prevDate = index === 0 ? null : visibleEvents[index - 1].timestamp; - return ( - - } - variant={event.markerVariant} - state={state} - helpText={event.helpText} - style={style} - /> - - - ); - })} - 0 ? visibleEvents[visibleEvents.length - 1].timestamp : null + return ( + + } + variant={event.markerVariant} + state={state} + helpText={event.helpText} + style={style} + /> + - } - variant={"start-cap-thick"} + + ); + })} + 0 ? visibleEvents[visibleEvents.length - 1].timestamp : null + } + /> + } + variant={"start-cap-thick"} + state={state} + helpText={getHelpTextForEvent("Started")} + style={style} + /> + {state === "inprogress" ? ( + } state={state} - helpText={getHelpTextForEvent("Started")} + variant="normal" style={style} /> - {state === "inprogress" ? ( + ) : ( + <> } - state={state} + title={formatDuration( + startTime, + new Date(startTime.getTime() + nanosecondsToMilliseconds(duration)) + )} + state={isError ? "error" : undefined} variant="normal" style={style} /> - ) : ( - <> - - - } - state={isError ? "error" : undefined} - variant="end-cap-thick" - helpText={getHelpTextForEvent("Finished")} - style={style} - /> - - )} -
- + + } + state={isError ? "error" : undefined} + variant="end-cap-thick" + helpText={getHelpTextForEvent("Finished")} + style={style} + /> + + )} +
); } diff --git a/apps/webapp/app/components/runs/v3/AIFilterInput.tsx b/apps/webapp/app/components/runs/v3/AIFilterInput.tsx index d6a6b32340b..664cffcf68c 100644 --- a/apps/webapp/app/components/runs/v3/AIFilterInput.tsx +++ b/apps/webapp/app/components/runs/v3/AIFilterInput.tsx @@ -39,6 +39,7 @@ export function AIFilterInput() { useEffect(() => { if (fetcher.data?.success && fetcher.state === "loading") { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setText(""); setIsFocused(false); @@ -53,7 +54,7 @@ export function AIFilterInput() { inputRef.current.focus(); } } - }, [fetcher.data, navigate]); + }, [fetcher.data, fetcher.state, navigate]); const isLoading = fetcher.state === "submitting"; @@ -184,6 +185,7 @@ function ErrorPopover({ useEffect(() => { if (error) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setIsOpen(true); } if (timeout.current) { diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx new file mode 100644 index 00000000000..024d3731209 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -0,0 +1,407 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; +import { useEffect, useMemo, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon"; +import { Button } from "~/components/primitives/Buttons"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "~/components/primitives/Dialog"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; +import { + SMART_COLUMN_DISPLAYS, + type SmartColumnDef, + type SmartColumnDisplay, + type SmartColumnSource, +} from "./runColumns"; +import { + extractSmartValue, + labelFromPath, + parseSource, + type ParsedSource, +} from "./smartColumnData"; +import { SmartColumnSample } from "./SmartColumnSample"; +import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell"; +import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample"; + +type AddSmartColumnDialogProps = { + open: boolean; + /** When set, the dialog edits this existing column instead of adding a new one. */ + editing: SmartColumnDef | null; + onOpenChange: (open: boolean) => void; + onSubmit: (def: SmartColumnDef) => void; + currentSearch: string; + /** + * Extra filters merged into the sample request so the preview samples the + * runs the host page actually lists (e.g. its task or error), for pages that + * carry that scope in the route path rather than the query string. + */ + sampleFilters?: Record; +}; + +const SOURCE_CARDS: { value: SmartColumnSource; label: string; description: string }[] = [ + { value: "payload", label: "Payload", description: "What you triggered the run with." }, + { value: "metadata", label: "Metadata", description: "What the run writes while it runs." }, + { value: "output", label: "Output", description: "What the run returned." }, +]; + +const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ + label: display.charAt(0).toUpperCase() + display.slice(1), + value: display, +})); + +const DEFAULT_SOURCE: SmartColumnSource = "payload"; + +/** One title row for all three columns, so their labels and content line up. */ +const TITLE_ROW_CLASS = "flex min-h-6 items-center"; + +/** + * The sample and preview panels fill their column but contribute no height to it, so the + * dialog is sized by the form alone. Without this, wrapped preview text pushed the whole + * dialog taller as you typed. + */ +const PANEL_FRAME_CLASS = "relative min-h-0 flex-1"; + +export function AddSmartColumnDialog({ + open, + editing, + onOpenChange, + onSubmit, + currentSearch, + sampleFilters, +}: AddSmartColumnDialogProps) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const sample = useTypedFetcher(); + + const [source, setSource] = useState(DEFAULT_SOURCE); + const [path, setPath] = useState(""); + const [label, setLabel] = useState(""); + const [labelEdited, setLabelEdited] = useState(false); + const [displayAs, setDisplayAs] = useState("text"); + const [sampleIndex, setSampleIndex] = useState(0); + + useEffect(() => { + if (!open) return; + setSource(editing?.source ?? DEFAULT_SOURCE); + setPath(editing?.path ?? ""); + setLabel(editing?.label ?? ""); + setLabelEdited(editing !== null); + setDisplayAs(editing?.displayAs ?? "text"); + setSampleIndex(0); + }, [open, editing]); + + const sampleFiltersKey = sampleFilters ? JSON.stringify(sampleFilters) : ""; + const sampleUrl = useMemo(() => { + const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/smart-column-sample`; + const params = new URLSearchParams(currentSearch.replace(/^\?/, "")); + if (sampleFilters) { + for (const [key, val] of Object.entries(sampleFilters)) params.set(key, val); + } + params.set("source", source); + const qs = params.toString(); + return qs ? `${base}?${qs}` : base; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [organization.slug, project.slug, environment.slug, currentSearch, sampleFiltersKey, source]); + + useEffect(() => { + if (open) { + sample.load(sampleUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, sampleUrl]); + + useEffect(() => { + setSampleIndex(0); + }, [source]); + + const handleSourceChange = (next: SmartColumnSource) => { + if (next === source) return; + setSource(next); + setPath(""); + setLabel(""); + setLabelEdited(false); + }; + + const effectiveLabel = labelEdited ? label : labelFromPath(path); + + const sampleLoaded = sample.data !== undefined && sample.state === "idle"; + const sampleData = sample.data; + + const { perRun, usable, anyOffloaded, runCount } = useMemo(() => { + const runs = sampleData?.runs ?? []; + const perRun = runs.map((run) => ({ + hasFinished: run.hasFinished, + parsed: + source === "payload" + ? parseSource({ data: run.payload, dataType: run.payloadType }) + : source === "metadata" + ? parseSource({ data: run.metadata, dataType: run.metadataType }) + : parseSource({ data: run.output, dataType: run.outputType }), + })); + return { + runCount: runs.length, + perRun, + anyOffloaded: perRun.some((r) => r.parsed.state === "offloaded"), + usable: perRun.filter( + (r): r is { hasFinished: boolean; parsed: Extract } => + r.parsed.state === "parsed" + ), + }; + }, [sampleData, source]); + + const activeIndex = usable.length > 0 ? Math.min(sampleIndex, usable.length - 1) : 0; + const activeSample = usable[activeIndex]?.parsed; + + const canSubmit = path.trim().length > 0; + + const previewDef: SmartColumnDef = { + source, + path: path.trim(), + label: effectiveLabel, + displayAs, + }; + + const handleSubmit = () => { + if (!canSubmit) return; + onSubmit({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs }); + onOpenChange(false); + }; + + return ( + + {/* Bounded height with the columns absorbing it, so the stacked form can't push the + header or footer off a short screen. */} + + {editing ? "Edit smart column" : "Add smart column"} +
+ + Pick a source, then click a value in the sample payload to turn it into a column. Smart + columns are display only, so you can't sort or filter by them. + + +
+ {/* p-1/-m-1: overflow-y-auto clips at the content box, which cut the inputs' focus ring. */} +
+ +
+ +
+ handleSourceChange(next as SmartColumnSource)} + > + {SOURCE_CARDS.map((card) => ( + + ))} + +
+ + + + setPath(e.target.value)} + placeholder="$.order.total" + spellCheck={false} + /> + + e.g. $.order.total, $.items[0].sku,{" "} + $.items.length + + + + + + { + setLabel(e.target.value); + setLabelEdited(true); + }} + placeholder={labelFromPath(path)} + /> + + + + + setDisplayAs(next as SmartColumnDisplay)} + > + {DISPLAY_OPTIONS.map((option) => ( + + ))} + + +
+ +
+
+ + {usable.length > 1 && ( + setSampleIndex((i) => Math.max(0, i - 1))} + onNext={() => setSampleIndex((i) => Math.min(usable.length - 1, i + 1))} + /> + )} +
+
+
+ {!sampleLoaded ? ( + + Loadingโ€ฆ + + ) : activeSample ? ( + + ) : runCount === 0 ? ( + + No runs to sample yet. + + ) : anyOffloaded ? ( + + Recent {source}s are too large to sample here. + + ) : ( + + No recent run has a {source} to sample. + + )} +
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + +
+
+ ); +} + +function SampleRunPicker({ + index, + total, + onPrev, + onNext, +}: { + index: number; + total: number; + onPrev: () => void; + onNext: () => void; +}) { + return ( +
+ + +
+ ); +} + +function SmartColumnPreview({ + rows, + def, + loaded, +}: { + rows: { hasFinished: boolean; parsed: ParsedSource }[]; + def: SmartColumnDef; + loaded: boolean; +}) { + const numeric = isNumericSmartDisplay(def.displayAs); + const alignClass = numeric ? "justify-end text-right tabular-nums" : "justify-start text-left"; + + return ( +
+
+ + {def.label || "Column"} + + +
+
+ {!loaded ? ( +
Loadingโ€ฆ
+ ) : rows.length === 0 ? ( +
No runs yet
+ ) : ( + rows.map((row, index) => { + const cell = def.path + ? extractSmartValue(row.parsed, def.path) + : ({ state: "empty" } as const); + return ( +
+ +
+ ); + }) + )} +
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index 3dff30204d7..eaca1d7f401 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -40,7 +40,7 @@ import { TimeFilter, } from "./SharedFilters"; -export const BatchStatus = z.enum(allBatchStatuses); +const BatchStatus = z.enum(allBatchStatuses); export const BatchListFilters = z.object({ cursor: z.string().optional(), diff --git a/apps/webapp/app/components/runs/v3/BatchStatus.tsx b/apps/webapp/app/components/runs/v3/BatchStatus.tsx index 243c5eaac19..7a806f0c018 100644 --- a/apps/webapp/app/components/runs/v3/BatchStatus.tsx +++ b/apps/webapp/app/components/runs/v3/BatchStatus.tsx @@ -41,7 +41,7 @@ export function BatchStatusCombo({ ); } -export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) { +function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) { // system-mono-label: System themes uncolor the label (see tailwind.css) return ( @@ -50,13 +50,7 @@ export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) { ); } -export function BatchStatusIcon({ - status, - className, -}: { - status: BatchTaskRunStatus; - className: string; -}) { +function BatchStatusIcon({ status, className }: { status: BatchTaskRunStatus; className: string }) { switch (status) { case "PROCESSING": return ; @@ -74,7 +68,7 @@ export function BatchStatusIcon({ } } -export function batchStatusColor(status: BatchTaskRunStatus): string { +function batchStatusColor(status: BatchTaskRunStatus): string { switch (status) { case "PROCESSING": return "text-blue-500"; diff --git a/apps/webapp/app/components/runs/v3/BulkAction.tsx b/apps/webapp/app/components/runs/v3/BulkAction.tsx index c472ebdaaef..3b8832888ad 100644 --- a/apps/webapp/app/components/runs/v3/BulkAction.tsx +++ b/apps/webapp/app/components/runs/v3/BulkAction.tsx @@ -23,11 +23,11 @@ export function BulkActionTypeCombo({ ); } -export function BulkActionLabel({ type, className }: { type: BulkActionType; className?: string }) { +function BulkActionLabel({ type, className }: { type: BulkActionType; className?: string }) { return {bulkActionTitle(type)}; } -export function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) { +function BulkActionIcon({ type, className }: { type: BulkActionType; className: string }) { switch (type) { case "REPLAY": return ; @@ -39,7 +39,7 @@ export function BulkActionIcon({ type, className }: { type: BulkActionType; clas } } -export function bulkActionClassName(type: BulkActionType): string { +function bulkActionClassName(type: BulkActionType): string { switch (type) { case "REPLAY": return "text-indigo-500"; @@ -51,7 +51,7 @@ export function bulkActionClassName(type: BulkActionType): string { } } -export function bulkActionTitle(type: BulkActionType): string { +function bulkActionTitle(type: BulkActionType): string { switch (type) { case "REPLAY": return "Replay"; @@ -63,18 +63,6 @@ export function bulkActionTitle(type: BulkActionType): string { } } -export function bulkActionVerb(type: BulkActionType): string { - switch (type) { - case "REPLAY": - return "Replaying"; - case "CANCEL": - return "Canceling"; - default: { - assertNever(type); - } - } -} - export function BulkActionStatusCombo({ status, className, @@ -94,7 +82,7 @@ export function BulkActionStatusCombo({ ); } -export function BulkActionStatusIcon({ +function BulkActionStatusIcon({ status, className, }: { @@ -114,7 +102,7 @@ export function BulkActionStatusIcon({ } } -export function BulkActionStatusLabel({ +function BulkActionStatusLabel({ status, className, }: { diff --git a/apps/webapp/app/components/runs/v3/CheckBatchCompletionDialog.tsx b/apps/webapp/app/components/runs/v3/CheckBatchCompletionDialog.tsx deleted file mode 100644 index 1f5f07bbf72..00000000000 --- a/apps/webapp/app/components/runs/v3/CheckBatchCompletionDialog.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { DialogClose } from "@radix-ui/react-dialog"; -import { Form, useNavigation } from "@remix-run/react"; -import { Button } from "~/components/primitives/Buttons"; -import { DialogContent, DialogHeader } from "~/components/primitives/Dialog"; -import { FormButtons } from "~/components/primitives/FormButtons"; -import { Paragraph } from "~/components/primitives/Paragraph"; -import { SpinnerWhite } from "~/components/primitives/Spinner"; - -type CheckBatchCompletionDialogProps = { - batchId: string; - redirectPath: string; -}; - -export function CheckBatchCompletionDialog({ - batchId, - redirectPath, -}: CheckBatchCompletionDialogProps) { - const navigation = useNavigation(); - - const formAction = `/resources/batches/${batchId}/check-completion`; - const isLoading = navigation.formAction === formAction; - - return ( - - Try and resume batch -
- - In rare cases, parent runs don't continue after child runs have completed. - - - If this doesn't help, please get in touch. We are working on a permanent fix for this. - - - - - } - cancelButton={ - - - - } - /> -
-
- ); -} diff --git a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx index aae5f97ecb4..e72628bad4c 100644 --- a/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx +++ b/apps/webapp/app/components/runs/v3/DeploymentStatus.tsx @@ -27,7 +27,7 @@ export function DeploymentStatus({ ); } -export function DeploymentStatusLabel({ +function DeploymentStatusLabel({ status, isBuilt, }: { @@ -42,7 +42,7 @@ export function DeploymentStatusLabel({ ); } -export function DeploymentStatusIcon({ +function DeploymentStatusIcon({ status, className, }: { @@ -76,7 +76,7 @@ export function DeploymentStatusIcon({ } } -export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string { +function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): string { switch (status) { case "PENDING": return "text-text-faint"; @@ -97,7 +97,7 @@ export function deploymentStatusClassNameColor(status: WorkerDeploymentStatus): } } -export function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string { +function deploymentStatusTitle(status: WorkerDeploymentStatus, isBuilt: boolean): string { switch (status) { case "PENDING": return "Queuedโ€ฆ"; diff --git a/apps/webapp/app/components/runs/v3/LiveTimer.tsx b/apps/webapp/app/components/runs/v3/LiveTimer.tsx index 953bfb320b4..496acbf8638 100644 --- a/apps/webapp/app/components/runs/v3/LiveTimer.tsx +++ b/apps/webapp/app/components/runs/v3/LiveTimer.tsx @@ -23,7 +23,7 @@ export function LiveTimer({ }, updateInterval); return () => clearInterval(interval); - }, [startTime, endTime]); + }, [startTime, endTime, updateInterval]); return ( <> @@ -36,37 +36,6 @@ export function LiveTimer({ ); } -export function LiveCountUp({ - lastUpdated, - updateInterval = 250, - className, -}: { - lastUpdated: Date; - updateInterval?: number; - className?: string; -}) { - const [now, setNow] = useState(); - - useEffect(() => { - const interval = setInterval(() => { - const date = new Date(); - setNow(date); - }, updateInterval); - - return () => clearInterval(interval); - }, [lastUpdated]); - - return ( - <> - {formatDuration(lastUpdated, now, { - style: "short", - maxDecimalPoints: 0, - units: ["m", "s"], - })} - - ); -} - export function LiveCountdown({ endTime, updateInterval = 100, @@ -87,7 +56,7 @@ export function LiveCountdown({ }, updateInterval); return () => clearInterval(interval); - }, [endTime]); + }, [endTime, updateInterval]); return ( <> diff --git a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx index 86747f9e277..65381312d88 100644 --- a/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx +++ b/apps/webapp/app/components/runs/v3/ReplayRunDialog.tsx @@ -54,8 +54,10 @@ export function ReplayRunDialog({ runFriendlyId, failedRedirect }: ReplayRunDial function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) { const replayDataFetcher = useTypedFetcher(); + const { load: loadReplayData } = replayDataFetcher; const isLoading = replayDataFetcher.state === "loading"; const queueFetcher = useTypedFetcher(); + const { load: loadQueues } = queueFetcher; const [environmentIdOverride, setEnvironmentIdOverride] = useState(undefined); @@ -65,34 +67,35 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) searchParams.set("environmentIdOverride", environmentIdOverride); } - replayDataFetcher.load( - `/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}` - ); - }, [runFriendlyId, environmentIdOverride]); + loadReplayData(`/resources/taskruns/${runFriendlyId}/replay?${searchParams.toString()}`); + }, [environmentIdOverride, loadReplayData, runFriendlyId]); const params = useParams(); + const environmentOverrideSlug = environmentIdOverride + ? replayDataFetcher.data?.environments.find((env) => env.id === environmentIdOverride)?.slug + : undefined; + useEffect(() => { if (params.organizationSlug && params.projectParam && params.envParam) { const searchParams = new URLSearchParams(); searchParams.set("type", "custom"); searchParams.set("per_page", "100"); - let envSlug = params.envParam; - - if (environmentIdOverride) { - const environmentOverride = replayDataFetcher.data?.environments.find( - (env) => env.id === environmentIdOverride - ); - envSlug = environmentOverride?.slug ?? envSlug; - } + const envSlug = environmentOverrideSlug ?? params.envParam; - queueFetcher.load( + loadQueues( `/resources/orgs/${params.organizationSlug}/projects/${ params.projectParam }/env/${envSlug}/queues?${searchParams.toString()}` ); } - }, [params.organizationSlug, params.projectParam, params.envParam, environmentIdOverride]); + }, [ + environmentOverrideSlug, + loadQueues, + params.envParam, + params.organizationSlug, + params.projectParam, + ]); const customQueues = useMemo(() => { return queueFetcher.data?.queues ?? []; @@ -124,6 +127,22 @@ function ReplayContent({ runFriendlyId, failedRedirect }: ReplayRunDialogProps) const startingJson = "{\n\n}"; const machinePresets = Object.values(MachinePresetName.enum); +type ReplayEnvironment = UseDataFunctionReturn["environments"][number]; + +function renderReplayEnvironment( + environments: ReplayEnvironment[], + value: string +): React.ReactNode { + const environment = environments.find((environment) => environment.id === value); + if (!environment) return; + + return ( +
+ +
+ ); +} + function ReplayForm({ failedRedirect, runFriendlyId, @@ -572,14 +591,7 @@ function ReplayForm({ (item) => item.branchName?.replace(/\//g, " ").replace(/_/g, " ") ?? "", ], }} - text={(value) => { - const env = replayData.environments.find((env) => env.id === value)!; - return ( -
- -
- ); - }} + text={(value) => renderReplayEnvironment(replayData.environments, value)} > {(matches) => matches.map((env) => ( diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 560ce0fea39..f1168307da5 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -87,7 +87,7 @@ import { } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; -export const RunStatus = z.enum(allTaskRunStatuses); +const RunStatus = z.enum(allTaskRunStatuses); const StringOrStringArray = z.preprocess((value) => { if (typeof value === "string") { @@ -105,7 +105,7 @@ const StringOrStringArray = z.preprocess((value) => { return undefined; }, z.string().array().optional()); -export const MachinePresetOrMachinePresetArray = z.preprocess((value) => { +const MachinePresetOrMachinePresetArray = z.preprocess((value) => { if (typeof value === "string") { if (value.length > 0) { const parsed = MachinePresetName.safeParse(value); @@ -406,6 +406,15 @@ export function RunsFilters(props: RunFiltersProps) { {hasFilters && (
+ {searchParams.getAll("cols").map((v, i) => ( + + ))} + {searchParams.getAll("hide").map((v, i) => ( + + ))} + {searchParams.getAll("sc").map((v, i) => ( + + ))} } content={copied ? "Copied!" : "Copy tag"} disableHoverableContent @@ -158,18 +162,22 @@ function DeleteButton({ return ( e.stopPropagation()} className={cn( - "absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover", - isHovered ? "flex" : "hidden", + "absolute -right-6 top-0 z-10 flex size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100", + isHovered ? "opacity-100" : "pointer-events-none opacity-0", "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-rose-400" )} > - + } content="Remove tag" disableHoverableContent diff --git a/apps/webapp/app/components/runs/v3/RunTagInput.tsx b/apps/webapp/app/components/runs/v3/RunTagInput.tsx index 25d818f402e..f25a1106fa9 100644 --- a/apps/webapp/app/components/runs/v3/RunTagInput.tsx +++ b/apps/webapp/app/components/runs/v3/RunTagInput.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect, type KeyboardEvent } from "react"; +import { useCallback, useState, type KeyboardEvent } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Input } from "~/components/primitives/Input"; import { RunTag } from "./RunTag"; @@ -26,39 +26,30 @@ export function RunTagInput({ maxTagLength = 128, onTagsChange, }: TagInputProps) { - // Use controlled tags if provided, otherwise use default - const initialTags = controlledTags ?? defaultTags; - - const [tags, setTags] = useState(initialTags); + const [internalTags, setInternalTags] = useState(defaultTags); + const tags = controlledTags ?? internalTags; const [inputValue, setInputValue] = useState(""); - // Sync internal state with external tag changes - useEffect(() => { - if (controlledTags !== undefined) { - setTags(controlledTags); - } - }, [controlledTags]); - const addTag = useCallback( (tagText: string) => { const trimmedTag = tagText.trim(); if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) { const newTags = [...tags, trimmedTag]; - setTags(newTags); + if (controlledTags === undefined) setInternalTags(newTags); onTagsChange?.(newTags); } setInputValue(""); }, - [tags, onTagsChange, maxTags] + [tags, controlledTags, onTagsChange, maxTags] ); const removeTag = useCallback( (tagToRemove: string) => { const newTags = tags.filter((tag) => tag !== tagToRemove); - setTags(newTags); + if (controlledTags === undefined) setInternalTags(newTags); onTagsChange?.(newTags); }, - [tags, onTagsChange] + [tags, controlledTags, onTagsChange] ); const handleKeyDown = useCallback( diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx new file mode 100644 index 00000000000..d93e4db1ff6 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -0,0 +1,422 @@ +import { PencilSquareIcon, StarIcon as StarIconSolid, XMarkIcon } from "@heroicons/react/20/solid"; +import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline"; +import { GripVerticalIcon } from "lucide-react"; +import { useMemo, useRef, useState } from "react"; +import { ColumnsIcon } from "~/assets/icons/ColumnsIcon"; +import { ResetIcon } from "~/assets/icons/ResetIcon"; +import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon"; +import { useFavoritePageToggle } from "~/components/navigation/favoritePages"; +import { Button } from "~/components/primitives/Buttons"; +import { Checkbox } from "~/components/primitives/Checkbox"; +import { + Popover, + PopoverContent, + PopoverMenuItem, + PopoverTrigger, +} from "~/components/primitives/Popover"; +import { ShortcutKey } from "~/components/primitives/ShortcutKey"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useFeatures } from "~/hooks/useFeatures"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { cn } from "~/utils/cn"; +import { + encodeColumnLayout, + parseColumnParams, + resolveColumnLayout, + type LayoutColumn, + type ResolvedColumn, + type RunColumnRuntime, + type SmartColumnDef, +} from "./runColumns"; +import { AddSmartColumnDialog } from "./AddSmartColumnDialog"; + +function keyFor(col: ResolvedColumn): string { + return col.kind === "standard" ? `std:${col.def.id}` : `smart:${col.index}`; +} + +type SmartEditTarget = { index: number; def: SmartColumnDef }; + +/** The three footer actions share one icon size so the mixed icon sets line up. */ +const FOOTER_ICON_CLASS = "size-[1.15rem]"; + +/** Opens the Columns popover. "l" is free on every list this control appears on. */ +export const COLUMNS_SHORTCUT = { key: "l" as const }; + +export function RunsDisplayOptions({ + sampleFilters, +}: { + sampleFilters?: Record; +} = {}) { + const environment = useEnvironment(); + const { isManagedCloud } = useFeatures(); + const location = useOptimisticLocation(); + const { value, values, replace } = useSearchParams(); + // Same favorite the page-header star toggles, so the two stay in lockstep on this URL. + const { isFavorited, canFavorite, toggle: toggleFavorite } = useFavoritePageToggle(); + const [addOpen, setAddOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [dragKey, setDragKey] = useState(null); + const [overKey, setOverKey] = useState(null); + const [open, setOpen] = useState(false); + // Whether this open came from the shortcut, which decides if focus moves into the list. + const openedByShortcut = useRef(false); + + useShortcutKeys({ + shortcut: COLUMNS_SHORTCUT, + action: (event) => { + event.preventDefault(); + event.stopPropagation(); + openedByShortcut.current = true; + setOpen((previous) => !previous); + }, + }); + + const runtime: RunColumnRuntime = { + isManagedCloud, + isDevelopment: environment.type === "DEVELOPMENT", + }; + + const colsParam = value("cols"); + const hideParam = value("hide"); + const sc = values("sc"); + const layout = useMemo( + () => resolveColumnLayout(parseColumnParams(colsParam, sc, hideParam), runtime), + // eslint-disable-next-line react-hooks/exhaustive-deps + [colsParam, hideParam, sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment] + ); + + const totalCount = layout.ordered.filter((o) => o.col.kind === "standard").length; + const shownCount = layout.ordered.filter((o) => o.col.kind === "standard" && !o.hidden).length; + + const applyLayout = (next: LayoutColumn[]) => { + const encoded = encodeColumnLayout(next, runtime); + replace({ + cols: encoded.cols.length > 0 ? encoded.cols.join(",") : undefined, + sc: encoded.sc.length > 0 ? encoded.sc : undefined, + hide: encoded.hide.length > 0 ? encoded.hide.join(",") : undefined, + }); + }; + + const reset = () => replace({ cols: undefined, sc: undefined, hide: undefined }); + + const toggleHidden = (key: string) => { + applyLayout( + layout.ordered.map((o) => (keyFor(o.col) === key ? { ...o, hidden: !o.hidden } : o)) + ); + }; + + const removeSmart = (index: number) => { + applyLayout(layout.ordered.filter((o) => !(o.col.kind === "smart" && o.col.index === index))); + }; + + const submitSmart = (def: SmartColumnDef) => { + if (editing) { + applyLayout( + layout.ordered.map((o) => + o.col.kind === "smart" && o.col.index === editing.index + ? { ...o, col: { ...o.col, def } } + : o + ) + ); + } else { + applyLayout([ + ...layout.ordered, + { col: { kind: "smart", index: layout.smartColumns.length, def }, hidden: false }, + ]); + } + }; + + const reorder = (fromKey: string, toKey: string) => { + if (fromKey === toKey) return; + const arr = [...layout.ordered]; + const from = arr.findIndex((o) => keyFor(o.col) === fromKey); + const to = arr.findIndex((o) => keyFor(o.col) === toKey); + if (from < 0 || to < 0) return; + const [moved] = arr.splice(from, 1); + arr.splice(from < to ? to - 1 : to, 0, moved); + applyLayout(arr); + }; + + const move = (key: string, delta: number) => { + const arr = [...layout.ordered]; + const from = arr.findIndex((o) => keyFor(o.col) === key); + const to = from + delta; + if (from < 0 || to < 0 || to >= arr.length) return; + const [moved] = arr.splice(from, 1); + arr.splice(to, 0, moved); + applyLayout(arr); + }; + + const endDrag = () => { + setDragKey(null); + setOverKey(null); + }; + + return ( + <> + { + setOpen(next); + if (!next) openedByShortcut.current = false; + }} + > + + + + +
+ } + content={ + + Customize columns + + + } + /> + { + if (!openedByShortcut.current) event.preventDefault(); + openedByShortcut.current = false; + }} + > +
+ Columns + + {shownCount} of {totalCount} + +
+
+ {layout.ordered.map(({ col, hidden }) => { + const key = keyFor(col); + return ( + setDragKey(key)} + onDragEnter={() => setOverKey(key)} + onDragEnd={endDrag} + onDrop={() => { + if (dragKey) reorder(dragKey, key); + endDrag(); + }} + onToggle={() => toggleHidden(key)} + onMove={(delta) => move(key, delta)} + onEdit={ + col.kind === "smart" + ? () => setEditing({ index: col.index, def: col.def }) + : undefined + } + onRemove={col.kind === "smart" ? () => removeSmart(col.index) : undefined} + /> + ); + })} +
+
+ setAddOpen(true)} + className="h-8" + leadingIconClassName={FOOTER_ICON_CLASS} + /> + {canFavorite && ( + + ) : ( + // The outline star is 1.5px by default, noticeably thinner than the + // custom 2px icons beside it. + + ) + } + title={isFavorited ? "Remove from favorites" : "Save to favorites"} + onClick={toggleFavorite} + className="h-8" + /> + )} + {/* Wrapper carries the cursor: the disabled button has pointer-events-none. */} +
+ +
+
+
+ + { + if (!next) { + setAddOpen(false); + setEditing(null); + } + }} + onSubmit={submitSmart} + currentSearch={location.search} + sampleFilters={sampleFilters} + /> + + ); +} + +/** The label owns the focus ring (see ColumnRow), so the checkbox itself never rings. */ +const CHECKBOX_NO_RING = "focus:ring-0 group-focus:ring-0 focus-visible:ring-0"; + +/** + * The row's hover-revealed actions. Square, and hidden until the row is hovered or the + * control itself takes keyboard focus (a checkbox click must not reveal them). + */ +const ROW_ACTION_CLASS = + "aspect-square h-6 p-1 opacity-0 transition group-hover:opacity-100 group-focus-visible/button:opacity-100"; + +function ColumnRow({ + col, + checked, + locked, + dragging, + isOver, + onToggle, + onMove, + onEdit, + onRemove, + onDragStart, + onDragEnter, + onDragEnd, + onDrop, +}: { + col: ResolvedColumn; + checked: boolean; + locked: boolean; + dragging: boolean; + isOver: boolean; + onToggle: () => void; + onMove: (delta: number) => void; + onEdit?: () => void; + onRemove?: () => void; + onDragStart: () => void; + onDragEnter: () => void; + onDragEnd: () => void; + onDrop: () => void; +}) { + const isSmart = col.kind === "smart"; + + return ( +
{ + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", ""); + onDragStart(); + }} + onDragEnter={onDragEnter} + onDragEnd={onDragEnd} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + onDrop(); + }} + > + {isOver &&
} + {/* Native label so the whole name area toggles the column, matching CheckboxWithLabel. */} + {/* The label is the hit area, so it carries the focus ring rather than the checkbox + inside it, and only for keyboard focus -- a click must not ring anything. */} + +
+ {onRemove && ( +
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx index de1c6b3ad7a..ce8f9c5f001 100644 --- a/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx +++ b/apps/webapp/app/components/runs/v3/ScheduleFilters.tsx @@ -1,22 +1,4 @@ -import * as Ariakit from "@ariakit/react"; -import { ClockIcon, XMarkIcon } from "@heroicons/react/20/solid"; -import { useNavigate } from "@remix-run/react"; -import { useCallback, useRef } from "react"; import { z } from "zod"; -import { AppliedFilter } from "~/components/primitives/AppliedFilter"; -import { SearchInput } from "~/components/primitives/SearchInput"; -import { - SelectItem, - SelectList, - SelectPopover, - SelectProvider, -} from "~/components/primitives/Select"; -import { ShortcutKey } from "~/components/primitives/ShortcutKey"; -import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; -import { useShortcutKeys } from "~/hooks/useShortcutKeys"; -import { Button } from "../../primitives/Buttons"; -import { ScheduleTypeIcon, scheduleTypeName } from "./ScheduleType"; -import { FilterMenuProvider } from "./SharedFilters"; export const ScheduleListFilters = z.object({ page: z.coerce.number().default(1), @@ -29,233 +11,3 @@ export const ScheduleListFilters = z.object({ }); export type ScheduleListFilters = z.infer; - -type ScheduleFiltersProps = { - possibleTasks: string[]; -}; - -export function ScheduleFilters({ possibleTasks }: ScheduleFiltersProps) { - const location = useOptimisticLocation(); - const searchParams = new URLSearchParams(location.search); - const hasFilters = - searchParams.has("tasks") || searchParams.has("search") || searchParams.has("type"); - - return ( -
- - - - {hasFilters && } -
- ); -} - -function ScheduleSearchInput() { - return ; -} - -const typeShortcut = { key: "y" }; - -function PermanentTypeFilter() { - const navigate = useNavigate(); - const location = useOptimisticLocation(); - const searchParams = new URLSearchParams(location.search); - const currentType = searchParams.get("type") ?? undefined; - const triggerRef = useRef(null); - - useShortcutKeys({ - shortcut: typeShortcut, - action: (e) => { - e.preventDefault(); - e.stopPropagation(); - triggerRef.current?.click(); - }, - }); - - const handleChange = useCallback( - (value: string | string[]) => { - const selected = Array.isArray(value) ? value[0] : value; - const params = new URLSearchParams(location.search); - if (!selected || selected === "ALL") { - params.delete("type"); - } else { - params.set("type", selected); - } - params.delete("page"); - navigate(`${location.pathname}?${params.toString()}`); - }, - [location, navigate] - ); - - const typeLabel = currentType - ? scheduleTypeName(currentType.toUpperCase() as "IMPERATIVE" | "DECLARATIVE") - : "All types"; - - return ( - - {() => ( - - - } - /> - } - > - handleChange("ALL")} - variant="secondary/small" - /> - - -
- Filter by type - -
-
-
- - - - All types - - -
- - {scheduleTypeName("DECLARATIVE")} -
-
- -
- - {scheduleTypeName("IMPERATIVE")} -
-
-
-
-
- )} -
- ); -} - -const taskShortcut = { key: "t" }; - -function PermanentTaskFilter({ possibleTasks }: { possibleTasks: string[] }) { - const navigate = useNavigate(); - const location = useOptimisticLocation(); - const searchParams = new URLSearchParams(location.search); - const currentTask = searchParams.get("tasks") ?? undefined; - const triggerRef = useRef(null); - - useShortcutKeys({ - shortcut: taskShortcut, - action: (e) => { - e.preventDefault(); - e.stopPropagation(); - triggerRef.current?.click(); - }, - }); - - const handleChange = useCallback( - (value: string | string[]) => { - const selected = Array.isArray(value) ? value[0] : value; - const params = new URLSearchParams(location.search); - if (!selected || selected === "ALL") { - params.delete("tasks"); - } else { - params.set("tasks", selected); - } - params.delete("page"); - navigate(`${location.pathname}?${params.toString()}`); - }, - [location, navigate] - ); - - const taskLabel = currentTask ?? "All tasks"; - - return ( - - {() => ( - - - } - /> - } - > - } - value={taskLabel} - removable={!!currentTask} - onRemove={() => handleChange("ALL")} - variant="secondary/small" - /> - - -
- Filter by task - -
-
-
- - - - All tasks - - {possibleTasks.map((task) => ( - } - className="text-text-bright" - > - {task} - - ))} - - -
- )} -
- ); -} - -function ClearFiltersButton() { - const navigate = useNavigate(); - const location = useOptimisticLocation(); - - const clearFilters = useCallback(() => { - const params = new URLSearchParams(location.search); - params.delete("page"); - params.delete("tasks"); - params.delete("search"); - params.delete("type"); - navigate(`${location.pathname}?${params.toString()}`); - }, [location, navigate]); - - return ( -
-
- ); -} diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx index f87d1031bae..eef5f98b185 100644 --- a/apps/webapp/app/components/runs/v3/SharedFilters.tsx +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -464,7 +464,7 @@ function getInitialCustomDuration(period?: string): { value: string; unit: strin type SectionType = "duration" | "dateRange"; -export function TimeDropdown({ +function TimeDropdown({ trigger, period, from, @@ -495,7 +495,6 @@ export function TimeDropdown({ const organization = useOptionalOrganization(); const [open, setOpen] = useState(); const { replace } = useSearchParams(); - const extraCleared = Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])); const [fromValue, setFromValue] = useState(from); const [toValue, setToValue] = useState(to); @@ -520,6 +519,7 @@ export function TimeDropdown({ // Sync state when props change useEffect(() => { const parsed = getInitialCustomDuration(period); + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setCustomValue(parsed.value); setCustomUnit(parsed.unit); @@ -569,7 +569,7 @@ export function TimeDropdown({ onValueChange(values); } else { replace({ - ...extraCleared, + ...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])), period: periodToApply, cursor: undefined, direction: undefined, @@ -583,7 +583,7 @@ export function TimeDropdown({ setOpen(false); onApply?.(values); }, - [maxPeriodDays, onValueChange, replace, onApply] + [clearParams, maxPeriodDays, onValueChange, replace, onApply] ); const applySelection = useCallback(() => { @@ -629,7 +629,7 @@ export function TimeDropdown({ } else { // URL mode - navigate replace({ - ...extraCleared, + ...Object.fromEntries((clearParams ?? []).map((key) => [key, undefined])), period: undefined, cursor: undefined, direction: undefined, @@ -643,6 +643,7 @@ export function TimeDropdown({ } }, [ activeSection, + clearParams, selectedPeriod, isCustomDurationValid, customValue, @@ -668,16 +669,18 @@ export function TimeDropdown({ >
{/* Duration section */} -
{ - setActiveSection("duration"); - setValidationError(null); - setSelectedQuickDate(null); - }} - className="flex cursor-pointer gap-3 rounded-md pb-3" - > - -
+
+ +
{/* Custom duration row */}
e.stopPropagation()} > {/* Date range section */} -
{ - setActiveSection("dateRange"); - setValidationError(null); - }} - className="flex cursor-pointer gap-3" - > - -
+
+ +
+
-
e.stopPropagation()} className="-ml-8"> +
{/* Quick select date ranges */} -
e.stopPropagation()}> +
-
e.stopPropagation()}> +
void; +}) { + return ( +
+ +
+ ); +} + +function childPath(parentPath: string, key: string | number): string { + if (typeof key === "number") return `${parentPath}[${key}]`; + if (key !== "length" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) return `${parentPath}.${key}`; + return `${parentPath}['${key.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}']`; +} + +function JsonNode({ + name, + path, + value, + activePath, + onSelectPath, +}: { + name: string | number | undefined; + path: string; + value: unknown; + activePath: string; + onSelectPath: (path: string) => void; +}) { + const isObject = value !== null && typeof value === "object"; + const selected = path === activePath; + const keyLabel = name === undefined ? null : typeof name === "number" ? name : `"${name}"`; + + if (!isObject) { + const target = name === undefined ? "$" : path; + return ( + + ); + } + + const isArray = Array.isArray(value); + const entries: [string | number, unknown][] = isArray + ? (value as unknown[]).map((v, i) => [i, v]) + : Object.entries(value as Record); + const shown = entries.slice(0, MAX_CHILDREN); + const openBrace = isArray ? "[" : "{"; + const closeBrace = isArray ? "]" : "}"; + + if (entries.length === 0) { + return ( +
+ {keyLabel !== null && {keyLabel}} + {keyLabel !== null && : } + + {openBrace} + {closeBrace} + +
+ ); + } + + return ( +
+
+ {keyLabel !== null && {keyLabel}} + {keyLabel !== null && : } + {openBrace} +
+
+ {shown.map(([key, childValue]) => ( + + ))} + {entries.length > MAX_CHILDREN && ( +
โ€ฆ {entries.length - MAX_CHILDREN} more
+ )} +
+
{closeBrace}
+
+ ); +} + +function PrimitiveValue({ value }: { value: unknown }) { + if (value === null) return null; + if (typeof value === "string") { + const truncated = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}โ€ฆ` : value; + return "{truncated}"; + } + if (typeof value === "number") return {String(value)}; + if (typeof value === "boolean") return {String(value)}; + return {String(value)}; +} diff --git a/apps/webapp/app/components/runs/v3/SpanEvents.tsx b/apps/webapp/app/components/runs/v3/SpanEvents.tsx index 069246c89b7..9a06b0b2058 100644 --- a/apps/webapp/app/components/runs/v3/SpanEvents.tsx +++ b/apps/webapp/app/components/runs/v3/SpanEvents.tsx @@ -67,7 +67,7 @@ function SpanEvent({ spanEvent }: { spanEvent: OtelSpanEvent }) { ); } -export function SpanEventError({ +function SpanEventError({ spanEvent, exception, }: { diff --git a/apps/webapp/app/components/runs/v3/SpanTitle.tsx b/apps/webapp/app/components/runs/v3/SpanTitle.tsx index be363ca6ab3..eb08937de86 100644 --- a/apps/webapp/app/components/runs/v3/SpanTitle.tsx +++ b/apps/webapp/app/components/runs/v3/SpanTitle.tsx @@ -103,7 +103,7 @@ function SpanPill({ text, icon }: { text: string; icon?: string }) { ); } -export function SpanCodePathAccessory({ +function SpanCodePathAccessory({ accessory, className, }: { diff --git a/apps/webapp/app/components/runs/v3/TaskPath.tsx b/apps/webapp/app/components/runs/v3/TaskPath.tsx index 2ccb01c3688..fbf2410302c 100644 --- a/apps/webapp/app/components/runs/v3/TaskPath.tsx +++ b/apps/webapp/app/components/runs/v3/TaskPath.tsx @@ -1,25 +1,7 @@ import type { InlineCodeVariant } from "~/components/code/InlineCode"; import { InlineCode } from "~/components/code/InlineCode"; -import { SpanCodePathAccessory } from "./SpanTitle"; import { cn } from "~/utils/cn"; -type TaskPathProps = { - filePath: string; - functionName: string; - className?: string; -}; - -export function TaskPath({ filePath, functionName, className }: TaskPathProps) { - return ( - - ); -} - type TaskFileNameProps = { fileName: string; variant?: InlineCodeVariant; diff --git a/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx index 6358ccecd9a..6c8bebf71d1 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunAttemptStatus.tsx @@ -9,13 +9,8 @@ import type { TaskRunAttemptStatus as TaskRunAttemptStatusType } from "@trigger. import assertNever from "assert-never"; import { HourglassIcon } from "lucide-react"; import { Spinner } from "~/components/primitives/Spinner"; -import { TaskRunAttemptStatus } from "~/database-types"; import { cn } from "~/utils/cn"; -export const allTaskRunAttemptStatuses = Object.values( - TaskRunAttemptStatus -) as TaskRunAttemptStatusType[]; - export type ExtendedTaskAttemptStatus = TaskRunAttemptStatusType | "ENQUEUED"; export function TaskRunAttemptStatusCombo({ @@ -33,11 +28,7 @@ export function TaskRunAttemptStatusCombo({ ); } -export function TaskRunAttemptStatusLabel({ - status, -}: { - status: ExtendedTaskAttemptStatus | null; -}) { +function TaskRunAttemptStatusLabel({ status }: { status: ExtendedTaskAttemptStatus | null }) { return ( // system-mono-label: System themes uncolor the label (see tailwind.css) @@ -46,7 +37,7 @@ export function TaskRunAttemptStatusLabel({ ); } -export function TaskRunAttemptStatusIcon({ +function TaskRunAttemptStatusIcon({ status, className, }: { @@ -80,7 +71,7 @@ export function TaskRunAttemptStatusIcon({ } } -export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus | null): string { +function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus | null): string { if (status === null) { return "text-text-faint"; } @@ -106,7 +97,7 @@ export function runAttemptStatusClassNameColor(status: ExtendedTaskAttemptStatus } } -export function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null): string { +function runAttemptStatusTitle(status: ExtendedTaskAttemptStatus | null): string { if (status === null) { return "Enqueued"; } diff --git a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx index d0a4c74ffbb..48e5821b39a 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunStatus.tsx @@ -16,7 +16,6 @@ import { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3" import assertNever from "assert-never"; import { HourglassIcon } from "lucide-react"; import { TimedOutIcon } from "~/assets/icons/TimedOutIcon"; -import { Callout } from "~/components/primitives/Callout"; import { Spinner } from "~/components/primitives/Spinner"; import { cn } from "~/utils/cn"; @@ -111,44 +110,7 @@ export function TaskRunStatusCombo({ ); } -const statusReasonsToDescription: Record = { - NO_DEPLOYMENT: "No deployment or deployment image reference found for deployed run", - NO_WORKER: "No worker found for run", - TASK_NEVER_REGISTERED: "Task never registered", - QUEUE_NOT_FOUND: "Queue not found", - TASK_NOT_IN_LATEST: "Task not in latest version", - BACKGROUND_WORKER_MISMATCH: "Background worker mismatch", -}; - -export function TaskRunStatusReason({ - status, - statusReason, -}: { - status: TaskRunStatus; - statusReason?: string; -}) { - if (status !== "PENDING_VERSION") { - return null; - } - - if (!statusReason) { - return null; - } - - const description = statusReasonsToDescription[statusReason]; - - if (!description) { - return null; - } - - return ( - - {description} - - ); -} - -export function TaskRunStatusLabel({ status }: { status: TaskRunStatus }) { +function TaskRunStatusLabel({ status }: { status: TaskRunStatus }) { // system-mono-label: System themes uncolor the label (see tailwind.css) return ( diff --git a/apps/webapp/app/components/runs/v3/TaskRunsList.tsx b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx index 1b9830ec54f..db92ad601de 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsList.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx @@ -78,22 +78,20 @@ export function TaskRunsList({ } ); - const onClickShowNewRuns = () => { - const isPaginated = has("cursor") || has("direction"); - dismissNewRuns(); - if (isPaginated) { - replace({ cursor: undefined, direction: undefined }); - return; - } - revalidator.revalidate(); - }; - // Surface the banner to the top-bar button rendered by the page: keep the // ref's action current, mirror the count up, and clear it when this boundary // unmounts (e.g. the table re-suspends on a filter change). useEffect(() => { - showNewRunsRef.current = onClickShowNewRuns; - }, [onClickShowNewRuns, showNewRunsRef]); + showNewRunsRef.current = () => { + const isPaginated = has("cursor") || has("direction"); + dismissNewRuns(); + if (isPaginated) { + replace({ cursor: undefined, direction: undefined }); + return; + } + revalidator.revalidate(); + }; + }, [dismissNewRuns, has, replace, revalidator, showNewRunsRef]); useEffect(() => { onNewRunsCountChange(newRunsCount); }, [newRunsCount, onNewRunsCountChange]); diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index ef0c6e25f5e..04368e6fcc3 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -9,7 +9,7 @@ import { import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; import { useLocation } from "@remix-run/react"; import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; @@ -63,6 +63,18 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; +import { SmartColumnIcon } from "~/assets/icons/SmartColumnIcon"; +import { + parseColumnParams, + resolveColumnLayout, + visibleSmartSources, + type ResolvedColumn, + type RunColumnRuntime, + type SmartColumnDef, + type SmartColumnSource, +} from "./runColumns"; +import { extractSmartValue, parseSource, type ParsedSource } from "./smartColumnData"; +import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell"; type RunsTableProps = { total: number; @@ -79,6 +91,13 @@ type RunsTableProps = { showTopBorder?: boolean; stickyHeader?: boolean; childrenStatusesBasePath?: string; + /** + * Whether URL-driven smart columns render here. Default true; embedded run + * tables whose loader does not hydrate payload/metadata/output (schedule + * inspector, waitpoint, webhook) pass false so they never show a column they + * cannot fill. + */ + enableSmartColumns?: boolean; /** * Display-only write:runs flags from the caller's loader. Default true so * callers that don't pass them (and OSS, where the ability is permissive) @@ -89,6 +108,484 @@ type RunsTableProps = { canReplayRuns?: boolean; }; +type CellRenderContext = { + run: NextRunListItem; + path: string; + regionByMasterQueue: Map; + childrenStatusesBasePath?: string; + sources: Partial>; +}; + +type StandardColumnRenderer = { + header: React.ReactNode; + cell: (ctx: CellRenderContext) => React.ReactNode; + /** Cells/header this column occupies (Duration renders three). */ + span: number; +}; + +const STANDARD_RENDERERS: Record = { + id: { + span: 1, + header: ID, + cell: ({ run, path }) => ( + + + + ), + }, + task: { + span: 1, + header: Task, + cell: ({ run, path }) => ( + + + + {run.taskIdentifier} + {run.rootTaskRunId === null ? Root : null} + + + ), + }, + ver: { + span: 1, + header: Version, + cell: ({ run, path }) => {run.version ?? "โ€“"}, + }, + status: { + span: 1, + header: ( + + {filterableTaskRunStatuses.map((status) => ( +
+
+ +
+ + {descriptionForTaskRunStatus(status)} + +
+ ))} +
+ } + > + Status + + ), + cell: ({ run, path, childrenStatusesBasePath }) => ( + + {run.rootTaskRunId === null && childrenStatusesBasePath ? ( + + ) : ( + } + /> + )} + + ), + }, + started: { + span: 1, + header: Started, + cell: ({ run, path }) => ( + {run.startedAt ? : "โ€“"} + ), + }, + dur: { + span: 3, + header: ( + +
+
+ + Queued duration +
+ + The amount of time from when the run was created to it starting to run. + +
+
+
+ Run duration +
+ + The total amount of time from the run starting to it finishing. This includes all + time spent waiting. + +
+
+
+ + Compute duration +
+ + The amount of compute time used in the run. This does not include time spent + waiting. + +
+
+ } + > + Duration + + ), + cell: ({ run, path }) => ( + <> + +
+ + {run.isPending ? ( + "โ€“" + ) : run.startedAt ? ( + formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), { + style: "short", + }) + ) : run.isCancellable ? ( + + ) : ( + formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), { + style: "short", + }) + )} +
+
+ +
+ + {run.startedAt && run.finishedAt ? ( + formatDuration(new Date(run.startedAt), new Date(run.finishedAt), { + style: "short", + }) + ) : run.startedAt ? ( + + ) : ( + "โ€“" + )} +
+
+ +
+ + {run.usageDurationMs > 0 + ? formatDurationMilliseconds(run.usageDurationMs, { + style: "short", + }) + : "โ€“"} +
+
+ + ), + }, + compute: { + span: 1, + header: Compute, + cell: ({ run, path }) => ( + + {run.costInCents > 0 + ? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100) + : "โ€“"} + + ), + }, + machine: { + span: 1, + header: ( + }> + Machine + + ), + cell: ({ run, path }) => ( + + + + ), + }, + queue: { + span: 1, + header: Queue, + cell: ({ run, path }) => ( + + {run.queue.type === "task" ? ( + + + {run.queue.name} + + } + content={`This queue was automatically created from your "${run.queue.name}" task`} + disableHoverableContent + /> + ) : ( + + + {run.queue.name} + + } + content={`This is a custom queue you added in your code.`} + disableHoverableContent + /> + )} + + ), + }, + region: { + span: 1, + header: Region, + cell: ({ run, path, regionByMasterQueue }) => ( + + {run.region ? ( + + ) : ( + "โ€“" + )} + + ), + }, + test: { + span: 1, + header: Test, + cell: ({ run, path }) => ( + + {run.isTest ? ( + + ) : ( + "โ€“" + )} + + ), + }, + created: { + span: 1, + header: Created at, + cell: ({ run, path }) => ( + {run.createdAt ? : "โ€“"} + ), + }, + delayed: { + span: 1, + header: ( + + + When you want to trigger a task now, but have it run at a later time, you can use the + delay option. + + + Runs that are delayed and have not been enqueued yet will display in the dashboard + with a โ€œDelayedโ€ status. + + + Read docs + +
+ } + > + Delayed until + + ), + cell: ({ run, path }) => ( + {run.delayUntil ? : "โ€“"} + ), + }, + ttl: { + span: 1, + header: ( + + + You can set a TTL (time to live) when triggering a task, which will automatically + expire the run if it hasnโ€™t started within the specified time. + + + All runs in development have a default ttl of 10 minutes. You can disable this by + setting the ttl option. + + + Read docs + +
+ } + > + TTL + + ), + cell: ({ run, path }) => {run.ttl ?? "โ€“"}, + }, + tags: { + span: 1, + header: ( + + + You can add tags to a run and then filter runs using them. + + + You can add tags when triggering a run or inside the run function. + + + Read docs + +
+ } + > + Tags + + ), + cell: ({ run, path }) => ( + +
+ {run.tags.length > 0 ? run.tags.map((tag) => ) : "โ€“"} +
+
+ ), + }, +}; + +const SMART_SOURCE_LABELS: Record = { + payload: "payload", + metadata: "metadata", + output: "output", +}; + +function SmartColumnHeader({ def }: { def: SmartColumnDef }) { + return ( + + + {def.label} + {/* The bolt is the tooltip trigger, so the cell doesn't also get an info icon. */} + } + content={ + + Reads {def.path} from each run's{" "} + {SMART_SOURCE_LABELS[def.source]}, shown as {def.displayAs}. Display only, so this + column can't be sorted or filtered. + + } + /> + + + ); +} + +function SmartColumnCell({ + def, + run, + path, + parsed, +}: { + def: SmartColumnDef; + run: NextRunListItem; + path: string; + parsed: ParsedSource | undefined; +}) { + const numeric = isNumericSmartDisplay(def.displayAs); + const cell = extractSmartValue(parsed ?? { state: "empty" }, def.path); + + return ( + + + + ); +} + +const EMPTY_SOURCES: Partial> = {}; + +function buildRowSources( + run: NextRunListItem, + sources: SmartColumnSource[] +): Partial> { + const result: Partial> = {}; + for (const source of sources) { + switch (source) { + case "payload": + result.payload = parseSource({ data: run.payload, dataType: run.payloadType }); + break; + case "metadata": + result.metadata = parseSource({ data: run.metadata, dataType: run.metadataType }); + break; + case "output": + result.output = parseSource({ data: run.output, dataType: run.outputType }); + break; + } + } + return result; +} + +function columnKey(col: ResolvedColumn): string { + return col.kind === "standard" ? `std:${col.def.id}` : `smart:${col.index}`; +} + +function ColumnHeader({ column }: { column: ResolvedColumn }) { + if (column.kind === "smart") { + return ; + } + return STANDARD_RENDERERS[column.def.id]?.header ?? null; +} + +function ColumnCell({ column, ctx }: { column: ResolvedColumn; ctx: CellRenderContext }) { + if (column.kind === "smart") { + return ( + + ); + } + return STANDARD_RENDERERS[column.def.id]?.cell(ctx) ?? null; +} + export function TaskRunsTable({ total, hasFilters, @@ -103,6 +600,7 @@ export function TaskRunsTable({ showTopBorder = true, stickyHeader = false, childrenStatusesBasePath, + enableSmartColumns = true, canCancelRuns = true, canReplayRuns = true, }: RunsTableProps) { @@ -114,7 +612,7 @@ export function TaskRunsTable({ const checkboxes = useRef<(HTMLInputElement | null)[]>([]); const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); - const { value } = useSearchParams(); + const { value, values } = useSearchParams(); const location = useOptimisticLocation(); const params = new URLSearchParams(location.search || ""); if (!value("rootOnly")) { @@ -129,8 +627,37 @@ export function TaskRunsTable({ /** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */ const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search); - const showCompute = isManagedCloud; - const showRegion = environment.type !== "DEVELOPMENT"; + const isDevelopment = environment.type === "DEVELOPMENT"; + const colsParam = value("cols"); + const hideParam = value("hide"); + const scFromUrl = values("sc"); + const scKey = scFromUrl.join(" "); + const layout = useMemo(() => { + const runtime: RunColumnRuntime = { isManagedCloud, isDevelopment }; + return resolveColumnLayout(parseColumnParams(colsParam, scFromUrl, hideParam), runtime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colsParam, hideParam, scKey, isManagedCloud, isDevelopment]); + + const visibleColumns = useMemo( + () => (enableSmartColumns ? layout.visible : layout.visible.filter((c) => c.kind !== "smart")), + [layout, enableSmartColumns] + ); + const referencedSources = useMemo(() => visibleSmartSources(visibleColumns), [visibleColumns]); + + const sourcesByRunId = useMemo(() => { + const map = new Map>>(); + if (referencedSources.length === 0) return map; + for (const run of runs) { + map.set(run.id, buildRowSources(run, referencedSources)); + } + return map; + }, [runs, referencedSources]); + + const dataColSpan = visibleColumns.reduce( + (sum, col) => sum + (col.kind === "standard" ? (STANDARD_RENDERERS[col.def.id]?.span ?? 1) : 1), + 0 + ); + const totalColSpan = (allowSelection ? 1 : 0) + dataColSpan + 1; const navigateCheckboxes = useCallback( (event: React.KeyboardEvent, index: number) => { @@ -155,7 +682,7 @@ export function TaskRunsTable({ } } }, - [checkboxes, runs] + [checkboxes, runs, select] ); return ( @@ -189,152 +716,9 @@ export function TaskRunsTable({ )} )} - ID - Task - Version - - {filterableTaskRunStatuses.map((status) => ( -
-
- -
- - {descriptionForTaskRunStatus(status)} - -
- ))} -
- } - > - Status - - Started - -
-
- - Queued duration -
- - The amount of time from when the run was created to it starting to run. - -
-
-
- Run duration -
- - The total amount of time from the run starting to it finishing. This includes - all time spent waiting. - -
-
-
- - Compute duration -
- - The amount of compute time used in the run. This does not include time spent - waiting. - -
-
- } - > - Duration - - {showCompute && ( - <> - Compute - - )} - }> - Machine - - Queue - {showRegion && Region} - Test - Created at - - - When you want to trigger a task now, but have it run at a later time, you can use - the delay option. - - - Runs that are delayed and have not been enqueued yet will display in the dashboard - with a โ€œDelayedโ€ status. - - - Read docs - -
- } - > - Delayed until - - - - You can set a TTL (time to live) when triggering a task, which will automatically - expire the run if it hasnโ€™t started within the specified time. - - - All runs in development have a default ttl of 10 minutes. You can disable this by - setting the ttl option. - - - Read docs - -
- } - > - TTL - - - - You can add tags to a run and then filter runs using them. - - - You can add tags when triggering a run or inside the run function. - - - Read docs - -
- } - > - Tags - + {visibleColumns.map((col) => ( + + ))} Go to page @@ -342,11 +726,11 @@ export function TaskRunsTable({ {total === 0 && !hasFilters ? ( - + {!isLoading && } ) : runs.length === 0 ? ( - + ) : ( runs.map((run, index) => { const searchParams = new URLSearchParams(); @@ -363,6 +747,7 @@ export function TaskRunsTable({ }, searchParams ); + const sources = sourcesByRunId.get(run.id) ?? EMPTY_SOURCES; return ( {allowSelection && ( @@ -379,149 +764,13 @@ export function TaskRunsTable({ /> )} - - - - - - - {run.taskIdentifier} - {run.rootTaskRunId === null ? Root : null} - - - {run.version ?? "โ€“"} - - {run.rootTaskRunId === null && childrenStatusesBasePath ? ( - - ) : ( - } - /> - )} - - - {run.startedAt ? : "โ€“"} - - -
- - {run.isPending ? ( - "โ€“" - ) : run.startedAt ? ( - formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), { - style: "short", - }) - ) : run.isCancellable ? ( - - ) : ( - formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), { - style: "short", - }) - )} -
-
- -
- - {run.startedAt && run.finishedAt ? ( - formatDuration(new Date(run.startedAt), new Date(run.finishedAt), { - style: "short", - }) - ) : run.startedAt ? ( - - ) : ( - "โ€“" - )} -
-
- -
- - {run.usageDurationMs > 0 - ? formatDurationMilliseconds(run.usageDurationMs, { - style: "short", - }) - : "โ€“"} -
-
- {showCompute && ( - - {run.costInCents > 0 - ? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100) - : "โ€“"} - - )} - - - - - {run.queue.type === "task" ? ( - - - {run.queue.name} - - } - content={`This queue was automatically created from your "${run.queue.name}" task`} - disableHoverableContent - /> - ) : ( - - - {run.queue.name} - - } - content={`This is a custom queue you added in your code.`} - disableHoverableContent - /> - )} - - {showRegion && ( - - {run.region ? ( - - ) : ( - "โ€“" - )} - - )} - - {run.isTest ? ( - - ) : ( - "โ€“" - )} - - - {run.createdAt ? : "โ€“"} - - - {run.delayUntil ? : "โ€“"} - - {run.ttl ?? "โ€“"} - -
- {run.tags.map((tag) => ) || "โ€“"} -
-
+ {visibleColumns.map((col) => ( + + ))} Loadingโ€ฆ @@ -711,13 +960,12 @@ function NoRuns({ title }: { title: string }) { function BlankState({ isLoading, filters, - showRegion, -}: Pick & { showRegion: boolean }) { + colSpan, +}: Pick & { colSpan: number }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const colSpan = showRegion ? 16 : 15; - if (isLoading) return ; + if (isLoading) return ; const { tasks, from, to, ...otherFilters } = filters; const singleTaskFromFilters = filters.tasks.length === 1 ? filters.tasks[0] : null; diff --git a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx index 7841f5248d9..19063131e4a 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointDetails.tsx @@ -87,9 +87,7 @@ export function WaitpointDetailTable({
{waitpoint.completedAfter ? ( - <> - - + ) : ( "โ€“" )} @@ -127,9 +125,8 @@ export function WaitpointDetailTable({ {waitpoint.completedAt ? : "โ€“"} - {waitpoint.status === "WAITING" ? null : waitpoint.status === "TIMED_OUT" ? ( - <> - ) : waitpoint.output ? ( + {waitpoint.status === "WAITING" ? null : waitpoint.status === + "TIMED_OUT" ? null : waitpoint.output ? ( ) : waitpoint.completedAfter ? null : ( "Completed with no output" diff --git a/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx b/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx index e5825088b3a..6ee3adc2f0d 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointStatus.tsx @@ -22,7 +22,7 @@ export function WaitpointStatusCombo({ ); } -export function WaitpointStatusLabel({ status }: { status: WaitpointTokenStatus }) { +function WaitpointStatusLabel({ status }: { status: WaitpointTokenStatus }) { return ( // system-mono-label: System themes uncolor the label (see tailwind.css) @@ -31,7 +31,7 @@ export function WaitpointStatusLabel({ status }: { status: WaitpointTokenStatus ); } -export function WaitpointStatusIcon({ +function WaitpointStatusIcon({ status, className, }: { @@ -51,7 +51,7 @@ export function WaitpointStatusIcon({ } } -export function waitpointStatusClassNameColor(status: WaitpointTokenStatus): string { +function waitpointStatusClassNameColor(status: WaitpointTokenStatus): string { switch (status) { case "WAITING": return "text-blue-500"; diff --git a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx index 4eb31e617c3..3a7efa026f3 100644 --- a/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx +++ b/apps/webapp/app/components/runs/v3/WaitpointTokenFilters.tsx @@ -264,31 +264,30 @@ function TagsDropdown({ }; const fetcher = useFetcher(); + const { load } = fetcher; useEffect(() => { const searchParams = new URLSearchParams(); if (searchValue) { searchParams.set("name", searchValue); } - fetcher.load( + load( `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/waitpoints/tags?${searchParams}` ); - }, [searchValue]); - - const filtered = useMemo(() => { - let items: string[] = []; - if (searchValue === "") { - items = values("tags"); - } + }, [environment.slug, load, organization.slug, project.slug, searchValue]); - if (fetcher.data === undefined) { - return matchSorter(items, searchValue); - } + let items: string[] = []; + if (searchValue === "") { + items = values("tags"); + } + let filtered: string[]; + if (fetcher.data === undefined) { + filtered = matchSorter(items, searchValue); + } else { items.push(...fetcher.data.tags.map((t) => t.name)); - - return matchSorter(Array.from(new Set(items)), searchValue); - }, [searchValue, fetcher.data]); + filtered = matchSorter(Array.from(new Set(items)), searchValue); + } return ( diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index 81a45edef2d..44009377f65 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -2,6 +2,7 @@ import type { UIMessage } from "@ai-sdk/react"; import { memo } from "react"; import { AssistantResponse, ChatBubble, ToolUseRow } from "~/components/runs/v3/ai/AIChatMessages"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { textLinkClassName } from "~/components/primitives/TextLink"; // --------------------------------------------------------------------------- // AgentMessageView โ€” renders an AI SDK UIMessage[] conversation. @@ -219,12 +220,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { } return ( @@ -274,12 +270,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { } return ( diff --git a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx index 72926ac35e9..f2570e3b928 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx @@ -294,6 +294,7 @@ function useAgentSessionMessages({ const lastFlushAtRef = useRef(0); const pendingTimerRef = useRef | null>(null); const scheduleFlush = useRef<() => void>(() => {}); + scheduleFlush.current = () => { if (pendingTimerRef.current !== null) return; // already scheduled const now = Date.now(); @@ -670,6 +671,7 @@ function useAgentSessionMessages({ return useMemo(() => { const timestamps = timestampsRef.current; const arr = Array.from(messagesById.values()); + arr.sort((a, b) => { const ta = timestamps.get(a.id) ?? 0; const tb = timestamps.get(b.id) ?? 0; diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index ae46cbe867c..046ac151d4b 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -288,8 +288,9 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) { // Auto-select input tab when input arrives after initial render (e.g. streaming tool calls) useEffect(() => { - if (!hasSubAgent && hasInput && activeTab === null) { - setActiveTab("input"); + if (!hasSubAgent && hasInput) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. + setActiveTab((current) => current ?? "input"); } }, [hasInput, hasSubAgent]); @@ -344,6 +345,7 @@ export function ToolUseRow({ tool }: { tool: ToolUse }) { > {availableTabs.map((tab) => (
diff --git a/apps/webapp/app/components/runs/v3/ai/AIToolCallSpanDetails.tsx b/apps/webapp/app/components/runs/v3/ai/AIToolCallSpanDetails.tsx index 5557f2728c2..e35c9a7f698 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIToolCallSpanDetails.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIToolCallSpanDetails.tsx @@ -1,6 +1,8 @@ +import type { SpanEvent as OtelSpanEvent } from "@trigger.dev/core/v3"; import { Header3 } from "~/components/primitives/Headers"; import { CodeBlock } from "~/components/code/CodeBlock"; import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue"; +import { SpanEvents } from "~/components/runs/v3/SpanEvents"; import { formatDuration, tryPrettyJson } from "./aiHelpers"; import { SpanMetricRow as MetricRow } from "./SpanMetricRow"; @@ -36,7 +38,13 @@ export function extractAIToolCallData( }; } -export function AIToolCallSpanDetails({ data }: { data: AIToolCallData }) { +export function AIToolCallSpanDetails({ + data, + spanEvents, +}: { + data: AIToolCallData; + spanEvents?: OtelSpanEvent[]; +}) { return (
@@ -68,6 +76,12 @@ export function AIToolCallSpanDetails({ data }: { data: AIToolCallData }) { />
)} + + {spanEvents && spanEvents.some((event) => !event.name.startsWith("trigger.dev/")) && ( +
+ +
+ )}
diff --git a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx index f7b09b6daf1..77c534bd73a 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { CodeBlock } from "~/components/code/CodeBlock"; import type { AISpanData, ToolDefinition } from "./types"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { textLinkClassName } from "~/components/primitives/TextLink"; +import { cn } from "~/utils/cn"; export function AIToolsInventory({ aiData }: { aiData: AISpanData }) { const defs = aiData.toolDefinitions ?? []; @@ -47,8 +49,9 @@ function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolea {def.parametersJson && (
diff --git a/apps/webapp/app/components/runs/v3/ai/index.ts b/apps/webapp/app/components/runs/v3/ai/index.ts index 5acb9ff17a2..14bc84fb114 100644 --- a/apps/webapp/app/components/runs/v3/ai/index.ts +++ b/apps/webapp/app/components/runs/v3/ai/index.ts @@ -2,7 +2,4 @@ export { AISpanDetails } from "./AISpanDetails"; export { extractAISpanData } from "./extractAISpanData"; export { extractAISummarySpanData } from "./extractAISummarySpanData"; export { AIToolCallSpanDetails, extractAIToolCallData } from "./AIToolCallSpanDetails"; -export type { AIToolCallData } from "./AIToolCallSpanDetails"; export { AIEmbedSpanDetails, extractAIEmbedData } from "./AIEmbedSpanDetails"; -export type { AIEmbedData } from "./AIEmbedSpanDetails"; -export type { AISpanData, DisplayItem, ToolUse } from "./types"; diff --git a/apps/webapp/app/components/runs/v3/ai/types.ts b/apps/webapp/app/components/runs/v3/ai/types.ts index b1765a2e59c..adb085714af 100644 --- a/apps/webapp/app/components/runs/v3/ai/types.ts +++ b/apps/webapp/app/components/runs/v3/ai/types.ts @@ -34,25 +34,25 @@ export type ToolUse = { // --------------------------------------------------------------------------- /** System prompt text (collapsible) */ -export type SystemItem = { +type SystemItem = { type: "system"; text: string; }; /** User message text */ -export type UserItem = { +type UserItem = { type: "user"; text: string; }; /** One or more tool calls with their results, grouped */ -export type ToolUseItem = { +type ToolUseItem = { type: "tool-use"; tools: ToolUse[]; }; /** Final assistant text response */ -export type AssistantItem = { +type AssistantItem = { type: "assistant"; text: string; }; diff --git a/apps/webapp/app/components/runs/v3/runColumns.test.ts b/apps/webapp/app/components/runs/v3/runColumns.test.ts new file mode 100644 index 00000000000..d2c3782e8af --- /dev/null +++ b/apps/webapp/app/components/runs/v3/runColumns.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "vitest"; +import { + availableStandardColumns, + decodeSmartColumn, + deriveRunSelect, + encodeColumnLayout, + encodeSmartColumn, + resolveColumnLayout, + type ResolvedColumn, + type RunColumnRuntime, + type SmartColumnDef, +} from "./runColumns"; + +const cloud: RunColumnRuntime = { isManagedCloud: true, isDevelopment: false }; +const dev: RunColumnRuntime = { isManagedCloud: false, isDevelopment: true }; + +describe("deriveRunSelect", () => { + it("always includes the presenter's scalar contract", () => { + const select = deriveRunSelect([], []); + for (const field of [ + "id", + "friendlyId", + "spanId", + "status", + "runtimeEnvironmentId", + "rootTaskRunId", + "createdAt", + "updatedAt", + "startedAt", + "lockedAt", + "completedAt", + "queueTimestamp", + "delayUntil", + "scheduleId", + "taskIdentifier", + "machinePreset", + "queue", + "runTags", + ]) { + expect(select[field as keyof typeof select]).toBe(true); + } + }); + + it("does not hydrate the source blobs unless a smart column references them", () => { + const select = deriveRunSelect(["task", "status", "tags"], []); + expect(select.payload).toBeUndefined(); + expect(select.payloadType).toBeUndefined(); + expect(select.output).toBeUndefined(); + expect(select.outputType).toBeUndefined(); + expect(select.metadata).toBeUndefined(); + expect(select.metadataType).toBeUndefined(); + }); + + it("adds payload/output fields only for referenced smart sources", () => { + const payloadOnly = deriveRunSelect([], ["payload"]); + expect(payloadOnly.payload).toBe(true); + expect(payloadOnly.payloadType).toBe(true); + expect(payloadOnly.output).toBeUndefined(); + + const both = deriveRunSelect([], ["payload", "output"]); + expect(both.output).toBe(true); + expect(both.outputType).toBe(true); + }); + + it("adds metadata fields only when a metadata smart column references them", () => { + expect(deriveRunSelect([], []).metadata).toBeUndefined(); + const select = deriveRunSelect([], ["metadata"]); + expect(select.metadata).toBe(true); + expect(select.metadataType).toBe(true); + }); +}); + +describe("availableStandardColumns gating", () => { + it("includes compute and region on managed cloud", () => { + const ids = availableStandardColumns(cloud).map((c) => c.id); + expect(ids).toContain("compute"); + expect(ids).toContain("region"); + }); + + it("drops compute and region on development / self-host", () => { + const ids = availableStandardColumns(dev).map((c) => c.id); + expect(ids).not.toContain("compute"); + expect(ids).not.toContain("region"); + }); +}); + +const orderedIds = (layout: { ordered: { col: ResolvedColumn }[] }) => + layout.ordered.map((o) => (o.col.kind === "standard" ? o.col.def.id : o.col.def.label)); + +const visibleIds = (layout: { visible: ResolvedColumn[] }) => + layout.visible.map((c) => (c.kind === "standard" ? c.def.id : c.def.label)); + +const params = (over: Partial<{ cols: string[]; sc: string[]; hide: string[] }> = {}) => ({ + cols: [], + sc: [], + hide: [], + ...over, +}); + +describe("resolveColumnLayout", () => { + it("returns the default layout when no params are set", () => { + const layout = resolveColumnLayout(params(), cloud); + expect(layout.isCustomized).toBe(false); + expect(layout.ordered.every((o) => !o.hidden)).toBe(true); + expect(layout.ordered[0].col).toMatchObject({ kind: "standard", def: { id: "id" } }); + expect(layout.visible).toHaveLength(availableStandardColumns(cloud).length); + }); + + it("keeps every column in the requested order (columns are reorderable)", () => { + const layout = resolveColumnLayout(params({ cols: ["task", "status", "id"] }), cloud); + expect(orderedIds(layout).slice(0, 3)).toEqual(["task", "status", "id"]); + }); + + it("hides columns from the `hide` list in place, keeping the default order", () => { + const layout = resolveColumnLayout(params({ hide: ["ttl"] }), cloud); + const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl"); + expect(ttl?.hidden).toBe(true); + const ids = orderedIds(layout); + expect(ids.indexOf("ttl")).toBeLessThan(ids.indexOf("tags")); + expect(visibleIds(layout)).not.toContain("ttl"); + }); + + it("never hides locked columns even if the `hide` list names them", () => { + const layout = resolveColumnLayout(params({ hide: ["task", "status"] }), cloud); + const locked = layout.ordered.filter((o) => o.col.kind === "standard" && o.col.def.locked); + expect(locked.every((o) => !o.hidden)).toBe(true); + }); + + it("reinserts standard columns missing from the URL as visible", () => { + const layout = resolveColumnLayout(params({ cols: ["id", "ver"] }), cloud); + expect(visibleIds(layout)).toEqual(expect.arrayContaining(["task", "status", "tags", "ttl"])); + }); + + it("resolves smart-column refs positionally, even without a cols order", () => { + const sc = [ + encodeSmartColumn({ + source: "metadata", + path: "$.failed", + label: "Failed", + displayAs: "number", + }), + ]; + const layout = resolveColumnLayout(params({ sc }), cloud); + const smart = layout.visible.find((c) => c.kind === "smart"); + expect(smart).toMatchObject({ kind: "smart", def: { label: "Failed", source: "metadata" } }); + }); + + it("drops gated columns referenced on a runtime that lacks them", () => { + const layout = resolveColumnLayout(params({ cols: ["id", "region", "compute", "task"] }), dev); + expect(orderedIds(layout)).not.toContain("region"); + expect(orderedIds(layout)).not.toContain("compute"); + expect(orderedIds(layout).slice(0, 2)).toEqual(["id", "task"]); + }); +}); + +describe("encodeColumnLayout compactness + round-trip", () => { + const std = (id: string) => ({ + kind: "standard" as const, + def: availableStandardColumns(cloud).find((c) => c.id === id)!, + }); + + it("encodes the default layout to empty params", () => { + const layout = resolveColumnLayout(params(), cloud); + expect(encodeColumnLayout(layout.ordered, cloud)).toEqual({ cols: [], sc: [], hide: [] }); + }); + + it("hiding a column with the default order emits only a hide entry, no cols", () => { + const layout = resolveColumnLayout(params({ hide: ["ver"] }), cloud); + const encoded = encodeColumnLayout(layout.ordered, cloud); + expect(encoded.cols).toEqual([]); + expect(encoded.hide).toEqual(["ver"]); + expect(encoded.sc).toEqual([]); + }); + + it("appending a smart column with the default order emits only sc, no cols", () => { + const scDef: SmartColumnDef = { + source: "metadata", + path: "$.failed", + label: "Failed", + displayAs: "number", + }; + const layout = resolveColumnLayout(params(), cloud); + const encoded = encodeColumnLayout( + [...layout.ordered, { col: { kind: "smart", index: 0, def: scDef }, hidden: false }], + cloud + ); + expect(encoded.cols).toEqual([]); + expect(encoded.sc).toHaveLength(1); + }); + + it("round-trips a reordered, hidden, smart-augmented layout", () => { + const scDef: SmartColumnDef = { + source: "payload", + path: "$.order.total", + label: "Order total", + displayAs: "number", + }; + const encoded = encodeColumnLayout( + [ + { col: std("id"), hidden: false }, + { col: std("status"), hidden: false }, + { col: std("ttl"), hidden: true }, + { col: { kind: "smart", index: 0, def: scDef }, hidden: false }, + ], + cloud + ); + expect(encoded.cols).toEqual(["id", "status", "ttl", "sc1"]); + expect(encoded.hide).toEqual(["ttl"]); + expect(encoded.sc).toHaveLength(1); + + const layout = resolveColumnLayout(encoded, cloud); + const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl"); + expect(ttl?.hidden).toBe(true); + expect(visibleIds(layout)).toContain("Order total"); + expect(visibleIds(layout)).not.toContain("ttl"); + }); +}); + +describe("smart column codec", () => { + it("round-trips including delimiter-dangerous characters", () => { + const def: SmartColumnDef = { + source: "metadata", + path: "$['a:b'].c", + label: "Weird: 50%", + displayAs: "badge", + }; + const decoded = decodeSmartColumn(encodeSmartColumn(def)); + expect(decoded).toEqual(def); + }); + + it("rejects an unknown source or display", () => { + expect(decodeSmartColumn("bogus:$.a:A:number")).toBeUndefined(); + expect(decodeSmartColumn("metadata:$.a:A:bogus")).toBeUndefined(); + expect(decodeSmartColumn("metadata::A:number")).toBeUndefined(); + }); +}); diff --git a/apps/webapp/app/components/runs/v3/runColumns.ts b/apps/webapp/app/components/runs/v3/runColumns.ts new file mode 100644 index 00000000000..6f54fb9a7a4 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/runColumns.ts @@ -0,0 +1,434 @@ +import type { Prisma } from "@trigger.dev/database"; + +/** + * Isomorphic column catalog for the runs list. Shared by the client table + * renderer, the display-options popover, the URL codec, and the server-side + * Postgres select derivation, so none of it may import React or server code. + * + * The order of `RUN_COLUMN_IDS`/`STANDARD_COLUMNS` is the default column order. + */ +const RUN_COLUMN_IDS = [ + "id", + "task", + "status", + "ver", + "started", + "dur", + "compute", + "machine", + "queue", + "region", + "test", + "created", + "delayed", + "ttl", + "tags", +] as const; + +export type RunColumnId = (typeof RUN_COLUMN_IDS)[number]; + +type RunColumnGate = "managedCloud" | "nonDev"; + +type RunSelectField = keyof Prisma.TaskRunSelect; + +export type StandardColumnDef = { + id: RunColumnId; + label: string; + /** + * When set, the column only exists in this runtime; otherwise it is absent + * from the table AND the popover (not merely hidden). + */ + gate?: RunColumnGate; + /** Locked columns can be reordered but never hidden (their toggle is disabled). */ + locked?: boolean; + /** Raw ListedRun/TaskRun fields the column needs hydrated from Postgres. */ + fields: readonly RunSelectField[]; +}; + +/** + * The scalar fields the shared presenter always maps into its stable output, + * regardless of which columns show. These are all small single-row columns with + * no DB win from narrowing, so the select keeps them for a stable contract and + * gates only the large blobs: payload, output, and metadata are added solely + * when a smart column references them (metadata is display-only on the list, so + * there is no reason to hydrate it for every row otherwise). + */ +const ALWAYS_SELECTED_FIELDS = [ + "id", + "friendlyId", + "taskIdentifier", + "taskVersion", + "runtimeEnvironmentId", + "status", + "createdAt", + "queueTimestamp", + "scheduleId", + "startedAt", + "lockedAt", + "delayUntil", + "updatedAt", + "completedAt", + "isTest", + "spanId", + "idempotencyKey", + "ttl", + "expiredAt", + "costInCents", + "baseCostInCents", + "usageDurationMs", + "runTags", + "depth", + "rootTaskRunId", + "batchId", + "machinePreset", + "queue", + "workerQueue", + "region", + "annotations", +] as const satisfies readonly RunSelectField[]; + +const STANDARD_COLUMNS: readonly StandardColumnDef[] = [ + { id: "id", label: "ID", locked: true, fields: ["friendlyId", "spanId"] }, + { + id: "task", + label: "Task", + locked: true, + fields: ["taskIdentifier", "annotations", "rootTaskRunId"], + }, + { id: "status", label: "Status", locked: true, fields: ["status"] }, + { id: "ver", label: "Version", fields: ["taskVersion"] }, + { id: "started", label: "Started", fields: ["startedAt", "lockedAt"] }, + { + id: "dur", + label: "Duration", + fields: [ + "startedAt", + "lockedAt", + "completedAt", + "updatedAt", + "createdAt", + "queueTimestamp", + "delayUntil", + "scheduleId", + "usageDurationMs", + "status", + ], + }, + { + id: "compute", + label: "Compute", + gate: "managedCloud", + fields: ["costInCents", "baseCostInCents"], + }, + { id: "machine", label: "Machine", fields: ["machinePreset"] }, + { id: "queue", label: "Queue", fields: ["queue"] }, + { id: "region", label: "Region", gate: "nonDev", fields: ["region", "workerQueue"] }, + { id: "test", label: "Test", fields: ["isTest"] }, + { id: "created", label: "Created at", fields: ["createdAt"] }, + { id: "delayed", label: "Delayed until", fields: ["delayUntil"] }, + { id: "ttl", label: "TTL", fields: ["ttl", "expiredAt"] }, + { id: "tags", label: "Tags", fields: ["runTags"] }, +]; + +const STANDARD_COLUMNS_BY_ID = new Map(STANDARD_COLUMNS.map((c) => [c.id, c] as const)); + +const SMART_COLUMN_SOURCES = ["payload", "metadata", "output"] as const; +export type SmartColumnSource = (typeof SMART_COLUMN_SOURCES)[number]; + +export const SMART_COLUMN_DISPLAYS = ["text", "number", "duration", "badge"] as const; +export type SmartColumnDisplay = (typeof SMART_COLUMN_DISPLAYS)[number]; + +export type SmartColumnDef = { + source: SmartColumnSource; + path: string; + label: string; + displayAs: SmartColumnDisplay; +}; + +const SMART_SOURCE_FIELDS: Record = { + payload: ["payload", "payloadType"], + metadata: ["metadata", "metadataType"], + output: ["output", "outputType"], +}; + +/** + * The search params the column layout lives in. Exported so callers that reason about the + * runs URL as a whole (e.g. summarising a favorite's filters) can tell layout from filters. + */ +export const RUN_COLUMN_SEARCH_PARAMS = ["cols", "sc", "hide"] as const; + +const SMART_REF_PREFIX = "sc"; + +function smartColumnRef(index: number): string { + return `${SMART_REF_PREFIX}${index + 1}`; +} + +function parseSmartColumnRef(ref: string): number | undefined { + if (!ref.startsWith(SMART_REF_PREFIX)) return undefined; + const n = Number(ref.slice(SMART_REF_PREFIX.length)); + return Number.isInteger(n) && n >= 1 ? n - 1 : undefined; +} + +/** + * Build the Postgres `select` for a page from the visible columns. Fields for + * shown standard columns are added on top of the always-selected set (a no-op + * while that set is the full scalar contract); payload/output are hydrated + * solely when a smart column references them. + */ +export function deriveRunSelect( + visibleStandardIds: readonly RunColumnId[], + smartSources: readonly SmartColumnSource[] +): Prisma.TaskRunSelect { + const select: Prisma.TaskRunSelect = {}; + + const add = (field: RunSelectField) => { + (select as Record)[field] = true; + }; + + for (const field of ALWAYS_SELECTED_FIELDS) add(field); + + for (const id of visibleStandardIds) { + const def = STANDARD_COLUMNS_BY_ID.get(id); + if (!def) continue; + for (const field of def.fields) add(field); + } + + for (const source of smartSources) { + for (const field of SMART_SOURCE_FIELDS[source]) add(field); + } + + return select; +} + +export type RunColumnRuntime = { + isManagedCloud: boolean; + isDevelopment: boolean; +}; + +function isColumnAvailable(def: StandardColumnDef, runtime: RunColumnRuntime): boolean { + switch (def.gate) { + case "managedCloud": + return runtime.isManagedCloud; + case "nonDev": + return !runtime.isDevelopment; + default: + return true; + } +} + +export function availableStandardColumns(runtime: RunColumnRuntime): StandardColumnDef[] { + return STANDARD_COLUMNS.filter((def) => isColumnAvailable(def, runtime)); +} + +function escapeSmartPart(value: string): string { + return value.replace(/%/g, "%25").replace(/:/g, "%3A"); +} + +function unescapeSmartPart(value: string): string { + return value.replace(/%3A/g, ":").replace(/%25/g, "%"); +} + +export function encodeSmartColumn(def: SmartColumnDef): string { + return [def.source, escapeSmartPart(def.path), escapeSmartPart(def.label), def.displayAs].join( + ":" + ); +} + +export function decodeSmartColumn(raw: string): SmartColumnDef | undefined { + const parts = raw.split(":"); + if (parts.length < 4) return undefined; + + const [source, path, label, displayAs] = parts; + if (!SMART_COLUMN_SOURCES.includes(source as SmartColumnSource)) return undefined; + if (!SMART_COLUMN_DISPLAYS.includes(displayAs as SmartColumnDisplay)) return undefined; + + const decodedPath = unescapeSmartPart(path); + if (decodedPath.length === 0) return undefined; + + return { + source: source as SmartColumnSource, + path: decodedPath, + label: unescapeSmartPart(label), + displayAs: displayAs as SmartColumnDisplay, + }; +} + +export type ResolvedColumn = + | { kind: "standard"; def: StandardColumnDef } + | { kind: "smart"; index: number; def: SmartColumnDef }; + +/** A column in the popover's full display order, with its current visibility. */ +export type LayoutColumn = { col: ResolvedColumn; hidden: boolean }; + +export type ColumnLayout = { + /** Every column in display order, hidden ones included (drives the popover). */ + ordered: LayoutColumn[]; + /** Shown columns in display order (drives the table). */ + visible: ResolvedColumn[]; + /** All decoded smart columns (visible or not), indexed by position. */ + smartColumns: SmartColumnDef[]; + /** Whether the layout differs from the default (drives "Reset to default"). */ + isCustomized: boolean; +}; + +export type ColumnLayoutParams = { cols: string[]; sc: string[]; hide: string[] }; +export type EncodedColumnLayout = { cols: string[]; sc: string[]; hide: string[] }; + +/** + * The order columns take when `cols` is absent: standard columns in default + * order, then smart columns in their `sc` definition order. + */ +function canonicalOrder(available: StandardColumnDef[], smartCount: number): string[] { + return [ + ...available.map((def) => def.id as string), + ...Array.from({ length: smartCount }, (_, i) => smartColumnRef(i)), + ]; +} + +/** + * Resolve the on-screen layout from the URL params and the runtime gates. + * `cols` is present only when the order differs from the default; otherwise the + * default order is used. `hide` lists the columns that are hidden but still + * occupy their slot, so hiding a column does not rewrite the whole order. + */ +export function resolveColumnLayout( + params: ColumnLayoutParams, + runtime: RunColumnRuntime +): ColumnLayout { + const available = availableStandardColumns(runtime); + const availableById = new Map(available.map((c) => [c.id, c] as const)); + const smartColumns = params.sc + .map(decodeSmartColumn) + .filter((c): c is SmartColumnDef => c !== undefined); + const hideSet = new Set(params.hide); + + const baseTokens = + params.cols.length > 0 ? params.cols : canonicalOrder(available, smartColumns.length); + + const ordered: LayoutColumn[] = []; + const seenStandard = new Set(); + const seenSmart = new Set(); + + for (const token of baseTokens) { + const smartIndex = parseSmartColumnRef(token); + if (smartIndex !== undefined) { + const def = smartColumns[smartIndex]; + if (!def || seenSmart.has(smartIndex)) continue; + ordered.push({ col: { kind: "smart", index: smartIndex, def }, hidden: hideSet.has(token) }); + seenSmart.add(smartIndex); + continue; + } + + if (seenStandard.has(token as RunColumnId)) continue; + const def = availableById.get(token as RunColumnId); + if (!def) continue; + ordered.push({ + col: { kind: "standard", def }, + hidden: hideSet.has(token) && !def.locked, + }); + seenStandard.add(def.id); + } + + ensureAllStandardColumnsPresent(ordered, seenStandard, available); + + for (let i = 0; i < smartColumns.length; i++) { + if (seenSmart.has(i)) continue; + ordered.push({ + col: { kind: "smart", index: i, def: smartColumns[i] }, + hidden: hideSet.has(smartColumnRef(i)), + }); + } + + const visible = ordered.filter((o) => !o.hidden).map((o) => o.col); + const isCustomized = params.cols.length > 0 || params.hide.length > 0 || smartColumns.length > 0; + return { ordered, visible, smartColumns, isCustomized }; +} + +/** + * Any available standard column missing from `cols` (a locked column, or one + * added after a URL was saved) is inserted, shown, at its default position. + */ +function ensureAllStandardColumnsPresent( + ordered: LayoutColumn[], + seenStandard: Set, + available: StandardColumnDef[] +): void { + const defaultIndex = new Map(available.map((def, index) => [def.id, index] as const)); + for (const def of available) { + if (seenStandard.has(def.id)) continue; + const target = defaultIndex.get(def.id) ?? 0; + let insertAt = ordered.length; + for (let i = 0; i < ordered.length; i++) { + const { col } = ordered[i]; + if (col.kind === "standard" && (defaultIndex.get(col.def.id) ?? 0) > target) { + insertAt = i; + break; + } + } + ordered.splice(insertAt, 0, { col: { kind: "standard", def }, hidden: false }); + seenStandard.add(def.id); + } +} + +/** + * Serialize a layout to compact `cols`/`sc`/`hide` params. `cols` is omitted + * whenever the order still matches the default, so hiding a column produces just + * a `hide` entry rather than the entire ordered list. All arrays empty means the + * default layout, and the caller deletes the keys. + */ +export function encodeColumnLayout( + ordered: LayoutColumn[], + runtime: RunColumnRuntime +): EncodedColumnLayout { + const available = availableStandardColumns(runtime); + + const sc: string[] = []; + const smartRefByIndex = new Map(); + for (const { col } of ordered) { + if (col.kind === "smart") { + const ref = smartColumnRef(sc.length); + smartRefByIndex.set(col.index, ref); + sc.push(encodeSmartColumn(col.def)); + } + } + + const tokenFor = (col: ResolvedColumn) => + col.kind === "standard" ? (col.def.id as string) : (smartRefByIndex.get(col.index) as string); + + const baseTokens = ordered.map(({ col }) => tokenFor(col)); + const hide = ordered.filter((o) => o.hidden).map(({ col }) => tokenFor(col)); + + const canonical = canonicalOrder(available, sc.length); + const orderIsDefault = + baseTokens.length === canonical.length && baseTokens.every((t, i) => t === canonical[i]); + + return { cols: orderIsDefault ? [] : baseTokens, sc, hide }; +} + +/** + * Parse the raw URL values into layout params. `cols` and `hide` are single + * comma-joined params; `sc` is repeated. + */ +export function parseColumnParams( + cols: string | null | undefined, + sc: string[], + hide: string | null | undefined +): ColumnLayoutParams { + const split = (value: string | null | undefined) => + value ? value.split(",").filter(Boolean) : []; + return { cols: split(cols), sc, hide: split(hide) }; +} + +/** The set of smart-column sources referenced by the visible layout. */ +export function visibleSmartSources(visible: ResolvedColumn[]): SmartColumnSource[] { + const sources = new Set(); + for (const col of visible) { + if (col.kind === "smart") sources.add(col.def.source); + } + return Array.from(sources); +} + +/** Visible standard column ids, for select derivation. */ +export function visibleStandardIds(visible: ResolvedColumn[]): RunColumnId[] { + return visible.filter((c) => c.kind === "standard").map((c) => c.def.id); +} diff --git a/apps/webapp/app/components/runs/v3/smartColumnCell.tsx b/apps/webapp/app/components/runs/v3/smartColumnCell.tsx new file mode 100644 index 00000000000..cbbc32a5f4b --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnCell.tsx @@ -0,0 +1,131 @@ +import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; +import { Badge } from "~/components/primitives/Badge"; +import { MiddleTruncate } from "~/components/primitives/MiddleTruncate"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { cn } from "~/utils/cn"; +import type { SmartColumnDef } from "./runColumns"; +import type { SmartCellValue } from "./smartColumnData"; + +/** Number and duration columns right-align and use tabular figures. */ +export function isNumericSmartDisplay(display: SmartColumnDef["displayAs"]): boolean { + return display === "number" || display === "duration"; +} + +function stringifySmartValue(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Coerce to a finite number only from an actual number or a non-empty numeric + * string. Returns NaN for null/boolean/empty-string/array/object so those fall + * back to their raw rendering instead of coercing to a misleading 0. + */ +function toFiniteNumber(value: unknown): number { + if (typeof value === "number") return value; + if (typeof value === "string" && value.trim().length > 0) return Number(value); + return NaN; +} + +/** + * Long text renders in a fixed-width box, not a max-width one. MiddleTruncate measures its + * parent, and a runs table column is auto-width: a max-width box narrows as the text is + * elided, which shrinks the column, which re-triggers truncation, and so on -- the text + * visibly ate itself a character at a time and never settled. A definite width can't be + * influenced by its own content, so the measurement converges on the first pass. + */ +const TEXT_CELL_WIDTH = "w-[600px]"; +/** + * Whether a value is long enough to need the fixed box, decided from the raw string so the + * choice never depends on layout (which is what made the loop possible). ~600px of 13px text. + */ +const TEXT_CELL_CHAR_BUDGET = 100; +/** Long values are common enough that an instant tooltip would fire while just scanning rows. */ +const TEXT_CELL_TOOLTIP_DELAY_MS = 500; +/** A whole payload string can be arbitrarily long, so the tooltip is capped and scrolls. */ +const TEXT_CELL_TOOLTIP_CLASS = + "block max-w-sm max-h-64 overflow-y-auto whitespace-pre-wrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"; + +function renderSmartValue( + value: unknown, + displayAs: SmartColumnDef["displayAs"], + truncate: boolean +): React.ReactNode { + switch (displayAs) { + case "number": { + const n = toFiniteNumber(value); + return Number.isFinite(n) ? n.toLocaleString() : stringifySmartValue(value); + } + case "duration": { + const n = toFiniteNumber(value); + return Number.isFinite(n) + ? formatDurationMilliseconds(n, { style: "short" }) + : stringifySmartValue(value); + } + case "badge": + return {stringifySmartValue(value)}; + default: { + const text = stringifySmartValue(value); + if (!truncate || text.length <= TEXT_CELL_CHAR_BUDGET) return text; + return ( + + + + ); + } + } +} + +/** + * The inner content of a smart-column cell (no table/row wrapper), shared by the + * runs table and the add-column preview so both look identical. `offloaded` + * shows a "Too large" tooltip, an absent path shows "โ€“", and an in-flight run's + * value is dotted-underlined to mark it provisional. + */ +export function SmartCellContent({ + cell, + def, + provisional, + truncate = false, +}: { + cell: SmartCellValue; + def: SmartColumnDef; + provisional: boolean; + /** Middle-truncate long text to a fixed cap. On for the table; the preview scrolls instead. */ + truncate?: boolean; +}) { + if (cell.state === "offloaded") { + return ( + + Too large + + } + content={`This run's ${def.source} is offloaded to object storage instead of the run row. Open the run to read it.`} + /> + ); + } + + if (cell.state === "empty") { + return โ€“; + } + + return ( + + {renderSmartValue(cell.value, def.displayAs, truncate)} + + ); +} diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.test.ts b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts new file mode 100644 index 00000000000..63605e9e11d --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts @@ -0,0 +1,140 @@ +import superjson from "superjson"; +import { describe, expect, it } from "vitest"; +import { extractSmartValue, getAtPath, labelFromPath, parseSource } from "./smartColumnData"; + +describe("parseSource", () => { + it("reports empty for missing data", () => { + expect(parseSource({ data: null, dataType: "application/json" })).toEqual({ state: "empty" }); + expect(parseSource({ data: undefined, dataType: "application/json" })).toEqual({ + state: "empty", + }); + expect(parseSource({ data: "", dataType: "application/json" })).toEqual({ state: "empty" }); + }); + + it("reports offloaded for application/store without touching the path", () => { + expect(parseSource({ data: "s3://bucket/key", dataType: "application/store" })).toEqual({ + state: "offloaded", + }); + }); + + it("parses application/json", () => { + expect(parseSource({ data: '{"a":1}', dataType: "application/json" })).toEqual({ + state: "parsed", + value: { a: 1 }, + }); + }); + + it("parses application/super+json (dates survive)", () => { + const serialized = superjson.stringify({ when: new Date("2026-01-01T00:00:00.000Z"), n: 2 }); + const parsed = parseSource({ data: serialized, dataType: "application/super+json" }); + expect(parsed.state).toBe("parsed"); + if (parsed.state === "parsed") { + const value = parsed.value as { when: Date; n: number }; + expect(value.when).toBeInstanceOf(Date); + expect(value.n).toBe(2); + } + }); + + it("defaults an unknown/absent content type to raw string and json respectively", () => { + expect(parseSource({ data: "hello", dataType: "text/plain" })).toEqual({ + state: "parsed", + value: "hello", + }); + expect(parseSource({ data: '{"a":1}', dataType: undefined })).toEqual({ + state: "parsed", + value: { a: 1 }, + }); + }); + + it("falls back to the raw string on malformed json", () => { + expect(parseSource({ data: "{not json", dataType: "application/json" })).toEqual({ + state: "parsed", + value: "{not json", + }); + }); +}); + +describe("getAtPath", () => { + const obj = { + failed: 3, + suites: [{ name: "nightly" }, { name: "smoke" }], + "a.b": { c: 7 }, + nested: { deep: { value: "x" } }, + }; + + it("reads a top-level key with and without $ / dot prefixes", () => { + expect(getAtPath(obj, "$.failed")).toBe(3); + expect(getAtPath(obj, "failed")).toBe(3); + expect(getAtPath(obj, ".failed")).toBe(3); + }); + + it("reads array indices and nested keys", () => { + expect(getAtPath(obj, "$.suites[0].name")).toBe("nightly"); + expect(getAtPath(obj, "suites[1].name")).toBe("smoke"); + expect(getAtPath(obj, "nested.deep.value")).toBe("x"); + }); + + it("reads quoted bracket keys containing a dot", () => { + expect(getAtPath(obj, "$['a.b'].c")).toBe(7); + }); + + it("returns undefined for missing segments", () => { + expect(getAtPath(obj, "$.nope")).toBeUndefined(); + expect(getAtPath(obj, "$.suites[9].name")).toBeUndefined(); + expect(getAtPath(obj, "$.failed.x")).toBeUndefined(); + }); + + it("rejects malformed paths", () => { + expect(getAtPath(obj, "$.a..b")).toBeUndefined(); + expect(getAtPath(obj, "$.a[b]")).toBeUndefined(); + }); + + it("reads bracket keys with escaped quotes and backslashes (the form childPath emits)", () => { + expect(getAtPath({ "a'b": 1 }, "$['a\\'b']")).toBe(1); + expect(getAtPath({ "a\\b": 2 }, "$['a\\\\b']")).toBe(2); + }); + + it("computes a dot-accessed .length for arrays, strings, and objects", () => { + const data = { tags: ["a", "b", "c"], name: "hello", info: { x: 1, y: 2 }, count: 5 }; + expect(getAtPath(data, "$.tags.length")).toBe(3); + expect(getAtPath(data, "$.name.length")).toBe(5); + expect(getAtPath(data, "$.info.length")).toBe(2); + expect(getAtPath(data, "$.count.length")).toBeUndefined(); + }); + + it("treats a bracket-quoted ['length'] as a literal key, not the computed length", () => { + expect(getAtPath({ length: 42 }, "$['length']")).toBe(42); + expect(getAtPath({ length: 42 }, "$.length")).toBe(1); + }); +}); + +describe("extractSmartValue", () => { + it("passes through empty and offloaded states", () => { + expect(extractSmartValue({ state: "empty" }, "$.a")).toEqual({ state: "empty" }); + expect(extractSmartValue({ state: "offloaded" }, "$.a")).toEqual({ state: "offloaded" }); + }); + + it("returns the value when present and empty when absent", () => { + const parsed = { state: "parsed" as const, value: { a: { b: 5 } } }; + expect(extractSmartValue(parsed, "$.a.b")).toEqual({ state: "value", value: 5 }); + expect(extractSmartValue(parsed, "$.a.c")).toEqual({ state: "empty" }); + }); +}); + +describe("labelFromPath", () => { + it("uses the last named key", () => { + expect(labelFromPath("$.suites[0].name")).toBe("name"); + expect(labelFromPath("$.failed")).toBe("failed"); + expect(labelFromPath("failed")).toBe("failed"); + }); + + it("skips trailing array indices and uses the array's key", () => { + expect(labelFromPath("$.tags[0]")).toBe("tags"); + expect(labelFromPath("$.matrix[0][1]")).toBe("matrix"); + expect(labelFromPath("$.a.b[3]")).toBe("b"); + }); + + it("keeps a numeric object key that was addressed with quotes", () => { + expect(labelFromPath("$.data['2024']")).toBe("2024"); + }); +}); diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.ts b/apps/webapp/app/components/runs/v3/smartColumnData.ts new file mode 100644 index 00000000000..4d9885bce59 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnData.ts @@ -0,0 +1,154 @@ +import superjson from "superjson"; + +export type SourcePacket = { + data: string | null | undefined; + dataType: string | null | undefined; +}; + +export type ParsedSource = + | { state: "empty" } + | { state: "offloaded" } + | { state: "parsed"; value: unknown }; + +/** + * Parse a raw payload/metadata/output packet on the client, respecting its + * content type. Never fetches: an offloaded (`application/store`) packet returns + * the `offloaded` state rather than its object-store path. A parse failure falls + * back to the raw string so a malformed value degrades to text, not a throw. + */ +export function parseSource(packet: SourcePacket): ParsedSource { + const { data, dataType } = packet; + if (data === null || data === undefined || data === "") { + return { state: "empty" }; + } + + const type = dataType ?? "application/json"; + if (type === "application/store") { + return { state: "offloaded" }; + } + + try { + switch (type) { + case "application/json": + return { state: "parsed", value: JSON.parse(data) }; + case "application/super+json": + return { state: "parsed", value: superjson.parse(data) }; + default: + return { state: "parsed", value: data }; + } + } catch { + return { state: "parsed", value: data }; + } +} + +export type SmartCellValue = + | { state: "empty" } + | { state: "offloaded" } + | { state: "value"; value: unknown }; + +export function extractSmartValue(parsed: ParsedSource, path: string): SmartCellValue { + if (parsed.state === "empty") return { state: "empty" }; + if (parsed.state === "offloaded") return { state: "offloaded" }; + + const value = getAtPath(parsed.value, path); + if (value === undefined) return { state: "empty" }; + return { state: "value", value }; +} + +const PATH_TOKEN_RE = /\.([^.[\]]+)|\[(\d+)\]|\['((?:\\.|[^'\\])*)'\]|\["((?:\\.|[^"\\])*)"\]/g; + +/** Reverse the backslash escaping applied to bracket-notation keys (e.g. `\'` -> `'`). */ +function unescapeBracketKey(raw: string): string { + return raw.replace(/\\(.)/g, "$1"); +} + +type PathToken = + | { kind: "dot"; key: string } + | { kind: "key"; key: string } + | { kind: "index"; index: number }; + +/** + * Read a value out of a parsed object with dot/bracket notation. Accepts a + * leading `$`, dotted keys, and numeric or quoted bracket indices, e.g. + * `$.failed`, `suites[0].name`, `$['a.b'].c`. Returns undefined when any + * segment is missing. + * + * A dot-accessed `.length` is computed: array/string length, or an object's + * key count. To read a real property literally named `length`, use a bracket + * key (`['length']`). + */ +export function getAtPath(root: unknown, path: string): unknown { + let normalized = path.trim(); + if (normalized.startsWith("$")) normalized = normalized.slice(1); + if (normalized.length === 0) return root; + if (!normalized.startsWith(".") && !normalized.startsWith("[")) { + normalized = `.${normalized}`; + } + + const tokens: PathToken[] = []; + let lastIndex = 0; + PATH_TOKEN_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PATH_TOKEN_RE.exec(normalized)) !== null) { + if (match.index !== lastIndex) return undefined; + lastIndex = PATH_TOKEN_RE.lastIndex; + + if (match[1] !== undefined) tokens.push({ kind: "dot", key: match[1] }); + else if (match[2] !== undefined) tokens.push({ kind: "index", index: Number(match[2]) }); + else if (match[3] !== undefined) + tokens.push({ kind: "key", key: unescapeBracketKey(match[3]) }); + else if (match[4] !== undefined) + tokens.push({ kind: "key", key: unescapeBracketKey(match[4]) }); + } + if (lastIndex !== normalized.length) return undefined; + + let current: unknown = root; + for (const token of tokens) { + if (current === null || current === undefined) return undefined; + + if (token.kind === "dot" && token.key === "length") { + if (Array.isArray(current) || typeof current === "string") { + current = current.length; + } else if (typeof current === "object") { + current = Object.keys(current).length; + } else { + return undefined; + } + continue; + } + + if (typeof current !== "object") return undefined; + const key = token.kind === "index" ? token.index : token.key; + current = (current as Record)[key]; + } + return current; +} + +/** + * Default column label from a path: its last named key, ignoring trailing array + * indices (so `$.tags[0]` labels as `tags`, not `0`). Falls back to the last + * segment, then the raw path. + */ +export function labelFromPath(path: string): string { + let normalized = path.trim(); + if (normalized.startsWith("$")) normalized = normalized.slice(1); + if (normalized.length > 0 && !normalized.startsWith(".") && !normalized.startsWith("[")) { + normalized = `.${normalized}`; + } + + const re = /\.([^.[\]]+)|\[(\d+)\]|\['((?:\\.|[^'\\])*)'\]|\["((?:\\.|[^"\\])*)"\]/g; + let lastKey: string | undefined; + let lastSegment: string | undefined; + let match: RegExpExecArray | null; + while ((match = re.exec(normalized)) !== null) { + const bracketKey = match[3] ?? match[4]; + const key = match[1] ?? (bracketKey !== undefined ? unescapeBracketKey(bracketKey) : undefined); + if (key !== undefined) { + lastKey = key; + lastSegment = key; + } else if (match[2] !== undefined) { + lastSegment = match[2]; + } + } + return lastKey ?? lastSegment ?? path; +} diff --git a/apps/webapp/app/components/scheduled/timezones.tsx b/apps/webapp/app/components/scheduled/timezones.tsx index 429598d60b3..023c20920b3 100644 --- a/apps/webapp/app/components/scheduled/timezones.tsx +++ b/apps/webapp/app/components/scheduled/timezones.tsx @@ -5,6 +5,7 @@ import { SelectItem } from "../primitives/Select"; export function TimezoneList({ timezones }: { timezones: string[] }) { const parentRef = useRef(null); + // oxlint-disable-next-line react/incompatible-library -- TanStack Virtual is not compatible with compiler memoization. const rowVirtualizer = useVirtualizer({ count: timezones.length, getScrollElement: () => parentRef.current, diff --git a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx index 3054a0cdf48..c28ed7b49b3 100644 --- a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx +++ b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx @@ -67,12 +67,16 @@ export function PurchaseSchedulesModal({ const isLoading = fetcher.state !== "idle"; const [open, setOpen] = useState(false); - // Reset the bundle stepper to the user's current extra-schedules count on - // each open. Earlier this only re-synced when `extraSchedules`/`stepSize` - // props changed, so if the user opened the modal, typed a value, cancelled, - // and reopened without purchasing, the stale draft persisted. + // Reset the bundle stepper to the user's current extra-schedules count on each open. + const handleOpenChange = (nextOpen: boolean) => { + if (nextOpen) setBundles(Math.round(extraSchedules / stepSize)); + setOpen(nextOpen); + }; + useEffect(() => { - if (open) setBundles(Math.round(extraSchedules / stepSize)); + if (!open) return; + // oxlint-disable-next-line react/set-state-in-effect -- Keep the open draft aligned with authoritative billing values. + setBundles(Math.round(extraSchedules / stepSize)); }, [open, extraSchedules, stepSize]); useEffect(() => { @@ -84,6 +88,7 @@ export function PurchaseSchedulesModal({ "ok" in data && data.ok ) { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setOpen(false); } }, [fetcher.state, fetcher.data]); @@ -113,7 +118,7 @@ export function PurchaseSchedulesModal({ } return ( - + {triggerButton ?? ( @@ -254,7 +259,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "need_to_delete"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Remove ${formatNumber( + {`Remove ${formatNumber( extraSchedules - amountValue )} ${extraSchedules - amountValue === 1 ? "schedule" : "schedules"}`} @@ -268,7 +273,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "no_change"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Purchase ${formatNumber( + {`Purchase ${formatNumber( amountValue - extraSchedules )} ${amountValue - extraSchedules === 1 ? "schedule" : "schedules"}`} diff --git a/apps/webapp/app/components/schedules/ScheduleInspector.tsx b/apps/webapp/app/components/schedules/ScheduleInspector.tsx index b2a64450f11..6f6874d2bb6 100644 --- a/apps/webapp/app/components/schedules/ScheduleInspector.tsx +++ b/apps/webapp/app/components/schedules/ScheduleInspector.tsx @@ -47,7 +47,7 @@ type EnvironmentRow = React.ComponentProps["environment id: string; }; -export type ScheduleInspectorData = { +type ScheduleInspectorData = { id: string; friendlyId: string; type: "DECLARATIVE" | "IMPERATIVE"; @@ -184,6 +184,7 @@ export function ScheduleInspector({
Last 5 runs ; -export type SessionListSearchFilterKey = keyof SessionListSearchFilters; export function getSessionFiltersFromSearchParams( searchParams: URLSearchParams @@ -237,6 +236,7 @@ function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: Men {filtered.map((type, index) => ( { clearSearchValue(); setFilterType(type.name); diff --git a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx index 69dfdf5092d..681baa8c54a 100644 --- a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx +++ b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx @@ -30,7 +30,7 @@ export function sessionStatusTitle(status: SessionStatus): string { } } -export function sessionStatusColor(status: SessionStatus): string { +function sessionStatusColor(status: SessionStatus): string { switch (status) { case "ACTIVE": return "text-pending"; @@ -43,7 +43,7 @@ export function sessionStatusColor(status: SessionStatus): string { } } -export function SessionStatusIcon({ +function SessionStatusIcon({ status, className, pulse = true, @@ -73,7 +73,7 @@ export function SessionStatusIcon({ } } -export function SessionStatusLabel({ status }: { status: SessionStatus }) { +function SessionStatusLabel({ status }: { status: SessionStatus }) { // system-mono-label: System themes uncolor the label (see tailwind.css) return ( diff --git a/apps/webapp/app/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts new file mode 100644 index 00000000000..3e287be6194 --- /dev/null +++ b/apps/webapp/app/components/themeOptions.ts @@ -0,0 +1,54 @@ +import { type FunctionComponent } from "react"; +import { CircleFilledIcon } from "~/assets/icons/CircleFilledIcon"; +import { CircleOutlineIcon } from "~/assets/icons/CircleOutlineIcon"; +import { MonitorIcon } from "~/assets/icons/MonitorIcon"; +import { MoonIcon } from "~/assets/icons/MoonIcon"; +import { SunIcon } from "~/assets/icons/SunIcon"; +import { type ThemeAppearance } from "~/hooks/useSystemThemeSync"; +import { type ThemePreference } from "~/utils/themePreference"; + +export type ThemeOption = { + value: ThemePreference; + label: string; + icon: FunctionComponent<{ className?: string }>; +}; + +/** Shared by every theme picker, in display order. */ +export const THEME_OPTIONS: ThemeOption[] = [ + { value: "system", label: "System", icon: MonitorIcon }, + { value: "light", label: "Light", icon: SunIcon }, + { value: "dark", label: "Dark", icon: MoonIcon }, +]; + +/** Account page only. Icons are the dark-theme pair; `themeOptionIcon` swaps them. */ +const FLAT_OPTIONS: ThemeOption[] = [ + { value: "white", label: "White", icon: CircleFilledIcon }, + { value: "black", label: "Black", icon: CircleOutlineIcon }, +]; + +export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIONS]; + +export const THEME_OPTIONS_BY_VALUE = Object.fromEntries( + ALL_THEME_OPTIONS.map((option) => [option.value, option]) +) as Record; + +/** + * Black and White show the active background through the circle: the option + * matching the current end is a ring, the opposing one a solid disc. + */ +export function themeOptionIcon(option: ThemeOption, appearance: ThemeAppearance) { + if (option.value === "black") { + return appearance === "dark" ? CircleOutlineIcon : CircleFilledIcon; + } + if (option.value === "white") { + return appearance === "light" ? CircleOutlineIcon : CircleFilledIcon; + } + return option.icon; +} + +export const SYSTEM_LIGHT_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter( + (option) => option.value === "light" || option.value === "white" +); +export const SYSTEM_DARK_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter( + (option) => option.value === "dark" || option.value === "black" +); diff --git a/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx b/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx index f705568b26e..9392670da7b 100644 --- a/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx +++ b/apps/webapp/app/components/webhookConsole/SampleSourcePicker.tsx @@ -40,12 +40,13 @@ export function SampleSourcePicker({ }, [bodyFetcher.data, onLoad]); const manifest = listFetcher.data?.kind === "manifest" ? listFetcher.data : undefined; - const providers = manifest?.providers ?? []; - const samples = manifest?.samples ?? []; + const providers = manifest?.providers; + const samples = manifest?.samples; const listLoading = listFetcher.data === undefined; useEffect(() => { - if (providers.length === 0) return; + if (!providers || providers.length === 0) return; + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setSelectedProvider((current) => { if (current && providers.some((p) => p.id === current)) return current; if (endpointSource && providers.some((p) => p.id === endpointSource)) return endpointSource; @@ -54,6 +55,8 @@ export function SampleSourcePicker({ }, [providers, endpointSource]); const filteredProviders = useMemo(() => { + if (!providers) return []; + const query = producerQuery.trim().toLowerCase(); if (!query) return providers; return providers.filter( @@ -72,7 +75,7 @@ export function SampleSourcePicker({ return [...groups.entries()]; }, [filteredProviders]); - const events = samples + const events = (samples ?? []) .filter((item) => item.provider === selectedProvider) .filter((item) => { const query = topicQuery.trim().toLowerCase(); diff --git a/apps/webapp/app/components/webhookConsole/WebhookComposer.tsx b/apps/webapp/app/components/webhookConsole/WebhookComposer.tsx index cee5b70fe96..9c9d76038cd 100644 --- a/apps/webapp/app/components/webhookConsole/WebhookComposer.tsx +++ b/apps/webapp/app/components/webhookConsole/WebhookComposer.tsx @@ -26,7 +26,7 @@ import { SampleSourcePicker } from "./SampleSourcePicker"; type SourceTab = "body" | "sample" | "replay" | "ai"; -export type WebhookComposerEndpoint = { +type WebhookComposerEndpoint = { friendlyId: string; label: string; source: string; diff --git a/apps/webapp/app/components/webhookDeliveries/v1/DeliveryStatus.tsx b/apps/webapp/app/components/webhookDeliveries/v1/DeliveryStatus.tsx index 0fac38937a4..8558e1bdaf9 100644 --- a/apps/webapp/app/components/webhookDeliveries/v1/DeliveryStatus.tsx +++ b/apps/webapp/app/components/webhookDeliveries/v1/DeliveryStatus.tsx @@ -3,7 +3,7 @@ import { cn } from "~/utils/cn"; // Reuse the run-status hex palette for the four delivery statuses (matches the // detail page activity chart and the task-list status bars). No invented colors. -export const DELIVERY_STATUS_COLOR: Record = { +const DELIVERY_STATUS_COLOR: Record = { SUCCEEDED: "#28BF5C", FAILED: "#E11D48", PROCESSING: "#3B82F6", @@ -11,7 +11,7 @@ export const DELIVERY_STATUS_COLOR: Record = { FILTERED: "#64748B", // received + verified, intentionally not routed; neutral, not a failure }; -export const DELIVERY_STATUS_LABEL: Record = { +const DELIVERY_STATUS_LABEL: Record = { SUCCEEDED: "Succeeded", FAILED: "Failed", PROCESSING: "Processing", diff --git a/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx b/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx index 20a2a4c94ea..4de3ccb6e14 100644 --- a/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx +++ b/apps/webapp/app/components/webhookDeliveries/v1/WebhookDeliveryFilters.tsx @@ -165,6 +165,7 @@ function MainMenu({ trigger, clearSearchValue, setFilterType }: MenuProps) { {filterTypes.map((type, index) => ( { clearSearchValue(); setFilterType(type.name); diff --git a/apps/webapp/app/components/webhookDeliveries/v1/buildDeliveryTimelineItems.ts b/apps/webapp/app/components/webhookDeliveries/v1/buildDeliveryTimelineItems.ts index f5cc0af596a..25ab202f9d8 100644 --- a/apps/webapp/app/components/webhookDeliveries/v1/buildDeliveryTimelineItems.ts +++ b/apps/webapp/app/components/webhookDeliveries/v1/buildDeliveryTimelineItems.ts @@ -5,7 +5,7 @@ import { type TimelineLineVariant, } from "~/components/run/RunTimeline"; -export type DeliveryRunTarget = { +type DeliveryRunTarget = { run: { friendlyId: string } | null; session: { friendlyId: string; externalId: string | null } | null; }; @@ -22,7 +22,7 @@ export type DeliveryTimelineEventItem = { target?: DeliveryRunTarget; }; -export type DeliveryTimelineLineItem = { +type DeliveryTimelineLineItem = { type: "line"; id: string; from: Date; diff --git a/apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts b/apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts index 3905a8aea85..5469c68d006 100644 --- a/apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts +++ b/apps/webapp/app/components/webhookDeliveries/v1/useDeliveriesLiveReload.ts @@ -124,6 +124,7 @@ export function useDeliveriesLiveReload({ const location = useLocation(); const deliveriesPollFetcher = useTypedFetcher(); const deliveriesPollFetcherStateRef = useRef(deliveriesPollFetcher.state); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. deliveriesPollFetcherStateRef.current = deliveriesPollFetcher.state; const [visibleDeliveries, setVisibleDeliveries] = useState(deliveries); @@ -140,6 +141,7 @@ export function useDeliveriesLiveReload({ } = useNewDeliveriesDetection({ deliveries, isLoading }); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setVisibleDeliveries(deliveries); resetNewDeliveriesTracking(); }, [deliveries, location.search, resetNewDeliveriesTracking]); @@ -148,6 +150,7 @@ export function useDeliveriesLiveReload({ const data = deliveriesPollFetcher.data; if (!data?.deliveries.length) return; + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setVisibleDeliveries((current) => patchVisibleDeliveriesWithLiveUpdates(current, data.deliveries) ); diff --git a/apps/webapp/app/consts.ts b/apps/webapp/app/consts.ts index 4bd070a5baf..ba5fe8e065e 100644 --- a/apps/webapp/app/consts.ts +++ b/apps/webapp/app/consts.ts @@ -1,16 +1,6 @@ -export const LIVE_ENVIRONMENT = "live"; -export const DEV_ENVIRONMENT = "development"; -export const MAX_LIVE_PROJECTS = 1; -export const DEFAULT_MAX_CONCURRENT_RUNS = 10; -export const MAX_CONCURRENT_RUNS_LIMIT = 20; -export const PREPROCESS_RETRY_LIMIT = 2; -export const EXECUTE_JOB_RETRY_LIMIT = 10; -export const MAX_RUN_YIELDED_EXECUTIONS = 100; -export const RUN_CHUNK_EXECUTION_BUFFER = 350; -export const MAX_RUN_CHUNK_EXECUTION_LIMIT = 120000; // 2 minutes -export const VERCEL_RESPONSE_TIMEOUT_STATUS_CODES = [408, 504]; +// 2 minutes + export const MAX_BATCH_TRIGGER_ITEMS = 100; export const MAX_API_KEY_TASK_IDENTIFIERS = 10; -export const MAX_TASK_RUN_ATTEMPTS = 250; + export const BULK_ACTION_RUN_LIMIT = 250; -export const MAX_JOB_RUN_EXECUTION_COUNT = 250; diff --git a/apps/webapp/app/database-types.ts b/apps/webapp/app/database-types.ts index 3305dc67d57..f4bdf9fd654 100644 --- a/apps/webapp/app/database-types.ts +++ b/apps/webapp/app/database-types.ts @@ -52,11 +52,3 @@ export const RuntimeEnvironmentType = { DEVELOPMENT: "DEVELOPMENT", PREVIEW: "PREVIEW", } as const satisfies Record; - -export function isTaskRunAttemptStatus(value: string): value is keyof typeof TaskRunAttemptStatus { - return Object.values(TaskRunAttemptStatus).includes(value as keyof typeof TaskRunAttemptStatus); -} - -export function isTaskRunStatus(value: string): value is keyof typeof TaskRunStatus { - return Object.values(TaskRunStatus).includes(value as keyof typeof TaskRunStatus); -} diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 077730cc33e..a69c83cd375 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -15,7 +15,6 @@ import { markReadReplicaClient } from "@internal/run-store"; import { PrismaPg } from "@prisma/adapter-pg"; import { Pool } from "pg"; import invariant from "tiny-invariant"; -import { z } from "zod"; import { env } from "./env.server"; import { logger } from "./services/logger.server"; import { isValidDatabaseUrl } from "./utils/db"; @@ -274,8 +273,8 @@ export const webhookReplica: WebhookReplicaDatabase = singleton("webhookReplica" return $replica; }); -export type RunOpsClients = { writer: PrismaClient; replica: PrismaReplicaClient }; -export type NewRunOpsClients = { writer: RunOpsPrismaClient; replica: RunOpsPrismaClient }; +type RunOpsClients = { writer: PrismaClient; replica: PrismaReplicaClient }; +type NewRunOpsClients = { writer: RunOpsPrismaClient; replica: RunOpsPrismaClient }; export type RunOpsTopology = { newRunOps: NewRunOpsClients; legacyRunOps: RunOpsClients; @@ -1142,10 +1141,6 @@ function redactUrlSecrets(hrefOrUrl: string | URL) { export type { PrismaClient } from "@trigger.dev/database"; -export const PrismaErrorSchema = z.object({ - code: z.string(), -}); - function getDatabaseSchema() { if (!isValidDatabaseUrl(env.DATABASE_URL)) { throw new Error("Invalid Database URL"); @@ -1162,6 +1157,6 @@ function getDatabaseSchema() { return schemaFromSearchParam; } -export const DATABASE_SCHEMA = singleton("DATABASE_SCHEMA", getDatabaseSchema); +const DATABASE_SCHEMA = singleton("DATABASE_SCHEMA", getDatabaseSchema); export const sqlDatabaseSchema = Prisma.sql([`${DATABASE_SCHEMA}`]); diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 719ae50aa3b..074bc39d760 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -10,6 +10,7 @@ import { PassThrough } from "stream"; import { initMollifierDrainerWorker } from "~/v3/mollifierDrainerWorker.server"; import { initMollifierStaleSweepWorker } from "~/v3/mollifierStaleSweepWorker.server"; import { initBillingLimitWorker } from "~/v3/billingLimitWorker.server"; +import { initLogsSearchProjectorWorker } from "~/v3/logsSearchProjectorWorker.server"; import { initQueueMetricsConsumer, initQueueMetricsEmitter } from "~/v3/queueMetrics.server"; import { bootstrap } from "./bootstrap"; import { LocaleContextProvider } from "./components/primitives/LocaleProvider"; @@ -17,7 +18,7 @@ import type { OperatingSystemPlatform } from "./components/primitives/OperatingS import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider"; import { assertRunOpsSplitSentinel, Prisma } from "./db.server"; import { env } from "./env.server"; -import { eventLoopMonitor } from "./eventLoopMonitor.server"; +import { eventLoopMonitor, eventLoopUtilizationMonitor } from "./eventLoopMonitor.server"; import { logger } from "./services/logger.server"; import { buildImgSrcDirective, parseCspImageOrigins, withImgSrc } from "./utils/cspImageOrigins"; import { singleton } from "./utils/singleton"; @@ -277,6 +278,7 @@ export const handleError = wrapHandleErrorWithSentry((error, { request }) => { initMollifierDrainerWorker(); initMollifierStaleSweepWorker(); initBillingLimitWorker(); +initLogsSearchProjectorWorker(); initQueueMetricsEmitter(); initQueueMetricsConsumer(); @@ -358,6 +360,10 @@ if (env.EVENT_LOOP_MONITOR_ENABLED === "1") { eventLoopMonitor.enable(); } +if (env.EVENT_LOOP_UTILIZATION_MONITOR_ENABLED === "1") { + eventLoopUtilizationMonitor.enable(); +} + if (remoteBuildsEnabled()) { console.log("๐Ÿ—๏ธ Remote builds enabled"); } else { diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 7a6c4c8aea1..c9179306124 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -192,6 +192,7 @@ const EnvironmentSchema = z // standard chat.agent SDK flow. When unset, the live agent is disabled โ€” the // conversation store / History still work, no chat can start. DASHBOARD_AGENT_SECRET_KEY: z.string().optional(), + DASHBOARD_AGENT_BASE_URL: z.string().optional(), // Pins agent sessions to a specific deployed version (paired with // --skip-promotion deploys); unset => the project env's current version. DASHBOARD_AGENT_VERSION: z.string().optional(), @@ -209,6 +210,28 @@ const EnvironmentSchema = z // uses its own key on the Trigger side. When unset, Head Start is disabled // and the first turn falls back to the normal cold-start path. ANTHROPIC_API_KEY: z.string().optional(), + // Selects the dashboard agent's LLM provider (default anthropic). The internal + // seam reads process.env directly; this entry validates the value webapp-side. + DASHBOARD_AGENT_MODEL_PROVIDER: z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : v), + z.enum(["anthropic", "bedrock"]).default("anthropic") + ), + // AWS credentials for the dashboard agent's Bedrock provider (only used when + // DASHBOARD_AGENT_MODEL_PROVIDER=bedrock; default path stays Anthropic). The + // provider resolves credentials itself, so only the region is read here. + AWS_REGION: z.string().optional(), + AWS_DEFAULT_REGION: z.string().optional(), + AWS_ACCESS_KEY_ID: z.string().optional(), + AWS_SECRET_ACCESS_KEY: z.string().optional(), + AWS_SESSION_TOKEN: z.string().optional(), + AWS_BEARER_TOKEN_BEDROCK: z.string().optional(), + // Dedicated, non-global credentials for the dashboard agent's Bedrock calls (a + // Bedrock-invoke-only IAM user). Kept separate from AWS_ACCESS_KEY_ID/etc so + // injecting them can't hijack the default credential chain the ECR/STS deploy + // clients rely on. + DASHBOARD_AGENT_AWS_ACCESS_KEY_ID: z.string().optional(), + DASHBOARD_AGENT_AWS_SECRET_ACCESS_KEY: z.string().optional(), + DASHBOARD_AGENT_AWS_REGION: z.string().optional(), DIRECT_URL: z .string() .refine( @@ -466,6 +489,31 @@ const EnvironmentSchema = z .default(process.env.REDIS_TLS_DISABLED ?? "false"), TASK_META_CACHE_CURRENT_ENV_TTL_SECONDS: z.coerce.number().default(86400), + EXTERNAL_DEPLOYMENT_CACHE_REDIS_HOST: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_HOST), + EXTERNAL_DEPLOYMENT_CACHE_REDIS_PORT: z.coerce + .number() + .optional() + .transform( + (v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined) + ), + EXTERNAL_DEPLOYMENT_CACHE_REDIS_USERNAME: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_USERNAME), + EXTERNAL_DEPLOYMENT_CACHE_REDIS_PASSWORD: z + .string() + .optional() + .transform((v) => v ?? process.env.REDIS_PASSWORD), + EXTERNAL_DEPLOYMENT_CACHE_REDIS_TLS_DISABLED: z + .string() + .default(process.env.REDIS_TLS_DISABLED ?? "false"), + EXTERNAL_DEPLOYMENT_CACHE_TTL_SECONDS: z.coerce.number().default(2592000), + EXTERNAL_DEPLOYMENT_CACHE_MISSING_TTL_SECONDS: z.coerce.number().default(20), + EXTERNAL_DEPLOYMENT_PARK_DEADLINE_MS: z.coerce.number().default(3600000), + // Runs-list empty-state check: how far back the ClickHouse "does this env have any run" // probe looks. Bounds the prove-absence partition scan. 0 = unbounded ("any run ever"). RUN_LIST_HAS_RUNS_LOOKBACK_DAYS: z.coerce.number().default(30), @@ -947,7 +995,8 @@ const EnvironmentSchema = z CENTS_PER_RUN: z.coerce.number().default(0), - EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"), + EVENT_LOOP_MONITOR_ENABLED: z.string().default("0"), + EVENT_LOOP_UTILIZATION_MONITOR_ENABLED: z.string().default("1"), MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000), MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000), MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000), @@ -2072,20 +2121,28 @@ const EnvironmentSchema = z .nonnegative() .optional(), - // Logs list pagination tuning (page sizing + recent-first probe windows). + // Scheduled logs-search projection. Disabled by default. LOGS_CLICKHOUSE_URL, or the + // CLICKHOUSE_URL fallback, must reach both source and destination tables and allow writes. + LOGS_SEARCH_PROJECTOR_ENABLED: BoolEnv.default(false), + LOGS_SEARCH_PROJECTOR_PREVIEW_ENABLED: BoolEnv.default(false), + LOGS_SEARCH_PROJECTOR_MAX_WINDOWS_PER_TICK: z.coerce.number().int().min(1).max(20).default(5), + LOGS_SEARCH_PROJECTOR_MAX_EXECUTION_TIME_SECONDS: z.coerce + .number() + .int() + .min(1) + .max(300) + .default(120), + LOGS_SEARCH_PROJECTOR_MAX_ROWS_TO_READ: z.coerce.number().int().positive().default(10_000_000), + LOGS_SEARCH_PROJECTOR_MAX_MEMORY_USAGE: z.coerce + .number() + .int() + .positive() + .default(1_500_000_000), + LOGS_SEARCH_PROJECTOR_MAX_THREADS: z.coerce.number().int().min(1).max(8).default(2), + + // Logs list pagination tuning. LOGS_LIST_DEFAULT_PAGE_SIZE: z.coerce.number().int().positive().default(50), LOGS_LIST_MAX_PAGE_SIZE: z.coerce.number().int().positive().default(100), - // Days back from the page ceiling to probe before widening to the full requested window, - // comma-separated. Empty disables narrowing (a single full-window query). - LOGS_LIST_RECENT_FIRST_PROBE_DAYS: z - .string() - .default("1,7") - .transform((s) => - s - .split(",") - .map((v) => Number(v.trim())) - .filter((n) => Number.isFinite(n) && n > 0) - ), // Query feature flag QUERY_FEATURE_ENABLED: z.string().default("1"), @@ -2094,10 +2151,7 @@ const EnvironmentSchema = z AI_FEATURES_ENABLED: z.string().default("0"), // Logs page ClickHouse URL (for logs queries) - LOGS_CLICKHOUSE_URL: z - .string() - .optional() - .transform((v) => v ?? process.env.CLICKHOUSE_READER_URL ?? process.env.CLICKHOUSE_URL), + LOGS_CLICKHOUSE_URL: z.string().optional(), // Query page ClickHouse limits (for TSQL queries) QUERY_CLICKHOUSE_URL: z diff --git a/apps/webapp/app/eventLoopMonitor.server.ts b/apps/webapp/app/eventLoopMonitor.server.ts index 2e45676e38b..5eb5ec82ceb 100644 --- a/apps/webapp/app/eventLoopMonitor.server.ts +++ b/apps/webapp/app/eventLoopMonitor.server.ts @@ -89,25 +89,51 @@ function after(asyncId: number) { } } +/** + * Per-async-resource blocked-loop detection. This is the expensive half: the + * hook fires for every async resource the process creates, and enabling any + * async hook also puts V8 on the slow path for promise instrumentation + * process-wide. On a request-heavy instance it costs roughly a seventh of all + * on-CPU time, which is why it is opt-in rather than on by default. + */ export const eventLoopMonitor = singleton("eventLoopMonitor", () => { const hook = createHook({ init, before, after, destroy }); - let stopEventLoopUtilizationMonitoring: () => void; - return { enable: () => { console.log("๐Ÿฅธ Initializing event loop monitor"); hook.enable(); - - stopEventLoopUtilizationMonitoring = startEventLoopUtilizationMonitoring(); }, disable: () => { console.log("๐Ÿฅธ Disabling event loop monitor"); hook.disable(); + }, + }; +}); + +/** + * The cheap half: a single interval timer reading `eventLoopUtilization()`. + * It costs nothing per request, so it stays on by default and is what a + * high-traffic instance should rely on when the async hook is too expensive. + */ +export const eventLoopUtilizationMonitor = singleton("eventLoopUtilizationMonitor", () => { + let stop: (() => void) | undefined; - stopEventLoopUtilizationMonitoring?.(); + return { + enable: () => { + if (stop) { + return; + } + + console.log("๐Ÿฅธ Initializing event loop utilization monitor"); + + stop = startEventLoopUtilizationMonitoring(); + }, + disable: () => { + stop?.(); + stop = undefined; }, }; }); diff --git a/apps/webapp/app/hooks/useAutoRevalidate.ts b/apps/webapp/app/hooks/useAutoRevalidate.ts index 4205b03bcc0..ff12f01c209 100644 --- a/apps/webapp/app/hooks/useAutoRevalidate.ts +++ b/apps/webapp/app/hooks/useAutoRevalidate.ts @@ -1,5 +1,5 @@ import { useRevalidator } from "@remix-run/react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; type UseAutoRevalidateOptions = { interval?: number; // in milliseconds @@ -10,15 +10,18 @@ type UseAutoRevalidateOptions = { export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { const { interval = 5000, onFocus = true, disabled = false } = options; const revalidator = useRevalidator(); + const revalidatorRef = useRef(revalidator); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. + revalidatorRef.current = revalidator; useEffect(() => { if (!interval || interval <= 0 || disabled) return; const intervalId = setInterval(() => { - if (revalidator.state === "loading") { + if (revalidatorRef.current.state === "loading") { return; } - revalidator.revalidate(); + revalidatorRef.current.revalidate(); }, interval); return () => clearInterval(intervalId); @@ -28,8 +31,8 @@ export function useAutoRevalidate(options: UseAutoRevalidateOptions = {}) { if (!onFocus || disabled) return; const handleFocus = () => { - if (document.visibilityState === "visible" && revalidator.state !== "loading") { - revalidator.revalidate(); + if (document.visibilityState === "visible" && revalidatorRef.current.state !== "loading") { + revalidatorRef.current.revalidate(); } }; diff --git a/apps/webapp/app/hooks/useCanViewLogsPage.ts b/apps/webapp/app/hooks/useCanViewLogsPage.ts deleted file mode 100644 index 3eb36b0641b..00000000000 --- a/apps/webapp/app/hooks/useCanViewLogsPage.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useEffect } from "react"; -import { useTypedFetcher } from "remix-typedjson"; -import { useOrganization } from "~/hooks/useOrganizations"; -import { type loader as canViewLogsPageLoader } from "~/routes/resources.orgs.$organizationSlug.can-view-logs-page/route"; - -export function useCanViewLogsPage(): boolean | undefined { - const organization = useOrganization(); - const fetcher = useTypedFetcher(); - - useEffect(() => { - const url = `/resources/orgs/${organization.slug}/can-view-logs-page`; - fetcher.load(url); - }, [organization.slug]); - - return fetcher.data?.canViewLogsPage; -} diff --git a/apps/webapp/app/hooks/useChanged.ts b/apps/webapp/app/hooks/useChanged.ts index 650a6b37814..5f05559f196 100644 --- a/apps/webapp/app/hooks/useChanged.ts +++ b/apps/webapp/app/hooks/useChanged.ts @@ -2,25 +2,30 @@ import { useEffect, useRef } from "react"; /** Call a function when the id of the item changes */ export function useChanged( - getItem: () => T | undefined, + item: T | undefined, action: (item: T | undefined) => void, sendInitialUndefined = true ) { const previousItemId = useRef(); - const item = getItem(); + const isInitialRender = useRef(true); + const actionRef = useRef(action); + const itemRef = useRef(); + const itemId = item?.id; + + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. + actionRef.current = action; + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. + itemRef.current = item; - //when the value changes, call the action useEffect(() => { - if (previousItemId.current !== item?.id) { - action(item); - } + const shouldSendInitialUndefined = + isInitialRender.current && itemId === undefined && sendInitialUndefined; - previousItemId.current = item?.id; - }, [item]); + if (previousItemId.current !== itemId || shouldSendInitialUndefined) { + actionRef.current(itemRef.current); + } - //if sendInitialUndefined is true, call the action when the component first renders - useEffect(() => { - if (item !== undefined || sendInitialUndefined === false) return; - action(item); - }, []); + previousItemId.current = itemId; + isInitialRender.current = false; + }, [itemId, sendInitialUndefined]); } diff --git a/apps/webapp/app/hooks/useApiOrigin.ts b/apps/webapp/app/hooks/useDashboardAgentBaseUrl.ts similarity index 65% rename from apps/webapp/app/hooks/useApiOrigin.ts rename to apps/webapp/app/hooks/useDashboardAgentBaseUrl.ts index b26d0caff01..538f88ffe56 100644 --- a/apps/webapp/app/hooks/useApiOrigin.ts +++ b/apps/webapp/app/hooks/useDashboardAgentBaseUrl.ts @@ -1,8 +1,8 @@ import { useTypedRouteLoaderData } from "remix-typedjson"; import type { loader } from "../root"; -export function useApiOrigin() { +export function useDashboardAgentBaseUrl() { const routeMatch = useTypedRouteLoaderData("root"); - return routeMatch!.apiOrigin; + return routeMatch!.dashboardAgentBaseUrl; } diff --git a/apps/webapp/app/hooks/useDashboardEditor.ts b/apps/webapp/app/hooks/useDashboardEditor.ts index c12a02005c5..7affadccce3 100644 --- a/apps/webapp/app/hooks/useDashboardEditor.ts +++ b/apps/webapp/app/hooks/useDashboardEditor.ts @@ -206,6 +206,9 @@ export function useDashboardEditor({ const layoutDebounceRef = useRef | null>(null); const isInitializedRef = useRef(false); const currentLayoutJsonRef = useRef(JSON.stringify(initialData.layout)); + const initialDataRef = useRef(initialData); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. + initialDataRef.current = initialData; // Sync queue to prevent race conditions const syncQueueRef = useRef([]); @@ -217,6 +220,8 @@ export function useDashboardEditor({ widgets: initialData.widgets, }); useEffect(() => { + const { layout, widgets } = initialDataRef.current; + // Cancel any pending layout save if (layoutDebounceRef.current) { clearTimeout(layoutDebounceRef.current); @@ -229,11 +234,11 @@ export function useDashboardEditor({ // Reset state to new initial data dispatch({ type: "RESET_STATE", - payload: { layout: initialData.layout, widgets: initialData.widgets }, + payload: { layout, widgets }, }); // Update refs - currentLayoutJsonRef.current = JSON.stringify(initialData.layout); + currentLayoutJsonRef.current = JSON.stringify(layout); isInitializedRef.current = false; // Allow saves after a short delay to skip initial mount callbacks @@ -253,6 +258,7 @@ export function useDashboardEditor({ // Sync queue processor - ensures only one sync runs at a time // ------------------------------------------------------------------------- + /* oxlint-disable react/preserve-manual-memoization -- The recursive callback drains a serialized sync queue. */ const processNextSync = useCallback(async () => { // If already syncing or queue is empty, do nothing if (isSyncingRef.current || syncQueueRef.current.length === 0) { @@ -305,6 +311,7 @@ export function useDashboardEditor({ processNextSync(); } }, [widgetActionUrl, layoutActionUrl, onSyncError]); + /* oxlint-enable react/preserve-manual-memoization */ // ------------------------------------------------------------------------- // Queue helpers diff --git a/apps/webapp/app/hooks/useDebounce.ts b/apps/webapp/app/hooks/useDebounce.ts index da63330f2a7..42545216ec0 100644 --- a/apps/webapp/app/hooks/useDebounce.ts +++ b/apps/webapp/app/hooks/useDebounce.ts @@ -29,6 +29,7 @@ export function useDebounceEffect(value: T, fn: (value: T) => void, delay: nu const fnRef = useRef(fn); // Update the ref whenever the function changes + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. fnRef.current = fn; useEffect(() => { diff --git a/apps/webapp/app/hooks/useElementVisibility.ts b/apps/webapp/app/hooks/useElementVisibility.ts index 2f1531882ce..be04c1f998d 100644 --- a/apps/webapp/app/hooks/useElementVisibility.ts +++ b/apps/webapp/app/hooks/useElementVisibility.ts @@ -8,6 +8,7 @@ export function useElementVisibility({ onVisibilityChange }: UseElementVisibilit const ref = useRef(null); const isVisibleRef = useRef(false); const callbackRef = useRef(onVisibilityChange); + // oxlint-disable-next-line react/refs -- This ref intentionally coordinates an imperative integration outside React state. callbackRef.current = onVisibilityChange; useEffect(() => { diff --git a/apps/webapp/app/hooks/useEnvironments.ts b/apps/webapp/app/hooks/useEnvironments.ts deleted file mode 100644 index 43fe8a7b8bc..00000000000 --- a/apps/webapp/app/hooks/useEnvironments.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { UIMatch } from "@remix-run/react"; -import type { MatchedProject } from "./useProject"; -import { useOptionalProject } from "./useProject"; - -export type ProjectJobEnvironment = MatchedProject["environments"][number]; - -export function useEnvironments(matches?: UIMatch[]) { - const project = useOptionalProject(matches); - if (!project) return; - - return project.environments; -} diff --git a/apps/webapp/app/hooks/useEventSource.tsx b/apps/webapp/app/hooks/useEventSource.tsx index 4bcdac6f522..4f76db5e05a 100644 --- a/apps/webapp/app/hooks/useEventSource.tsx +++ b/apps/webapp/app/hooks/useEventSource.tsx @@ -24,6 +24,7 @@ export function useEventSource( } // reset data if dependencies change + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. setData(null); const eventSource = new EventSource(url, init); diff --git a/apps/webapp/app/hooks/useFuzzyFilter.ts b/apps/webapp/app/hooks/useFuzzyFilter.ts index 0efff831114..0ba80f5cecc 100644 --- a/apps/webapp/app/hooks/useFuzzyFilter.ts +++ b/apps/webapp/app/hooks/useFuzzyFilter.ts @@ -55,7 +55,7 @@ export function useFuzzyFilter({ }), items ); - }, [items, filterText]); + }, [items, keys, filterText]); return { filterText, diff --git a/apps/webapp/app/hooks/useLazyRef.ts b/apps/webapp/app/hooks/useLazyRef.ts deleted file mode 100644 index c2fc66273bb..00000000000 --- a/apps/webapp/app/hooks/useLazyRef.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { MutableRefObject } from "react"; -import { useRef } from "react"; - -const useLazyRef = (initialValFunc: () => T) => { - const ref: MutableRefObject = useRef(null); - if (ref.current === null) { - ref.current = initialValFunc(); - } - return ref; -}; - -export default useLazyRef; diff --git a/apps/webapp/app/hooks/useList.tsx b/apps/webapp/app/hooks/useList.tsx index 1d0c9dcfde8..687301f0551 100644 --- a/apps/webapp/app/hooks/useList.tsx +++ b/apps/webapp/app/hooks/useList.tsx @@ -1,7 +1,7 @@ import type { Reducer } from "react"; import { useReducer } from "react"; -export type ListState = { +type ListState = { items: T[]; }; diff --git a/apps/webapp/app/hooks/useMetricResourceQuery.ts b/apps/webapp/app/hooks/useMetricResourceQuery.ts index d219ee73c08..fe8e8f397ee 100644 --- a/apps/webapp/app/hooks/useMetricResourceQuery.ts +++ b/apps/webapp/app/hooks/useMetricResourceQuery.ts @@ -13,6 +13,30 @@ export type MetricResourceTimeRange = { to: string | null; }; +export function useIsMetricResponseFresh( + responseReceivedAt: number | null, + dataTimestamp: number, + maxAgeMs: number +) { + const expiresAt = + responseReceivedAt !== null && Number.isFinite(dataTimestamp) ? dataTimestamp + maxAgeMs : null; + const [expiredAt, setExpiredAt] = useState(null); + + useEffect(() => { + if (expiresAt === null) return; + + const timeout = setTimeout(() => setExpiredAt(expiresAt), Math.max(0, expiresAt - Date.now())); + return () => clearTimeout(timeout); + }, [expiresAt]); + + return ( + expiresAt !== null && + responseReceivedAt !== null && + responseReceivedAt < expiresAt && + expiredAt !== expiresAt + ); +} + export type MetricResourceQueryOptions = { organizationId: string; projectId: string; @@ -102,6 +126,8 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO ); const [isLoading, setIsLoading] = useState(true); const [failed, setFailed] = useState(false); + const [responseReceivedAt, setResponseReceivedAt] = useState(null); + const [lastSuccessfulResponseAt, setLastSuccessfulResponseAt] = useState(null); const abortRef = useRef(null); const loadedKeyRef = useRef(null); @@ -111,6 +137,8 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO loadedKeyRef.current = cacheKey; setRows(null); setFailed(false); + setResponseReceivedAt(null); + setLastSuccessfulResponseAt(null); setIsLoading(false); return; } @@ -125,6 +153,8 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO loadedKeyRef.current = cacheKey; setRows(responseCache.get(cacheKey) ?? null); setFailed(false); + setResponseReceivedAt(null); + setLastSuccessfulResponseAt(null); } setIsLoading(true); fetch("/resources/metric", { @@ -150,10 +180,14 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO if (controller.signal.aborted) return; if (data.success) { cacheSet(cacheKey, data.data.rows); + const receivedAt = Date.now(); setRows(data.data.rows); setFailed(false); + setResponseReceivedAt(receivedAt); + setLastSuccessfulResponseAt(receivedAt); } else { setFailed(true); + setResponseReceivedAt(null); } setIsLoading(false); }) @@ -161,6 +195,7 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO if (error instanceof DOMException && error.name === "AbortError") return; if (!controller.signal.aborted) { setFailed(true); + setResponseReceivedAt(null); setIsLoading(false); } }); @@ -179,6 +214,7 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO ]); useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change. load(); return () => abortRef.current?.abort(); }, [load]); @@ -191,5 +227,12 @@ export function useMetricResourceQuery(query: string, opts: MetricResourceQueryO callback: load, }); - return { rows: rows ?? [], isLoading, showLoading: isLoading && !rows, failed }; + return { + rows: rows ?? [], + isLoading, + showLoading: isLoading && !rows, + failed, + responseReceivedAt, + lastSuccessfulResponseAt, + }; } diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index df3ec699633..4cd603b29e8 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -8,7 +8,7 @@ import { useTypedMatchesData } from "./useTypedMatchData"; export type MatchedOrganization = UseDataFunctionReturn["organizations"][number]; export const organizationMatchId = "routes/_app.orgs.$organizationSlug"; -export function useOptionalOrganizations(matches?: UIMatch[]) { +function useOptionalOrganizations(matches?: UIMatch[]) { const data = useTypedMatchesData({ id: "routes/_app.orgs.$organizationSlug", matches, @@ -42,16 +42,9 @@ export function useOrganization(matches?: UIMatch[]) { return org; } -export function useIsNewOrganizationPage(matches?: UIMatch[]): boolean { - const data = useTypedMatchesData({ - id: "routes/_app.orgs.new", - matches, - }); - return !!data; -} - export const useOrganizationChanged = (action: (org: MatchedOrganization | undefined) => void) => { - useChanged(useOptionalOrganization, action); + const organization = useOptionalOrganization(); + useChanged(organization, action); }; export function useIsImpersonating(matches?: UIMatch[]) { @@ -62,8 +55,6 @@ export function useIsImpersonating(matches?: UIMatch[]) { return data?.isImpersonating === true; } -export type CustomDashboard = UseDataFunctionReturn["customDashboards"][number]; - export function useCustomDashboards(matches?: UIMatch[]) { const data = useTypedMatchesData({ id: "routes/_app.orgs.$organizationSlug", diff --git a/apps/webapp/app/hooks/usePostHog.ts b/apps/webapp/app/hooks/usePostHog.ts index 887151ca397..73b45f704eb 100644 --- a/apps/webapp/app/hooks/usePostHog.ts +++ b/apps/webapp/app/hooks/usePostHog.ts @@ -38,7 +38,7 @@ export const usePostHog = ( }, }); postHogInitialized.current = true; - }, [apiKey, uiHost, logging, user]); + }, [apiKey, uiHost, logging, debug, user]); useUserChanged((user) => { if (postHogInitialized.current === false) return; diff --git a/apps/webapp/app/hooks/useProject.tsx b/apps/webapp/app/hooks/useProject.tsx index 2280694c102..2e04322c27f 100644 --- a/apps/webapp/app/hooks/useProject.tsx +++ b/apps/webapp/app/hooks/useProject.tsx @@ -24,5 +24,6 @@ export function useProject(matches?: UIMatch[]) { } export const useProjectChanged = (action: (org: MatchedProject | undefined) => void) => { - useChanged(useOptionalProject, action); + const project = useOptionalProject(); + useChanged(project, action); }; diff --git a/apps/webapp/app/hooks/useReplaceSearchParams.ts b/apps/webapp/app/hooks/useReplaceSearchParams.ts index 822217d963b..6bd9d7e863f 100644 --- a/apps/webapp/app/hooks/useReplaceSearchParams.ts +++ b/apps/webapp/app/hooks/useReplaceSearchParams.ts @@ -20,7 +20,7 @@ export function useReplaceSearchParams() { return s; }, navigateOpts); }, - [searchParams] + [setSearchParams] ); return { searchParams, setSearchParams, replaceSearchParam }; diff --git a/apps/webapp/app/hooks/useRevalidateOnParam.ts b/apps/webapp/app/hooks/useRevalidateOnParam.ts deleted file mode 100644 index de05141d95e..00000000000 --- a/apps/webapp/app/hooks/useRevalidateOnParam.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useEffect } from "react"; -import { useRevalidator, useSearchParams } from "@remix-run/react"; - -type UseRevalidateOnParamOptions = { - /** The query param(s) that trigger revalidation */ - param: string | string[]; - /** Callback fired when revalidation is triggered */ - onRevalidate?: () => void; -}; - -/** - * Hook that triggers revalidation when specific query params are present, - * then removes those params from the URL. - * - * Usage: - * ```ts - * // Revalidate when ?_revalidate is present - * useRevalidateOnParam({ param: "_revalidate" }); - * - * // With callback to close a modal - * useRevalidateOnParam({ - * param: "_revalidate", - * onRevalidate: () => setEditorMode(null), - * }); - * ``` - * - * The redirect should include the param: - * ```ts - * return redirect(`${dashboardPath}?_revalidate=${Date.now()}`); - * ``` - */ -export function useRevalidateOnParam({ param, onRevalidate }: UseRevalidateOnParamOptions) { - const [searchParams, setSearchParams] = useSearchParams(); - const revalidator = useRevalidator(); - - const paramArray = Array.isArray(param) ? param : [param]; - - useEffect(() => { - // Check if any of the trigger params are present - const hasParam = paramArray.some((p) => searchParams.has(p)); - - if (hasParam) { - // Trigger revalidation - revalidator.revalidate(); - - // Call the callback if provided - onRevalidate?.(); - - // Remove the trigger params from the URL - const newParams = new URLSearchParams(searchParams); - paramArray.forEach((p) => newParams.delete(p)); - - // Update URL without the params (replace to avoid adding to history) - setSearchParams(newParams, { replace: true }); - } - }, [searchParams, setSearchParams, revalidator, paramArray, onRevalidate]); -} diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts index 6b2a678396f..bfd168c6a5b 100644 --- a/apps/webapp/app/hooks/useSystemThemeSync.ts +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -1,29 +1,96 @@ -import { useEffect } from "react"; -import { type ThemePreference } from "~/utils/themePreference"; +import { useEffect, useState } from "react"; +import { + type SystemDarkTheme, + type SystemLightTheme, + type ThemePreference, +} from "~/utils/themePreference"; + +/** Which theme `system` lands on at each end of the OS setting. */ +export type SystemThemes = { light: SystemLightTheme; dark: SystemDarkTheme }; + +const DEFAULT_SYSTEM_THEMES: SystemThemes = { light: "light", dark: "dark" }; + +/** Which end of the scale a theme sits on. */ +export type ThemeAppearance = "dark" | "light"; + +function themeAppearance(preference: ThemePreference, prefersDark: boolean): ThemeAppearance { + if (preference === "system") return prefersDark ? "dark" : "light"; + return preference === "light" || preference === "white" ? "light" : "dark"; +} /** - * Keeps `data-theme` on in sync with the preference. For `system` it - * follows the OS color scheme live; for pinned themes it writes the attribute - * explicitly - React can skip the write when its virtual DOM already matched - * the SSR fallback while the inline script had changed the real attribute. - * The single resolution rule (dark vs light) lives here and in the blocking - * inline script in root.tsx; downstream consumers react to the `data-theme` - * mutation (see useThemeColor). + * Resolved appearance, tracking the OS while the preference is `system`. Defaults + * to dark before the effect runs, matching root.tsx's SSR fallback. */ -export function useSystemThemeSync(preference: ThemePreference) { +export function useThemeAppearance(preference: ThemePreference): ThemeAppearance { + const [prefersDark, setPrefersDark] = useState(true); + + useEffect(() => { + if (preference !== "system") return; + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const apply = () => setPrefersDark(media.matches); + apply(); + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, [preference]); + + return themeAppearance(preference, prefersDark); +} + +/** Only `system` needs resolving; it lands on the variant picked for that end. */ +export function resolveThemePreference( + preference: ThemePreference, + prefersDark: boolean, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +): ThemePreference { + if (preference !== "system") return preference; + return prefersDark ? systemThemes.dark : systemThemes.light; +} + +/** Just the percent; each theme maps it onto its own range in CSS. */ +export function applyThemeContrast(percent: number) { + document.documentElement.style.setProperty("--theme-contrast-percent", String(percent / 100)); +} + +/** Applies a theme immediately, rather than waiting for the loader round-trip. */ +export function applyThemePreference( + preference: ThemePreference, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +) { + const prefersDark = + preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches; + document.documentElement.setAttribute( + "data-theme", + resolveThemePreference(preference, prefersDark, systemThemes) + ); + document.documentElement.setAttribute("data-theme-preference", preference); +} + +/** + * Keeps `data-theme` in sync with the preference. Pinned themes are written + * explicitly: React can skip a write its virtual DOM thinks already matched, + * while root.tsx's inline script had changed the real attribute. + */ +export function useSystemThemeSync( + preference: ThemePreference, + systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES +) { + const { light, dark } = systemThemes; + useEffect(() => { if (preference !== "system") { - document.documentElement.setAttribute("data-theme", preference); + applyThemePreference(preference); return; } const media = window.matchMedia("(prefers-color-scheme: dark)"); const apply = () => { - document.documentElement.setAttribute("data-theme", media.matches ? "dark" : "light"); + document.documentElement.setAttribute("data-theme", media.matches ? dark : light); }; apply(); media.addEventListener("change", apply); return () => media.removeEventListener("change", apply); - }, [preference]); + // Destructured so a fresh object each render doesn't re-run this + }, [preference, light, dark]); } diff --git a/apps/webapp/app/hooks/useTextFilter.ts b/apps/webapp/app/hooks/useTextFilter.ts deleted file mode 100644 index b28019bd20d..00000000000 --- a/apps/webapp/app/hooks/useTextFilter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useMemo, useState } from "react"; - -type TextFilterProps = { - defaultValue?: string; - items: T[]; - filter: (item: T, filterText: string) => boolean; -}; - -export function useTextFilter({ defaultValue = "", items, filter }: TextFilterProps) { - const [filterText, setFilterText] = useState(defaultValue); - - const filteredItems = useMemo(() => { - if (filterText === "") { - return items; - } - return items.filter((item) => { - return filter(item, filterText); - }); - }, [items, filterText]); - - return { - filterText, - setFilterText, - filteredItems, - }; -} diff --git a/apps/webapp/app/hooks/useThemeColor.ts b/apps/webapp/app/hooks/useThemeColor.ts index f088d16b525..4555f6415ec 100644 --- a/apps/webapp/app/hooks/useThemeColor.ts +++ b/apps/webapp/app/hooks/useThemeColor.ts @@ -7,7 +7,8 @@ import { useEffect, useState } from "react"; */ function toRgb(color: string): string { const canvas = document.createElement("canvas"); - canvas.width = canvas.height = 1; + canvas.width = 1; + canvas.height = 1; const ctx = canvas.getContext("2d"); if (!ctx) return color; ctx.fillStyle = color; diff --git a/apps/webapp/app/hooks/useThemeMode.ts b/apps/webapp/app/hooks/useThemeMode.ts index e2d39d94989..5145409e89b 100644 --- a/apps/webapp/app/hooks/useThemeMode.ts +++ b/apps/webapp/app/hooks/useThemeMode.ts @@ -1,7 +1,13 @@ import { useEffect, useState } from "react"; +import { SystemLightTheme } from "~/utils/themePreference"; export type ThemeMode = "dark" | "light"; +/* Which themes read as light. Taken from the enum that also drives the "Light" + end of the `system` preference, so a new theme only has to be classified once + - anything not in here (dark, black) reads as dark. */ +const LIGHT_THEMES = new Set(SystemLightTheme.options); + /** * The active theme's mode, for colors that can't come from a CSS variable. Resolved in an * effect so server and hydration renders agree; `root.tsx` can flip `data-theme` pre-paint. @@ -10,7 +16,8 @@ export function useThemeMode(): ThemeMode { const [mode, setMode] = useState("dark"); useEffect(() => { const resolve = () => { - setMode(document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"); + const theme = document.documentElement.getAttribute("data-theme"); + setMode(theme !== null && LIGHT_THEMES.has(theme) ? "light" : "dark"); }; resolve(); const observer = new MutationObserver(resolve); diff --git a/apps/webapp/app/hooks/useThrottle.ts b/apps/webapp/app/hooks/useThrottle.ts deleted file mode 100644 index d00ef9b460e..00000000000 --- a/apps/webapp/app/hooks/useThrottle.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect, useRef } from "react"; - -export function useThrottle(fn: (...args: any[]) => void, duration: number) { - const timeout = useRef>(); - - // Clean up when the component is unmounted - useEffect(() => { - return () => { - if (timeout.current) clearTimeout(timeout.current); - }; - }, []); - - return (...args: Parameters) => { - if (timeout.current) { - clearTimeout(timeout.current); - } - - timeout.current = setTimeout(() => { - fn(...args); - timeout.current = undefined; - }, duration); - }; -} diff --git a/apps/webapp/app/hooks/useToggleFilter.ts b/apps/webapp/app/hooks/useToggleFilter.ts deleted file mode 100644 index 2e099a9f979..00000000000 --- a/apps/webapp/app/hooks/useToggleFilter.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo, useState } from "react"; - -type ToggleFilterProps = { - items: T[]; - filter: (item: T, isToggleActive: boolean) => boolean; - defaultValue?: boolean; -}; - -export function useToggleFilter({ items, filter, defaultValue = false }: ToggleFilterProps) { - const [isToggleActive, setToggleActive] = useState(defaultValue); - - const filteredItems = useMemo(() => { - return items.filter((item) => filter(item, isToggleActive)); - }, [items, isToggleActive]); - - return { - isToggleActive, - setToggleActive, - filteredItems, - }; -} diff --git a/apps/webapp/app/hooks/useTypedMatchData.ts b/apps/webapp/app/hooks/useTypedMatchData.ts index d2a1514c59a..056e5116b87 100644 --- a/apps/webapp/app/hooks/useTypedMatchData.ts +++ b/apps/webapp/app/hooks/useTypedMatchData.ts @@ -23,14 +23,12 @@ export function useTypedMatchesData({ id: string; matches?: UIMatch[]; }): UseDataFunctionReturn | undefined { - if (!matches) { - matches = useMatches(); - } + const routeMatches = useMatches(); - return useTypedDataFromMatches({ id, matches }); + return useTypedDataFromMatches({ id, matches: matches ?? routeMatches }); } -export function useTypedMatchData( +function useTypedMatchData( match: UIMatch | undefined ): UseDataFunctionReturn | undefined { if (!match) { diff --git a/apps/webapp/app/hooks/useUser.ts b/apps/webapp/app/hooks/useUser.ts index aa86ba63865..2eed91b9734 100644 --- a/apps/webapp/app/hooks/useUser.ts +++ b/apps/webapp/app/hooks/useUser.ts @@ -27,7 +27,8 @@ export function useUser(matches?: UIMatch[]): User { } export function useUserChanged(callback: (user: User | undefined) => void) { - useChanged(useOptionalUser, callback); + const user = useOptionalUser(); + useChanged(user, callback); } /** diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index c70a41ae85f..de105c4d3cf 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -13,7 +13,7 @@ import { ssoController } from "~/services/sso.server"; import { boundedIn } from "@trigger.dev/database"; export const INVITE_NOT_FOUND = "Invite not found"; -export const INVITE_BLOCKED_DIRECTORY_MANAGED = +const INVITE_BLOCKED_DIRECTORY_MANAGED = "Membership for this organization is managed by Directory Sync, so invites can't be accepted."; export const ENV_SETUP_INCOMPLETE = "You joined the organization, but we couldn't finish setting up your development environments. Please try accepting the invite again, or contact support if this persists."; diff --git a/apps/webapp/app/models/message.server.ts b/apps/webapp/app/models/message.server.ts index eec7316e095..3ff8b4e1a94 100644 --- a/apps/webapp/app/models/message.server.ts +++ b/apps/webapp/app/models/message.server.ts @@ -94,38 +94,6 @@ export function setErrorMessage(session: Session, message: string, options?: Toa } as ToastMessage); } -export async function setRequestErrorMessage( - request: Request, - message: string, - options?: ToastMessageOptions -) { - const session = await getSession(request.headers.get("cookie")); - - setErrorMessage(session, message, options); - - return session; -} - -export async function setRequestSuccessMessage( - request: Request, - message: string, - options?: ToastMessageOptions -) { - const session = await getSession(request.headers.get("cookie")); - - setSuccessMessage(session, message, options); - - return session; -} - -export async function setToastMessageCookie(session: Session) { - return { - "Set-Cookie": await commitSession(session, { - expires: new Date(Date.now() + ONE_YEAR), - }), - }; -} - export async function jsonWithSuccessMessage( data: any, request: Request, diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts index f2bd0feebce..16641f3a133 100644 --- a/apps/webapp/app/models/orgIntegration.server.ts +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -9,10 +9,15 @@ import { z } from "zod"; import { $transaction, prisma } from "~/db.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; +import { redirectWithErrorMessage } from "./message.server"; import { slackSecretLogFields } from "./safeIntegrationLog"; import { slackAccessResultLogFields } from "./slackOAuthResultLog"; import { getSecretStore } from "~/services/secrets/secretStore.server"; -import { commitSession, getUserSession } from "~/services/sessionStorage.server"; +import { + clearSlackOAuthSessionBinding, + consumeSlackOAuthStateForSession, + createSlackOAuthStateForSession, +} from "~/models/slackOAuthState.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; const SlackSecretSchema = z.object({ @@ -27,8 +32,6 @@ const SlackSecretSchema = z.object({ type SlackSecret = z.infer; -const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth"; - export type OrganizationIntegrationForService = Omit< AuthenticatableIntegration, "service" @@ -138,22 +141,26 @@ export class OrgIntegrationRepository { static async redirectToAuthService( service: IntegrationService, - state: string, + organizationId: string, + userId: string, request: Request, redirectTo: string ) { - const session = await getUserSession(request); - session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo); - - const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined; - - if (!authUrl) { + if (service !== "SLACK") { throw new Response("Unsupported service", { status: 400 }); } + const { nonce, sessionCookie } = await createSlackOAuthStateForSession(request, { + userId, + organizationId, + service: "slack", + redirectTo, + }); + + const authUrl = this.slackAuthorizationUrl(nonce); + logger.debug("Redirecting to auth service", { service, - authUrl, redirectTo, }); @@ -161,35 +168,33 @@ export class OrgIntegrationRepository { status: 302, headers: { location: authUrl, - "Set-Cookie": await commitSession(session), + "Set-Cookie": sessionCookie, }, }); } - static async redirectAfterAuth(request: Request) { - const session = await getUserSession(request); - - logger.debug("Redirecting back after auth", { - sessionData: session.data, - }); - - const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY); + static async redirectAfterAuth(request: Request, redirectTo: string, errorMessage?: string) { + const sessionCookie = await clearSlackOAuthSessionBinding(request); - if (!redirectTo) { - throw new Response("Invalid redirect", { status: 400 }); + if (errorMessage) { + const response = await redirectWithErrorMessage(redirectTo, request, errorMessage); + response.headers.append("Set-Cookie", sessionCookie); + return response; } - session.unset(REDIRECT_AFTER_AUTH_KEY); - return new Response(null, { status: 302, headers: { location: redirectTo, - "Set-Cookie": await commitSession(session), + "Set-Cookie": sessionCookie, }, }); } + static async consumeSlackOAuthState(request: Request, state: string, userId: string) { + return consumeSlackOAuthStateForSession(request, state, userId); + } + static async createOrgIntegration(serviceName: string, code: string, org: Organization) { switch (serviceName) { case "slack": { diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 2ed317fe879..f309ae3212d 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -113,6 +113,7 @@ export async function createProject( // for historical rows; the V1->V2 upgrade guards on worker-register / deploy // stay in place to migrate existing legacy projects. engine: "V2", + defaultRuntime: "node-24", onboardingData, }, include: { diff --git a/apps/webapp/app/models/projectAlert.server.ts b/apps/webapp/app/models/projectAlert.server.ts index dbcb672ad7d..95909dbde96 100644 --- a/apps/webapp/app/models/projectAlert.server.ts +++ b/apps/webapp/app/models/projectAlert.server.ts @@ -15,10 +15,6 @@ export const ProjectAlertEmailProperties = z.object({ export type ProjectAlertEmailProperties = z.infer; -export const DeleteProjectAlertChannel = z.object({ - id: z.string(), -}); - export const ProjectAlertSlackProperties = z.object({ channelId: z.string(), channelName: z.string(), diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 8dc5a68b63f..790576200ec 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -6,6 +6,7 @@ import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver. import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; import { hashApiKey } from "~/utils/apiKeys"; +import { BuildRuntime } from "@trigger.dev/core/v3"; import { isAdditionalApiKey } from "@trigger.dev/core/v3/apiKeys"; import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { scopesGrantFullAccess } from "@trigger.dev/rbac"; @@ -77,6 +78,7 @@ export function toAuthenticated( defaultWorkerGroupId: env.project.defaultWorkerGroupId, organizationId: env.project.organizationId, builderProjectId: env.project.builderProjectId, + defaultRuntime: BuildRuntime.nullable().safeParse(env.project.defaultRuntime).data ?? null, }, organization: { id: env.organization.id, @@ -497,77 +499,6 @@ export async function findEnvironmentFromRun( }; } -export async function createNewSession( - environment: Pick, - ipAddress: string -) { - const session = await prisma.runtimeEnvironmentSession.create({ - data: { - environmentId: environment.id, - ipAddress, - }, - }); - - await prisma.runtimeEnvironment.update({ - where: { - id: environment.id, - }, - data: { - currentSessionId: session.id, - }, - }); - - return session; -} - -export async function disconnectSession(environmentId: string) { - const environment = await prisma.runtimeEnvironment.findFirst({ - where: { - id: environmentId, - }, - }); - - if (!environment || !environment.currentSessionId) { - return null; - } - - const session = await prisma.runtimeEnvironmentSession.update({ - where: { - id: environment.currentSessionId, - }, - data: { - disconnectedAt: new Date(), - }, - }); - - await prisma.runtimeEnvironment.update({ - where: { - id: environment.id, - }, - data: { - currentSessionId: null, - }, - }); - - return session; -} - -export async function findLatestSession( - environmentId: string, - client: PrismaClientOrTransaction = $replica -) { - const session = await client.runtimeEnvironmentSession.findFirst({ - where: { - environmentId, - }, - orderBy: { - createdAt: "desc", - }, - }); - - return session; -} - export type DisplayableInputEnvironment = Prisma.RuntimeEnvironmentGetPayload<{ select: { id: true; diff --git a/apps/webapp/app/models/slackOAuthState.server.ts b/apps/webapp/app/models/slackOAuthState.server.ts new file mode 100644 index 00000000000..93ad9ee5b57 --- /dev/null +++ b/apps/webapp/app/models/slackOAuthState.server.ts @@ -0,0 +1,139 @@ +import { randomBytes } from "node:crypto"; +import { z } from "zod"; +import { env } from "~/env.server"; +import { createRedisClient, type RedisClient } from "~/redis.server"; +import { commitSession, getUserSession } from "~/services/sessionStorage.server"; +import { singleton } from "~/utils/singleton"; + +const STATE_TTL_SECONDS = 10 * 60; +const CREATE_ATTEMPTS = 2; +const KEY_PREFIX = "oauth:slack:state:"; +const SLACK_OAUTH_SESSION_BINDING_KEY = "slack-oauth-session-binding"; + +const SlackOAuthStateSchema = z.object({ + userId: z.string(), + sessionBinding: z.string(), + organizationId: z.string(), + service: z.literal("slack"), + redirectTo: z.string().regex(/^\/(?!\/)/), +}); + +export type SlackOAuthState = z.infer; + +type CreateSlackOAuthState = SlackOAuthState; +type StartSlackOAuthState = Omit; +type ConsumeSlackOAuthState = Pick; + +const consumeScript = ` +local raw = redis.call("GET", KEYS[1]) +if not raw then return nil end +local decoded, state = pcall(cjson.decode, raw) +if not decoded or type(state) ~= "table" then return nil end +if state.userId ~= ARGV[1] or state.sessionBinding ~= ARGV[2] or state.service ~= ARGV[3] then + return nil +end +redis.call("DEL", KEYS[1]) +return raw +`; + +export class SlackOAuthStateStore { + constructor(private readonly redis: Pick) {} + + async create(state: CreateSlackOAuthState): Promise { + const parsedState = SlackOAuthStateSchema.parse(state); + + for (let attempt = 0; attempt < CREATE_ATTEMPTS; attempt++) { + const nonce = randomBytes(32).toString("base64url"); + const created = await this.redis.set( + this.#key(nonce), + JSON.stringify(parsedState), + "EX", + STATE_TTL_SECONDS, + "NX" + ); + if (created === "OK") return nonce; + } + + throw new Error("Failed to create a unique Slack OAuth state"); + } + + async consume( + nonce: string, + expected: ConsumeSlackOAuthState + ): Promise { + if (!/^[A-Za-z0-9_-]{43}$/.test(nonce)) return undefined; + + const raw = await this.redis.eval( + consumeScript, + 1, + this.#key(nonce), + expected.userId, + expected.sessionBinding, + expected.service + ); + if (typeof raw !== "string") return undefined; + + try { + return SlackOAuthStateSchema.safeParse(JSON.parse(raw)).data; + } catch { + return undefined; + } + } + + #key(nonce: string): string { + return `${KEY_PREFIX}{${nonce}}`; + } +} + +export async function createSlackOAuthStateForSession( + request: Request, + state: StartSlackOAuthState, + stateStore: SlackOAuthStateStore = getSlackOAuthStateStore() +): Promise<{ nonce: string; sessionCookie: string }> { + const session = await getUserSession(request); + const sessionBinding = randomBytes(32).toString("base64url"); + const nonce = await stateStore.create({ ...state, sessionBinding }); + session.set(SLACK_OAUTH_SESSION_BINDING_KEY, sessionBinding); + + return { nonce, sessionCookie: await commitSession(session) }; +} + +export async function consumeSlackOAuthStateForSession( + request: Request, + nonce: string, + userId: string, + stateStore: SlackOAuthStateStore = getSlackOAuthStateStore() +): Promise { + const session = await getUserSession(request); + const sessionBinding = session.get(SLACK_OAUTH_SESSION_BINDING_KEY); + if (typeof sessionBinding !== "string") return undefined; + + return stateStore.consume(nonce, { userId, sessionBinding, service: "slack" }); +} + +export async function clearSlackOAuthSessionBinding(request: Request): Promise { + const session = await getUserSession(request); + session.unset(SLACK_OAUTH_SESSION_BINDING_KEY); + return commitSession(session); +} + +function getSlackOAuthStateStore(): SlackOAuthStateStore { + if (!env.CACHE_REDIS_HOST) { + throw new Error("Cache Redis is required for Slack OAuth state"); + } + + return singleton( + "slackOAuthStateStore", + () => + new SlackOAuthStateStore( + createRedisClient("trigger:slack-oauth-state", { + host: env.CACHE_REDIS_HOST, + port: env.CACHE_REDIS_PORT, + username: env.CACHE_REDIS_USERNAME, + password: env.CACHE_REDIS_PASSWORD, + tlsDisabled: env.CACHE_REDIS_TLS_DISABLED === "true", + clusterMode: env.CACHE_REDIS_CLUSTER_MODE_ENABLED === "1", + }) + ) + ); +} diff --git a/apps/webapp/app/models/task.server.ts b/apps/webapp/app/models/task.server.ts index 54dca73a01b..5aa9a5096c0 100644 --- a/apps/webapp/app/models/task.server.ts +++ b/apps/webapp/app/models/task.server.ts @@ -3,7 +3,6 @@ import type { PrismaClientOrTransaction } from "~/db.server"; import { sqlDatabaseSchema } from "~/db.server"; export { getTaskIdentifiers } from "~/services/taskIdentifierRegistry.server"; -export type { TaskIdentifierEntry } from "~/services/taskIdentifierCache.server"; /** * diff --git a/apps/webapp/app/models/taskQueue.server.ts b/apps/webapp/app/models/taskQueue.server.ts index 0e9f26450ef..c8d449f165b 100644 --- a/apps/webapp/app/models/taskQueue.server.ts +++ b/apps/webapp/app/models/taskQueue.server.ts @@ -1,61 +1,4 @@ -import { QueueManifest } from "@trigger.dev/core/v3/schemas"; -import type { TaskQueue } from "@trigger.dev/database"; -import { prisma } from "~/db.server"; - -export async function findQueueInEnvironment( - queueName: string, - environmentId: string, - backgroundWorkerTaskId?: string, - backgroundTask?: { queueConfig?: unknown } -): Promise { - const sanitizedQueueName = sanitizeQueueName(queueName); - - const queue = await prisma.taskQueue.findFirst({ - where: { - runtimeEnvironmentId: environmentId, - name: sanitizedQueueName, - }, - }); - - if (queue) { - return queue; - } - - const task = backgroundTask - ? backgroundTask - : backgroundWorkerTaskId - ? await prisma.backgroundWorkerTask.findFirst({ - where: { - id: backgroundWorkerTaskId, - }, - }) - : undefined; - - if (!task) { - return; - } - - const queueConfig = QueueManifest.safeParse(task.queueConfig); - - if (queueConfig.success) { - const taskQueueName = queueConfig.data.name - ? sanitizeQueueName(queueConfig.data.name) - : undefined; - - if (taskQueueName && taskQueueName !== sanitizedQueueName) { - const queue = await prisma.taskQueue.findFirst({ - where: { - runtimeEnvironmentId: environmentId, - name: taskQueueName, - }, - }); - - if (queue) { - return queue; - } - } - } -} +import type {} from "@trigger.dev/database"; // Only allow alphanumeric characters, underscores, hyphens, and slashes (and only the first 128 characters) export function sanitizeQueueName(queueName: string) { diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index b499d753b45..302e16c2953 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -64,9 +64,7 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise { +async function findOrCreateMagicLinkUser({ email }: FindOrCreateMagicLink): Promise { assertEmailAllowed(email); const existingUser = await prisma.user.findFirst({ @@ -97,7 +95,7 @@ export async function findOrCreateMagicLinkUser({ }; } -export async function findOrCreateGithubUser({ +async function findOrCreateGithubUser({ email, authenticationProfile, authenticationExtraParams, @@ -187,7 +185,7 @@ export async function findOrCreateGithubUser({ }; } -export async function findOrCreateGoogleUser({ +async function findOrCreateGoogleUser({ email, authenticationProfile, authenticationExtraParams, @@ -375,10 +373,6 @@ export async function getUserById(id: User["id"]) { }; } -export async function getUserByEmail(email: User["email"]) { - return prisma.user.findUnique({ where: { email } }); -} - export function updateUser({ id, name, @@ -404,15 +398,36 @@ export function updateUser({ }); } -export async function grantUserCloudAccess({ id, inviteCode }: { id: string; inviteCode: string }) { +/** + * One column each. `updateUser` above is the onboarding write and confirms basic + * details as a side effect, which is wrong for a profile edit. + */ +export function updateUserName({ id, name }: Pick) { return prisma.user.update({ where: { id }, - data: { - invitationCode: { - connect: { - code: inviteCode, - }, - }, - }, + data: { name }, }); } + +export function updateUserEmail({ id, email }: Pick) { + return prisma.user.update({ + where: { id }, + data: { email }, + }); +} + +/** + * `updateMany` so the WHERE does the comparing: a redundant request updates zero + * rows rather than churning the row and its updatedAt. + */ +export async function updateUserMarketingEmails({ + id, + marketingEmails, +}: Pick) { + const { count } = await prisma.user.updateMany({ + where: { id, marketingEmails: { not: marketingEmails } }, + data: { marketingEmails }, + }); + + return { changed: count > 0 }; +} diff --git a/apps/webapp/app/models/vercelIntegration.server.ts b/apps/webapp/app/models/vercelIntegration.server.ts index 9365dc46de0..cdf43b4108d 100644 --- a/apps/webapp/app/models/vercelIntegration.server.ts +++ b/apps/webapp/app/models/vercelIntegration.server.ts @@ -21,6 +21,8 @@ import type { import { shouldSyncEnvVar, envTypeToVercelTarget, + isVercelStandardTarget, + SKEW_PROTECTION_ENV_VAR_KEY, } from "~/v3/vercel/vercelProjectIntegrationSchema"; import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { isReservedForExternalSync } from "~/v3/environmentVariableRules.server"; @@ -41,6 +43,23 @@ function normalizeTarget(target: string[] | string | undefined): string[] { return []; } +function readProjectEnvs( + response: unknown, + logContext: Record +): ResponseBodyEnvs[] { + const cursor = (response as { pagination?: { next?: unknown } } | null | undefined)?.pagination + ?.next; + + if (typeof cursor === "number" && cursor > 0) { + logger.error( + "Vercel project env list returned a pagination cursor โ€” this endpoint has always returned every record in one response, so this read is incomplete and needs paginating", + logContext + ); + } + + return extractVercelEnvs(response as FilterProjectEnvsResponseBody); +} + function extractVercelEnvs(response: FilterProjectEnvsResponseBody): ResponseBodyEnvs[] { if ("envs" in response && Array.isArray(response.envs)) { return response.envs; @@ -48,10 +67,55 @@ function extractVercelEnvs(response: FilterProjectEnvsResponseBody): ResponseBod return []; } +function isVercelEnvListComplete(response: FilterProjectEnvsResponseBody): boolean { + if (!("pagination" in response) || !response.pagination) { + return true; + } + + const next = "next" in response.pagination ? response.pagination.next : null; + return !(typeof next === "number" && next > 0); +} + +function hasVercelEnvVarForTarget(envs: ResponseBodyEnvs[], key: string, target: string): boolean { + return envs.some((env) => { + if (env.key !== key) return false; + if (typeof env.gitBranch === "string" && env.gitBranch.length > 0) return false; + if (normalizeTarget(env.target).includes(target)) return true; + return (env.customEnvironmentIds ?? []).includes(target); + }); +} + function isVercelSecretType(type: string): boolean { return type === "secret" || type === "sensitive"; } +export type CreateEnvVarsIfAbsentResult = { + written: string[]; + skipped: string[]; + conflicted: string[]; + failed: string[]; + unresolved: string[]; + errors: string[]; +}; + +function extractCreateProjectEnvFailures(response: unknown): string[] { + if (!response || typeof response !== "object" || !("failed" in response)) { + return []; + } + + const failed = (response as { failed?: unknown }).failed; + if (!Array.isArray(failed)) { + return []; + } + + return failed.map((entry) => { + const error = (entry as { error?: { code?: unknown; message?: unknown } } | null)?.error; + const code = typeof error?.code === "string" ? error.code : "unknown"; + const message = typeof error?.message === "string" ? error.message : ""; + return message ? `${code}: ${message}` : code; + }); +} + // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- @@ -124,7 +188,7 @@ function isVercelApiErrorShape(error: unknown): error is VercelApiError { // Schemas & token types // --------------------------------------------------------------------------- -export const VercelSecretSchema = z.object({ +const VercelSecretSchema = z.object({ accessToken: z.string(), tokenType: z.string().optional(), teamId: z.string().nullable().optional(), @@ -133,7 +197,7 @@ export const VercelSecretSchema = z.object({ raw: z.record(z.any()).optional(), }); -export type VercelSecret = z.infer; +type VercelSecret = z.infer; export type TokenResponse = { accessToken: string; @@ -462,19 +526,7 @@ export class VercelIntegrationRepository { { projectId, teamId }, toVercelApiError ).map((response) => { - // Warn if response is paginated (more data exists that we're not fetching) - if ( - "pagination" in response && - response.pagination && - "next" in response.pagination && - response.pagination.next !== null - ) { - logger.warn( - "Vercel filterProjectEnvs returned paginated response - some env vars may be missing", - { projectId, count: response.pagination.count } - ); - } - return extractVercelEnvs(response).map(toVercelEnvironmentVariable); + return readProjectEnvs(response, { projectId, teamId }).map(toVercelEnvironmentVariable); }); } @@ -497,7 +549,7 @@ export class VercelIntegrationRepository { toVercelApiError ).andThen((response) => { // Apply all filters BEFORE decryption to avoid unnecessary API calls - const filteredEnvs = extractVercelEnvs(response).filter((env) => { + const filteredEnvs = readProjectEnvs(response, { projectId, teamId }).filter((env) => { if (target && !normalizeTarget(env.target).includes(target)) return false; if (shouldIncludeKey && !shouldIncludeKey(env.key)) return false; if (isVercelSecretType(env.type)) return false; @@ -999,6 +1051,8 @@ export class VercelIntegrationRepository { environmentType: string; }> = []; + const skewProtectionTargetSet = new Set(); + for (const runtimeEnv of environments) { const vercelTarget = envTypeToVercelTarget( runtimeEnv.type as TriggerEnvironmentType, @@ -1009,6 +1063,10 @@ export class VercelIntegrationRepository { continue; } + for (const target of vercelTarget) { + skewProtectionTargetSet.add(target); + } + envVarsToSync.push({ key: "TRIGGER_SECRET_KEY", value: runtimeEnv.apiKey, @@ -1022,6 +1080,8 @@ export class VercelIntegrationRepository { return { created: 0, updated: 0, errors: [] as string[] }; } + const skewProtectionTargets = Array.from(skewProtectionTargetSet); + await this.removeAllVercelEnvVarsByKey({ client, vercelProjectId: params.vercelProjectId, @@ -1036,6 +1096,25 @@ export class VercelIntegrationRepository { envVars: envVarsToSync, }); + const skewResult = await this.createVercelEnvVarsIfAbsent({ + client, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + key: SKEW_PROTECTION_ENV_VAR_KEY, + value: "1", + type: "plain", + targets: skewProtectionTargets, + }); + + if (skewResult.unresolved.length > 0 || skewResult.failed.length > 0) { + logger.error("Skew protection env var did not reach every target at connect", { + projectId: params.projectId, + vercelProjectId: params.vercelProjectId, + key: SKEW_PROTECTION_ENV_VAR_KEY, + ...skewResult, + }); + } + logger.info("Synced API keys to Vercel", { projectId: params.projectId, vercelProjectId: params.vercelProjectId, @@ -1163,7 +1242,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const existingEnv = envs.find((env) => { if (env.key !== key) return false; @@ -1222,7 +1301,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const existingEnv = envs.find((env) => { if (env.key !== key) return false; @@ -1594,7 +1673,7 @@ export class VercelIntegrationRepository { } ); - const existingEnvsList = extractVercelEnvs(existingEnvs); + const existingEnvsList = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const toCreate: Array<{ key: string; @@ -1706,6 +1785,155 @@ export class VercelIntegrationRepository { return { created, updated, errors }; } + private static async createVercelEnvVarsIfAbsent(params: { + client: Vercel; + vercelProjectId: string; + teamId: string | null; + key: string; + value: string; + type: "sensitive" | "encrypted" | "plain"; + targets: string[]; + }): Promise { + const { client, vercelProjectId, teamId, key, value, type, targets } = params; + + const result: CreateEnvVarsIfAbsentResult = { + written: [], + skipped: [], + conflicted: [], + failed: [], + unresolved: [], + errors: [], + }; + + if (targets.length === 0) { + return result; + } + + const logContext = { key, vercelProjectId, teamId, targets }; + + const existingEnvs = await callVercelWithRecovery( + client.projects.filterProjectEnvs({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + }), + VercelSchemas.filterProjectEnvs, + { context: "createVercelEnvVarsIfAbsent" } + ).match( + (val) => val, + (error) => { + logger.error("Could not read Vercel env vars โ€” skew protection was not written", { + ...logContext, + outcome: "read_failed", + error, + }); + return null; + } + ); + + if (!existingEnvs) { + return { ...result, unresolved: targets, errors: ["Failed to read Vercel env vars"] }; + } + + if (!isVercelEnvListComplete(existingEnvs)) { + logger.error("Vercel env var list was truncated โ€” skew protection was not written", { + ...logContext, + outcome: "list_truncated", + }); + return { ...result, unresolved: targets, errors: ["Vercel env var list was truncated"] }; + } + + const envs = extractVercelEnvs(existingEnvs); + const targetsToCreate: string[] = []; + + for (const target of targets) { + if (hasVercelEnvVarForTarget(envs, key, target)) { + result.skipped.push(target); + } else { + targetsToCreate.push(target); + } + } + + for (const target of targetsToCreate) { + const requestBody = isVercelStandardTarget(target) + ? { key, value, type, target: [target] } + : { key, value, type, customEnvironmentIds: [target] }; + + const createResult = await ResultAsync.fromPromise( + client.projects.createProjectEnv({ + idOrName: vercelProjectId, + ...(teamId && { teamId }), + requestBody, + }), + (error) => error + ); + + if (createResult.isErr()) { + const errorMsg = `Failed to create ${key} env var for ${target}: ${createResult.error instanceof Error ? createResult.error.message : "Unknown error"}`; + result.failed.push(target); + result.errors.push(errorMsg); + logger.error(errorMsg, { + ...logContext, + target, + outcome: "failed", + error: createResult.error, + }); + continue; + } + + const failures = extractCreateProjectEnvFailures(createResult.value); + + if (failures.length > 0) { + result.conflicted.push(target); + result.errors.push(...failures); + logger.warn("Vercel rejected a skew protection env var record", { + ...logContext, + target, + outcome: "conflict", + failures, + }); + continue; + } + + result.written.push(target); + } + + logger.info("Finished writing Vercel env var", { + ...logContext, + outcome: "attempted", + written: result.written, + skipped: result.skipped, + conflicted: result.conflicted, + failed: result.failed, + }); + + return result; + } + + static ensureEnvVarForCustomEnvironment(params: { + orgIntegration: OrganizationIntegration & { tokenReference: SecretReference }; + vercelProjectId: string; + teamId: string | null; + key: string; + value: string; + type: "sensitive" | "encrypted" | "plain"; + customEnvironmentId: string; + }): ResultAsync { + return this.getVercelClient(params.orgIntegration).andThen((client) => + ResultAsync.fromPromise( + this.createVercelEnvVarsIfAbsent({ + client, + vercelProjectId: params.vercelProjectId, + teamId: params.teamId, + key: params.key, + value: params.value, + type: params.type, + targets: [params.customEnvironmentId], + }), + (error) => toVercelApiError(error) + ) + ); + } + private static async removeAllVercelEnvVarsByKey(params: { client: Vercel; vercelProjectId: string; @@ -1728,7 +1956,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); const idsToRemove = envs.filter((env) => env.key === key && env.id).map((env) => env.id!); if (idsToRemove.length === 0) { @@ -1767,7 +1995,7 @@ export class VercelIntegrationRepository { } ); - const envs = extractVercelEnvs(existingEnvs); + const envs = readProjectEnvs(existingEnvs, { vercelProjectId, teamId }); // Vercel can have multiple env vars with the same key but different targets const existingEnv = envs.find((existing) => { diff --git a/apps/webapp/app/models/vercelSdkRecovery.server.ts b/apps/webapp/app/models/vercelSdkRecovery.server.ts index d3e1bfd6961..95c5e2da0e4 100644 --- a/apps/webapp/app/models/vercelSdkRecovery.server.ts +++ b/apps/webapp/app/models/vercelSdkRecovery.server.ts @@ -52,7 +52,7 @@ function extractRawValue(error: unknown): unknown | undefined { * * Returns the validated data on success, or `undefined` if recovery fails. */ -export function recoverFromVercelSdkError( +function recoverFromVercelSdkError( error: unknown, schema: z.ZodType, options?: { context?: string } @@ -147,6 +147,7 @@ export const VercelSchemas = { .object({ envs: z.array(z.record(z.unknown())), pagination: z.unknown().optional(), + hiddenProductionEnvCount: z.number().optional(), }) .passthrough(), z.array(z.record(z.unknown())), diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 9737ab58ddd..e5d4fae1050 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -7,9 +7,7 @@ import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder"; import { SelectBestEnvironmentPresenter } from "./SelectBestEnvironmentPresenter.server"; import { sortEnvironments } from "~/utils/environmentSort"; import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar"; -import { env } from "~/env.server"; -import { flags } from "~/v3/featureFlags.server"; -import { validatePartialFeatureFlags } from "~/v3/featureFlags"; +import { globalFeatureFlags, mergeOrgFeatureFlags } from "~/v3/featureFlags.server"; import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server"; export class OrganizationsPresenter { @@ -155,23 +153,10 @@ export class OrganizationsPresenter { }, }); - // Get global feature flags with env-var-based defaults - const globalFlags = await flags({ - defaultValues: { - hasAiAccess: env.AI_FEATURES_ENABLED === "1", - hasDashboardAgentAccess: env.DASHBOARD_AGENT_ENABLED === "1", - hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1", - }, - }); + const globalFlags = await globalFeatureFlags(); return orgs.map((org) => { - const orgFlagsResult = org.featureFlags - ? validatePartialFeatureFlags(org.featureFlags as Record) - : ({ success: false } as const); - const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {}; - - // Combine global flags with org flags (org flags win) - const combinedFlags = { ...globalFlags, ...orgFlags }; + const combinedFlags = mergeOrgFeatureFlags(globalFlags, org.featureFlags); return { id: org.id, diff --git a/apps/webapp/app/presenters/ProjectPresenter.server.ts b/apps/webapp/app/presenters/ProjectPresenter.server.ts deleted file mode 100644 index 773b78edab0..00000000000 --- a/apps/webapp/app/presenters/ProjectPresenter.server.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { PrismaClient } from "~/db.server"; -import { prisma } from "~/db.server"; -import type { Project } from "~/models/project.server"; -import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; -import type { User } from "~/models/user.server"; -import { sortEnvironments } from "~/utils/environmentSort"; - -export class ProjectPresenter { - #prismaClient: PrismaClient; - - constructor(prismaClient: PrismaClient = prisma) { - this.#prismaClient = prismaClient; - } - - public async call({ - userId, - id, - }: Pick & { - userId: User["id"]; - }) { - const project = await this.#prismaClient.project.findFirst({ - select: { - id: true, - slug: true, - name: true, - organizationId: true, - createdAt: true, - updatedAt: true, - deletedAt: true, - version: true, - externalRef: true, - environments: { - where: { archivedAt: null }, - select: { - id: true, - slug: true, - type: true, - orgMember: { - select: { - user: { - select: { - id: true, - name: true, - displayName: true, - }, - }, - }, - }, - apiKey: true, - }, - }, - }, - where: { id, deletedAt: null, organization: { members: { some: { userId } } } }, - }); - - if (!project) { - return undefined; - } - - return { - id: project.id, - slug: project.slug, - ref: project.externalRef, - name: project.name, - organizationId: project.organizationId, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - deletedAt: project.deletedAt, - version: project.version, - environments: sortEnvironments( - project.environments.map((environment) => ({ - ...displayableEnvironment(environment, userId), - userId: environment.orgMember?.user.id, - })) - ), - }; - } -} diff --git a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts index 5d4fa6914f8..78ca42b8eac 100644 --- a/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentDetailPresenter.server.ts @@ -18,7 +18,7 @@ export type AgentDetail = { config: unknown; }; -export type AgentActivityPoint = { +type AgentActivityPoint = { bucket: number; // epoch ms } & Record; diff --git a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts index f866892469b..a76c62fa218 100644 --- a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts @@ -1,29 +1,18 @@ -import { - type PrismaClientOrTransaction, - type RuntimeEnvironmentType, - type TaskTriggerSource, -} from "@trigger.dev/database"; +import { type PrismaClientOrTransaction, type RuntimeEnvironmentType } from "@trigger.dev/database"; import { type ClickHouse } from "@internal/clickhouse"; import { z } from "zod"; import { $replica } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; -export type AgentListItem = { - slug: string; - filePath: string; - createdAt: Date; - triggerSource: TaskTriggerSource; - config: unknown; -}; - export type AgentActiveState = { running: number; suspended: number; }; -export class AgentListPresenter { +class AgentListPresenter { constructor(private readonly _replica: PrismaClientOrTransaction) {} public async call({ @@ -97,15 +86,18 @@ export class AgentListPresenter { }; } - // All queries are deferred for streaming - const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs); - const conversationSparklines = this.#getConversationSparklines( - clickhouse, - environmentId, - slugs + // Deferred for streaming, and backstopped: consumers subscribe late or, + // for some callers, not at all. + const activeStates = backstopPromise(this.#getActiveStates(clickhouse, environmentId, slugs)); + const conversationSparklines = backstopPromise( + this.#getConversationSparklines(clickhouse, environmentId, slugs) + ); + const costSparklines = backstopPromise( + this.#getCostSparklines(clickhouse, environmentId, slugs) + ); + const tokenSparklines = backstopPromise( + this.#getTokenSparklines(clickhouse, environmentId, slugs) ); - const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs); - const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs); return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines }; } diff --git a/apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts b/apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts index 0b1f6eb14e9..f24ac6902fb 100644 --- a/apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AlertChannelListPresenter.server.ts @@ -10,12 +10,9 @@ import { } from "~/models/projectAlert.server"; import { getLimit } from "~/services/platform.v3.server"; -export type AlertChannelListPresenterData = Awaited>; +type AlertChannelListPresenterData = Awaited>; export type AlertChannelListPresenterRecord = AlertChannelListPresenterData["alertChannels"][number]; -export type AlertChannelListPresenterAlertProperties = NonNullable< - AlertChannelListPresenterRecord["properties"] ->; export class AlertChannelListPresenter extends BasePresenter { public async call(projectId: string, environmentType?: RuntimeEnvironmentType) { diff --git a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts index dff916a6aa1..625b37fe493 100644 --- a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts @@ -23,21 +23,21 @@ export const ApiAlertType = z.enum([ export type ApiAlertType = z.infer; -export const ApiAlertEnvironmentType = z.enum(["STAGING", "PRODUCTION"]); +const ApiAlertEnvironmentType = z.enum(["STAGING", "PRODUCTION"]); -export type ApiAlertEnvironmentType = z.infer; +type ApiAlertEnvironmentType = z.infer; export const ApiAlertChannel = z.enum(["email", "webhook"]); export type ApiAlertChannel = z.infer; -export const ApiAlertChannelData = z.object({ +const ApiAlertChannelData = z.object({ email: z.string().optional(), url: z.string().optional(), secret: z.string().optional(), }); -export type ApiAlertChannelData = z.infer; +type ApiAlertChannelData = z.infer; export const ApiCreateAlertChannel = z.object({ alertTypes: ApiAlertType.array(), diff --git a/apps/webapp/app/presenters/v3/ApiErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiErrorGroupPresenter.server.ts index 2ecf7cccc86..cbcad90d7d5 100644 --- a/apps/webapp/app/presenters/v3/ApiErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiErrorGroupPresenter.server.ts @@ -38,7 +38,7 @@ function parseClickHouseDateTime(value: string): Date { return new Date(value.replace(" ", "T") + "Z"); } -export class ApiErrorGroupPresenter extends BasePresenter { +class ApiErrorGroupPresenter extends BasePresenter { /** * Resolves a single error group to its API detail shape, or `undefined` if no * such fingerprint exists in the environment (the route turns that into 404). diff --git a/apps/webapp/app/presenters/v3/ApiErrorListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiErrorListPresenter.server.ts index 13d794d6593..ee61fd3131a 100644 --- a/apps/webapp/app/presenters/v3/ApiErrorListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiErrorListPresenter.server.ts @@ -48,9 +48,7 @@ export const ApiErrorListSearchParams = z.object({ const statuses = value.split(","); // hasOwnProperty, not `in`: `in` walks the prototype chain, so // `filter[status]=toString` would pass and map to a function. - const invalid = statuses.filter( - (status) => !Object.prototype.hasOwnProperty.call(API_STATUS_TO_DB, status) - ); + const invalid = statuses.filter((status) => !Object.hasOwn(API_STATUS_TO_DB, status)); if (invalid.length > 0) { ctx.addIssue({ diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index b345b456415..18586de7850 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -183,6 +183,7 @@ export class ApiRunListPresenter extends BasePresenter { return this.trace("call", async (span) => { const options: RunListOptions = { projectId: project.id, + columns: { visibleStandardIds: [], smartSources: ["metadata"] }, }; // pagination @@ -310,7 +311,7 @@ export class ApiRunListPresenter extends BasePresenter { const metadata = await parsePacket( { data: run.metadata ?? undefined, - dataType: run.metadataType, + dataType: run.metadataType ?? "application/json", }, { filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"], diff --git a/apps/webapp/app/presenters/v3/ApiWebhookDeliveryPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWebhookDeliveryPresenter.server.ts index d1c247939d3..0c6b2d1909b 100644 --- a/apps/webapp/app/presenters/v3/ApiWebhookDeliveryPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWebhookDeliveryPresenter.server.ts @@ -55,9 +55,7 @@ export const ApiWebhookDeliveryListSearchParams = z.object({ .transform((value, ctx) => { if (!value) return undefined; const statuses = value.split(","); - const invalid = statuses.filter( - (s) => !Object.prototype.hasOwnProperty.call(API_STATUS_TO_DB, s) - ); + const invalid = statuses.filter((s) => !Object.hasOwn(API_STATUS_TO_DB, s)); if (invalid.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, @@ -109,7 +107,7 @@ export class ApiWebhookDeliveryListPresenter extends BasePresenter { } } -export class ApiWebhookDeliveryPresenter extends BasePresenter { +class ApiWebhookDeliveryPresenter extends BasePresenter { public async call( environment: { id: string; projectId: string; organizationId: string }, deliveryFriendlyId: string diff --git a/apps/webapp/app/presenters/v3/ApiWebhookEndpointPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiWebhookEndpointPresenter.server.ts index 07881b018e2..d1378288625 100644 --- a/apps/webapp/app/presenters/v3/ApiWebhookEndpointPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiWebhookEndpointPresenter.server.ts @@ -80,7 +80,7 @@ export class ApiWebhookEndpointListPresenter extends BasePresenter { } } -export class ApiWebhookEndpointPresenter extends BasePresenter { +class ApiWebhookEndpointPresenter extends BasePresenter { public async call( environmentId: string, endpointFriendlyId: string diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index 4596b08f02c..5440b602877 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -26,8 +26,6 @@ export type BatchListOptions = { const DEFAULT_PAGE_SIZE = 25; export type BatchList = Awaited>; -export type BatchListItem = BatchList["batches"][0]; -export type BatchListAppliedFilters = BatchList["filters"]; // The row shape of the raw BatchTaskRun keyset scan. Extracted to a named type so the // store-selected scan closure and the keyset merge in `#scanBatchTaskRun` can reference it. diff --git a/apps/webapp/app/presenters/v3/BatchPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchPresenter.server.ts index 3e9ef0be858..dc4187c0523 100644 --- a/apps/webapp/app/presenters/v3/BatchPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchPresenter.server.ts @@ -31,8 +31,6 @@ type BatchPresenterDeps = { resolveDisplayableEnvironment?: typeof findDisplayableEnvironment; }; -export type BatchPresenterData = Awaited>; - export class BatchPresenter extends BasePresenter { constructor( _prisma?: PrismaClientOrTransaction, diff --git a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts index 8cdd50ada8c..06dad762b0c 100644 --- a/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BranchesPresenter.server.ts @@ -16,9 +16,6 @@ import { toBranchableEnvironmentType, } from "~/utils/branchableEnvironment"; -type Result = Awaited>; -export type Branch = Result["branches"][number]; - const BRANCHES_PER_PAGE = 25; /** diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index 6d758f6b5ad..99683240529 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -17,7 +17,7 @@ import { const pageSize = 20; -export type DeploymentList = Awaited>; +type DeploymentList = Awaited>; export type DeploymentListItem = DeploymentList["deployments"][0]; export class DeploymentListPresenter { @@ -153,6 +153,7 @@ export class DeploymentListPresenter { userDisplayName: string | null; userAvatarUrl: string | null; type: WorkerInstanceGroupType; + externalId: string | null; git: Prisma.JsonValue | null; integrationDeploymentId: string | null; }[] @@ -173,6 +174,7 @@ export class DeploymentListPresenter { wd."builtAt", wd."deployedAt", wd."type", + wd."externalId", wd."git" ${vercelSelect} FROM @@ -258,6 +260,7 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; avatarUrl: deployment.userAvatarUrl, } : undefined, + externalId: deployment.externalId, git: processGitMetadata(deployment.git), vercelDeploymentUrl, }; diff --git a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts index a9909575884..3e5df605257 100644 --- a/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentPresenter.server.ts @@ -101,6 +101,7 @@ export class DeploymentPresenter { externalBuildData: true, projectId: true, type: true, + externalId: true, environment: { select: { id: true, @@ -272,6 +273,7 @@ export class DeploymentPresenter { errorData: DeploymentPresenter.prepareErrorData(deployment.errorData), isBuilt: !!deployment.builtAt, type: deployment.type, + externalId: deployment.externalId, git: gitMetadata, triggeredVia: deployment.triggeredVia, vercelDeploymentUrl, diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts index d1eb4740045..4a217b568bc 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -12,7 +12,7 @@ import { boundedIn, type Prisma } from "@trigger.dev/database"; type Result = Awaited>; export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number]; -export const DEFAULT_ENV_VARS_PAGE_SIZE = 50; +const DEFAULT_ENV_VARS_PAGE_SIZE = 50; export class EnvironmentVariablesPresenter { #prismaClient: PrismaClient; diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index d2f6bbfcbe3..38149abe5e7 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -1,10 +1,9 @@ -import { z } from "zod"; import { type ClickHouse, msToClickHouseInterval } from "@internal/clickhouse"; import { TimeGranularity } from "~/utils/timeGranularity"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; -import { type Direction, DirectionSchema } from "~/components/ListPagination"; +import { type Direction } from "~/components/ListPagination"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { BasePresenter } from "~/presenters/v3/basePresenter.server"; @@ -13,6 +12,12 @@ import { type NextRunList, } from "~/presenters/v3/NextRunListPresenter.server"; import { sortVersionsDescending } from "~/utils/semver"; +import type { RunColumnId, SmartColumnSource } from "~/components/runs/v3/runColumns"; + +type RunColumnsSelect = { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; +}; const errorGroupGranularity = new TimeGranularity([ { max: "1h", granularity: "1m" }, @@ -34,25 +39,11 @@ export type ErrorGroupOptions = { to?: number; cursor?: string; direction?: Direction; + columns?: RunColumnsSelect; }; -export const ErrorGroupOptionsSchema = z.object({ - userId: z.string().optional(), - projectId: z.string(), - fingerprint: z.string(), - versions: z.array(z.string()).optional(), - runsPageSize: z.number().int().positive().max(1000).optional(), - period: z.string().optional(), - from: z.number().int().nonnegative().optional(), - to: z.number().int().nonnegative().optional(), - cursor: z.string().optional(), - direction: DirectionSchema.optional(), -}); - const DEFAULT_RUNS_PAGE_SIZE = 25; -export type ErrorGroupDetail = Awaited>; - function parseClickHouseDateTime(value: string): Date { const asNum = Number(value); if (!isNaN(asNum) && asNum > 1e12) { @@ -115,6 +106,7 @@ export class ErrorGroupPresenter extends BasePresenter { to, cursor, direction, + columns, }: ErrorGroupOptions ) { const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId); @@ -144,6 +136,7 @@ export class ErrorGroupPresenter extends BasePresenter { to: time.to.getTime(), cursor, direction, + columns, }), this.getState(environmentId, summary?.taskIdentifier, fingerprint), ]); @@ -413,6 +406,7 @@ export class ErrorGroupPresenter extends BasePresenter { to?: number; cursor?: string; direction?: Direction; + columns?: RunColumnsSelect; } ): Promise { const runListPresenter = new NextRunListPresenter(this.replica, this.clickhouse); @@ -428,6 +422,7 @@ export class ErrorGroupPresenter extends BasePresenter { to: options.to, cursor: options.cursor, direction: options.direction, + columns: options.columns, }); if (result.runs.length === 0) { diff --git a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts index 76a2319fee4..55c675cb743 100644 --- a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts @@ -41,28 +41,10 @@ export type ErrorsListOptions = { pageSize?: number; }; -export const ErrorsListOptionsSchema = z.object({ - userId: z.string().optional(), - projectId: z.string(), - tasks: z.array(z.string()).optional(), - versions: z.array(z.string()).optional(), - statuses: z.array(z.enum(["UNRESOLVED", "RESOLVED", "IGNORED"])).optional(), - period: z.string().optional(), - from: z.number().int().nonnegative().optional(), - to: z.number().int().nonnegative().optional(), - defaultPeriod: z.string().optional(), - retentionLimitDays: z.number().int().positive().optional(), - search: z.string().max(1000).optional(), - direction: z.enum(["forward", "backward"]).optional(), - cursor: z.string().optional(), - pageSize: z.number().int().positive().max(1000).optional(), -}); - const DEFAULT_PAGE_SIZE = 25; export type ErrorsList = Awaited>; export type ErrorGroup = ErrorsList["errorGroups"][0]; -export type ErrorsListAppliedFilters = ErrorsList["filters"]; export type ErrorOccurrences = Awaited>; export type ErrorOccurrenceActivity = ErrorOccurrences["data"][string]; diff --git a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts index 4aff7c49d8b..b2cb716ad32 100644 --- a/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogDetailPresenter.server.ts @@ -15,8 +15,6 @@ export type LogDetailOptions = { startTime: string; }; -export type LogDetail = Awaited>; - export class LogDetailPresenter { constructor( private readonly replica: PrismaClientOrTransaction, diff --git a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts index 9c19cb75715..934a69e9ed2 100644 --- a/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/LogsListPresenter.server.ts @@ -1,8 +1,4 @@ -import { - type ClickHouse, - type LogsSearchListResult, - type WhereCondition, -} from "@internal/clickhouse"; +import { type ClickHouse, type WhereCondition } from "@internal/clickhouse"; import { type PrismaClientOrTransaction } from "@trigger.dev/database"; import { z } from "zod"; import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server"; @@ -19,20 +15,18 @@ import { convertDateToClickhouseDateTime, } from "~/v3/eventRepository/clickhouseEventRepository.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { + escapeClickHouseLike, + hasMinimumLogsSearchLength, + logsSearchExpansionPeriod, + LOGS_SEARCH_RETRY_OVERFETCH_FACTOR, + MIN_LOGS_SEARCH_LENGTH, + normalizeLogsSearchTerm, + prepareLogsSearchPage, +} from "~/utils/logSearch"; export type { LogLevel }; -type ErrorAttributes = { - error?: { - message?: unknown; - }; - [key: string]: unknown; -}; - -function escapeClickHouseString(val: string): string { - return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_"); -} - export type LogsListOptions = { userId?: string; projectId: string; @@ -70,15 +64,12 @@ export const LogsListOptionsSchema = z.object({ pageSize: z.number().int().positive().max(1000).optional(), }); -const DAY_MS = 24 * 60 * 60 * 1000; - -export type LogsList = Awaited>; +type LogsList = Awaited>; export type LogEntry = LogsList["logs"][0]; -export type LogsListAppliedFilters = LogsList["filters"]; // Bump when the cursor shape changes so stale cursors are ignored (reset to the first page) // rather than misparsed. -const LOG_CURSOR_VERSION = 2; +const LOG_CURSOR_VERSION = 4; // Cursor is a base64 encoded JSON of the pagination keys type LogCursor = { @@ -88,6 +79,7 @@ type LogCursor = { triggeredTimestamp: string; // DateTime64(9) string traceId: string; spanId: string; + projectionFingerprint?: string; }; const LogCursorSchema = z.object({ @@ -97,6 +89,7 @@ const LogCursorSchema = z.object({ triggeredTimestamp: z.string(), traceId: z.string(), spanId: z.string(), + projectionFingerprint: z.string().optional(), }); function encodeCursor(cursor: LogCursor): string { @@ -117,34 +110,6 @@ function decodeCursor(cursor: string): LogCursor | null { } } -// Ordered list of lower bounds to try, narrowest (most recent) first, ending at the user's -// requested floor (or undefined for an unbounded-below window). Because rows are returned -// newest-first, a narrow window that already fills a page returns the exact same top rows the -// full window would, so widening only happens when a page comes back short. -function buildProbeFloors( - ceil: Date, - hardFloor: Date | undefined, - stepDays: number[] -): (Date | undefined)[] { - const floors: (Date | undefined)[] = []; - - for (const days of stepDays) { - let candidate = new Date(ceil.getTime() - days * DAY_MS); - if (hardFloor && candidate <= hardFloor) { - candidate = hardFloor; - } - floors.push(candidate); - if (hardFloor && candidate.getTime() === hardFloor.getTime()) { - // Reached the requested floor; nothing wider left to probe. - return floors; - } - } - - // Final probe always covers the full requested window (or unbounded if no floor was given). - floors.push(hardFloor); - return floors; -} - // Convert display level to ClickHouse kinds and statuses function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?: string[] } { switch (level) { @@ -262,6 +227,7 @@ export class LogsListPresenter extends BasePresenter { } const effectivePageSize = Math.min(pageSize, env.LOGS_LIST_MAX_PAGE_SIZE); + const queryLimit = (effectivePageSize + 1) * LOGS_SEARCH_RETRY_OVERFETCH_FACTOR; // Only honor a cursor scoped to this org+env; one copied from another scope would shift the // pagination anchor instead of resetting to the first page. @@ -273,21 +239,26 @@ export class LogsListPresenter extends BasePresenter { ? parsedCursor : null; - // Effective upper bound, always clamped to now so a probe never runs [floor, +inf). + // Effective upper bound, always clamped to now so a request never runs [floor, +inf). const now = new Date(); const clampedTo = effectiveTo !== undefined ? (effectiveTo > now ? now : effectiveTo) : now; + const rawSearchTerm = search?.trim() ?? ""; + const normalizedSearchTerm = normalizeLogsSearchTerm(rawSearchTerm); + if (rawSearchTerm !== "" && !hasMinimumLogsSearchLength(normalizedSearchTerm)) { + throw new ServiceValidationError( + `Log searches must be at least ${MIN_LOGS_SEARCH_LENGTH} characters.` + ); + } const searchTerm = - search && search.trim() !== "" - ? escapeClickHouseString(search.trim()).toLowerCase() - : undefined; + normalizedSearchTerm === "" ? undefined : escapeClickHouseLike(normalizedSearchTerm); - // Runs the full list query restricted to a single [floor, ceil] window. The recent-first - // probe loop below calls this with progressively wider floors. - const runProbe = (floor: Date | undefined) => { + // Run exactly one bounded query. Broadening a search window is an explicit user action; + // silently rescanning the same recent rows makes absence queries needlessly expensive. + const runQuery = () => { const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder(); - // The materialized view excludes events without a trace_id; this guards the legacy tail. + // The projector excludes events without a trace_id. queryBuilder.where("trace_id != ''"); queryBuilder.where("environment_id = {environmentId: String}", { environmentId }); queryBuilder.where("organization_id = {organizationId: String}", { organizationId }); @@ -299,9 +270,9 @@ export class LogsListPresenter extends BasePresenter { }); } - if (floor) { + if (effectiveFrom) { queryBuilder.where("triggered_timestamp >= {triggeredAtStart: DateTime64(3)}", { - triggeredAtStart: convertDateToClickhouseDateTime(floor), + triggeredAtStart: convertDateToClickhouseDateTime(effectiveFrom), }); } @@ -315,12 +286,10 @@ export class LogsListPresenter extends BasePresenter { queryBuilder.where("run_id = {runId: String}", { runId }); } - // Case-insensitive search in message and attributes if (searchTerm !== undefined) { - queryBuilder.where( - "(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})", - { searchPattern: `%${searchTerm}%` } - ); + queryBuilder.where("search_text LIKE {searchPattern: String}", { + searchPattern: `%${searchTerm}%`, + }); } if (levels && levels.length > 0) { @@ -350,61 +319,51 @@ export class LogsListPresenter extends BasePresenter { queryBuilder.whereOr(conditions); } - // Keyset pagination over the full sort key. ORDER BY is DESC, so the next page is the rows - // that sort after the cursor (strictly less-than). (triggered_timestamp, trace_id) is not - // unique because spans of a trace share both, so span_id is the final tiebreaker; without - // it rows at a tie boundary could be skipped or duplicated across pages. + // Keyset pagination over the sort key. ORDER BY is DESC, so the next page is the rows + // that sort after the cursor (strictly less-than). V2 adds the projection identity as the + // final tiebreaker so retry copies and distinct rows at a span boundary paginate safely. if (decodedCursor) { + const cursorParams = { + cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp, + cursorTraceId: decodedCursor.traceId, + cursorSpanId: decodedCursor.spanId, + ...(decodedCursor.projectionFingerprint + ? { cursorProjectionFingerprint: decodedCursor.projectionFingerprint } + : {}), + }; queryBuilder.where( - `(triggered_timestamp < {cursorTriggeredTimestamp: String} - OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String}) - OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))`, - { - cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp, - cursorTraceId: decodedCursor.traceId, - cursorSpanId: decodedCursor.spanId, - } + decodedCursor.projectionFingerprint + ? `(triggered_timestamp < {cursorTriggeredTimestamp: String} + OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String}) + OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}) + OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id = {cursorSpanId: String} AND projection_fingerprint < {cursorProjectionFingerprint: UInt128}))` + : `(triggered_timestamp < {cursorTriggeredTimestamp: String} + OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String}) + OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id = {cursorTraceId: String} AND span_id < {cursorSpanId: String}))`, + cursorParams ); } - queryBuilder.orderBy("triggered_timestamp DESC, trace_id DESC, span_id DESC"); - // Limit + 1 to check if there are more results - queryBuilder.limit(effectivePageSize + 1); + queryBuilder.orderBy( + "triggered_timestamp DESC, trace_id DESC, span_id DESC, projection_fingerprint DESC" + ); + queryBuilder.limit(queryLimit); return queryBuilder.execute(); }; - // Page ceiling: the cursor (deeper pages) or the requested upper bound. Widen the lower - // bound only when a recent window doesn't fill the page. - const ceil = decodedCursor - ? convertClickhouseDateTime64ToJsDate(decodedCursor.triggeredTimestamp) - : (clampedTo ?? new Date()); - - const probeFloors = buildProbeFloors( - ceil, - effectiveFrom ?? undefined, - env.LOGS_LIST_RECENT_FIRST_PROBE_DAYS - ); - - let records: LogsSearchListResult[] = []; - for (const floor of probeFloors) { - const [queryError, probeRecords] = await runProbe(floor); - - if (queryError) { - throw queryError; - } - - records = probeRecords ?? []; - - if (records.length > effectivePageSize) { - // Page is full from this window; older rows can't be in the top page, stop widening. - break; - } + const [queryError, queryResult] = await runQuery(); + if (queryError) { + throw queryError; } - const results = records; - const hasMore = results.length > effectivePageSize; - const logs = results.slice(0, effectivePageSize); + // ClickHouse's break overflow modes can return a short prefix without a reliable completion + // marker. Keep the default throw behavior so the product never presents truncated results as + // complete. + const results = queryResult ?? []; + const page = prepareLogsSearchPage(results, effectivePageSize, queryLimit); + const hasMore = page.hasMore; + const logs = page.rows; // Build next cursor from the last item let nextCursor: string | undefined; @@ -417,6 +376,7 @@ export class LogsListPresenter extends BasePresenter { triggeredTimestamp: lastLog.triggered_timestamp, traceId: lastLog.trace_id, spanId: lastLog.span_id, + projectionFingerprint: lastLog.projection_fingerprint_string, }); } @@ -425,17 +385,10 @@ export class LogsListPresenter extends BasePresenter { const transformedLogs = logs.map((log) => { let displayMessage = log.message; - // For error logs with status ERROR, try to extract error message from attributes - if (log.status === "ERROR" && log.attributes_text) { - try { - const attributes = JSON.parse(log.attributes_text) as ErrorAttributes; - - if (attributes?.error?.message && typeof attributes.error.message === "string") { - displayMessage = attributes.error.message; - } - } catch { - // If attributes parsing fails, use the regular message - } + // The search table extracts this leaf in the materialized view, so list queries never + // need to read or parse the complete attributes blob. + if (log.status === "ERROR" && log.error_message) { + displayMessage = log.error_message; } return { @@ -457,6 +410,11 @@ export class LogsListPresenter extends BasePresenter { }; }); + const searchExpansion = + searchTerm !== undefined && time.isDefault && transformedLogs.length === 0 + ? logsSearchExpansionPeriod(effectiveFrom, clampedTo, retentionLimitDays) + : undefined; + return { logs: transformedLogs, pagination: { @@ -479,6 +437,7 @@ export class LogsListPresenter extends BasePresenter { hasFilters, hasAnyLogs: transformedLogs.length > 0, searchTerm: search, + searchExpansion: searchExpansion ? { nextPeriod: searchExpansion } : undefined, retention: retentionLimitDays !== undefined ? { diff --git a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts index 0b84e971b2f..445313dcf70 100644 --- a/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/MetricDashboardPresenter.server.ts @@ -1,26 +1,10 @@ import { BasePresenter } from "./basePresenter.server"; -import { type QueryScope } from "~/services/queryService.server"; import { getLimit } from "~/services/platform.v3.server"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; import { builtInDashboard } from "./BuiltInDashboards.server"; import { QueryWidgetConfig } from "~/components/metrics/QueryWidget"; -export type MetricFilters = { - /** Org, project, environment */ - scope: QueryScope; - /** Time filter settings */ - filterPeriod: string | null; - filterFrom: Date | null; - filterTo: Date | null; - /** Tasks */ - taskIdentifiers?: string[]; - /** Queues */ - queues?: string[]; - /** Tags */ - tags?: string[]; -}; - export const LayoutItem = z.object({ i: z.string(), x: z.number(), diff --git a/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts b/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts index 90cab7cb914..cd092f60bf7 100644 --- a/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts @@ -61,7 +61,7 @@ function inferProvider(modelName: string): string { } /** Format a model as provider:name (e.g. "openai:gpt-5"). */ -export function formatModelId(provider: string, modelName: string): string { +function formatModelId(provider: string, modelName: string): string { return `${provider}:${modelName}`; } @@ -138,7 +138,7 @@ export type ModelCatalogItem = { variants: ModelVariant[]; }; -export type ModelVariant = { +type ModelVariant = { friendlyId: string; modelName: string; displayId: string; diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index c9a120334f3..52836fad293 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -25,6 +25,11 @@ import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; import { runTriggeredAt } from "~/v3/runTimestamps"; +import { + deriveRunSelect, + type RunColumnId, + type SmartColumnSource, +} from "~/components/runs/v3/runColumns"; // Positive-only cache: only envs known to have runs are stored (empty envs are re-checked), // so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis. @@ -81,6 +86,15 @@ export type RunListOptions = { pageSize?: number; // Run the empty-state "has any run ever" probe. Only the runs list consumes it. includeHasAnyRuns?: boolean; + /** + * Visible-column set used to derive the Postgres select. Omitted => the + * default select (all fields, no payload/output). Provided by the list route + * so payload/output are only hydrated when a smart column references them. + */ + columns?: { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; + }; }; const DEFAULT_PAGE_SIZE = 25; @@ -159,6 +173,7 @@ export class NextRunListPresenter { cursor, pageSize = DEFAULT_PAGE_SIZE, includeHasAnyRuns = false, + columns, }: RunListOptions ) { //get the time values from the raw values (including a default period) @@ -255,7 +270,12 @@ export class NextRunListPresenter { return date > now ? now : date; } + const runSelect = columns + ? deriveRunSelect(columns.visibleStandardIds, columns.smartSources) + : undefined; + const { runs, pagination } = await runsRepository.listRuns({ + runSelect, organizationId, environmentId, projectId, @@ -335,6 +355,10 @@ export class NextRunListPresenter { rootTaskRunId: run.rootTaskRunId, metadata: run.metadata, metadataType: run.metadataType, + payload: run.payload, + payloadType: run.payloadType, + output: run.output, + outputType: run.outputType, machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, queue: { name: run.queue.replace("task/", ""), diff --git a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts index 17ccc68a03b..0dc3daa9856 100644 --- a/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/QueueListPresenter.server.ts @@ -17,7 +17,7 @@ const MAX_ITEMS_PER_PAGE = 100; export type QueueListSort = "busiest" | "queued" | "name"; /** Ranking reads recent aggregated gauges, so ordering is a stable snapshot, not a live sort. */ -export const QUEUE_RANKING_WINDOW_MINUTES = 15; +const QUEUE_RANKING_WINDOW_MINUTES = 15; const MAX_RANKED_QUEUES = 5000; const typeToDBQueueType: Record<"task" | "custom", TaskQueueType> = { diff --git a/apps/webapp/app/presenters/v3/RunPresenter.server.ts b/apps/webapp/app/presenters/v3/RunPresenter.server.ts index 8a99f3e60f9..36e264235a2 100644 --- a/apps/webapp/app/presenters/v3/RunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunPresenter.server.ts @@ -13,10 +13,6 @@ import { runStore } from "~/v3/runStore.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { runTriggeredAt } from "~/v3/runTimestamps"; -type Result = Awaited>; -export type Run = Result["run"]; -export type RunEvent = NonNullable["events"][0]; - export class RunEnvironmentMismatchError extends Error { constructor(message: string) { super(message); diff --git a/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts b/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts index 0defa6178ab..c5096145ad2 100644 --- a/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunQueueMetricsPresenter.server.ts @@ -41,6 +41,7 @@ const DELAY_GRID_MS = 5 * 60 * 1000; * waiting to start, live counts and recent delay percentiles. Null when flag off. */ export async function resolveRunQueueMetrics(options: { + request: Request; userId: string; organizationSlug: string; projectParam: string; @@ -52,10 +53,10 @@ export async function resolveRunQueueMetrics(options: { queue: { name: string; concurrencyKey?: string | null }; }; }): Promise { - const { userId, organizationSlug, projectParam, envParam, run } = options; + const { request, userId, organizationSlug, projectParam, envParam, run } = options; try { - if (!(await canAccessQueueMetricsUi({ userId, organizationSlug }))) { + if (!(await canAccessQueueMetricsUi({ request, userId, organizationSlug }))) { return null; } diff --git a/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts index 59f4e1047ef..f3761ee7fd3 100644 --- a/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunTagListPresenter.server.ts @@ -19,9 +19,6 @@ export type TagListOptions = { const DEFAULT_PAGE_SIZE = 25; -export type TagList = Awaited>; -export type TagListItem = TagList["tags"][number]; - export class RunTagListPresenter extends BasePresenter { public async call({ organizationId, diff --git a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts index c0eb6fc7a37..a4af281a8ec 100644 --- a/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ScheduleListPresenter.server.ts @@ -5,9 +5,9 @@ import { getTaskIdentifiers } from "~/models/task.server"; import { getCurrentPlan, getPlans } from "~/services/platform.v3.server"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; -import { calculateNextScheduleRunTimes, formatScheduleWindow } from "~/v3/scheduleWindow.server"; +import { formatScheduleWindow } from "~/v3/scheduleWindow.server"; import { CheckScheduleService } from "~/v3/services/checkSchedule.server"; -import { previousScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server"; +import { resolveScheduleTimings } from "~/v3/scheduleTimings.server"; import { env } from "~/env.server"; import { BasePresenter } from "./basePresenter.server"; @@ -16,11 +16,17 @@ type ScheduleListOptions = { environmentId: string; userId?: string; pageSize?: number; + /** + * Walking each cron backwards to approximate "last run" costs an order of + * magnitude more than everything else here, so it is opt-in: only the + * dashboard renders the column. Defaults off. + */ + includeLastRun?: boolean; } & ScheduleListFilters; const DEFAULT_PAGE_SIZE = 20; -export type ScheduleListItem = { +type ScheduleListItem = { id: string; type: ScheduleType; friendlyId: string; @@ -43,8 +49,6 @@ export type ScheduleListItem = { branchName?: string; }[]; }; -export type ScheduleList = Awaited>; -export type ScheduleListAppliedFilters = ScheduleList["filters"]; export class ScheduleListPresenter extends BasePresenter { public async call({ @@ -56,6 +60,7 @@ export class ScheduleListPresenter extends BasePresenter { page, type, pageSize = DEFAULT_PAGE_SIZE, + includeLastRun = false, }: ScheduleListOptions) { const hasFilters = type !== undefined || tasks !== undefined || (search !== undefined && search !== ""); @@ -276,46 +281,33 @@ export class ScheduleListPresenter extends BasePresenter { skip: (page - 1) * pageSize, }); - const schedules: ScheduleListItem[] = rawSchedules.map((schedule) => { - // Approximate "last run" from the cron's previous slot. Skip inactive - // schedules โ€” the cron's previous slot reflects what *would* have - // fired, but a deactivated schedule didn't actually fire there. Skip - // when the cron's previous slot predates `updatedAt`: any config - // change (cron edited, timezone changed, deactivate/reactivate) - // bumps updatedAt, and a slot from before the most recent change - // didn't fire under the current configuration. cron-parser throws - // on malformed expressions, so degrade to undefined per-row rather - // than failing the whole list. UI is best-effort; the runs page is - // the source of truth. - let lastRun: Date | undefined; - if (schedule.active) { - try { - const cronPrev = previousScheduledTimestamp( - schedule.generatorExpression, - schedule.timezone - ); - lastRun = cronPrev.getTime() > schedule.updatedAt.getTime() ? cronPrev : undefined; - } catch { - lastRun = undefined; - } - } - + const instances = rawSchedules.map((schedule) => { const instance = schedule.instances.find( (instance) => instance.environmentId === environmentId ); if (!instance) { throw new Error(`Schedule instance not found for environment: ${environmentId}`); } - const [nextRun] = calculateNextScheduleRunTimes({ + return instance; + }); + + const timings = resolveScheduleTimings( + rawSchedules.map((schedule, index) => ({ cron: schedule.generatorExpression, timezone: schedule.timezone, deduplicationKey: schedule.deduplicationKey, environmentId, - schedulePhase: instance.schedulePhase, - phaseSecret: env.ENCRYPTION_KEY, + schedulePhase: instances[index].schedulePhase, windowDurationSeconds: schedule.windowDurationSeconds, windowPercentage: schedule.windowPercentage, - }); + active: schedule.active, + updatedAt: schedule.updatedAt, + })), + { phaseSecret: env.ENCRYPTION_KEY, includeLastRun } + ); + + const schedules: ScheduleListItem[] = rawSchedules.map((schedule, index) => { + const { nextRun, nextRunEffectiveAt, lastRun } = timings[index]; return { id: schedule.id, @@ -331,8 +323,8 @@ export class ScheduleListPresenter extends BasePresenter { active: schedule.active, externalId: schedule.externalId, lastRun, - nextRun: nextRun.nominalAt, - nextRunEffectiveAt: nextRun.effectiveAt, + nextRun, + nextRunEffectiveAt, environments: schedule.instances.map((instance) => { const environment = project.environments.find((env) => env.id === instance.environmentId); if (!environment) { diff --git a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts index 1e6d1fa2391..0c4ec78e8ef 100644 --- a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts @@ -40,7 +40,6 @@ const DEFAULT_PAGE_SIZE = 25; export type SessionList = Awaited>; export type SessionListItem = SessionList["sessions"][0]; -export type SessionListAppliedFilters = SessionList["filters"]; export class SessionListPresenter { constructor( diff --git a/apps/webapp/app/presenters/v3/SessionPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts index 5f0c0466cb9..b367c521f84 100644 --- a/apps/webapp/app/presenters/v3/SessionPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts @@ -11,8 +11,6 @@ import { runStore } from "~/v3/runStore.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { startActiveSpan } from "~/v3/tracer.server"; -export type SessionDetail = NonNullable>>; - export class SessionPresenter { constructor(private readonly replica: PrismaClientOrTransaction) {} diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 11b923566cb..8482bb39dc0 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -1,3 +1,4 @@ +import { readExternalDeploymentIdAnnotation } from "@internal/run-engine"; import { type MachinePreset, prettyPrintPacket, @@ -399,6 +400,7 @@ export class SpanPresenter extends BasePresenter { ttl: run.ttl, taskIdentifier: run.taskIdentifier, version: lockedWorker?.lockedToVersion?.version, + externalDeploymentId: readExternalDeploymentIdAnnotation(run.annotations), sdkVersion: lockedWorker?.lockedToVersion?.sdkVersion, runtime: lockedWorker?.lockedToVersion?.runtime, runtimeVersion: lockedWorker?.lockedToVersion?.runtimeVersion, diff --git a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts index d4bd38cf643..df73464cfd4 100644 --- a/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskDetailPresenter.server.ts @@ -15,14 +15,14 @@ import { zeroFillGroupedSeries, } from "./activitySeries.server"; -export type TaskDetailQueue = { +type TaskDetailQueue = { friendlyId: string; name: string; concurrencyLimit: number | null; paused: boolean; }; -export type TaskDetailRetry = { +type TaskDetailRetry = { maxAttempts?: number; factor?: number; minTimeoutInMs?: number; @@ -47,7 +47,7 @@ export type TaskDetail = { hasPayloadSchema: boolean; }; -export type TaskActivityPoint = { +type TaskActivityPoint = { bucket: number; } & Record; diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index 1541329884f..082376dbf09 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -9,6 +9,7 @@ import { ClickHouseEnvironmentMetricsRepository, type CurrentRunningStats, } from "~/services/environmentMetricsRepository.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; @@ -19,7 +20,7 @@ export type TaskListItem = { triggerSource: TaskTriggerSource; }; -export class TaskListPresenter { +class TaskListPresenter { constructor(private readonly _replica: PrismaClientOrTransaction) {} public async call({ @@ -84,16 +85,17 @@ export class TaskListPresenter { }); // IMPORTANT: Don't await this, we want to return the promise - // so we can defer the loading of the data. The caller is responsible for - // consuming it โ€” an unconsumed promise here would become an unhandled - // rejection if the underlying query fails. - const runningStats = environmentMetricsRepository.getCurrentRunningStats({ - organizationId, - projectId, - environmentId, - days: 6, - tasks: slugs, - }); + // so we can defer the loading of the data. Backstopped: the caller + // subscribes only after further awaits. + const runningStats = backstopPromise( + environmentMetricsRepository.getCurrentRunningStats({ + organizationId, + projectId, + environmentId, + days: 6, + tasks: slugs, + }) + ); return { tasks, runningStats }; } diff --git a/apps/webapp/app/presenters/v3/TaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskPresenter.server.ts deleted file mode 100644 index e000c8dc41c..00000000000 --- a/apps/webapp/app/presenters/v3/TaskPresenter.server.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { BackgroundWorkerTask } from "@trigger.dev/database"; -import type { PrismaClient } from "~/db.server"; -import { prisma } from "~/db.server"; -import type { Project } from "~/models/project.server"; -import type { User } from "~/models/user.server"; - -export class TaskPresenter { - #prismaClient: PrismaClient; - - constructor(prismaClient: PrismaClient = prisma) { - this.#prismaClient = prismaClient; - } - - public async call({ - userId, - taskFriendlyId, - projectSlug, - }: { - userId: User["id"]; - taskFriendlyId: BackgroundWorkerTask["friendlyId"]; - projectSlug: Project["slug"]; - }) { - const task = await this.#prismaClient.backgroundWorkerTask.findFirst({ - select: { - id: true, - slug: true, - filePath: true, - friendlyId: true, - createdAt: true, - worker: { - select: { - id: true, - version: true, - sdkVersion: true, - cliVersion: true, - createdAt: true, - updatedAt: true, - friendlyId: true, - }, - }, - runtimeEnvironment: { - select: { - id: true, - slug: true, - type: true, - orgMember: { - select: { - user: { - select: { - id: true, - name: true, - displayName: true, - }, - }, - }, - }, - }, - }, - }, - where: { - friendlyId: taskFriendlyId, - runtimeEnvironment: { - organization: { - members: { - some: { - userId, - }, - }, - }, - }, - project: { - slug: projectSlug, - }, - }, - }); - - if (!task) { - return undefined; - } - - return task; - } -} diff --git a/apps/webapp/app/presenters/v3/TasksDashboardPresenter.server.ts b/apps/webapp/app/presenters/v3/TasksDashboardPresenter.server.ts index 83b9c6afc84..4cdfa052862 100644 --- a/apps/webapp/app/presenters/v3/TasksDashboardPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TasksDashboardPresenter.server.ts @@ -8,15 +8,13 @@ import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.s const DAYS = 7; -export type TaskKind = "AGENT" | "STANDARD" | "SCHEDULED"; - export type DailyRunPoint = { /** ISO date (YYYY-MM-DD, UTC) */ day: string; count: number; }; -export type TasksDashboardResult = { +type TasksDashboardResult = { counts: { agents: number; standard: number; @@ -29,7 +27,7 @@ export type TasksDashboardResult = { }>; }; -export class TasksDashboardPresenter { +class TasksDashboardPresenter { constructor(private readonly _replica: PrismaClientOrTransaction) {} public async call({ diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index 22cac2c384f..7c5495064ea 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -10,7 +10,7 @@ type TaskListOptions = { environmentType: RuntimeEnvironmentType; }; -export type TaskList = Awaited>; +type TaskList = Awaited>; export type TaskListItem = NonNullable[0]; export class TestPresenter extends BasePresenter { diff --git a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts index f0508d9eed0..7f444a269de 100644 --- a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts @@ -8,6 +8,7 @@ import { import { z } from "zod"; import { $replica } from "~/db.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { backstopPromise } from "~/utils/backstopPromise"; import { singleton } from "~/utils/singleton"; import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server"; import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server"; @@ -33,7 +34,7 @@ export type UnifiedRunningStates = Record; /** One hour bucket: the bucket start date, a total count for axis scaling, * and per-status counts (sparse โ€” only statuses that occurred are present). */ -export type HourlyTaskActivityBucket = { +type HourlyTaskActivityBucket = { date: Date; total: number; } & Partial>; @@ -41,7 +42,7 @@ export type HourlyTaskActivityBucket = { /** 24h hourly stacked-by-status series keyed by task slug. */ export type HourlyTaskActivity = Record; -export class UnifiedTaskListPresenter { +class UnifiedTaskListPresenter { constructor(private readonly _replica: PrismaClientOrTransaction) {} public async call(args: { @@ -69,26 +70,31 @@ export class UnifiedTaskListPresenter { const items = toUnifiedItems(taskResult.tasks, agentResult.agents); const allSlugs = items.map((item) => item.slug); + // Backstopped: the route subscribes via typeddefer only after further + // awaits, so a rejection in that gap would be unhandled. const hourlyActivity: Promise = allSlugs.length === 0 ? Promise.resolve({}) - : (async () => { - const clickhouse = await clickhouseFactory.getClickhouseForOrganization( - args.organizationId, - "standard" - ); - return getHourlyTaskActivity(clickhouse, { - organizationId: args.organizationId, - projectId: args.projectId, - environmentId: args.environmentId, - slugs: allSlugs, - }); - })(); + : backstopPromise( + (async () => { + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + args.organizationId, + "standard" + ); + return getHourlyTaskActivity(clickhouse, { + organizationId: args.organizationId, + projectId: args.projectId, + environmentId: args.environmentId, + slugs: allSlugs, + }); + })() + ); - const runningStates: Promise = Promise.all([ - taskResult.runningStats, - agentResult.activeStates, - ]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates)); + const runningStates: Promise = backstopPromise( + Promise.all([taskResult.runningStats, agentResult.activeStates]).then( + ([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates) + ) + ); return { items, hourlyActivity, runningStates }; } diff --git a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts index a3b96cf6ac3..cec59f1048f 100644 --- a/apps/webapp/app/presenters/v3/UsagePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UsagePresenter.server.ts @@ -15,16 +15,6 @@ type Options = { startDate: Date; }; -export type TaskUsageItem = { - taskIdentifier: string; - runCount: number; - averageDuration: number; - averageCost: number; - totalDuration: number; - totalCost: number; - totalBaseCost: number; -}; - export type UsageSeriesData = { date: string; dollars: number; diff --git a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts index 4b684949952..10a46c01b3a 100644 --- a/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/VercelSettingsPresenter.server.ts @@ -50,7 +50,7 @@ export type VercelSettingsResult = { currentTriggerVersionFetchFailed?: boolean; }; -export type VercelAvailableProject = { +type VercelAvailableProject = { id: string; name: string; }; diff --git a/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts index d17105076f4..0992c119a59 100644 --- a/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WaitpointTagListPresenter.server.ts @@ -12,9 +12,6 @@ export type TagListOptions = { const DEFAULT_PAGE_SIZE = 25; -export type TagList = Awaited>; -export type TagListItem = TagList["tags"][number]; - export class WaitpointTagListPresenter extends BasePresenter { constructor( prismaClient?: PrismaClientOrTransaction, diff --git a/apps/webapp/app/presenters/v3/WebhookDetailPresenter.server.ts b/apps/webapp/app/presenters/v3/WebhookDetailPresenter.server.ts index b91b2f104ca..4324e1f670a 100644 --- a/apps/webapp/app/presenters/v3/WebhookDetailPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/WebhookDetailPresenter.server.ts @@ -18,7 +18,7 @@ import { type WebhookComposerEndpointData, } from "./webhookComposerEndpoints.server"; -export type WebhookEndpointSummary = { +type WebhookEndpointSummary = { id: string; opaqueId: string; status: string; @@ -35,7 +35,7 @@ export type WebhookDetail = { endpoint: WebhookEndpointSummary; }; -export type WebhookActivityPoint = { +type WebhookActivityPoint = { bucket: number; // epoch ms } & Record; diff --git a/apps/webapp/app/presenters/v3/dashboardAgent/watch-wording.ts b/apps/webapp/app/presenters/v3/dashboardAgent/watch-wording.ts index a0da0585206..3733550d2b0 100644 --- a/apps/webapp/app/presenters/v3/dashboardAgent/watch-wording.ts +++ b/apps/webapp/app/presenters/v3/dashboardAgent/watch-wording.ts @@ -7,33 +7,20 @@ */ export { formatWatchCadence, - formatWatchDuration, - formatWatchSla, - formatWatchWait, formatWatchWindow, immediateWatchMessage, noteFor, presentResolvedWatch, WATCH_IN_CHAT_DELIVERY_LINE, WATCH_PRESENTATION_FALLBACK, - WATCH_UPDATE_LABEL, shortFingerprint, watchConditionLabel, - watchConditionWording, watchConfirmationBlockBody, watchDurationLabel, - watchExternalNotificationLine, - watchFollowUpLines, watchIdentityValue, - watchLifetimeSentence, watchNoteLine, watchOneShotBlockBody, - watchRequestSentence, watchSubjectLabel, - watchSubline, watchTooltipLabel, - type WatchConditionWording, - type WatchPresentation, type WatchResolvedInput, - type WatchSemanticIcon, } from "@internal/dashboard-agent-contracts"; diff --git a/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts index ce7654cc1bc..25b3e5ad2b9 100644 --- a/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts +++ b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts @@ -19,5 +19,11 @@ export function mapRunToLiveFields(run: ListedRun) { usageDurationMs: Number(run.usageDurationMs), costInCents: run.costInCents, baseCostInCents: run.baseCostInCents, + metadata: run.metadata, + metadataType: run.metadataType, + payload: run.payload, + payloadType: run.payloadType, + output: run.output, + outputType: run.outputType, }; } diff --git a/apps/webapp/app/presenters/v3/queueListPagination.server.ts b/apps/webapp/app/presenters/v3/queueListPagination.server.ts index b366ebe0f4e..44e9c3dafed 100644 --- a/apps/webapp/app/presenters/v3/queueListPagination.server.ts +++ b/apps/webapp/app/presenters/v3/queueListPagination.server.ts @@ -1,10 +1,10 @@ -export type QueueListFilteredPagination = { +type QueueListFilteredPagination = { mode: "filtered"; currentPage: number; hasMore: boolean; }; -export type QueueListUnfilteredPagination = { +type QueueListUnfilteredPagination = { mode: "unfiltered"; currentPage: number; totalPages: number; diff --git a/apps/webapp/app/presenters/v3/reports/health/execution.ts b/apps/webapp/app/presenters/v3/reports/health/execution.ts index 4357c680dcc..0df0b3e336c 100644 --- a/apps/webapp/app/presenters/v3/reports/health/execution.ts +++ b/apps/webapp/app/presenters/v3/reports/health/execution.ts @@ -1,7 +1,7 @@ import { isOk, maxSeverity, type Finding, type Metric } from "../report-view-model"; import { HEALTH_THRESHOLDS, metricById, type HealthInput } from "./health-core"; -export const EXECUTION_METRIC_IDS = ["failures", "dur_p95"]; +const EXECUTION_METRIC_IDS = ["failures", "dur_p95"]; export function interpretExecution(metrics: Metric[], input: HealthInput): Finding { const exec = EXECUTION_METRIC_IDS.map((id) => metricById(metrics, id)); diff --git a/apps/webapp/app/presenters/v3/reports/health/flow.ts b/apps/webapp/app/presenters/v3/reports/health/flow.ts index e8cf27f915c..ce6ba724527 100644 --- a/apps/webapp/app/presenters/v3/reports/health/flow.ts +++ b/apps/webapp/app/presenters/v3/reports/health/flow.ts @@ -19,7 +19,7 @@ import { type HealthInput, } from "./health-core"; -export const FLOW_METRIC_IDS = ["start_latency_p95", "pending", "throughput"]; +const FLOW_METRIC_IDS = ["start_latency_p95", "pending", "throughput"]; /** Unmeasurable backlog: verdict is unassessable. Distinct from "unknown", the staleness guard. */ export const FLOW_UNMEASURED = "flow_unmeasured"; diff --git a/apps/webapp/app/presenters/v3/reports/health/health-data.ts b/apps/webapp/app/presenters/v3/reports/health/health-data.ts index ea2d54c51aa..3c14b498e57 100644 --- a/apps/webapp/app/presenters/v3/reports/health/health-data.ts +++ b/apps/webapp/app/presenters/v3/reports/health/health-data.ts @@ -263,7 +263,7 @@ async function tryQuery( } } -export type FlowData = { +type FlowData = { flowSource: HealthInput["flowSource"]; pending: HealthInput["pending"]; startLatency: HealthInput["startLatency"]; @@ -286,12 +286,12 @@ type RunsContext = { liveScalar: Row; liveSeries: Row[]; baselineScalar: Row }; * "unavailable" is a recognized rollout state, so the next source down is a legitimate substitute. * "failed" is anything else and must make the flow verdict unassessable, never fall through to it. */ -export type FlowLoadResult = +type FlowLoadResult = | { status: "ok"; data: FlowData } | { status: "unavailable" } | { status: "failed"; error: unknown }; -export interface FlowSource { +interface FlowSource { loadFlow( env: AuthenticatedEnvironment, period: string, @@ -334,7 +334,7 @@ function isRolloutError(error: unknown): boolean { } /** Measured depth and scheduling-delay p95 from `env_metrics`. Unavailable until it is populated. */ -export const QueueMetricsSource: FlowSource = { +const QueueMetricsSource: FlowSource = { async loadFlow(env, period, ctx, deps) { try { // The rejection must be guarded: if the queries below throw first this is never awaited, and @@ -495,7 +495,7 @@ function buildQueueMetricsFlow(args: { * Fallback: live Redis depth plus a backlog proxy from `runs` (triggered minus finished). The proxy * is shape-only: it starts at 0 within the window and can't see backlog that predates it. */ -export const SnapshotFlowSource: FlowSource = { +const SnapshotFlowSource: FlowSource = { async loadFlow(env, _period, ctx, deps) { // Last-resort source, so a Redis failure must not break the report. const pendingNow = await deps.lengthOfEnvQueue(env).catch(() => undefined); diff --git a/apps/webapp/app/presenters/v3/reports/health/health.ts b/apps/webapp/app/presenters/v3/reports/health/health.ts index 1ee163fed77..4780064b443 100644 --- a/apps/webapp/app/presenters/v3/reports/health/health.ts +++ b/apps/webapp/app/presenters/v3/reports/health/health.ts @@ -129,7 +129,7 @@ function collectLinks(findings: Finding[]): ReportViewModel["links"] { } // The health verdict. All health semantics live here and no presentation does. -export type HealthAssessment = { +type HealthAssessment = { scope: string; period: string; baselineLabel: string; @@ -147,7 +147,7 @@ export type HealthAssessment = { facts: Record; }; -export function assessHealth(input: HealthInput): HealthAssessment { +function assessHealth(input: HealthInput): HealthAssessment { const metrics = buildMetrics(input); const drain = computeDrain(input); diff --git a/apps/webapp/app/presenters/v3/reports/report-layout.ts b/apps/webapp/app/presenters/v3/reports/report-layout.ts index 35e17a9e932..10e297e6adf 100644 --- a/apps/webapp/app/presenters/v3/reports/report-layout.ts +++ b/apps/webapp/app/presenters/v3/reports/report-layout.ts @@ -96,8 +96,6 @@ export const REPORT_SECTION_ORDER = [ "footer", ] as const; -export type ReportSectionId = (typeof REPORT_SECTION_ORDER)[number]; - /** * Reasons that mean "we can't say" rather than a verdict, so their finding renders headline-only. * A measured finding never carries one: an unmeasured input costs its own metric, not the verdict. @@ -125,11 +123,11 @@ export function reportTrust(vm: { facts?: Record }): LayoutTrus return (typeof reason === "string" ? TRUST_CAVEATS[reason] : undefined) ?? TRUST_CAVEAT_FALLBACK; } -export function reportTone(severity: Severity, reason?: string): ReportTone { +function reportTone(severity: Severity, reason?: string): ReportTone { return reason !== undefined && NEUTRAL_REASONS.has(reason) ? "neutral" : severity; } -export function reportGlyph(severity: Severity, reason?: string): string { +function reportGlyph(severity: Severity, reason?: string): string { return REPORT_GLYPH[reportTone(severity, reason)]; } @@ -160,11 +158,11 @@ export function reportFooterStyle(code: string): ReportFooterStyle { const MINUS = "โˆ’"; // U+2212 -export function fmtCount(n: number): string { +function fmtCount(n: number): string { return Math.round(n).toLocaleString("en-US"); } -export function fmtDuration(ms: number): string { +function fmtDuration(ms: number): string { if (ms < 1000) return `${Math.round(ms)}ms`; const s = ms / 1000; if (s < 60) return Number.isInteger(s) ? `${s}s` : `${s.toFixed(1)}s`; @@ -172,16 +170,16 @@ export function fmtDuration(ms: number): string { return Number.isInteger(m) ? `${m}m` : `${m.toFixed(1)}m`; } -export function fmtPct(ratio: number): string { +function fmtPct(ratio: number): string { return `${(ratio * 100).toFixed(1)}%`; } -export function fmtRate(n: number): string { +function fmtRate(n: number): string { return `${fmtCount(n)}/min`; } /** A net rate carries its sign; a plain rate does not, so it isn't read as a change. */ -export function fmtSignedRate(net: number): string { +function fmtSignedRate(net: number): string { const sign = net < 0 ? MINUS : net > 0 ? "+" : ""; return `${sign}${fmtCount(Math.abs(net))}/min`; } @@ -200,10 +198,7 @@ export function fmtValue(value: number, unit: Unit): string { } /** Fill the `{token}` placeholders a message catalog leaves for the renderer. */ -export function fillTokens( - template: string, - tokens: Record -): string { +function fillTokens(template: string, tokens: Record): string { return template.replace(/\{(\w+)\}/g, (whole, key: string) => { const value = tokens[key]; if (value === undefined) return whole; @@ -230,7 +225,7 @@ export type LayoutMetricInput = { severity: Severity; }; -export type LayoutFindingInput = { +type LayoutFindingInput = { type: string; severity: Severity; reason: string; @@ -260,10 +255,10 @@ export type LayoutViewModel = { // --- output shapes ---------------------------------------------------------- -export type LayoutDelta = { text: string; dir: "up" | "down" | "flat" }; +type LayoutDelta = { text: string; dir: "up" | "down" | "flat" }; /** A metric's aside. `kind` lets a renderer choose its own frame around shared wording. */ -export type LayoutNote = { kind: "annotation" | "baseline" | "estimated"; text: string }; +type LayoutNote = { kind: "annotation" | "baseline" | "estimated"; text: string }; export type LayoutMetricRow = { id: string; @@ -301,9 +296,9 @@ export type LayoutFinding = { attributionKey?: string; }; -export type LayoutStatement = { tone: ReportTone; glyph: string; severity: Severity; text: string }; +type LayoutStatement = { tone: ReportTone; glyph: string; severity: Severity; text: string }; -export type LayoutFooterEntry = { +type LayoutFooterEntry = { code: string; style: ReportFooterStyle; label: string; diff --git a/apps/webapp/app/presenters/v3/reports/report-view-model.ts b/apps/webapp/app/presenters/v3/reports/report-view-model.ts index bbd07effa93..1031141480c 100644 --- a/apps/webapp/app/presenters/v3/reports/report-view-model.ts +++ b/apps/webapp/app/presenters/v3/reports/report-view-model.ts @@ -6,10 +6,7 @@ import { type ReportExclusion, type ReportFinding, type ReportFooterEntry, - type ReportLink as CoreReportLink, - type ReportLinkKey, type ReportMetric, - type ReportMetricSeries, type ReportObservation, type ReportReasonCode, type ReportRecommendation, @@ -23,10 +20,7 @@ export type Severity = ReportSeverity; export type Unit = ReportUnit; /** A code resolved to a human string by `report-messages.ts`. */ export type ReasonCode = ReportReasonCode; -/** A key into `ReportViewModel.links`, so a recommendation can point at a URL. */ -export type LinkKey = ReportLinkKey; export type Delta = ReportDelta; -export type MetricSeries = ReportMetricSeries; export type Metric = ReportMetric; export type Recommendation = ReportRecommendation; export type FooterEntry = ReportFooterEntry; @@ -34,7 +28,6 @@ export type Exclusion = ReportExclusion; export type Observation = ReportObservation; export type Finding = ReportFinding; export type SummaryStatement = ReportSummaryStatement; -export type ReportLink = CoreReportLink; export type ReportViewModel = CoreReportViewModel; /** Direction and rounded multiplier of `value` against a `normal` baseline. */ diff --git a/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts new file mode 100644 index 00000000000..1a2c7c8d3c0 --- /dev/null +++ b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts @@ -0,0 +1,34 @@ +import { + parseColumnParams, + resolveColumnLayout, + visibleSmartSources, + visibleStandardIds, + type RunColumnId, + type SmartColumnSource, +} from "~/components/runs/v3/runColumns"; + +/** + * Read the runs-list column state (`cols`/`sc`) off the request and resolve the + * column set the presenter needs to derive its Postgres select. Gates are + * resolved permissively here because they do not affect the always-selected + * fields; only the referenced smart-column sources change what is hydrated. + */ +export function getRunColumnsForSelect(request: Request): { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; +} { + const url = new URL(request.url); + const layout = resolveColumnLayout( + parseColumnParams( + url.searchParams.get("cols"), + url.searchParams.getAll("sc"), + url.searchParams.get("hide") + ), + { isManagedCloud: true, isDevelopment: false } + ); + + return { + visibleStandardIds: visibleStandardIds(layout.visible), + smartSources: visibleSmartSources(layout.visible), + }; +} diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 1e550155fde..3cb547db4c7 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -20,11 +20,15 @@ import { TimezoneSetter } from "./components/TimezoneSetter"; import { env } from "./env.server"; import { featuresForRequest } from "./features.server"; import { usePostHog } from "./hooks/usePostHog"; -import { useSystemThemeSync } from "./hooks/useSystemThemeSync"; +import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { getImpersonationState } from "./services/impersonation.server"; import { getUser } from "./services/session.server"; import { + normalizeIconContrast, + normalizeSystemDarkTheme, + normalizeSystemLightTheme, normalizeThemeContrast, + normalizeUnderlineLinks, normalizeThemePreference, type ThemePreference, } from "~/utils/themePreference"; @@ -81,20 +85,29 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }; const user = await getUser(request); - // Theme switching is feature-flagged; while off, everyone stays on the - // classic theme even if a preference was saved earlier. Admins always get - // the switcher so the team can dogfood before the flag flips. Cached: the - // root loader runs on every document request and client navigation. + // Feature-flagged; while off everyone stays on Dark at contrast 0. Admins + // always get it. Cached: this loader runs on every request and navigation. const showThemeSwitcher = user ? user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })) : false; - // Logged-out pages (login, invites) always render the branded Classic look. + // Logged-out pages always render the branded dark look. const themePreference: ThemePreference = showThemeSwitcher ? normalizeThemePreference(user?.dashboardPreferences.theme) - : "classic"; + : "dark"; const themeContrast = showThemeSwitcher ? normalizeThemeContrast(user?.dashboardPreferences.contrast) : 0; + // Forced off with the switcher hidden, so unflagged pages render the base set. + const iconContrast = showThemeSwitcher + ? normalizeIconContrast(user?.dashboardPreferences.iconContrast) + : false; + const underlineLinks = showThemeSwitcher + ? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks) + : false; + const systemThemes = { + light: normalizeSystemLightTheme(user?.dashboardPreferences.systemLightTheme), + dark: normalizeSystemDarkTheme(user?.dashboardPreferences.systemDarkTheme), + }; // Display-only: while impersonating, an admin can ask to see the dashboard // the way the impersonated user sees it. Exposed from root so every route can // read it. @@ -120,11 +133,15 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { appEnv: env.APP_ENV, appOrigin: env.APP_ORIGIN, apiOrigin: env.API_ORIGIN ?? env.APP_ORIGIN, + dashboardAgentBaseUrl: env.DASHBOARD_AGENT_BASE_URL ?? "https://api.trigger.dev", triggerCliTag: env.TRIGGER_CLI_TAG, kapa, timezone, showThemeSwitcher, + iconContrast, + underlineLinks, themePreference, + systemThemes, themeContrast, // Consumed by ResizablePanel: the browser check must match between SSR // and hydration, so it is derived from the request user-agent. @@ -146,73 +163,82 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => { export function ErrorBoundary() { return ( - <> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ); } export default function App() { - const { posthogProjectKey, posthogUiHost, themePreference, themeContrast } = - useTypedLoaderData(); + const { + posthogProjectKey, + posthogUiHost, + themePreference, + themeContrast, + iconContrast, + underlineLinks, + systemThemes, + } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); - useSystemThemeSync(themePreference); - // SSR falls back to dark for `system`; the inline script below corrects it - // before paint, and useSystemThemeSync keeps it live afterwards. - const resolvedTheme = themePreference === "system" ? "dark" : themePreference; + useSystemThemeSync(themePreference, systemThemes); + // SSR falls back to the dark end for `system`; the script below fixes it + // before paint, and useSystemThemeSync keeps it live after. + const resolvedTheme = resolveThemePreference(themePreference, true, systemThemes); return ( - <> - - -