diff --git a/.changeset/ai-prompt-management.md b/.changeset/ai-prompt-management.md deleted file mode 100644 index d3250bebda7..00000000000 --- a/.changeset/ai-prompt-management.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/sdk": patch ---- - -Define and manage AI prompts with `prompts.define()`. Create typesafe prompt templates with variables, resolve them at runtime, and manage versions and overrides from the dashboard without redeploying. diff --git a/.changeset/fix-dev-build-dir-leak.md b/.changeset/fix-dev-build-dir-leak.md deleted file mode 100644 index a1e6219c8bb..00000000000 --- a/.changeset/fix-dev-build-dir-leak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"trigger.dev": patch ---- - -Fix dev CLI leaking build directories on rebuild, causing disk space accumulation. Deprecated workers are now pruned (capped at 2 retained) when no active runs reference them. The watchdog process also cleans up `.trigger/tmp/` when the dev CLI is killed ungracefully (e.g. SIGKILL from pnpm). diff --git a/.changeset/fix-list-deploys-nullable.md b/.changeset/fix-list-deploys-nullable.md deleted file mode 100644 index d9d5e82116a..00000000000 --- a/.changeset/fix-list-deploys-nullable.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Fix `list_deploys` MCP tool failing when deployments have null `runtime` or `runtimeVersion` fields. diff --git a/.changeset/llm-metadata-run-tags.md b/.changeset/llm-metadata-run-tags.md deleted file mode 100644 index 85f04c363b8..00000000000 --- a/.changeset/llm-metadata-run-tags.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@trigger.dev/core": patch ---- - -Propagate run tags to span attributes so they can be extracted server-side for LLM cost attribution metadata. diff --git a/.changeset/mcp-get-span-details.md b/.changeset/mcp-get-span-details.md deleted file mode 100644 index e69b7979b07..00000000000 --- a/.changeset/mcp-get-span-details.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@trigger.dev/core": patch -"trigger.dev": patch ---- - -Add `get_span_details` MCP tool for inspecting individual spans within a run trace. - -- New `get_span_details` tool returns full span attributes, timing, events, and AI enrichment (model, tokens, cost, speed) -- Span IDs now shown in `get_run_details` trace output for easy discovery -- New API endpoint `GET /api/v1/runs/:runId/spans/:spanId` -- New `retrieveSpan()` method on the API client diff --git a/.changeset/mcp-query-tools.md b/.changeset/mcp-query-tools.md deleted file mode 100644 index 23e09c1afec..00000000000 --- a/.changeset/mcp-query-tools.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@trigger.dev/core": patch -"trigger.dev": patch ---- - -MCP server improvements: new tools, bug fixes, and new flags. - -**New tools:** -- `get_query_schema` — discover available TRQL tables and columns -- `query` — execute TRQL queries against your data -- `list_dashboards` — list built-in dashboards and their widgets -- `run_dashboard_query` — execute a single dashboard widget query -- `whoami` — show current profile, user, and API URL -- `list_profiles` — list all configured CLI profiles -- `switch_profile` — switch active profile for the MCP session -- `start_dev_server` — start `trigger dev` in the background and stream output -- `stop_dev_server` — stop the running dev server -- `dev_server_status` — check dev server status and view recent logs - -**New API endpoints:** -- `GET /api/v1/query/schema` — query table schema discovery -- `GET /api/v1/query/dashboards` — list built-in dashboards - -**New features:** -- `--readonly` flag hides write tools (`deploy`, `trigger_task`, `cancel_run`) so the AI cannot make changes -- `read:query` JWT scope for query endpoint authorization -- `get_run_details` trace output is now paginated with cursor support -- MCP tool annotations (`readOnlyHint`, `destructiveHint`) for all tools - -**Bug fixes:** -- Fixed `search_docs` tool failing due to renamed upstream Mintlify tool (`SearchTriggerDev` → `search_trigger_dev`) -- Fixed `list_deploys` failing when deployments have null `runtime`/`runtimeVersion` fields (#3139) -- Fixed `list_preview_branches` crashing due to incorrect response shape access -- Fixed `metrics` table column documented as `value` instead of `metric_value` in query docs -- Fixed dev CLI leaking build directories on rebuild — deprecated workers now clean up their build dirs when their last run completes - -**Context optimizations:** -- `get_query_schema` now requires a table name and returns only one table's schema (was returning all tables) -- `get_current_worker` no longer inlines payload schemas; use new `get_task_schema` tool instead -- Query results formatted as text tables instead of JSON (~50% fewer tokens) -- `cancel_run`, `list_deploys`, `list_preview_branches` formatted as text instead of raw JSON -- Schema and dashboard API responses cached to avoid redundant fetches diff --git a/.changeset/tame-oranges-change.md b/.changeset/tame-oranges-change.md deleted file mode 100644 index 9755a41a26a..00000000000 --- a/.changeset/tame-oranges-change.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@trigger.dev/redis-worker": patch -"@trigger.dev/sdk": patch -"trigger.dev": patch -"@trigger.dev/core": patch ---- - -Adapted the CLI API client to propagate the trigger source via http headers. diff --git a/.claude/skills/span-timeline-events/SKILL.md b/.claude/skills/span-timeline-events/SKILL.md new file mode 100644 index 00000000000..122f49912d7 --- /dev/null +++ b/.claude/skills/span-timeline-events/SKILL.md @@ -0,0 +1,78 @@ +--- +name: span-timeline-events +description: Use when adding, modifying, or debugging OTel span timeline events in the trace view. Covers event structure, ClickHouse storage constraints, rendering in SpanTimeline component, admin visibility, and the step-by-step process for adding new events. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# Span Timeline Events + +The trace view's right panel shows a timeline of events for the selected span. These are OTel span events rendered by `app/utils/timelineSpanEvents.ts` and the `SpanTimeline` component. + +## How They Work + +1. **Span events** in OTel are attached to a parent span. In ClickHouse, they're stored as separate rows with `kind: "SPAN_EVENT"` sharing the parent span's `span_id`. The `#mergeRecordsIntoSpanDetail` method reassembles them into the span's `events` array at query time. +2. The timeline only renders events whose `name` starts with `trigger.dev/` - all others are silently filtered out. +3. The **display name** comes from `properties.event` (not the span event name), mapped through `getFriendlyNameForEvent()`. +4. Events are shown on the **span they belong to** - events on one span don't appear in another span's timeline. + +## ClickHouse Storage Constraint + +When events are written to ClickHouse, `spanEventsToTaskEventV1Input()` filters out events whose `start_time` is not greater than the parent span's `startTime`. Events at or before the span start are silently dropped. This means span events must have timestamps strictly after the span's own `startTimeUnixNano`. + +## Timeline Rendering (SpanTimeline component) + +The `SpanTimeline` component in `app/components/run/RunTimeline.tsx` renders: + +1. **Events** (thin 1px line with hollow dots) - all events from `createTimelineSpanEventsFromSpanEvents()` +2. **"Started"** marker (thick cap) - at the span's `startTime` +3. **Duration bar** (thick 7px line) - from "Started" to "Finished" +4. **"Finished"** marker (thick cap) - at `startTime + duration` + +The thin line before "Started" only appears when there are events with timestamps between the span start and the first child span. For the Attempt span this works well (Dequeued -> Pod scheduled -> Launched -> etc. all happen before execution starts). Events all get `lineVariant: "light"` (thin) while the execution bar gets `variant: "normal"` (thick). + +## Trace View Sort Order + +Sibling spans (same parent) are sorted by `start_time ASC` from the ClickHouse query. The `createTreeFromFlatItems` function preserves this order. Event timestamps don't affect sort order - only the span's own `start_time`. + +## Event Structure + +```typescript +// OTel span event format +{ + name: "trigger.dev/run", // Must start with "trigger.dev/" to render + timeUnixNano: "1711200000000000000", + attributes: [ + { key: "event", value: { stringValue: "dequeue" } }, // The actual event type + { key: "duration", value: { intValue: 150 } }, // Optional: duration in ms + ] +} +``` + +## Admin-Only Events + +`getAdminOnlyForEvent()` controls visibility. Events default to **admin-only** (`true`). + +| Event | Admin-only | Friendly name | +|-------|-----------|---------------| +| `dequeue` | No | Dequeued | +| `fork` | No | Launched | +| `import` | No (if no fork event) | Importing task file | +| `create_attempt` | Yes | Attempt created | +| `lazy_payload` | Yes | Lazy attempt initialized | +| `pod_scheduled` | Yes | Pod scheduled | +| (default) | Yes | (raw event name) | + +## Adding New Timeline Events + +1. Add OTLP span event with `name: "trigger.dev/"` and `properties.event: ""` +2. Event timestamp must be strictly after the parent span's `startTimeUnixNano` (ClickHouse drops earlier events) +3. Add friendly name in `getFriendlyNameForEvent()` in `app/utils/timelineSpanEvents.ts` +4. Set admin visibility in `getAdminOnlyForEvent()` +5. Optionally add help text in `getHelpTextForEvent()` + +## Key Files + +- `app/utils/timelineSpanEvents.ts` - filtering, naming, admin logic +- `app/components/run/RunTimeline.tsx` - `SpanTimeline` component (thin line + thick bar rendering) +- `app/presenters/v3/SpanPresenter.server.ts` - loads span data including events +- `app/v3/eventRepository/clickhouseEventRepository.server.ts` - `spanEventsToTaskEventV1Input()` (storage filter), `#mergeRecordsIntoSpanDetail` (reassembly) diff --git a/.env.example b/.env.example index 35c8c976ff6..69d5acdc560 100644 --- a/.env.example +++ b/.env.example @@ -77,9 +77,28 @@ POSTHOG_PROJECT_KEY= # DEPOT_TOKEN= # DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://0.0.0.0:4318" # These are needed for the object store (for handling large payloads/outputs) -# OBJECT_STORE_BASE_URL="https://{bucket}.{accountId}.r2.cloudflarestorage.com" -# OBJECT_STORE_ACCESS_KEY_ID= -# OBJECT_STORE_SECRET_ACCESS_KEY= +# +# Default provider +# OBJECT_STORE_BASE_URL=http://localhost:9005 +# OBJECT_STORE_BUCKET=packets +# OBJECT_STORE_ACCESS_KEY_ID=minioadmin +# OBJECT_STORE_SECRET_ACCESS_KEY=minioadmin +# OBJECT_STORE_REGION=us-east-1 +# OBJECT_STORE_SERVICE=s3 +# +# OBJECT_STORE_DEFAULT_PROTOCOL=s3 # Only specify this if you're going to migrate object storage and set protocol values below +# Named providers (protocol-prefixed data) - optional for multi-provider support +# OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com +# OBJECT_STORE_S3_ACCESS_KEY_ID= +# OBJECT_STORE_S3_SECRET_ACCESS_KEY= +# OBJECT_STORE_S3_REGION=us-east-1 +# OBJECT_STORE_S3_SERVICE=s3 +# +# OBJECT_STORE_R2_BASE_URL=https://{bucket}.{accountId}.r2.cloudflarestorage.com +# OBJECT_STORE_R2_ACCESS_KEY_ID= +# OBJECT_STORE_R2_SECRET_ACCESS_KEY= +# OBJECT_STORE_R2_REGION=auto +# OBJECT_STORE_R2_SERVICE=s3 # CHECKPOINT_THRESHOLD_IN_MS=10000 # These control the server-side internal telemetry diff --git a/.server-changes/ai-prompt-management.md b/.server-changes/ai-prompt-management.md deleted file mode 100644 index 624ec391047..00000000000 --- a/.server-changes/ai-prompt-management.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -area: webapp -type: feature ---- - -AI prompt management dashboard and enhanced span inspectors. - -**Prompt management:** -- Prompts list page with version status, model, override indicators, and 24h usage sparklines -- Prompt detail page with template viewer, variable preview, version history timeline, and override editor -- Create, edit, and remove overrides to change prompt content or model without redeploying -- Promote any code-deployed version to current -- Generations tab with infinite scroll, live polling, and inline span inspector -- Per-prompt metrics: total generations, avg tokens, avg cost, latency, with version-level breakdowns - -**AI span inspectors:** -- Custom inspectors for `ai.generateText`, `ai.streamText`, `ai.generateObject`, `ai.streamObject` parent spans -- `ai.toolCall` inspector showing tool name, call ID, and input arguments -- `ai.embed` inspector showing model, provider, and input text -- Prompt tab on AI spans linking to prompt version with template and input variables -- Compact timestamp and duration header on all AI span inspectors - -**AI metrics dashboard:** -- Operations, Providers, and Prompts filters on the AI Metrics dashboard -- Cost by prompt widget -- "AI" section in the sidebar with Prompts and AI Metrics links - -**Other improvements:** -- Resizable panel sizes now persist across page refreshes -- Fixed `
` inside `

` DOM nesting warnings in span titles and chat messages diff --git a/.server-changes/allow-rollbacks-promote-api.md b/.server-changes/allow-rollbacks-promote-api.md deleted file mode 100644 index fc03fa114ff..00000000000 --- a/.server-changes/allow-rollbacks-promote-api.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Add allowRollbacks query param to the promote deployment API to enable version downgrades diff --git a/.server-changes/ck-index-master-queue-dedup.md b/.server-changes/ck-index-master-queue-dedup.md deleted file mode 100644 index a2ff6495e61..00000000000 --- a/.server-changes/ck-index-master-queue-dedup.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Concurrency-keyed queues now use a single master queue entry per base queue instead of one entry per key. Prevents high-CK-count tenants from consuming the entire parentQueueLimit window and starving other tenants on the same shard. diff --git a/.server-changes/fix-batch-waitpoint-lock-contention.md b/.server-changes/fix-batch-waitpoint-lock-contention.md deleted file mode 100644 index 6b545eb794b..00000000000 --- a/.server-changes/fix-batch-waitpoint-lock-contention.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Reduce lock contention when processing large `batchTriggerAndWait` batches. Previously, each batch item acquired a Redis lock on the parent run to insert a `TaskRunWaitpoint` row, causing `LockAcquisitionTimeoutError` with high concurrency (880 errors/24h in prod). Since `blockRunWithCreatedBatch` already transitions the parent to `EXECUTING_WITH_WAITPOINTS` before items are processed, the per-item lock is unnecessary. The new `blockRunWithWaitpointLockless` method performs only the idempotent CTE insert without acquiring the lock. diff --git a/.server-changes/fix-clickhouse-query-client-secure-param.md b/.server-changes/fix-clickhouse-query-client-secure-param.md deleted file mode 100644 index 4daa021fe40..00000000000 --- a/.server-changes/fix-clickhouse-query-client-secure-param.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: fix ---- - -Strip `secure` query parameter from QUERY_CLICKHOUSE_URL before passing to ClickHouse client. This was already done for the main and logs ClickHouse clients but was missing for the query client, causing a startup crash with `Error: Unknown URL parameters: secure`. diff --git a/.server-changes/llm-cost-tracking.md b/.server-changes/llm-cost-tracking.md deleted file mode 100644 index 7567aae7d1b..00000000000 --- a/.server-changes/llm-cost-tracking.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Add automatic LLM cost calculation for spans with GenAI semantic conventions. When a span arrives with `gen_ai.response.model` and token usage data, costs are calculated from an in-memory pricing registry backed by Postgres and dual-written to both span attributes (`trigger.llm.*`) and a new `llm_metrics_v1` ClickHouse table that captures usage, cost, performance (TTFC, tokens/sec), and behavioral (finish reason, operation type) metrics. diff --git a/.server-changes/mcp-get-span-details.md b/.server-changes/mcp-get-span-details.md deleted file mode 100644 index 336595d2203..00000000000 --- a/.server-changes/mcp-get-span-details.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -area: webapp -type: feature ---- - -Add API endpoint `GET /api/v1/runs/:runId/spans/:spanId` that returns detailed span information including properties, events, AI enrichment (model, tokens, cost), and triggered child runs. diff --git a/CLAUDE.md b/CLAUDE.md index 9e53955b092..0a54cced672 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ pnpm run changeset:add When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation. +## Dependency Pinning + +Zod is pinned to a single version across the entire monorepo (currently `3.25.76`). When adding zod to a new or existing package, use the **exact same version** as the rest of the repo - never a different version or a range. Mismatched zod versions cause runtime type incompatibilities (e.g., schemas from one package can't be used as body validators in another). + ## Architecture Overview ### Request Flow diff --git a/apps/supervisor/Containerfile b/apps/supervisor/Containerfile index d5bb5862e96..fc620a1d437 100644 --- a/apps/supervisor/Containerfile +++ b/apps/supervisor/Containerfile @@ -25,7 +25,8 @@ RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch - FROM deps-fetcher AS dev-deps ENV NODE_ENV development -RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --offline --ignore-scripts +# TEMP --no-frozen-lockfile and remove --offline for overrides for CVEs +RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --no-frozen-lockfile --ignore-scripts FROM base AS builder diff --git a/apps/supervisor/package.json b/apps/supervisor/package.json index e9609bf1541..7456d421850 100644 --- a/apps/supervisor/package.json +++ b/apps/supervisor/package.json @@ -14,9 +14,11 @@ }, "dependencies": { "@aws-sdk/client-ecr": "^3.839.0", + "@internal/compute": "workspace:*", "@kubernetes/client-node": "^1.0.0", "@trigger.dev/core": "workspace:*", "dockerode": "^4.0.6", + "p-limit": "^6.2.0", "prom-client": "^15.1.0", "socket.io": "4.7.4", "std-env": "^3.8.0", diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 9eb4aead840..060a6da5e43 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -3,153 +3,294 @@ import { env as stdEnv } from "std-env"; import { z } from "zod"; import { AdditionalEnvVars, BoolEnv } from "./envUtil.js"; -const Env = z.object({ - // This will come from `spec.nodeName` in k8s - TRIGGER_WORKER_INSTANCE_NAME: z.string().default(randomUUID()), - TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), - - // Required settings - TRIGGER_API_URL: z.string().url(), - TRIGGER_WORKER_TOKEN: z.string(), // accepts file:// path to read from a file - MANAGED_WORKER_SECRET: z.string(), - OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), // set on the runners - - // Workload API settings (coordinator mode) - the workload API is what the run controller connects to - TRIGGER_WORKLOAD_API_ENABLED: BoolEnv.default(true), - TRIGGER_WORKLOAD_API_PROTOCOL: z - .string() - .transform((s) => z.enum(["http", "https"]).parse(s.toLowerCase())) - .default("http"), - TRIGGER_WORKLOAD_API_DOMAIN: z.string().optional(), // If unset, will use orchestrator-specific default - TRIGGER_WORKLOAD_API_HOST_INTERNAL: z.string().default("0.0.0.0"), - TRIGGER_WORKLOAD_API_PORT_INTERNAL: z.coerce.number().default(8020), // This is the port the workload API listens on - TRIGGER_WORKLOAD_API_PORT_EXTERNAL: z.coerce.number().default(8020), // This is the exposed port passed to the run controller - - // Runner settings - RUNNER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().optional(), - RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().optional(), - RUNNER_ADDITIONAL_ENV_VARS: AdditionalEnvVars, // optional (csv) - RUNNER_PRETTY_LOGS: BoolEnv.default(false), - - // Dequeue settings (provider mode) - TRIGGER_DEQUEUE_ENABLED: BoolEnv.default(true), - TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(250), - TRIGGER_DEQUEUE_IDLE_INTERVAL_MS: z.coerce.number().int().default(1000), - TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(1), - TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT: z.coerce.number().int().default(1), - TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(10), - TRIGGER_DEQUEUE_SCALING_STRATEGY: z.enum(["none", "smooth", "aggressive"]).default("none"), - TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS: z.coerce.number().int().default(5000), // 5 seconds - TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS: z.coerce.number().int().default(30000), // 30 seconds - TRIGGER_DEQUEUE_SCALING_TARGET_RATIO: z.coerce.number().default(1.0), // Target ratio of queue items to consumers (1.0 = 1 item per consumer) - TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA: z.coerce.number().min(0).max(1).default(0.3), // Smooths queue length measurements (0=historical, 1=current) - TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS: z.coerce.number().int().positive().default(1000), // Batch window for metrics processing (ms) - TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR: z.coerce.number().min(0).max(1).default(0.7), // Smooths consumer count changes after EWMA (0=no scaling, 1=immediate) - - // Optional services - TRIGGER_WARM_START_URL: z.string().optional(), - TRIGGER_CHECKPOINT_URL: z.string().optional(), - TRIGGER_METADATA_URL: z.string().optional(), - - // Used by the resource monitor - RESOURCE_MONITOR_ENABLED: BoolEnv.default(false), - RESOURCE_MONITOR_OVERRIDE_CPU_TOTAL: z.coerce.number().optional(), - RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(), - - // Docker settings - DOCKER_API_VERSION: z.string().optional(), - DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64 - DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true), - DOCKER_REGISTRY_USERNAME: z.string().optional(), - DOCKER_REGISTRY_PASSWORD: z.string().optional(), - DOCKER_REGISTRY_URL: z.string().optional(), // e.g. https://index.docker.io/v1 - DOCKER_ENFORCE_MACHINE_PRESETS: BoolEnv.default(true), - DOCKER_AUTOREMOVE_EXITED_CONTAINERS: BoolEnv.default(true), - /** - * Network mode to use for all runners. Supported standard values are: `bridge`, `host`, `none`, and `container:`. - * Any other value is taken as a custom network's name to which all runners should connect to. - * - * Accepts a list of comma-separated values to attach to multiple networks. Additional networks are interpreted as network names and will be attached after container creation. - * - * **WARNING**: Specifying multiple networks will slightly increase startup times. - * - * @default "host" - */ - DOCKER_RUNNER_NETWORKS: z.string().default("host"), - - // Kubernetes settings - KUBERNETES_FORCE_ENABLED: BoolEnv.default(false), - KUBERNETES_NAMESPACE: z.string().default("default"), - KUBERNETES_WORKER_NODETYPE_LABEL: z.string().default("v4-worker"), - KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv - 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_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), - KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit - - // Per-preset overrides of the global KUBERNETES_CPU_REQUEST_RATIO - KUBERNETES_CPU_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), - - // Per-preset overrides of the global KUBERNETES_MEMORY_REQUEST_RATIO - KUBERNETES_MEMORY_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), - KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), - - 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 - // Large machine affinity settings - large-* presets prefer a dedicated pool - KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED: BoolEnv.default(false), - KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY: z.string().trim().min(1).default("node.cluster.x-k8s.io/machinepool"), - KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE: z.string().trim().min(1).default("large-machines"), - KUBERNETES_LARGE_MACHINE_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(100), - - // Project affinity settings - pods from the same project prefer the same node - KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false), - KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50), - KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"), - - // Schedule affinity settings - runs from schedule trees prefer a dedicated pool - KUBERNETES_SCHEDULE_AFFINITY_ENABLED: BoolEnv.default(false), - KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_KEY: z.string().trim().min(1).default("node.cluster.x-k8s.io/machinepool"), - KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE: z.string().trim().min(1).default("scheduled-runs"), - KUBERNETES_SCHEDULE_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(80), - KUBERNETES_SCHEDULE_ANTI_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(20), - - // Placement tags settings - PLACEMENT_TAGS_ENABLED: BoolEnv.default(false), - PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"), - - // Metrics - METRICS_ENABLED: BoolEnv.default(true), - METRICS_COLLECT_DEFAULTS: BoolEnv.default(true), - METRICS_HOST: z.string().default("127.0.0.1"), - METRICS_PORT: z.coerce.number().int().default(9090), - - // Pod cleaner - POD_CLEANER_ENABLED: BoolEnv.default(true), - POD_CLEANER_INTERVAL_MS: z.coerce.number().int().default(10000), - POD_CLEANER_BATCH_SIZE: z.coerce.number().int().default(500), - - // Failed pod handler - FAILED_POD_HANDLER_ENABLED: BoolEnv.default(true), - FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS: z.coerce.number().int().default(1000), - - // Debug - DEBUG: BoolEnv.default(false), - SEND_RUN_DEBUG_LOGS: BoolEnv.default(false), -}); +const Env = z + .object({ + // This will come from `spec.nodeName` in k8s + TRIGGER_WORKER_INSTANCE_NAME: z.string().default(randomUUID()), + TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), + + // Required settings + TRIGGER_API_URL: z.string().url(), + TRIGGER_WORKER_TOKEN: z.string(), // accepts file:// path to read from a file + MANAGED_WORKER_SECRET: z.string(), + OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), // set on the runners + + // Workload API settings (coordinator mode) - the workload API is what the run controller connects to + TRIGGER_WORKLOAD_API_ENABLED: BoolEnv.default(true), + TRIGGER_WORKLOAD_API_PROTOCOL: z + .string() + .transform((s) => z.enum(["http", "https"]).parse(s.toLowerCase())) + .default("http"), + TRIGGER_WORKLOAD_API_DOMAIN: z.string().optional(), // If unset, will use orchestrator-specific default + TRIGGER_WORKLOAD_API_HOST_INTERNAL: z.string().default("0.0.0.0"), + TRIGGER_WORKLOAD_API_PORT_INTERNAL: z.coerce.number().default(8020), // This is the port the workload API listens on + TRIGGER_WORKLOAD_API_PORT_EXTERNAL: z.coerce.number().default(8020), // This is the exposed port passed to the run controller + + // Runner settings + RUNNER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().optional(), + RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().optional(), + RUNNER_ADDITIONAL_ENV_VARS: AdditionalEnvVars, // optional (csv) + RUNNER_PRETTY_LOGS: BoolEnv.default(false), + + // Dequeue settings (provider mode) + TRIGGER_DEQUEUE_ENABLED: BoolEnv.default(true), + TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(250), + TRIGGER_DEQUEUE_IDLE_INTERVAL_MS: z.coerce.number().int().default(1000), + TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(1), + TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT: z.coerce.number().int().default(1), + TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(10), + TRIGGER_DEQUEUE_SCALING_STRATEGY: z.enum(["none", "smooth", "aggressive"]).default("none"), + TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS: z.coerce.number().int().default(5000), // 5 seconds + TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS: z.coerce.number().int().default(30000), // 30 seconds + TRIGGER_DEQUEUE_SCALING_TARGET_RATIO: z.coerce.number().default(1.0), // Target ratio of queue items to consumers (1.0 = 1 item per consumer) + TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA: z.coerce.number().min(0).max(1).default(0.3), // Smooths queue length measurements (0=historical, 1=current) + TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS: z.coerce.number().int().positive().default(1000), // Batch window for metrics processing (ms) + TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR: z.coerce.number().min(0).max(1).default(0.7), // Smooths consumer count changes after EWMA (0=no scaling, 1=immediate) + + // Optional services + TRIGGER_WARM_START_URL: z.string().optional(), + TRIGGER_CHECKPOINT_URL: z.string().optional(), + TRIGGER_METADATA_URL: z.string().optional(), + + // Used by the resource monitor + RESOURCE_MONITOR_ENABLED: BoolEnv.default(false), + RESOURCE_MONITOR_OVERRIDE_CPU_TOTAL: z.coerce.number().optional(), + RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(), + + // Docker settings + DOCKER_API_VERSION: z.string().optional(), + DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64 + DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true), + DOCKER_REGISTRY_USERNAME: z.string().optional(), + DOCKER_REGISTRY_PASSWORD: z.string().optional(), + DOCKER_REGISTRY_URL: z.string().optional(), // e.g. https://index.docker.io/v1 + DOCKER_ENFORCE_MACHINE_PRESETS: BoolEnv.default(true), + DOCKER_AUTOREMOVE_EXITED_CONTAINERS: BoolEnv.default(true), + /** + * Network mode to use for all runners. Supported standard values are: `bridge`, `host`, `none`, and `container:`. + * Any other value is taken as a custom network's name to which all runners should connect to. + * + * Accepts a list of comma-separated values to attach to multiple networks. Additional networks are interpreted as network names and will be attached after container creation. + * + * **WARNING**: Specifying multiple networks will slightly increase startup times. + * + * @default "host" + */ + DOCKER_RUNNER_NETWORKS: z.string().default("host"), + + // Compute settings + COMPUTE_GATEWAY_URL: z.string().url().optional(), + COMPUTE_GATEWAY_AUTH_TOKEN: z.string().optional(), + COMPUTE_GATEWAY_TIMEOUT_MS: z.coerce.number().int().default(30_000), + COMPUTE_SNAPSHOTS_ENABLED: BoolEnv.default(false), + COMPUTE_TRACE_SPANS_ENABLED: BoolEnv.default(true), + COMPUTE_TRACE_OTLP_ENDPOINT: z.string().url().optional(), // Override for span export (derived from TRIGGER_API_URL if unset) + COMPUTE_SNAPSHOT_DELAY_MS: z.coerce.number().int().min(0).max(60_000).default(5_000), + COMPUTE_SNAPSHOT_DISPATCH_LIMIT: z.coerce.number().int().min(1).max(100).default(10), + + // Kubernetes settings + KUBERNETES_FORCE_ENABLED: BoolEnv.default(false), + KUBERNETES_NAMESPACE: z.string().default("default"), + KUBERNETES_WORKER_NODETYPE_LABEL: z.string().default("v4-worker"), + KUBERNETES_WORKER_SERVICE_ACCOUNT: z.string().optional(), // Service account for worker pods + KUBERNETES_WORKER_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN: BoolEnv.default(false), // Whether to mount SA token + KUBERNETES_WORKER_POD_ANNOTATIONS: z + .string() + .default("{}") + .transform((v, ctx) => { + try { + const parsed = JSON.parse(v); + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== "string") + ) { + throw new Error("expected JSON object of string values"); + } + return parsed as Record; + } catch (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid KUBERNETES_WORKER_POD_ANNOTATIONS: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + return z.NEVER; + } + }), // Extra annotations to apply to every worker pod (e.g. for service mesh / cert injection) + KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv + 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_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), + KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit + + // Per-preset overrides of the global KUBERNETES_CPU_REQUEST_RATIO + KUBERNETES_CPU_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), + + // Per-preset overrides of the global KUBERNETES_MEMORY_REQUEST_RATIO + KUBERNETES_MEMORY_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), + + 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 + // Large machine affinity settings - large-* presets prefer a dedicated pool + KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY: z + .string() + .trim() + .min(1) + .default("node.cluster.x-k8s.io/machinepool"), + KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE: z + .string() + .trim() + .min(1) + .default("large-machines"), + KUBERNETES_LARGE_MACHINE_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(100), + + // Project affinity settings - pods from the same project prefer the same node + KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50), + KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z + .string() + .trim() + .min(1) + .default("kubernetes.io/hostname"), + + // Schedule affinity settings - runs from schedule trees prefer a dedicated pool + KUBERNETES_SCHEDULED_RUN_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_KEY: z + .string() + .trim() + .min(1) + .default("node.cluster.x-k8s.io/machinepool"), + KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE: z + .string() + .trim() + .min(1) + .default("scheduled-runs"), + KUBERNETES_SCHEDULED_RUN_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(80), + KUBERNETES_SCHEDULED_RUN_ANTI_AFFINITY_WEIGHT: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(20), + + // Schedule toleration settings - scheduled runs tolerate taints on the dedicated pool + // Comma-separated list of tolerations in the format: key=value:effect + // For Exists operator (no value): key:effect + KUBERNETES_SCHEDULED_RUN_TOLERATIONS: z + .string() + .transform((val, ctx) => { + const tolerations = val + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => { + const colonIdx = entry.lastIndexOf(":"); + if (colonIdx === -1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid toleration format (missing effect): "${entry}"`, + }); + return z.NEVER; + } + + const effect = entry.slice(colonIdx + 1); + const validEffects = ["NoSchedule", "NoExecute", "PreferNoSchedule"]; + if (!validEffects.includes(effect)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid toleration effect "${effect}" in "${entry}". Must be one of: ${validEffects.join(", ")}`, + }); + return z.NEVER; + } + + const keyValue = entry.slice(0, colonIdx); + const eqIdx = keyValue.indexOf("="); + const key = eqIdx === -1 ? keyValue : keyValue.slice(0, eqIdx); + + if (!key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid toleration format (empty key): "${entry}"`, + }); + return z.NEVER; + } + + if (eqIdx === -1) { + return { key, operator: "Exists" as const, effect }; + } + + return { + key, + operator: "Equal" as const, + value: keyValue.slice(eqIdx + 1), + effect, + }; + }); + + return tolerations; + }) + .optional(), + + // Placement tags settings + PLACEMENT_TAGS_ENABLED: BoolEnv.default(false), + PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"), + + // Metrics + METRICS_ENABLED: BoolEnv.default(true), + METRICS_COLLECT_DEFAULTS: BoolEnv.default(true), + METRICS_HOST: z.string().default("127.0.0.1"), + METRICS_PORT: z.coerce.number().int().default(9090), + + // Pod cleaner + POD_CLEANER_ENABLED: BoolEnv.default(true), + POD_CLEANER_INTERVAL_MS: z.coerce.number().int().default(10000), + POD_CLEANER_BATCH_SIZE: z.coerce.number().int().default(500), + + // Failed pod handler + FAILED_POD_HANDLER_ENABLED: BoolEnv.default(true), + FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS: z.coerce.number().int().default(1000), + + // Debug + DEBUG: BoolEnv.default(false), + SEND_RUN_DEBUG_LOGS: BoolEnv.default(false), + }) + .superRefine((data, ctx) => { + if (data.COMPUTE_SNAPSHOTS_ENABLED && !data.TRIGGER_METADATA_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "TRIGGER_METADATA_URL is required when COMPUTE_SNAPSHOTS_ENABLED is true", + path: ["TRIGGER_METADATA_URL"], + }); + } + if (data.COMPUTE_SNAPSHOTS_ENABLED && !data.TRIGGER_WORKLOAD_API_DOMAIN) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "TRIGGER_WORKLOAD_API_DOMAIN is required when COMPUTE_SNAPSHOTS_ENABLED is true", + path: ["TRIGGER_WORKLOAD_API_DOMAIN"], + }); + } + }) + .transform((data) => ({ + ...data, + COMPUTE_TRACE_OTLP_ENDPOINT: data.COMPUTE_TRACE_OTLP_ENDPOINT ?? `${data.TRIGGER_API_URL}/otel`, + })); export const env = Env.parse(stdEnv); diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index bcd68318246..6f5913c47ca 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -14,6 +14,7 @@ import { } from "./resourceMonitor.js"; import { KubernetesWorkloadManager } from "./workloadManager/kubernetes.js"; import { DockerWorkloadManager } from "./workloadManager/docker.js"; +import { ComputeWorkloadManager } from "./workloadManager/compute.js"; import { HttpServer, CheckpointClient, @@ -25,6 +26,8 @@ import { register } from "./metrics.js"; import { PodCleaner } from "./services/podCleaner.js"; import { FailedPodHandler } from "./services/failedPodHandler.js"; import { getWorkerToken } from "./workerToken.js"; +import { OtlpTraceService } from "./services/otlpTraceService.js"; +import { extractTraceparent, getRestoreRunnerId } from "./util.js"; if (env.METRICS_COLLECT_DEFAULTS) { collectDefaultMetrics({ register }); @@ -35,18 +38,25 @@ class ManagedSupervisor { private readonly metricsServer?: HttpServer; private readonly workloadServer: WorkloadServer; private readonly workloadManager: WorkloadManager; + private readonly computeManager?: ComputeWorkloadManager; private readonly logger = new SimpleStructuredLogger("managed-supervisor"); private readonly resourceMonitor: ResourceMonitor; private readonly checkpointClient?: CheckpointClient; private readonly podCleaner?: PodCleaner; private readonly failedPodHandler?: FailedPodHandler; + private readonly tracing?: OtlpTraceService; private readonly isKubernetes = isKubernetesEnvironment(env.KUBERNETES_FORCE_ENABLED); private readonly warmStartUrl = env.TRIGGER_WARM_START_URL; constructor() { - const { TRIGGER_WORKER_TOKEN, MANAGED_WORKER_SECRET, ...envWithoutSecrets } = env; + const { + TRIGGER_WORKER_TOKEN, + MANAGED_WORKER_SECRET, + COMPUTE_GATEWAY_AUTH_TOKEN, + ...envWithoutSecrets + } = env; if (env.DEBUG) { this.logger.debug("Starting up", { envWithoutSecrets }); @@ -77,9 +87,46 @@ class ManagedSupervisor { : new DockerResourceMonitor(new Docker()) : new NoopResourceMonitor(); - this.workloadManager = this.isKubernetes - ? new KubernetesWorkloadManager(workloadManagerOptions) - : new DockerWorkloadManager(workloadManagerOptions); + if (env.COMPUTE_GATEWAY_URL) { + if (!env.TRIGGER_WORKLOAD_API_DOMAIN) { + throw new Error("TRIGGER_WORKLOAD_API_DOMAIN is not set, cannot create compute manager"); + } + + const callbackUrl = `${env.TRIGGER_WORKLOAD_API_PROTOCOL}://${env.TRIGGER_WORKLOAD_API_DOMAIN}:${env.TRIGGER_WORKLOAD_API_PORT_EXTERNAL}/api/v1/compute/snapshot-complete`; + + if (env.COMPUTE_TRACE_SPANS_ENABLED) { + this.tracing = new OtlpTraceService({ + endpointUrl: env.COMPUTE_TRACE_OTLP_ENDPOINT, + }); + } + + const computeManager = new ComputeWorkloadManager({ + ...workloadManagerOptions, + gateway: { + url: env.COMPUTE_GATEWAY_URL, + authToken: env.COMPUTE_GATEWAY_AUTH_TOKEN, + timeoutMs: env.COMPUTE_GATEWAY_TIMEOUT_MS, + }, + snapshots: { + enabled: env.COMPUTE_SNAPSHOTS_ENABLED, + delayMs: env.COMPUTE_SNAPSHOT_DELAY_MS, + dispatchLimit: env.COMPUTE_SNAPSHOT_DISPATCH_LIMIT, + callbackUrl, + }, + tracing: this.tracing, + runner: { + instanceName: env.TRIGGER_WORKER_INSTANCE_NAME, + otelEndpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT, + prettyLogs: env.RUNNER_PRETTY_LOGS, + }, + }); + this.computeManager = computeManager; + this.workloadManager = computeManager; + } else { + this.workloadManager = this.isKubernetes + ? new KubernetesWorkloadManager(workloadManagerOptions) + : new DockerWorkloadManager(workloadManagerOptions); + } if (this.isKubernetes) { if (env.POD_CLEANER_ENABLED) { @@ -182,103 +229,161 @@ class ManagedSupervisor { } this.workerSession.on("runNotification", async ({ time, run }) => { - this.logger.log("runNotification", { time, run }); + this.logger.verbose("runNotification", { time, run }); this.workloadServer.notifyRun({ run }); }); - this.workerSession.on("runQueueMessage", async ({ time, message }) => { - this.logger.log(`Received message with timestamp ${time.toLocaleString()}`, message); + this.workerSession.on( + "runQueueMessage", + async ({ time, message, dequeueResponseMs, pollingIntervalMs }) => { + this.logger.verbose(`Received message with timestamp ${time.toLocaleString()}`, message); - if (message.completedWaitpoints.length > 0) { - this.logger.debug("Run has completed waitpoints", { - runId: message.run.id, - completedWaitpoints: message.completedWaitpoints.length, - }); - } - - if (!message.image) { - this.logger.error("Run has no image", { runId: message.run.id }); - return; - } - - const { checkpoint, ...rest } = message; - - if (checkpoint) { - this.logger.log("Restoring run", { runId: message.run.id }); + if (message.completedWaitpoints.length > 0) { + this.logger.debug("Run has completed waitpoints", { + runId: message.run.id, + completedWaitpoints: message.completedWaitpoints.length, + }); + } - if (!this.checkpointClient) { - this.logger.error("No checkpoint client", { runId: message.run.id }); + if (!message.image) { + this.logger.error("Run has no image", { runId: message.run.id }); return; } - try { - const didRestore = await this.checkpointClient.restoreRun({ - runFriendlyId: message.run.friendlyId, - snapshotFriendlyId: message.snapshot.friendlyId, - body: { - ...rest, - checkpoint, - }, - }); - - if (didRestore) { - this.logger.log("Restore successful", { runId: message.run.id }); - } else { - this.logger.error("Restore failed", { runId: message.run.id }); + const { checkpoint, ...rest } = message; + + // Register trace context early so snapshot spans work for all paths + // (cold create, restore, warm start). Re-registration on restore is safe + // since dequeue always provides fresh context. + if (this.computeManager?.traceSpansEnabled) { + const traceparent = extractTraceparent(message.run.traceContext); + + if (traceparent) { + this.workloadServer.registerRunTraceContext(message.run.friendlyId, { + traceparent, + envId: message.environment.id, + orgId: message.organization.id, + projectId: message.project.id, + }); } - } catch (error) { - this.logger.error("Failed to restore run", { error }); } - return; - } + if (checkpoint) { + this.logger.debug("Restoring run", { runId: message.run.id }); + + if (this.computeManager) { + try { + const runnerId = getRestoreRunnerId(message.run.friendlyId, checkpoint.id); + + const didRestore = await this.computeManager.restore({ + snapshotId: checkpoint.location, + runnerId, + runFriendlyId: message.run.friendlyId, + snapshotFriendlyId: message.snapshot.friendlyId, + machine: message.run.machine, + traceContext: message.run.traceContext, + envId: message.environment.id, + orgId: message.organization.id, + projectId: message.project.id, + dequeuedAt: message.dequeuedAt, + }); + + if (didRestore) { + this.logger.debug("Compute restore successful", { + runId: message.run.id, + runnerId, + }); + } else { + this.logger.error("Compute restore failed", { runId: message.run.id, runnerId }); + } + } catch (error) { + this.logger.error("Failed to restore run (compute)", { error }); + } + + return; + } + + if (!this.checkpointClient) { + this.logger.error("No checkpoint client", { runId: message.run.id }); + return; + } + + try { + const didRestore = await this.checkpointClient.restoreRun({ + runFriendlyId: message.run.friendlyId, + snapshotFriendlyId: message.snapshot.friendlyId, + body: { + ...rest, + checkpoint, + }, + }); + + if (didRestore) { + this.logger.debug("Restore successful", { runId: message.run.id }); + } else { + this.logger.error("Restore failed", { runId: message.run.id }); + } + } catch (error) { + this.logger.error("Failed to restore run", { error }); + } - this.logger.log("Scheduling run", { runId: message.run.id }); + return; + } - const didWarmStart = await this.tryWarmStart(message); + this.logger.debug("Scheduling run", { runId: message.run.id }); - if (didWarmStart) { - this.logger.log("Warm start successful", { runId: message.run.id }); - return; - } + const warmStartStart = performance.now(); + const didWarmStart = await this.tryWarmStart(message); + const warmStartCheckMs = Math.round(performance.now() - warmStartStart); - try { - if (!message.deployment.friendlyId) { - // mostly a type guard, deployments always exists for deployed environments - // a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments. - throw new Error("Deployment is missing"); + if (didWarmStart) { + this.logger.debug("Warm start successful", { runId: message.run.id }); + return; } - await this.workloadManager.create({ - dequeuedAt: message.dequeuedAt, - envId: message.environment.id, - envType: message.environment.type, - image: message.image, - machine: message.run.machine, - orgId: message.organization.id, - projectId: message.project.id, - deploymentFriendlyId: message.deployment.friendlyId, - deploymentVersion: message.backgroundWorker.version, - runId: message.run.id, - runFriendlyId: message.run.friendlyId, - version: message.version, - nextAttemptNumber: message.run.attemptNumber, - snapshotId: message.snapshot.id, - snapshotFriendlyId: message.snapshot.friendlyId, - placementTags: message.placementTags, - annotations: message.run.annotations, - }); + try { + if (!message.deployment.friendlyId) { + // mostly a type guard, deployments always exists for deployed environments + // a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments. + throw new Error("Deployment is missing"); + } - // Disabled for now - // this.resourceMonitor.blockResources({ - // cpu: message.run.machine.cpu, - // memory: message.run.machine.memory, - // }); - } catch (error) { - this.logger.error("Failed to create workload", { error }); + await this.workloadManager.create({ + dequeuedAt: message.dequeuedAt, + dequeueResponseMs, + pollingIntervalMs, + warmStartCheckMs, + envId: message.environment.id, + envType: message.environment.type, + image: message.image, + machine: message.run.machine, + orgId: message.organization.id, + projectId: message.project.id, + deploymentFriendlyId: message.deployment.friendlyId, + deploymentVersion: message.backgroundWorker.version, + runId: message.run.id, + runFriendlyId: message.run.friendlyId, + version: message.version, + nextAttemptNumber: message.run.attemptNumber, + snapshotId: message.snapshot.id, + snapshotFriendlyId: message.snapshot.friendlyId, + placementTags: message.placementTags, + traceContext: message.run.traceContext, + annotations: message.run.annotations, + hasPrivateLink: message.organization.hasPrivateLink, + }); + + // Disabled for now + // this.resourceMonitor.blockResources({ + // cpu: message.run.machine.cpu, + // memory: message.run.machine.memory, + // }); + } catch (error) { + this.logger.error("Failed to create workload", { error }); + } } - }); + ); if (env.METRICS_ENABLED) { this.metricsServer = new HttpServer({ @@ -297,6 +402,8 @@ class ManagedSupervisor { host: env.TRIGGER_WORKLOAD_API_HOST_INTERNAL, workerClient: this.workerSession.httpClient, checkpointClient: this.checkpointClient, + computeManager: this.computeManager, + tracing: this.tracing, }); this.workloadServer.on("runConnected", this.onRunConnected.bind(this)); @@ -381,6 +488,7 @@ class ManagedSupervisor { async stop() { this.logger.log("Shutting down"); + await this.workloadServer.stop(); await this.workerSession.stop(); // Optional services diff --git a/apps/supervisor/src/services/computeSnapshotService.ts b/apps/supervisor/src/services/computeSnapshotService.ts new file mode 100644 index 00000000000..041e2902c75 --- /dev/null +++ b/apps/supervisor/src/services/computeSnapshotService.ts @@ -0,0 +1,242 @@ +import pLimit from "p-limit"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; +import type { SupervisorHttpClient } from "@trigger.dev/core/v3/workers"; +import { type SnapshotCallbackPayload } from "@internal/compute"; +import type { ComputeWorkloadManager } from "../workloadManager/compute.js"; +import { TimerWheel } from "./timerWheel.js"; +import type { OtlpTraceService } from "./otlpTraceService.js"; + +type DelayedSnapshot = { + runnerId: string; + runFriendlyId: string; + snapshotFriendlyId: string; +}; + +export type RunTraceContext = { + traceparent: string; + envId: string; + orgId: string; + projectId: string; +}; + +export type ComputeSnapshotServiceOptions = { + computeManager: ComputeWorkloadManager; + workerClient: SupervisorHttpClient; + tracing?: OtlpTraceService; +}; + +export class ComputeSnapshotService { + private readonly logger = new SimpleStructuredLogger("compute-snapshot-service"); + + private static readonly MAX_TRACE_CONTEXTS = 10_000; + private readonly runTraceContexts = new Map(); + private readonly timerWheel: TimerWheel; + private readonly dispatchLimit: ReturnType; + + private readonly computeManager: ComputeWorkloadManager; + private readonly workerClient: SupervisorHttpClient; + private readonly tracing?: OtlpTraceService; + + constructor(opts: ComputeSnapshotServiceOptions) { + this.computeManager = opts.computeManager; + this.workerClient = opts.workerClient; + this.tracing = opts.tracing; + + this.dispatchLimit = pLimit(this.computeManager.snapshotDispatchLimit); + this.timerWheel = new TimerWheel({ + delayMs: this.computeManager.snapshotDelayMs, + onExpire: (item) => { + this.dispatchLimit(() => this.dispatch(item.data)).catch((error) => { + this.logger.error("Snapshot dispatch failed", { + runId: item.data.runFriendlyId, + runnerId: item.data.runnerId, + error, + }); + }); + }, + }); + this.timerWheel.start(); + } + + /** Schedule a delayed snapshot for a run. Replaces any pending snapshot for the same run. */ + schedule(runFriendlyId: string, data: DelayedSnapshot) { + this.timerWheel.submit(runFriendlyId, data); + this.logger.debug("Snapshot scheduled", { + runFriendlyId, + snapshotFriendlyId: data.snapshotFriendlyId, + delayMs: this.computeManager.snapshotDelayMs, + }); + } + + /** Cancel a pending delayed snapshot. Returns true if one was cancelled. */ + cancel(runFriendlyId: string): boolean { + const cancelled = this.timerWheel.cancel(runFriendlyId); + if (cancelled) { + this.logger.debug("Snapshot cancelled", { runFriendlyId }); + } + return cancelled; + } + + /** Handle the callback from the gateway after a snapshot completes or fails. */ + async handleCallback(body: SnapshotCallbackPayload) { + const snapshotId = body.status === "completed" ? body.snapshot_id : undefined; + + this.logger.debug("Snapshot callback", { + snapshotId, + instanceId: body.instance_id, + status: body.status, + error: body.status === "failed" ? body.error : undefined, + metadata: body.metadata, + durationMs: body.duration_ms, + }); + + const runId = body.metadata?.runId; + const snapshotFriendlyId = body.metadata?.snapshotFriendlyId; + + if (!runId || !snapshotFriendlyId) { + this.logger.error("Snapshot callback missing metadata", { body }); + return { ok: false as const, status: 400 }; + } + + this.#emitSnapshotSpan(runId, body.duration_ms, snapshotId); + + if (body.status === "completed") { + const result = await this.workerClient.submitSuspendCompletion({ + runId, + snapshotId: snapshotFriendlyId, + body: { + success: true, + checkpoint: { + type: "COMPUTE", + location: body.snapshot_id, + }, + }, + }); + + if (result.success) { + this.logger.debug("Suspend completion submitted", { + runId, + instanceId: body.instance_id, + snapshotId: body.snapshot_id, + }); + } else { + this.logger.error("Failed to submit suspend completion", { + runId, + snapshotFriendlyId, + error: result.error, + }); + } + } else { + const result = await this.workerClient.submitSuspendCompletion({ + runId, + snapshotId: snapshotFriendlyId, + body: { + success: false, + error: body.error ?? "Snapshot failed", + }, + }); + + if (!result.success) { + this.logger.error("Failed to submit suspend failure", { + runId, + snapshotFriendlyId, + error: result.error, + }); + } + } + + return { ok: true as const, status: 200 }; + } + + registerTraceContext(runFriendlyId: string, ctx: RunTraceContext) { + // Evict oldest entries if we've hit the cap. This is best-effort: on a busy + // supervisor, entries for long-lived runs may be evicted before their snapshot + // callback arrives, causing those snapshot spans to be silently dropped. + // That's acceptable - trace spans are observability sugar, not correctness. + if (this.runTraceContexts.size >= ComputeSnapshotService.MAX_TRACE_CONTEXTS) { + const firstKey = this.runTraceContexts.keys().next().value; + if (firstKey) { + this.runTraceContexts.delete(firstKey); + } + } + + this.runTraceContexts.set(runFriendlyId, ctx); + } + + /** Stop the timer wheel, dropping pending snapshots. */ + stop(): string[] { + // Intentionally drop pending snapshots rather than dispatching them. The supervisor + // is shutting down, so our callback URL will be dead by the time the gateway responds. + // Runners detect the supervisor is gone and reconnect to a new instance, which + // re-triggers the snapshot workflow. Snapshots are an optimization, not a correctness + // requirement - runs continue fine without them. + const remaining = this.timerWheel.stop(); + const droppedRuns = remaining.map((item) => item.key); + + if (droppedRuns.length > 0) { + this.logger.info("Stopped, dropped pending snapshots", { count: droppedRuns.length }); + this.logger.debug("Dropped snapshot details", { runs: droppedRuns }); + } + + return droppedRuns; + } + + /** Dispatch a snapshot request to the gateway. */ + private async dispatch(snapshot: DelayedSnapshot): Promise { + const result = await this.computeManager.snapshot({ + runnerId: snapshot.runnerId, + metadata: { + runId: snapshot.runFriendlyId, + snapshotFriendlyId: snapshot.snapshotFriendlyId, + }, + }); + + if (!result) { + this.logger.error("Failed to request snapshot", { + runId: snapshot.runFriendlyId, + runnerId: snapshot.runnerId, + }); + } + } + + #emitSnapshotSpan(runFriendlyId: string, durationMs?: number, snapshotId?: string) { + if (!this.tracing) return; + + const ctx = this.runTraceContexts.get(runFriendlyId); + if (!ctx) return; + + const parsed = parseTraceparent(ctx.traceparent); + if (!parsed) return; + + const endEpochMs = Date.now(); + const startEpochMs = durationMs ? endEpochMs - durationMs : endEpochMs; + + const spanAttributes: Record = { + "compute.type": "snapshot", + }; + + if (durationMs !== undefined) { + spanAttributes["compute.total_ms"] = durationMs; + } + + if (snapshotId) { + spanAttributes["compute.snapshot_id"] = snapshotId; + } + + this.tracing.emit({ + traceId: parsed.traceId, + parentSpanId: parsed.spanId, + spanName: "compute.snapshot", + startTimeMs: startEpochMs, + endTimeMs: endEpochMs, + resourceAttributes: { + "ctx.environment.id": ctx.envId, + "ctx.organization.id": ctx.orgId, + "ctx.project.id": ctx.projectId, + "ctx.run.id": runFriendlyId, + }, + spanAttributes, + }); + } +} diff --git a/apps/supervisor/src/services/failedPodHandler.test.ts b/apps/supervisor/src/services/failedPodHandler.test.ts index d05783288ed..4dbfda16f43 100644 --- a/apps/supervisor/src/services/failedPodHandler.test.ts +++ b/apps/supervisor/src/services/failedPodHandler.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { FailedPodHandler } from "./failedPodHandler.js"; -import { K8sApi, createK8sApi } from "../clients/kubernetes.js"; +import { type K8sApi, createK8sApi } from "../clients/kubernetes.js"; import { Registry } from "prom-client"; import { setTimeout } from "timers/promises"; -describe("FailedPodHandler Integration Tests", () => { +// These tests require live K8s cluster credentials - skip by default +describe.skipIf(!process.env.K8S_INTEGRATION_TESTS)("FailedPodHandler Integration Tests", () => { const k8s = createK8sApi(); const namespace = "integration-test"; const register = new Registry(); diff --git a/apps/supervisor/src/services/failedPodHandler.ts b/apps/supervisor/src/services/failedPodHandler.ts index 07217243769..3d56c92b213 100644 --- a/apps/supervisor/src/services/failedPodHandler.ts +++ b/apps/supervisor/src/services/failedPodHandler.ts @@ -151,7 +151,7 @@ export class FailedPodHandler { } private async onPodCompleted(pod: V1Pod) { - this.logger.info("pod-completed", this.podSummary(pod)); + this.logger.debug("pod-completed", this.podSummary(pod)); this.informerEventsTotal.inc({ namespace: this.namespace, verb: "add" }); if (!pod.metadata?.name) { @@ -165,7 +165,7 @@ export class FailedPodHandler { } if (pod.metadata?.deletionTimestamp) { - this.logger.info("pod-completed: pod is being deleted", this.podSummary(pod)); + this.logger.verbose("pod-completed: pod is being deleted", this.podSummary(pod)); return; } @@ -188,7 +188,7 @@ export class FailedPodHandler { } private async onPodSucceeded(pod: V1Pod) { - this.logger.info("pod-succeeded", this.podSummary(pod)); + this.logger.debug("pod-succeeded", this.podSummary(pod)); this.processedPodsTotal.inc({ namespace: this.namespace, status: this.podStatus(pod), @@ -196,7 +196,7 @@ export class FailedPodHandler { } private async onPodFailed(pod: V1Pod) { - this.logger.info("pod-failed", this.podSummary(pod)); + this.logger.debug("pod-failed", this.podSummary(pod)); try { await this.processFailedPod(pod); @@ -208,7 +208,7 @@ export class FailedPodHandler { } private async processFailedPod(pod: V1Pod) { - this.logger.info("pod-failed: processing pod", this.podSummary(pod)); + this.logger.verbose("pod-failed: processing pod", this.podSummary(pod)); const mainContainer = pod.status?.containerStatuses?.find((c) => c.name === "run-controller"); @@ -231,7 +231,7 @@ export class FailedPodHandler { } private async deletePod(pod: V1Pod) { - this.logger.info("pod-failed: deleting pod", this.podSummary(pod)); + this.logger.verbose("pod-failed: deleting pod", this.podSummary(pod)); try { await this.k8s.core.deleteNamespacedPod({ name: pod.metadata!.name!, diff --git a/apps/supervisor/src/services/otlpTraceService.test.ts b/apps/supervisor/src/services/otlpTraceService.test.ts new file mode 100644 index 00000000000..baf3bd90306 --- /dev/null +++ b/apps/supervisor/src/services/otlpTraceService.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { buildPayload } from "./otlpTraceService.js"; + +describe("buildPayload", () => { + it("builds valid OTLP JSON with timing attributes", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + parentSpanId: "1234567890abcdef", + spanName: "compute.provision", + startTimeMs: 1000, + endTimeMs: 1250, + resourceAttributes: { + "ctx.environment.id": "env_123", + "ctx.organization.id": "org_456", + "ctx.project.id": "proj_789", + "ctx.run.id": "run_abc", + }, + spanAttributes: { + "compute.total_ms": 250, + "compute.gateway.schedule_ms": 1, + "compute.cache.image_cached": true, + }, + }); + + expect(payload.resourceSpans).toHaveLength(1); + + const resourceSpan = payload.resourceSpans[0]!; + + // $trigger=true so the webapp accepts it + const triggerAttr = resourceSpan.resource.attributes.find((a) => a.key === "$trigger"); + expect(triggerAttr).toEqual({ key: "$trigger", value: { boolValue: true } }); + + // Resource attributes + const envAttr = resourceSpan.resource.attributes.find( + (a) => a.key === "ctx.environment.id" + ); + expect(envAttr).toEqual({ + key: "ctx.environment.id", + value: { stringValue: "env_123" }, + }); + + // Span basics + const span = resourceSpan.scopeSpans[0]!.spans[0]!; + expect(span.name).toBe("compute.provision"); + expect(span.traceId).toBe("abcd1234abcd1234abcd1234abcd1234"); + expect(span.parentSpanId).toBe("1234567890abcdef"); + + // Integer attribute + const totalMs = span.attributes.find((a) => a.key === "compute.total_ms"); + expect(totalMs).toEqual({ key: "compute.total_ms", value: { intValue: 250 } }); + + // Boolean attribute + const cached = span.attributes.find((a) => a.key === "compute.cache.image_cached"); + expect(cached).toEqual({ key: "compute.cache.image_cached", value: { boolValue: true } }); + }); + + it("generates a valid 16-char hex span ID", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.spanId).toMatch(/^[0-9a-f]{16}$/); + }); + + it("converts timestamps to nanoseconds", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1250, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.startTimeUnixNano).toBe("1000000000"); + expect(span.endTimeUnixNano).toBe("1250000000"); + }); + + it("converts real epoch timestamps without precision loss", () => { + // Date.now() values exceed Number.MAX_SAFE_INTEGER when multiplied by 1e6 + const startMs = 1711929600000; // 2024-04-01T00:00:00Z + const endMs = 1711929600250; + + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: startMs, + endTimeMs: endMs, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.startTimeUnixNano).toBe("1711929600000000000"); + expect(span.endTimeUnixNano).toBe("1711929600250000000"); + }); + + it("preserves sub-millisecond precision from performance.now() arithmetic", () => { + // provisionStartEpochMs = Date.now() - (performance.now() - startMs) produces fractional ms. + // Use small epoch + fraction to avoid IEEE 754 noise in the fractional part. + const startMs = 1000.322; + const endMs = 1045.789; + + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: startMs, + endTimeMs: endMs, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.startTimeUnixNano).toBe("1000322000"); + expect(span.endTimeUnixNano).toBe("1045789000"); + }); + + it("sub-ms precision affects ordering for real epoch values", () => { + // Two spans within the same millisecond should have different nanosecond timestamps + const spanA = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "a", + startTimeMs: 1711929600000.3, + endTimeMs: 1711929600001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const spanB = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "b", + startTimeMs: 1711929600000.7, + endTimeMs: 1711929600001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const startA = BigInt(spanA.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.startTimeUnixNano); + const startB = BigInt(spanB.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.startTimeUnixNano); + // A should sort before B (both in the same ms but different sub-ms positions) + expect(startA).toBeLessThan(startB); + }); + + it("omits parentSpanId when not provided", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.parentSpanId).toBeUndefined(); + }); + + it("handles double values for non-integer numbers", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1001, + resourceAttributes: {}, + spanAttributes: { "compute.cpu": 0.25 }, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + const cpu = span.attributes.find((a) => a.key === "compute.cpu"); + expect(cpu).toEqual({ key: "compute.cpu", value: { doubleValue: 0.25 } }); + }); +}); diff --git a/apps/supervisor/src/services/otlpTraceService.ts b/apps/supervisor/src/services/otlpTraceService.ts new file mode 100644 index 00000000000..da3310711d0 --- /dev/null +++ b/apps/supervisor/src/services/otlpTraceService.ts @@ -0,0 +1,104 @@ +import { randomBytes } from "crypto"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; + +export type OtlpTraceServiceOptions = { + endpointUrl: string; + timeoutMs?: number; +}; + +export type OtlpTraceSpan = { + traceId: string; + parentSpanId?: string; + spanName: string; + startTimeMs: number; + endTimeMs: number; + resourceAttributes: Record; + spanAttributes: Record; +}; + +export class OtlpTraceService { + private readonly logger = new SimpleStructuredLogger("otlp-trace"); + + constructor(private opts: OtlpTraceServiceOptions) {} + + /** Fire-and-forget: build payload and send to the configured OTLP endpoint */ + emit(span: OtlpTraceSpan): void { + const payload = buildPayload(span); + + fetch(`${this.opts.endpointUrl}/v1/traces`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5_000), + }).catch((err) => { + this.logger.warn("failed to send compute trace span", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } +} + +// ── Payload builder (internal) ─────────────────────────────────────────────── + +/** @internal Exported for tests only */ +export function buildPayload(span: OtlpTraceSpan) { + const spanId = randomBytes(8).toString("hex"); + + return { + resourceSpans: [ + { + resource: { + attributes: [ + { key: "$trigger", value: { boolValue: true } }, + ...toOtlpAttributes(span.resourceAttributes), + ], + }, + scopeSpans: [ + { + scope: { name: "supervisor.compute" }, + spans: [ + { + traceId: span.traceId, + spanId, + parentSpanId: span.parentSpanId, + name: span.spanName, + kind: 3, // SPAN_KIND_CLIENT + startTimeUnixNano: msToNano(span.startTimeMs), + endTimeUnixNano: msToNano(span.endTimeMs), + attributes: toOtlpAttributes(span.spanAttributes), + status: { code: 1 }, // STATUS_CODE_OK + }, + ], + }, + ], + }, + ], + }; +} + +function toOtlpAttributes( + attrs: Record +): Array<{ key: string; value: Record }> { + return Object.entries(attrs).map(([key, value]) => ({ + key, + value: toOtlpValue(value), + })); +} + +function toOtlpValue(value: string | number | boolean): Record { + if (typeof value === "string") return { stringValue: value }; + if (typeof value === "boolean") return { boolValue: value }; + if (Number.isInteger(value)) return { intValue: value }; + return { doubleValue: value }; +} + +/** + * Convert epoch milliseconds to nanosecond string, preserving sub-ms precision. + * Fractional ms from performance.now() arithmetic carry meaningful microsecond + * data that affects span sort ordering when events happen within the same ms. + */ +function msToNano(ms: number): string { + const wholeMs = Math.trunc(ms); + const fracNs = Math.round((ms - wholeMs) * 1_000_000); + return String(BigInt(wholeMs) * 1_000_000n + BigInt(fracNs)); +} diff --git a/apps/supervisor/src/services/podCleaner.test.ts b/apps/supervisor/src/services/podCleaner.test.ts index 36bb5de6d1f..d6ed2bb737f 100644 --- a/apps/supervisor/src/services/podCleaner.test.ts +++ b/apps/supervisor/src/services/podCleaner.test.ts @@ -1,10 +1,11 @@ import { PodCleaner } from "./podCleaner.js"; -import { K8sApi, createK8sApi } from "../clients/kubernetes.js"; +import { type K8sApi, createK8sApi } from "../clients/kubernetes.js"; import { setTimeout } from "timers/promises"; import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { Registry } from "prom-client"; -describe("PodCleaner Integration Tests", () => { +// These tests require live K8s cluster credentials - skip by default +describe.skipIf(!process.env.K8S_INTEGRATION_TESTS)("PodCleaner Integration Tests", () => { const k8s = createK8sApi(); const namespace = "integration-test"; const register = new Registry(); diff --git a/apps/supervisor/src/services/podCleaner.ts b/apps/supervisor/src/services/podCleaner.ts index 56eaaeb88af..3ac5da293df 100644 --- a/apps/supervisor/src/services/podCleaner.ts +++ b/apps/supervisor/src/services/podCleaner.ts @@ -90,7 +90,7 @@ export class PodCleaner { status: "succeeded", }); - this.logger.info("Deleted batch of pods", { continuationToken }); + this.logger.debug("Deleted batch of pods", { continuationToken }); } catch (err) { this.logger.error("Failed to delete batch of pods", { err: err instanceof Error ? err.message : String(err), diff --git a/apps/supervisor/src/services/timerWheel.test.ts b/apps/supervisor/src/services/timerWheel.test.ts new file mode 100644 index 00000000000..3f6bb9aa19b --- /dev/null +++ b/apps/supervisor/src/services/timerWheel.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { TimerWheel } from "./timerWheel.js"; + +describe("TimerWheel", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("dispatches item after delay", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "snapshot-data"); + + // Not yet + vi.advanceTimersByTime(2900); + expect(dispatched).toEqual([]); + + // After delay + vi.advanceTimersByTime(200); + expect(dispatched).toEqual(["run-1"]); + + wheel.stop(); + }); + + it("cancels item before it fires", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "data"); + + vi.advanceTimersByTime(1000); + expect(wheel.cancel("run-1")).toBe(true); + + vi.advanceTimersByTime(5000); + expect(dispatched).toEqual([]); + expect(wheel.size).toBe(0); + + wheel.stop(); + }); + + it("cancel returns false for unknown key", () => { + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: () => {}, + }); + expect(wheel.cancel("nonexistent")).toBe(false); + }); + + it("deduplicates: resubmitting same key replaces the entry", () => { + const dispatched: { key: string; data: string }[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push({ key: item.key, data: item.data }), + }); + + wheel.start(); + wheel.submit("run-1", "old-data"); + + vi.advanceTimersByTime(1000); + wheel.submit("run-1", "new-data"); + + // Original would have fired at t=3000, but was replaced + // New one fires at t=1000+3000=4000 + vi.advanceTimersByTime(2100); + expect(dispatched).toEqual([]); + + vi.advanceTimersByTime(1000); + expect(dispatched).toEqual([{ key: "run-1", data: "new-data" }]); + + wheel.stop(); + }); + + it("handles many concurrent items", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + + for (let i = 0; i < 1000; i++) { + wheel.submit(`run-${i}`, `data-${i}`); + } + expect(wheel.size).toBe(1000); + + vi.advanceTimersByTime(3100); + expect(dispatched.length).toBe(1000); + expect(wheel.size).toBe(0); + + wheel.stop(); + }); + + it("handles items submitted at different times", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + + wheel.submit("run-1", "data"); + vi.advanceTimersByTime(1000); + wheel.submit("run-2", "data"); + vi.advanceTimersByTime(1000); + wheel.submit("run-3", "data"); + + // t=2000: nothing yet + expect(dispatched).toEqual([]); + + // t=3100: run-1 fires + vi.advanceTimersByTime(1100); + expect(dispatched).toEqual(["run-1"]); + + // t=4100: run-2 fires + vi.advanceTimersByTime(1000); + expect(dispatched).toEqual(["run-1", "run-2"]); + + // t=5100: run-3 fires + vi.advanceTimersByTime(1000); + expect(dispatched).toEqual(["run-1", "run-2", "run-3"]); + + wheel.stop(); + }); + + it("setDelay changes delay for new items only", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + + wheel.submit("run-1", "data"); // 3s delay + + vi.advanceTimersByTime(500); + wheel.setDelay(1000); + wheel.submit("run-2", "data"); // 1s delay + + // t=1500: run-2 should have fired (submitted at t=500 with 1s delay) + vi.advanceTimersByTime(1100); + expect(dispatched).toEqual(["run-2"]); + + // t=3100: run-1 fires at its original 3s delay + vi.advanceTimersByTime(1500); + expect(dispatched).toEqual(["run-2", "run-1"]); + + wheel.stop(); + }); + + it("stop returns unprocessed items", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "data-1"); + wheel.submit("run-2", "data-2"); + wheel.submit("run-3", "data-3"); + + const remaining = wheel.stop(); + expect(dispatched).toEqual([]); + expect(wheel.size).toBe(0); + expect(remaining.length).toBe(3); + expect(remaining.map((r) => r.key).sort()).toEqual(["run-1", "run-2", "run-3"]); + expect(remaining.find((r) => r.key === "run-1")?.data).toBe("data-1"); + }); + + it("after stop, new submissions are silently dropped", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.stop(); + + wheel.submit("run-late", "data"); + expect(dispatched).toEqual([]); + expect(wheel.size).toBe(0); + }); + + it("tracks size correctly through submit/cancel/dispatch", () => { + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: () => {}, + }); + + wheel.start(); + + wheel.submit("a", "data"); + wheel.submit("b", "data"); + expect(wheel.size).toBe(2); + + wheel.cancel("a"); + expect(wheel.size).toBe(1); + + vi.advanceTimersByTime(3100); + expect(wheel.size).toBe(0); + + wheel.stop(); + }); + + it("clamps delay to valid range", () => { + const dispatched: string[] = []; + + // Very small delay (should be at least 1 tick = 100ms) + const wheel = new TimerWheel({ + delayMs: 0, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "data"); + + vi.advanceTimersByTime(200); + expect(dispatched).toEqual(["run-1"]); + + wheel.stop(); + }); + + it("multiple cancel calls are safe", () => { + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: () => {}, + }); + + wheel.start(); + wheel.submit("run-1", "data"); + + expect(wheel.cancel("run-1")).toBe(true); + expect(wheel.cancel("run-1")).toBe(false); + + wheel.stop(); + }); +}); diff --git a/apps/supervisor/src/services/timerWheel.ts b/apps/supervisor/src/services/timerWheel.ts new file mode 100644 index 00000000000..9584423824d --- /dev/null +++ b/apps/supervisor/src/services/timerWheel.ts @@ -0,0 +1,160 @@ +/** + * TimerWheel implements a hashed timer wheel for efficiently managing large numbers + * of delayed operations with O(1) submit, cancel, and per-item dispatch. + * + * Used by the supervisor to delay snapshot requests so that short-lived waitpoints + * (e.g. triggerAndWait that resolves in <3s) skip the snapshot entirely. + * + * The wheel is a ring buffer of slots. A single setInterval advances a cursor. + * When the cursor reaches a slot, all items in that slot are dispatched. + * + * Fixed capacity: 600 slots at 100ms tick = 60s max delay. + */ + +const TICK_MS = 100; +const NUM_SLOTS = 600; // 60s max delay at 100ms tick + +export type TimerWheelItem = { + key: string; + data: T; +}; + +export type TimerWheelOptions = { + /** Called when an item's delay expires. */ + onExpire: (item: TimerWheelItem) => void; + /** Delay in milliseconds before items fire. Clamped to [100, 60000]. */ + delayMs: number; +}; + +type Entry = { + key: string; + data: T; + slotIndex: number; +}; + +export class TimerWheel { + private slots: Set[]; + private entries: Map>; + private cursor: number; + private intervalId: ReturnType | null; + private onExpire: (item: TimerWheelItem) => void; + private delaySlots: number; + + constructor(opts: TimerWheelOptions) { + this.slots = Array.from({ length: NUM_SLOTS }, () => new Set()); + this.entries = new Map(); + this.cursor = 0; + this.intervalId = null; + this.onExpire = opts.onExpire; + this.delaySlots = Math.max(1, Math.min(NUM_SLOTS, Math.ceil(opts.delayMs / TICK_MS))); + } + + /** Start the timer wheel. Must be called before submitting items. */ + start(): void { + if (this.intervalId) return; + this.intervalId = setInterval(() => this.tick(), TICK_MS); + // Don't hold the process open just for the timer wheel + if (this.intervalId && typeof this.intervalId === "object" && "unref" in this.intervalId) { + this.intervalId.unref(); + } + } + + /** + * Stop the timer wheel and return all unprocessed items. + * The wheel keeps running normally during graceful shutdown - call stop() + * only when you're ready to tear down. Caller decides what to do with leftovers. + */ + stop(): TimerWheelItem[] { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + const remaining: TimerWheelItem[] = []; + for (const [key, entry] of this.entries) { + remaining.push({ key, data: entry.data }); + } + + for (const slot of this.slots) { + slot.clear(); + } + this.entries.clear(); + + return remaining; + } + + /** + * Update the delay for future submissions. Already-queued items keep their original timing. + * Clamped to [TICK_MS, 60000ms]. + */ + setDelay(delayMs: number): void { + this.delaySlots = Math.max(1, Math.min(NUM_SLOTS, Math.ceil(delayMs / TICK_MS))); + } + + /** + * Submit an item to be dispatched after the configured delay. + * If an item with the same key already exists, it is replaced (dedup). + * No-op if the wheel is stopped. + */ + submit(key: string, data: T): void { + if (!this.intervalId) return; + + // Dedup: remove existing entry for this key + this.cancel(key); + + const slotIndex = (this.cursor + this.delaySlots) % NUM_SLOTS; + const entry: Entry = { key, data, slotIndex }; + + this.entries.set(key, entry); + this.slot(slotIndex).add(key); + } + + /** + * Cancel a pending item. Returns true if the item was found and removed. + */ + cancel(key: string): boolean { + const entry = this.entries.get(key); + if (!entry) return false; + + this.slot(entry.slotIndex).delete(key); + this.entries.delete(key); + return true; + } + + /** Number of pending items in the wheel. */ + get size(): number { + return this.entries.size; + } + + /** Whether the wheel is running. */ + get running(): boolean { + return this.intervalId !== null; + } + + /** Get a slot by index. The array is fully initialized so this always returns a Set. */ + private slot(index: number): Set { + const s = this.slots[index]; + if (!s) throw new Error(`TimerWheel: invalid slot index ${index}`); + return s; + } + + /** Advance the cursor and dispatch all items in the current slot. */ + private tick(): void { + this.cursor = (this.cursor + 1) % NUM_SLOTS; + const slot = this.slot(this.cursor); + + if (slot.size === 0) return; + + // Collect items to dispatch (copy keys since we mutate during iteration) + const keys = [...slot]; + slot.clear(); + + for (const key of keys) { + const entry = this.entries.get(key); + if (!entry) continue; + + this.entries.delete(key); + this.onExpire({ key, data: entry.data }); + } + } +} diff --git a/apps/supervisor/src/util.ts b/apps/supervisor/src/util.ts index 4fcda27b2aa..d14dd99bfe1 100644 --- a/apps/supervisor/src/util.ts +++ b/apps/supervisor/src/util.ts @@ -14,6 +14,18 @@ export function getDockerHostDomain() { return isMacOS || isWindows ? "host.docker.internal" : "localhost"; } +/** Extract the W3C traceparent string from an untyped trace context record */ +export function extractTraceparent(traceContext?: Record): string | undefined { + if ( + traceContext && + "traceparent" in traceContext && + typeof traceContext.traceparent === "string" + ) { + return traceContext.traceparent; + } + return undefined; +} + export function getRunnerId(runId: string, attemptNumber?: number) { const parts = ["runner", runId.replace("run_", "")]; @@ -23,3 +35,10 @@ export function getRunnerId(runId: string, attemptNumber?: number) { return parts.join("-"); } + +/** Derive a unique runnerId for a restore cycle using the checkpoint suffix */ +export function getRestoreRunnerId(runFriendlyId: string, checkpointId: string) { + const runIdShort = runFriendlyId.replace("run_", ""); + const checkpointSuffix = checkpointId.slice(-8); + return `runner-${runIdShort}-${checkpointSuffix}`; +} diff --git a/apps/supervisor/src/workloadManager/compute.ts b/apps/supervisor/src/workloadManager/compute.ts new file mode 100644 index 00000000000..1c00f33aad3 --- /dev/null +++ b/apps/supervisor/src/workloadManager/compute.ts @@ -0,0 +1,374 @@ +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; +import { flattenAttributes } from "@trigger.dev/core/v3/utils/flattenAttributes"; +import { + type WorkloadManager, + type WorkloadManagerCreateOptions, + type WorkloadManagerOptions, +} from "./types.js"; +import { ComputeClient, stripImageDigest } from "@internal/compute"; +import { extractTraceparent, getRunnerId } from "../util.js"; +import type { OtlpTraceService } from "../services/otlpTraceService.js"; +import { tryCatch } from "@trigger.dev/core"; + +type ComputeWorkloadManagerOptions = WorkloadManagerOptions & { + gateway: { + url: string; + authToken?: string; + timeoutMs: number; + }; + snapshots: { + enabled: boolean; + delayMs: number; + dispatchLimit: number; + callbackUrl: string; + }; + tracing?: OtlpTraceService; + runner: { + instanceName: string; + otelEndpoint: string; + prettyLogs: boolean; + }; +}; + +export class ComputeWorkloadManager implements WorkloadManager { + private readonly logger = new SimpleStructuredLogger("compute-workload-manager"); + private readonly compute: ComputeClient; + + constructor(private opts: ComputeWorkloadManagerOptions) { + if (opts.workloadApiDomain) { + this.logger.warn("⚠️ Custom workload API domain", { + domain: opts.workloadApiDomain, + }); + } + + this.compute = new ComputeClient({ + gatewayUrl: opts.gateway.url, + authToken: opts.gateway.authToken, + timeoutMs: opts.gateway.timeoutMs, + }); + } + + get snapshotsEnabled(): boolean { + return this.opts.snapshots.enabled; + } + + get snapshotDelayMs(): number { + return this.opts.snapshots.delayMs; + } + + get snapshotDispatchLimit(): number { + return this.opts.snapshots.dispatchLimit; + } + + get traceSpansEnabled(): boolean { + return !!this.opts.tracing; + } + + async create(opts: WorkloadManagerCreateOptions) { + const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); + + const envVars: Record = { + OTEL_EXPORTER_OTLP_ENDPOINT: this.opts.runner.otelEndpoint, + TRIGGER_DEQUEUED_AT_MS: String(opts.dequeuedAt.getTime()), + TRIGGER_POD_SCHEDULED_AT_MS: String(Date.now()), + TRIGGER_ENV_ID: opts.envId, + TRIGGER_DEPLOYMENT_ID: opts.deploymentFriendlyId, + TRIGGER_DEPLOYMENT_VERSION: opts.deploymentVersion, + TRIGGER_RUN_ID: opts.runFriendlyId, + TRIGGER_SNAPSHOT_ID: opts.snapshotFriendlyId, + TRIGGER_SUPERVISOR_API_PROTOCOL: this.opts.workloadApiProtocol, + TRIGGER_SUPERVISOR_API_PORT: String(this.opts.workloadApiPort), + TRIGGER_SUPERVISOR_API_DOMAIN: this.opts.workloadApiDomain ?? "", + TRIGGER_WORKER_INSTANCE_NAME: this.opts.runner.instanceName, + TRIGGER_RUNNER_ID: runnerId, + TRIGGER_MACHINE_CPU: String(opts.machine.cpu), + TRIGGER_MACHINE_MEMORY: String(opts.machine.memory), + PRETTY_LOGS: String(this.opts.runner.prettyLogs), + }; + + if (this.opts.warmStartUrl) { + envVars.TRIGGER_WARM_START_URL = this.opts.warmStartUrl; + } + + if (this.snapshotsEnabled && this.opts.metadataUrl) { + envVars.TRIGGER_METADATA_URL = this.opts.metadataUrl; + } + + if (this.opts.heartbeatIntervalSeconds) { + envVars.TRIGGER_HEARTBEAT_INTERVAL_SECONDS = String(this.opts.heartbeatIntervalSeconds); + } + + if (this.opts.snapshotPollIntervalSeconds) { + envVars.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS = String( + this.opts.snapshotPollIntervalSeconds + ); + } + + if (this.opts.additionalEnvVars) { + Object.assign(envVars, this.opts.additionalEnvVars); + } + + // Strip image digest - resolve by tag, not digest + const imageRef = stripImageDigest(opts.image); + + // Wide event: single canonical log line emitted in finally + const event: Record = { + // High-cardinality identifiers + runId: opts.runFriendlyId, + runnerId, + envId: opts.envId, + envType: opts.envType, + orgId: opts.orgId, + projectId: opts.projectId, + deploymentVersion: opts.deploymentVersion, + machine: opts.machine.name, + // Environment + instanceName: this.opts.runner.instanceName, + // Supervisor timing + dequeueResponseMs: opts.dequeueResponseMs, + pollingIntervalMs: opts.pollingIntervalMs, + warmStartCheckMs: opts.warmStartCheckMs, + // Request + image: imageRef, + }; + + const startMs = performance.now(); + + try { + const [error, data] = await tryCatch( + this.compute.instances.create({ + name: runnerId, + image: imageRef, + env: envVars, + cpu: opts.machine.cpu, + memory_gb: opts.machine.memory, + metadata: { + runId: opts.runFriendlyId, + envId: opts.envId, + envType: opts.envType, + orgId: opts.orgId, + projectId: opts.projectId, + deploymentVersion: opts.deploymentVersion, + machine: opts.machine.name, + }, + }) + ); + + if (error) { + event.error = error instanceof Error ? error.message : String(error); + event.errorType = + error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "fetch"; + // Intentional: errors are captured in the wide event, not thrown. This matches + // the Docker/K8s managers. The run will eventually time out if scheduling fails. + return; + } + + event.instanceId = data.id; + event.ok = true; + + // Parse timing data from compute response (optional - requires gateway timing flag) + if (data._timing) { + event.timing = data._timing; + } + + this.#emitProvisionSpan(opts, startMs, data._timing); + } finally { + event.durationMs = Math.round(performance.now() - startMs); + event.ok ??= false; + this.logger.debug("create instance", event); + } + } + + async snapshot(opts: { runnerId: string; metadata: Record }): Promise { + const [error] = await tryCatch( + this.compute.instances.snapshot(opts.runnerId, { + callback: { + url: this.opts.snapshots.callbackUrl, + metadata: opts.metadata, + }, + }) + ); + + if (error) { + this.logger.error("snapshot request failed", { + runnerId: opts.runnerId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + + this.logger.debug("snapshot request accepted", { runnerId: opts.runnerId }); + return true; + } + + async deleteInstance(runnerId: string): Promise { + const [error] = await tryCatch(this.compute.instances.delete(runnerId)); + + if (error) { + this.logger.error("delete instance failed", { + runnerId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + + this.logger.debug("delete instance success", { runnerId }); + return true; + } + + #emitProvisionSpan(opts: WorkloadManagerCreateOptions, startMs: number, timing?: unknown) { + if (!this.traceSpansEnabled) return; + + const parsed = parseTraceparent(extractTraceparent(opts.traceContext)); + if (!parsed) return; + + const endMs = performance.now(); + const now = Date.now(); + const provisionStartEpochMs = now - (endMs - startMs); + const endEpochMs = now; + + // Span starts at dequeue time so events (dequeue) render in the thin-line section + // before "Started". The actual provision call time is in provisionStartEpochMs. + // Subtract 1ms so compute span always sorts before the attempt span (same dequeue time) + const startEpochMs = opts.dequeuedAt.getTime() - 1; + + const spanAttributes: Record = { + "compute.type": "create", + "compute.provision_start_ms": provisionStartEpochMs, + ...(timing + ? (flattenAttributes(timing, "compute") as Record) + : {}), + }; + + if (opts.dequeueResponseMs !== undefined) { + spanAttributes["supervisor.dequeue_response_ms"] = opts.dequeueResponseMs; + } + if (opts.warmStartCheckMs !== undefined) { + spanAttributes["supervisor.warm_start_check_ms"] = opts.warmStartCheckMs; + } + + // Use the platform API URL, not the runner OTLP endpoint (which may be a VM gateway IP) + this.opts.tracing?.emit({ + traceId: parsed.traceId, + parentSpanId: parsed.spanId, + spanName: "compute.provision", + startTimeMs: startEpochMs, + endTimeMs: endEpochMs, + resourceAttributes: { + "ctx.environment.id": opts.envId, + "ctx.organization.id": opts.orgId, + "ctx.project.id": opts.projectId, + "ctx.run.id": opts.runFriendlyId, + }, + spanAttributes, + }); + } + + async restore(opts: { + snapshotId: string; + runnerId: string; + runFriendlyId: string; + snapshotFriendlyId: string; + machine: { cpu: number; memory: number }; + // Trace context for OTel span emission + traceContext?: Record; + envId?: string; + orgId?: string; + projectId?: string; + dequeuedAt?: Date; + }): Promise { + const metadata: Record = { + TRIGGER_RUNNER_ID: opts.runnerId, + TRIGGER_RUN_ID: opts.runFriendlyId, + TRIGGER_SNAPSHOT_ID: opts.snapshotFriendlyId, + TRIGGER_SUPERVISOR_API_PROTOCOL: this.opts.workloadApiProtocol, + TRIGGER_SUPERVISOR_API_PORT: String(this.opts.workloadApiPort), + TRIGGER_SUPERVISOR_API_DOMAIN: this.opts.workloadApiDomain ?? "", + TRIGGER_WORKER_INSTANCE_NAME: this.opts.runner.instanceName, + }; + + this.logger.verbose("restore request body", { + snapshotId: opts.snapshotId, + runnerId: opts.runnerId, + }); + + const startMs = performance.now(); + + const [error] = await tryCatch( + this.compute.snapshots.restore(opts.snapshotId, { + name: opts.runnerId, + metadata, + cpu: opts.machine.cpu, + memory_gb: opts.machine.memory, + }) + ); + + const durationMs = Math.round(performance.now() - startMs); + + if (error) { + this.logger.error("restore request failed", { + snapshotId: opts.snapshotId, + runnerId: opts.runnerId, + error: error instanceof Error ? error.message : String(error), + durationMs, + }); + return false; + } + + this.logger.debug("restore request success", { + snapshotId: opts.snapshotId, + runnerId: opts.runnerId, + durationMs, + }); + + this.#emitRestoreSpan(opts, startMs); + + return true; + } + + #emitRestoreSpan( + opts: { + snapshotId: string; + runnerId: string; + runFriendlyId: string; + traceContext?: Record; + envId?: string; + orgId?: string; + projectId?: string; + dequeuedAt?: Date; + }, + startMs: number + ) { + if (!this.traceSpansEnabled) return; + + const parsed = parseTraceparent(extractTraceparent(opts.traceContext)); + if (!parsed || !opts.envId || !opts.orgId || !opts.projectId) return; + + const endMs = performance.now(); + const now = Date.now(); + const restoreStartEpochMs = now - (endMs - startMs); + const endEpochMs = now; + + // Subtract 1ms so restore span always sorts before the attempt span + const startEpochMs = (opts.dequeuedAt?.getTime() ?? restoreStartEpochMs) - 1; + + this.opts.tracing?.emit({ + traceId: parsed.traceId, + parentSpanId: parsed.spanId, + spanName: "compute.restore", + startTimeMs: startEpochMs, + endTimeMs: endEpochMs, + resourceAttributes: { + "ctx.environment.id": opts.envId, + "ctx.organization.id": opts.orgId, + "ctx.project.id": opts.projectId, + "ctx.run.id": opts.runFriendlyId, + }, + spanAttributes: { + "compute.type": "restore", + "compute.snapshot_id": opts.snapshotId, + }, + }); + } +} diff --git a/apps/supervisor/src/workloadManager/docker.ts b/apps/supervisor/src/workloadManager/docker.ts index d6651d325a2..66405df9ba5 100644 --- a/apps/supervisor/src/workloadManager/docker.ts +++ b/apps/supervisor/src/workloadManager/docker.ts @@ -62,7 +62,7 @@ export class DockerWorkloadManager implements WorkloadManager { } async create(opts: WorkloadManagerCreateOptions) { - this.logger.log("create()", { opts }); + this.logger.verbose("create()", { opts }); const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts index 0aa5b170126..055a115fccd 100644 --- a/apps/supervisor/src/workloadManager/kubernetes.ts +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -100,7 +100,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { } async create(opts: WorkloadManagerCreateOptions) { - this.logger.log("[KubernetesWorkloadManager] Creating container", { opts }); + this.logger.verbose("[KubernetesWorkloadManager] Creating container", { opts }); const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); @@ -117,10 +117,14 @@ export class KubernetesWorkloadManager implements WorkloadManager { "app.kubernetes.io/part-of": "trigger-worker", "app.kubernetes.io/component": "create", }, + annotations: { + ...env.KUBERNETES_WORKER_POD_ANNOTATIONS, + }, }, spec: { ...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags), affinity: this.#getAffinity(opts), + tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)), terminationGracePeriodSeconds: 60 * 60, containers: [ { @@ -132,6 +136,14 @@ export class KubernetesWorkloadManager implements WorkloadManager { }, ], resources: this.#getResourcesForMachine(opts.machine), + securityContext: { + runAsNonRoot: true, + runAsUser: 1000, + allowPrivilegeEscalation: false, + capabilities: { + drop: ["ALL"], + }, + }, env: [ { name: "TRIGGER_DEQUEUED_AT_MS", @@ -306,13 +318,23 @@ export class KubernetesWorkloadManager implements WorkloadManager { get #defaultPodSpec(): Omit { return { restartPolicy: "Never", - automountServiceAccountToken: false, + // Explicit control over service account token mounting (defaults to false for security) + automountServiceAccountToken: env.KUBERNETES_WORKER_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN, imagePullSecrets: this.getImagePullSecrets(), ...(env.KUBERNETES_SCHEDULER_NAME ? { schedulerName: env.KUBERNETES_SCHEDULER_NAME, } : {}), + // Optionally specify a service account for the worker pods + ...(env.KUBERNETES_WORKER_SERVICE_ACCOUNT + ? { serviceAccountName: env.KUBERNETES_WORKER_SERVICE_ACCOUNT } + : {}), + securityContext: { + runAsNonRoot: true, + runAsUser: 1000, + fsGroup: 1000, + }, ...(env.KUBERNETES_WORKER_NODETYPE_LABEL ? { nodeSelector: { @@ -340,7 +362,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { } #getSharedLabels(opts: WorkloadManagerCreateOptions): Record { - return { + const labels: Record = { env: opts.envId, envtype: this.#envTypeToLabelValue(opts.envType), org: opts.orgId, @@ -352,6 +374,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { // and pool-level scheduling decisions; finer-grained source breakdowns live in run annotations. scheduled: String(this.#isScheduledRun(opts)), }; + + // Add privatelink label for CiliumNetworkPolicy matching + if (opts.hasPrivateLink) { + labels.privatelink = opts.orgId; + } + + return labels; } #getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities { @@ -478,7 +507,7 @@ export class KubernetesWorkloadManager implements WorkloadManager { } #getScheduleNodeAffinityRules(isScheduledRun: boolean): k8s.V1NodeAffinity | undefined { - if (!env.KUBERNETES_SCHEDULE_AFFINITY_ENABLED || !env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE) { + if (!env.KUBERNETES_SCHEDULED_RUN_AFFINITY_ENABLED || !env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE) { return undefined; } @@ -487,13 +516,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { return { preferredDuringSchedulingIgnoredDuringExecution: [ { - weight: env.KUBERNETES_SCHEDULE_AFFINITY_WEIGHT, + weight: env.KUBERNETES_SCHEDULED_RUN_AFFINITY_WEIGHT, preference: { matchExpressions: [ { - key: env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_KEY, + key: env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_KEY, operator: "In", - values: [env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE], + values: [env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE], }, ], }, @@ -506,13 +535,13 @@ export class KubernetesWorkloadManager implements WorkloadManager { return { preferredDuringSchedulingIgnoredDuringExecution: [ { - weight: env.KUBERNETES_SCHEDULE_ANTI_AFFINITY_WEIGHT, + weight: env.KUBERNETES_SCHEDULED_RUN_ANTI_AFFINITY_WEIGHT, preference: { matchExpressions: [ { - key: env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_KEY, + key: env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_KEY, operator: "NotIn", - values: [env.KUBERNETES_SCHEDULE_AFFINITY_POOL_LABEL_VALUE], + values: [env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE], }, ], }, @@ -521,6 +550,14 @@ export class KubernetesWorkloadManager implements WorkloadManager { }; } + #getScheduleTolerations(isScheduledRun: boolean): k8s.V1Toleration[] | undefined { + if (!isScheduledRun || !env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS?.length) { + return undefined; + } + + return env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS; + } + #getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined { if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) { return undefined; diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts index fca27b249a2..86199afe469 100644 --- a/apps/supervisor/src/workloadManager/types.ts +++ b/apps/supervisor/src/workloadManager/types.ts @@ -24,6 +24,10 @@ export interface WorkloadManagerCreateOptions { nextAttemptNumber?: number; dequeuedAt: Date; placementTags?: PlacementTag[]; + // Timing context (populated by supervisor handler, included in wide event) + dequeueResponseMs?: number; + pollingIntervalMs?: number; + warmStartCheckMs?: number; // identifiers envId: string; envType: EnvironmentType; @@ -35,5 +39,9 @@ export interface WorkloadManagerCreateOptions { runFriendlyId: string; snapshotId: string; snapshotFriendlyId: string; + // Trace context for OTel span emission (W3C format: { traceparent: "00-...", tracestate?: "..." }) + traceContext?: Record; annotations?: RunAnnotations; + // private networking + hasPrivateLink?: boolean; } diff --git a/apps/supervisor/src/workloadServer/index.ts b/apps/supervisor/src/workloadServer/index.ts index 35d53d36099..bd38cc8700f 100644 --- a/apps/supervisor/src/workloadServer/index.ts +++ b/apps/supervisor/src/workloadServer/index.ts @@ -24,6 +24,13 @@ import { HttpServer, type CheckpointClient } from "@trigger.dev/core/v3/serverOn import { type IncomingMessage } from "node:http"; import { register } from "../metrics.js"; import { env } from "../env.js"; +import { SnapshotCallbackPayloadSchema } from "@internal/compute"; +import { + ComputeSnapshotService, + type RunTraceContext, +} from "../services/computeSnapshotService.js"; +import type { ComputeWorkloadManager } from "../workloadManager/compute.js"; +import type { OtlpTraceService } from "../services/otlpTraceService.js"; // Use the official export when upgrading to socket.io@4.8.0 interface DefaultEventsMap { @@ -58,10 +65,13 @@ type WorkloadServerOptions = { host?: string; workerClient: SupervisorHttpClient; checkpointClient?: CheckpointClient; + computeManager?: ComputeWorkloadManager; + tracing?: OtlpTraceService; }; export class WorkloadServer extends EventEmitter { private checkpointClient?: CheckpointClient; + private readonly snapshotService?: ComputeSnapshotService; private readonly logger = new SimpleStructuredLogger("workload-server"); @@ -94,6 +104,14 @@ export class WorkloadServer extends EventEmitter { this.workerClient = opts.workerClient; this.checkpointClient = opts.checkpointClient; + if (opts.computeManager?.snapshotsEnabled) { + this.snapshotService = new ComputeSnapshotService({ + computeManager: opts.computeManager, + workerClient: opts.workerClient, + tracing: opts.tracing, + }); + } + this.httpServer = this.createHttpServer({ host, port }); this.websocketServer = this.createWebsocketServer(); } @@ -229,13 +247,28 @@ export class WorkloadServer extends EventEmitter { { paramsSchema: WorkloadActionParams, handler: async ({ reply, params, req }) => { - this.logger.debug("Suspend request", { params, headers: req.headers }); + const runnerId = this.runnerIdFromRequest(req); + const deploymentVersion = this.deploymentVersionFromRequest(req); + const projectRef = this.projectRefFromRequest(req); - if (!this.checkpointClient) { + this.logger.debug("Suspend request", { + params, + runnerId, + deploymentVersion, + projectRef, + }); + + if (!runnerId || !deploymentVersion || !projectRef) { + this.logger.error("Invalid headers for suspend request", { + ...params, + runnerId, + deploymentVersion, + projectRef, + }); reply.json( { ok: false, - error: "Checkpoints disabled", + error: "Invalid headers", } satisfies WorkloadSuspendRunResponseBody, false, 400 @@ -243,19 +276,25 @@ export class WorkloadServer extends EventEmitter { return; } - const runnerId = this.runnerIdFromRequest(req); - const deploymentVersion = this.deploymentVersionFromRequest(req); - const projectRef = this.projectRefFromRequest(req); + if (this.snapshotService) { + // Compute mode: delay snapshot to avoid wasted work on short-lived waitpoints. + // If the run continues before the delay expires, the snapshot is cancelled. + reply.json({ ok: true } satisfies WorkloadSuspendRunResponseBody, false, 202); - if (!runnerId || !deploymentVersion || !projectRef) { - this.logger.error("Invalid headers for suspend request", { - ...params, - headers: req.headers, + this.snapshotService.schedule(params.runFriendlyId, { + runnerId, + runFriendlyId: params.runFriendlyId, + snapshotFriendlyId: params.snapshotFriendlyId, }); + + return; + } + + if (!this.checkpointClient) { reply.json( { ok: false, - error: "Invalid headers", + error: "Checkpoints disabled", } satisfies WorkloadSuspendRunResponseBody, false, 400 @@ -298,6 +337,9 @@ export class WorkloadServer extends EventEmitter { handler: async ({ req, reply, params }) => { this.logger.debug("Run continuation request", { params }); + // Cancel any pending delayed snapshot for this run + this.snapshotService?.cancel(params.runFriendlyId); + const continuationResult = await this.workerClient.continueRunExecution( params.runFriendlyId, params.snapshotFriendlyId, @@ -394,6 +436,20 @@ export class WorkloadServer extends EventEmitter { }); } + // Compute snapshot callback endpoint + httpServer.route("/api/v1/compute/snapshot-complete", "POST", { + bodySchema: SnapshotCallbackPayloadSchema, + handler: async ({ reply, body }) => { + if (!this.snapshotService) { + reply.empty(404); + return; + } + + const result = await this.snapshotService.handleCallback(body); + reply.empty(result.status); + }, + }); + return httpServer; } @@ -408,7 +464,7 @@ export class WorkloadServer extends EventEmitter { > = io.of("/workload"); websocketServer.on("disconnect", (socket) => { - this.logger.log("[WS] disconnect", socket.id); + this.logger.verbose("[WS] disconnect", socket.id); }); websocketServer.use(async (socket, next) => { const setSocketDataFromHeader = ( @@ -490,7 +546,7 @@ export class WorkloadServer extends EventEmitter { socket.data.runFriendlyId = undefined; }; - socketLogger.log("wsServer socket connected", { ...getSocketMetadata() }); + socketLogger.debug("wsServer socket connected", { ...getSocketMetadata() }); // FIXME: where does this get set? if (socket.data.runFriendlyId) { @@ -498,7 +554,11 @@ export class WorkloadServer extends EventEmitter { } socket.on("disconnecting", (reason, description) => { - socketLogger.log("Socket disconnecting", { ...getSocketMetadata(), reason, description }); + socketLogger.verbose("Socket disconnecting", { + ...getSocketMetadata(), + reason, + description, + }); if (socket.data.runFriendlyId) { runDisconnected(socket.data.runFriendlyId); @@ -506,7 +566,7 @@ export class WorkloadServer extends EventEmitter { }); socket.on("disconnect", (reason, description) => { - socketLogger.log("Socket disconnected", { ...getSocketMetadata(), reason, description }); + socketLogger.debug("Socket disconnected", { ...getSocketMetadata(), reason, description }); }); socket.on("error", (error) => { @@ -527,7 +587,7 @@ export class WorkloadServer extends EventEmitter { ...message, }); - log.log("Handling run:start"); + log.debug("Handling run:start"); try { runConnected(message.run.friendlyId); @@ -543,10 +603,13 @@ export class WorkloadServer extends EventEmitter { ...message, }); - log.log("Handling run:stop"); + log.debug("Handling run:stop"); try { runDisconnected(message.run.friendlyId); + // Don't delete trace context here - run:stop fires after each snapshot/shutdown + // but the run may be restored on a new VM and snapshot again. Trace context is + // re-populated on dequeue, and entries are small (4 strings per run). } catch (error) { log.error("run:stop error", { error }); } @@ -588,11 +651,16 @@ export class WorkloadServer extends EventEmitter { } } + registerRunTraceContext(runFriendlyId: string, ctx: RunTraceContext) { + this.snapshotService?.registerTraceContext(runFriendlyId, ctx); + } + async start() { await this.httpServer.start(); } async stop() { + this.snapshotService?.stop(); await this.httpServer.stop(); } } diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index 2acd5c02d77..b0f5e09b829 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -98,3 +98,23 @@ The `app/v3/` directory name is misleading - most code is actively used by V2. O - `app/v3/sharedSocketConnection.ts` Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths. + +## Performance: Trigger Hot Path + +The `triggerTask.server.ts` service is the **highest-throughput code path** in the system. Every API trigger call goes through it. Keep it fast: + +- **Do NOT add database queries** to `triggerTask.server.ts` or `batchTriggerV3.server.ts`. Task defaults (TTL, etc.) are resolved via `backgroundWorkerTask.findFirst()` in the queue concern (`queues.server.ts`) - one query per request, in mutually exclusive branches depending on locked/non-locked path. Piggyback on the existing query instead of adding new ones. +- **Two-stage resolution pattern**: Task metadata is resolved in two stages by design: + 1. **Trigger time** (`triggerTask.server.ts`): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides. + 2. **Dequeue time** (`dequeueSystem.ts`): Full `BackgroundWorkerTask` is loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults. +- If you need to add a new task-level default, **add it to the existing `select` clause** in the `backgroundWorkerTask.findFirst()` query — do NOT add a second query. If the default doesn't need to be known at trigger time, resolve it at dequeue time instead. +- Batch triggers (`batchTriggerV3.server.ts`) follow the same pattern — keep batch paths equally fast. + +## Prisma Query Patterns + +- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues. + +## React Patterns + +- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations. +- Use named constants for sentinel/placeholder values (e.g. `const UNSET_VALUE = "__unset__"`) instead of raw string literals scattered across comparisons. diff --git a/apps/webapp/app/assets/icons/SlackMonoIcon.tsx b/apps/webapp/app/assets/icons/SlackMonoIcon.tsx new file mode 100644 index 00000000000..666393a229d --- /dev/null +++ b/apps/webapp/app/assets/icons/SlackMonoIcon.tsx @@ -0,0 +1,10 @@ +export function SlackMonoIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/components/BackgroundWrapper.tsx b/apps/webapp/app/components/BackgroundWrapper.tsx index ecff3af6dd4..aaf06d56aaf 100644 --- a/apps/webapp/app/components/BackgroundWrapper.tsx +++ b/apps/webapp/app/components/BackgroundWrapper.tsx @@ -5,10 +5,9 @@ import blurredDashboardBackgroundTable from "~/assets/images/blurred-dashboard-b export function BackgroundWrapper({ children }: { children: ReactNode }) { return ( -

- {/* Left menu top background - fixed width 260px, maintains aspect ratio */} +
- {/* Left menu bottom background - fixed width 260px, maintains aspect ratio */}
- {/* Right table background - fixed width 2000px, positioned next to menu */}
- {/* Content layer */}
{children}
); diff --git a/apps/webapp/app/components/GitMetadata.tsx b/apps/webapp/app/components/GitMetadata.tsx index efe3fb0efb7..fb53ee6bfea 100644 --- a/apps/webapp/app/components/GitMetadata.tsx +++ b/apps/webapp/app/components/GitMetadata.tsx @@ -25,9 +25,10 @@ export function GitMetadataBranch({ } + leadingIconClassName="group-hover/table-row:text-text-bright" iconSpacing="gap-x-1" to={git.branchUrl} - className="pl-1" + className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright" > {git.branchName} @@ -49,8 +50,9 @@ export function GitMetadataCommit({ variant="minimal/small" to={git.commitUrl} LeadingIcon={} + leadingIconClassName="group-hover/table-row:text-text-bright" iconSpacing="gap-x-1" - className="pl-1" + className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright" > {`${git.shortSha} / ${git.commitMessage}`} @@ -74,8 +76,9 @@ export function GitMetadataPullRequest({ variant="minimal/small" to={git.pullRequestUrl} LeadingIcon={} + leadingIconClassName="group-hover/table-row:text-text-bright" iconSpacing="gap-x-1" - className="pl-1" + className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright" > #{git.pullRequestNumber} {git.pullRequestTitle} diff --git a/apps/webapp/app/components/LoginPageLayout.tsx b/apps/webapp/app/components/LoginPageLayout.tsx index d9ac7ceb4d7..3e42cd6894f 100644 --- a/apps/webapp/app/components/LoginPageLayout.tsx +++ b/apps/webapp/app/components/LoginPageLayout.tsx @@ -46,10 +46,10 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) { }, []); return ( -
-
-
-
+
+
+
+
@@ -63,12 +63,12 @@ export function LoginPageLayout({ children }: { children: React.ReactNode }) {
{children}
- Having login issues? Email us{" "} + Having login issues? Email us{" "} or ask us in Discord
-
+
{randomQuote?.quote} diff --git a/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx new file mode 100644 index 00000000000..df8669d36dd --- /dev/null +++ b/apps/webapp/app/components/admin/FeatureFlagsDialog.tsx @@ -0,0 +1,290 @@ +import { useFetcher } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import stableStringify from "json-stable-stringify"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogDescription, + DialogFooter, +} from "~/components/primitives/Dialog"; +import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { LockClosedIcon } from "@heroicons/react/20/solid"; +import { CheckboxWithLabel } from "~/components/primitives/Checkbox"; +import { cn } from "~/utils/cn"; +import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags"; +import { + UNSET_VALUE, + BooleanControl, + EnumControl, + StringControl, + WorkerGroupControl, + type WorkerGroup, +} from "./FlagControls"; + +type LoaderData = { + org: { id: string; title: string; slug: string }; + orgFlags: Record; + globalFlags: Record; + controlTypes: Record; + workerGroupName?: string; + workerGroups?: WorkerGroup[]; + isManagedCloud?: boolean; +}; + +type ActionData = { + success?: boolean; + error?: string; +}; + +type FeatureFlagsDialogProps = { + orgId: string | null; + orgTitle: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function FeatureFlagsDialog({ + orgId, + orgTitle, + open, + onOpenChange, +}: FeatureFlagsDialogProps) { + const loadFetcher = useFetcher(); + const saveFetcher = useFetcher(); + + const [overrides, setOverrides] = useState>({}); + const [initialOverrides, setInitialOverrides] = useState>({}); + const [saveError, setSaveError] = useState(null); + const [unlocked, setUnlocked] = useState(false); + + const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key); + + useEffect(() => { + if (open && orgId) { + setSaveError(null); + setOverrides({}); + setInitialOverrides({}); + loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`); + } + }, [open, orgId]); + + useEffect(() => { + if (loadFetcher.data) { + const loaded = loadFetcher.data.orgFlags ?? {}; + setOverrides({ ...loaded }); + setInitialOverrides({ ...loaded }); + } + }, [loadFetcher.data]); + + useEffect(() => { + if (saveFetcher.data?.success) { + onOpenChange(false); + } else if (saveFetcher.data?.error) { + setSaveError(saveFetcher.data.error); + } + }, [saveFetcher.data]); + + const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides); + + const setFlagValue = (key: string, value: unknown) => { + setOverrides((prev) => ({ ...prev, [key]: value })); + }; + + const unsetFlag = (key: string) => { + setOverrides((prev) => { + const next = { ...prev }; + delete next[key]; + return next; + }); + }; + + const handleSave = () => { + if (!orgId) return; + const body = Object.keys(overrides).length === 0 ? null : overrides; + saveFetcher.submit(JSON.stringify(body), { + method: "POST", + action: `/admin/api/v2/orgs/${orgId}/feature-flags`, + encType: "application/json", + }); + }; + + const data = loadFetcher.data; + const isLoading = loadFetcher.state === "loading"; + const isSaving = saveFetcher.state === "submitting"; + + const jsonPreview = + Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2); + + const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : []; + + return ( + + + Feature flags - {orgTitle} + + Org-level overrides. Unset flags inherit from global defaults. + + + {data && ( +
+ +
+ )} + +
+ {isLoading ? ( +
Loading flags...
+ ) : data ? ( +
+ {sortedFlagKeys.map((key) => { + const control = data.controlTypes[key]; + const locked = isLocked(key); + const globalValue = data.globalFlags[key as keyof typeof data.globalFlags]; + const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId; + const globalDisplay = + isWorkerGroup && data.workerGroupName && globalValue !== undefined + ? `${data.workerGroupName} (${String(globalValue).slice(0, 8)}...)` + : globalValue !== undefined + ? String(globalValue) + : "unset"; + + if (locked) { + return ( +
+
+
{key}
+
global: {globalDisplay}
+
+ +
+ ); + } + + const isOverridden = key in overrides; + + return ( +
+
+
+ {key} +
+
global: {globalDisplay}
+
+ +
+ + + {isWorkerGroup && data.workerGroups ? ( + { + if (val === UNSET_VALUE) { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + ) : control.type === "boolean" ? ( + setFlagValue(key, val)} + dimmed={!isOverridden} + /> + ) : control.type === "enum" ? ( + { + if (val === UNSET_VALUE) { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + ) : control.type === "string" ? ( + { + if (val === "") { + unsetFlag(key); + } else { + setFlagValue(key, val); + } + }} + dimmed={!isOverridden} + /> + ) : null} +
+
+ ); + })} +
+ ) : null} +
+ + {data && ( +
+ + Preview JSON + +
+              {jsonPreview}
+            
+
+ )} + + {saveError && {saveError}} + + + + + +
+
+ ); +} diff --git a/apps/webapp/app/components/admin/FlagControls.tsx b/apps/webapp/app/components/admin/FlagControls.tsx new file mode 100644 index 00000000000..b08f925dd90 --- /dev/null +++ b/apps/webapp/app/components/admin/FlagControls.tsx @@ -0,0 +1,120 @@ +import { Switch } from "~/components/primitives/Switch"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { Input } from "~/components/primitives/Input"; +import { cn } from "~/utils/cn"; + +export const UNSET_VALUE = "__unset__"; + +export function BooleanControl({ + value, + onChange, + dimmed, +}: { + value: boolean | undefined; + onChange: (val: boolean) => void; + dimmed: boolean; +}) { + return ( + + ); +} + +export function EnumControl({ + value, + options, + onChange, + dimmed, +}: { + value: string | undefined; + options: string[]; + onChange: (val: string) => void; + dimmed: boolean; +}) { + const items = [UNSET_VALUE, ...options]; + + return ( + + ); +} + +export type WorkerGroup = { id: string; name: string }; + +export function WorkerGroupControl({ + value, + workerGroups, + onChange, + dimmed, +}: { + value: string | undefined; + workerGroups: WorkerGroup[]; + onChange: (val: string) => void; + dimmed: boolean; +}) { + const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)]; + + return ( + + ); +} + +export function StringControl({ + value, + onChange, + dimmed, +}: { + value: string; + onChange: (val: string) => void; + dimmed: boolean; +}) { + return ( + onChange(e.target.value)} + placeholder="unset" + className={cn("w-40", dimmed && "opacity-50")} + /> + ); +} diff --git a/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx new file mode 100644 index 00000000000..dc586c89438 --- /dev/null +++ b/apps/webapp/app/components/errors/ConfigureErrorAlerts.tsx @@ -0,0 +1,365 @@ +import { conform, list, requestIntent, useFieldList, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { + EnvelopeIcon, + GlobeAltIcon, + HashtagIcon, + LockClosedIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; +import { useFetcher, useNavigate } from "@remix-run/react"; +import { SlackIcon } from "@trigger.dev/companyicons"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { z } from "zod"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Callout, variantClasses } from "~/components/primitives/Callout"; +import { useToast } from "~/components/primitives/Toast"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { InlineCode } from "~/components/code/InlineCode"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { UnorderedList } from "~/components/primitives/UnorderedList"; +import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { cn } from "~/utils/cn"; +import { organizationSlackIntegrationPath } from "~/utils/pathBuilder"; +import { ExitIcon } from "~/assets/icons/ExitIcon"; +import { TextLink } from "~/components/primitives/TextLink"; +import { BellAlertIcon } from "@heroicons/react/24/solid"; + +export const ErrorAlertsFormSchema = z.object({ + emails: z.preprocess((i) => { + if (typeof i === "string") return i === "" ? [] : [i]; + if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== ""); + return []; + }, z.string().email().array()), + slackChannel: z.string().optional(), + slackIntegrationId: z.string().optional(), + webhooks: z.preprocess((i) => { + if (typeof i === "string") return i === "" ? [] : [i]; + if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== ""); + return []; + }, z.string().url().array()), +}); + +type ConfigureErrorAlertsProps = ErrorAlertChannelData & { + connectToSlackHref?: string; + formAction: string; +}; + +export function ConfigureErrorAlerts({ + emails: existingEmails, + webhooks: existingWebhooks, + slackChannel: existingSlackChannel, + slack, + emailAlertsEnabled, + connectToSlackHref, + formAction, +}: ConfigureErrorAlertsProps) { + const organization = useOrganization(); + const fetcher = useFetcher<{ ok?: boolean }>(); + const navigate = useNavigate(); + const toast = useToast(); + const location = useOptimisticLocation(); + const isSubmitting = fetcher.state !== "idle"; + + const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState( + existingSlackChannel + ? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}` + : undefined + ); + + const selectedSlackChannel = + slack.status === "READY" + ? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`) + : undefined; + + const closeHref = (() => { + const params = new URLSearchParams(location.search); + params.delete("alerts"); + const qs = params.toString(); + return qs ? `?${qs}` : location.pathname; + })(); + + const hasHandledSuccess = useRef(false); + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) { + hasHandledSuccess.current = true; + toast.success("Alert settings saved"); + navigate(closeHref, { replace: true }); + } + }, [fetcher.state, fetcher.data, closeHref, navigate, toast]); + + const emailFieldValues = useRef( + existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""] + ); + + const webhookFieldValues = useRef( + existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""] + ); + + const [form, { emails, webhooks, slackChannel, slackIntegrationId }] = useForm({ + id: "configure-error-alerts", + onValidate({ formData }) { + return parse(formData, { schema: ErrorAlertsFormSchema }); + }, + shouldRevalidate: "onSubmit", + defaultValue: { + emails: emailFieldValues.current, + webhooks: webhookFieldValues.current, + }, + }); + + const emailFields = useFieldList(form.ref, emails); + const webhookFields = useFieldList(form.ref, webhooks); + + return ( +
+
+ + Configure alerts + + +
+ + +
+
+
+ Receive alerts when + +
  • An error is seen for the first time
  • +
  • A resolved error re-occurs
  • +
  • An ignored error re-occurs based on settings you configured
  • +
    +
    + + {/* Email section */} +
    + Email + {emailAlertsEnabled ? ( + + {emailFields.map((emailField, index) => ( + + { + emailFieldValues.current[index] = e.target.value; + if ( + emailFields.length === emailFieldValues.current.length && + emailFieldValues.current.every((v) => v !== "") + ) { + requestIntent(form.ref.current ?? undefined, list.append(emails.name)); + } + }} + /> + {emailField.error} + + ))} + + ) : ( + + Email integration is not available. Please contact your organization + administrator. + + )} +
    + + {/* Slack section */} +
    + Slack + + + {slack.status === "READY" ? ( + <> + + {selectedSlackChannel && selectedSlackChannel.is_private && ( + + To receive alerts in the{" "} + {selectedSlackChannel.name}{" "} + channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in + Slack and type:{" "} + /invite @Trigger.dev. + + )} + + + Manage Slack connection + + + + + ) : slack.status === "NOT_CONFIGURED" ? ( + connectToSlackHref ? ( + + + Connect to Slack + + + ) : ( + + Slack is not connected. Connect Slack from the{" "} + Alerts page to enable + Slack notifications. + + ) + ) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? ( + connectToSlackHref ? ( +
    + + The Slack integration in your workspace has been revoked or has expired. + Please re-connect your Slack workspace. + + + + Connect to Slack + + +
    + ) : ( + + The Slack integration in your workspace has been revoked or expired. Please + re-connect from the{" "} + Alerts page. + + ) + ) : slack.status === "FAILED_FETCHING_CHANNELS" ? ( + + Failed loading channels from Slack. Please try again later. + + ) : ( + + Slack integration is not available. Please contact your organization + administrator. + + )} +
    +
    + + {/* Webhook section */} +
    + Webhook + + {webhookFields.map((webhookField, index) => ( + + { + webhookFieldValues.current[index] = e.target.value; + if ( + webhookFields.length === webhookFieldValues.current.length && + webhookFieldValues.current.every((v) => v !== "") + ) { + requestIntent(form.ref.current ?? undefined, list.append(webhooks.name)); + } + }} + /> + {webhookField.error} + + ))} + We'll issue POST requests to these URLs with a JSON payload. + +
    + + {form.error} +
    +
    + +
    + + Cancel + + +
    +
    +
    + ); +} + +function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) { + return ( +
    + {is_private ? : } + {name} +
    + ); +} diff --git a/apps/webapp/app/components/errors/ErrorStatusBadge.tsx b/apps/webapp/app/components/errors/ErrorStatusBadge.tsx new file mode 100644 index 00000000000..571a209ddf1 --- /dev/null +++ b/apps/webapp/app/components/errors/ErrorStatusBadge.tsx @@ -0,0 +1,34 @@ +import { type ErrorGroupStatus } from "@trigger.dev/database"; +import { cn } from "~/utils/cn"; + +const styles: Record = { + UNRESOLVED: "bg-error/10 text-error", + RESOLVED: "bg-success/10 text-success", + IGNORED: "bg-blue-500/10 text-blue-400", +}; + +const labels: Record = { + UNRESOLVED: "Unresolved", + RESOLVED: "Resolved", + IGNORED: "Ignored", +}; + +export function ErrorStatusBadge({ + status, + className, +}: { + status: ErrorGroupStatus; + className?: string; +}) { + return ( + + {labels[status]} + + ); +} diff --git a/apps/webapp/app/components/errors/ErrorStatusMenu.tsx b/apps/webapp/app/components/errors/ErrorStatusMenu.tsx new file mode 100644 index 00000000000..a981c8eee52 --- /dev/null +++ b/apps/webapp/app/components/errors/ErrorStatusMenu.tsx @@ -0,0 +1,250 @@ +import { CheckIcon } from "@heroicons/react/20/solid"; +import { + IconAlarmSnooze as IconAlarmSnoozeBase, + IconArrowBackUp as IconArrowBackUpBase, + IconBugOff as IconBugOffBase, +} from "@tabler/icons-react"; +import { useEffect, useRef, useState } from "react"; +import { type ErrorGroupStatus } from "@trigger.dev/database"; +import { useFetcher } from "@remix-run/react"; +import { Button } from "~/components/primitives/Buttons"; +import { useToast } from "~/components/primitives/Toast"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/primitives/Dialog"; + +const AlarmSnoozeIcon = ({ className }: { className?: string }) => ( + +); +const ArrowBackUpIcon = ({ className }: { className?: string }) => ( + +); +const BugOffIcon = ({ className }: { className?: string }) => ( + +); + +export function statusActionToastMessage(data: Record): string { + switch (data.action) { + case "resolve": + return "Error marked as resolved"; + case "unresolve": + return "Error marked as unresolved"; + case "ignore": { + const duration = data.duration ? Number(data.duration) : undefined; + if (!duration) return "Error ignored indefinitely"; + const hours = duration / (60 * 60 * 1000); + if (hours < 24) return `Error ignored for ${hours} ${hours === 1 ? "hour" : "hours"}`; + const days = hours / 24; + return `Error ignored for ${days} ${days === 1 ? "day" : "days"}`; + } + default: + return "Error status updated"; + } +} + +export function ErrorStatusMenuItems({ + status, + taskIdentifier, + onAction, + onCustomIgnore, +}: { + status: ErrorGroupStatus; + taskIdentifier: string; + onAction: (data: Record) => void; + onCustomIgnore: () => void; +}) { + return ( + <> + {status === "UNRESOLVED" && ( + <> + onAction({ taskIdentifier, action: "resolve" })} + /> + + onAction({ + taskIdentifier, + action: "ignore", + duration: String(60 * 60 * 1000), + }) + } + /> + + onAction({ + taskIdentifier, + action: "ignore", + duration: String(24 * 60 * 60 * 1000), + }) + } + /> + onAction({ taskIdentifier, action: "ignore" })} + /> + + + )} + + {status === "IGNORED" && ( + <> + onAction({ taskIdentifier, action: "resolve" })} + /> + onAction({ taskIdentifier, action: "unresolve" })} + /> + + )} + + {status === "RESOLVED" && ( + onAction({ taskIdentifier, action: "unresolve" })} + /> + )} + + ); +} + +export function CustomIgnoreDialog({ + open, + onOpenChange, + taskIdentifier, + formAction, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + taskIdentifier: string; + formAction?: string; +}) { + const fetcher = useFetcher<{ ok?: boolean }>(); + const isSubmitting = fetcher.state !== "idle"; + const [conditionError, setConditionError] = useState(null); + const toast = useToast(); + const hasHandledSuccess = useRef(false); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) { + hasHandledSuccess.current = true; + toast.success("Error ignored with custom condition"); + onOpenChange(false); + } + }, [fetcher.state, fetcher.data, onOpenChange, toast]); + + return ( + + + + + + Custom ignore condition + + + { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const rate = formData.get("occurrenceRate")?.toString().trim(); + const total = formData.get("totalOccurrences")?.toString().trim(); + + if (!rate && !total) { + setConditionError("At least one unignore condition is required"); + return; + } + + setConditionError(null); + hasHandledSuccess.current = false; + fetcher.submit(e.currentTarget, { method: "post", action: formAction }); + }} + > + + + +
    + + + conditionError && setConditionError(null)} + /> + + + + + conditionError && setConditionError(null)} + /> + + + {conditionError && {conditionError}} + + + + + +
    + + + + + +
    +
    +
    + ); +} diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index 9b285db81ec..7ff99d7d448 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -146,6 +146,11 @@ export function VercelOnboardingModal({ } return "project-selection"; } + // If onboarding was already completed but GitHub is not connected, + // go directly to the github-connection step (e.g., returning from GitHub App installation) + if (onboardingData?.isOnboardingComplete && !onboardingData?.isGitHubConnected) { + return "github-connection"; + } // For marketplace origin, skip env-mapping step and go directly to env-var-sync if (!fromMarketplaceContext) { const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; @@ -1159,26 +1164,7 @@ export function VercelOnboardingModal({ > Complete - ) : ( - - ) - } - cancelButton={ - isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? ( + ) : !fromMarketplaceContext ? ( - - - Join our Slack -
    -
    - - - As a subscriber, you have access to a dedicated Slack channel for 1-to-1 - support with the Trigger.dev team. - -
    -
    -
    - - - - Send us an email to this address from your Trigger.dev account email - address: - - - - - - - As soon as we can, we'll setup a Slack Connect channel and say hello! - - -
    -
    -
    - -
    - )} - -
    +
    + What's new + {changelogs.map((entry) => ( + + ))} + +
    ); } + +function GrayDotIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx new file mode 100644 index 00000000000..fdfbb2f8742 --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -0,0 +1,312 @@ +import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { useFetcher } from "@remix-run/react"; +import { motion } from "framer-motion"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { Header3 } from "~/components/primitives/Headers"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { usePlatformNotifications } from "~/routes/resources.platform-notifications"; +import { cn } from "~/utils/cn"; + +type Notification = { + id: string; + friendlyId: string; + scope: string; + priority: number; + payload: { + version: string; + data: { + title: string; + description: string; + image?: string; + actionLabel?: string; + actionUrl?: string; + dismissOnAction?: boolean; + }; + }; + isRead: boolean; +}; + +export function NotificationPanel({ + isCollapsed, + hasIncident, + organizationId, + projectId, +}: { + isCollapsed: boolean; + hasIncident: boolean; + organizationId: string; + projectId: string; +}) { + const { notifications } = usePlatformNotifications(organizationId, projectId) as { + notifications: Notification[]; + }; + const [dismissedIds, setDismissedIds] = useState>(new Set()); + const dismissFetcher = useFetcher(); + const seenIdsRef = useRef>(new Set()); + const seenFetcher = useFetcher(); + const clickedIdsRef = useRef>(new Set()); + const clickFetcher = useFetcher(); + + const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); + const notification = visibleNotifications[0] ?? null; + + const handleDismiss = useCallback((id: string) => { + setDismissedIds((prev) => new Set(prev).add(id)); + + dismissFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/dismiss`, + } + ); + }, []); + + 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`, + } + ); + }, []); + + // Fire seen beacon + const fireSeenBeacon = useCallback((n: Notification) => { + if (seenIdsRef.current.has(n.id)) return; + seenIdsRef.current.add(n.id); + + seenFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${n.id}/seen`, + } + ); + }, []); + + // Beacon current notification on mount + useEffect(() => { + if (notification && !hasIncident) { + fireSeenBeacon(notification); + } + }, [notification?.id, hasIncident]); + + if (!notification) { + return null; + } + + const card = ( + fireClickBeacon(notification.id)} + /> + ); + + return ( + +
    + {/* Expanded sidebar: show card directly */} + + {card} + + + {/* Collapsed sidebar: show bell icon that opens popover */} + + +
    + + + {visibleNotifications.length} + +
    + + } + content="Notifications" + side="right" + sideOffset={8} + disableHoverableContent + asChild + /> +
    +
    + + {card} + +
    + ); +} + +function NotificationCard({ + notification, + onDismiss, + onLinkClick, +}: { + notification: Notification; + onDismiss: (id: string) => void; + onLinkClick: () => void; +}) { + const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data; + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const descriptionRef = useRef(null); + + useLayoutEffect(() => { + const el = descriptionRef.current; + if (el) { + setIsOverflowing(el.scrollHeight > el.clientHeight); + } + }, [description]); + + const handleDismiss = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDismiss(notification.id); + }; + + const handleToggleExpand = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsExpanded((v) => !v); + }; + + const handleCardClick = () => { + onLinkClick(); + if (dismissOnAction) { + onDismiss(notification.id); + } + }; + + const Wrapper = actionUrl ? "a" : "div"; + const wrapperProps = actionUrl + ? { + href: actionUrl, + target: "_blank" as const, + rel: "noopener noreferrer" as const, + onClick: handleCardClick, + } + : {}; + + return ( + + {/* Header: title + dismiss */} +
    + + {title} + + +
    + + {/* Body: description + chevron */} +
    +
    +
    +
    + {description} +
    + {(isOverflowing || isExpanded) && ( + + )} +
    + {actionUrl && ( +
    + +
    + )} +
    + + {image && ( + + )} +
    +
    + ); +} + +/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */ +function sanitizeImageUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" || parsed.protocol === "http:") { + return parsed.href; + } + return ""; + } catch { + return ""; + } +} + +function getMarkdownComponents(onLinkClick: () => void) { + return { + p: ({ children }: { children?: React.ReactNode }) => ( +

    {children}

    + ), + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( + { + e.stopPropagation(); + onLinkClick(); + }} + > + {children} + + ), + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => {children}, + code: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + }; +} diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index e0e414b189e..c8cd131d962 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -3,11 +3,13 @@ import { ChartBarIcon, Cog8ToothIcon, CreditCardIcon, + LockClosedIcon, UserGroupIcon, } from "@heroicons/react/20/solid"; import { ArrowLeftIcon } from "@heroicons/react/24/solid"; import { SlackIcon } from "@trigger.dev/companyicons"; import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { useFeatureFlags } from "~/hooks/useFeatureFlags"; import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; @@ -19,6 +21,7 @@ import { rootPath, v3BillingAlertsPath, v3BillingPath, + v3PrivateConnectionsPath, v3UsagePath, } from "~/utils/pathBuilder"; import { LinkButton } from "../primitives/Buttons"; @@ -47,6 +50,7 @@ export function OrganizationSettingsSideMenu({ buildInfo: BuildInfo; }) { const { isManagedCloud } = useFeatures(); + const featureFlags = useFeatureFlags(); const currentPlan = useCurrentPlan(); const isAdmin = useHasAdminAccess(); const showBuildInfo = isAdmin || !isManagedCloud; @@ -79,6 +83,7 @@ export function OrganizationSettingsSideMenu({ name="Usage" icon={ChartBarIcon} activeIconColor="text-indigo-500" + inactiveIconColor="text-indigo-500" to={v3UsagePath(organization)} data-action="usage" /> @@ -86,6 +91,7 @@ export function OrganizationSettingsSideMenu({ name="Billing" icon={CreditCardIcon} activeIconColor="text-emerald-500" + inactiveIconColor="text-emerald-500" to={v3BillingPath(organization)} data-action="billing" badge={ @@ -98,15 +104,27 @@ export function OrganizationSettingsSideMenu({ name="Billing alerts" icon={BellAlertIcon} activeIconColor="text-rose-500" + inactiveIconColor="text-rose-500" to={v3BillingAlertsPath(organization)} data-action="billing-alerts" /> )} + {featureFlags.hasPrivateConnections && ( + + )} @@ -114,6 +132,7 @@ export function OrganizationSettingsSideMenu({ name="Settings" icon={Cog8ToothIcon} activeIconColor="text-orgSettings" + inactiveIconColor="text-orgSettings" to={organizationSettingsPath(organization)} data-action="settings" /> @@ -126,6 +145,8 @@ export function OrganizationSettingsSideMenu({ name="Vercel" icon={VercelLogo} activeIconColor="text-white" + inactiveIconColor="text-white" + iconClassName="size-4 ml-0.5" to={organizationVercelIntegrationPath(organization)} data-action="integrations" /> @@ -133,6 +154,7 @@ export function OrganizationSettingsSideMenu({ name="Slack" icon={SlackIcon} activeIconColor="text-white" + inactiveIconColor="text-white" to={organizationSlackIntegrationPath(organization)} data-action="integrations" /> @@ -183,7 +205,7 @@ export function OrganizationSettingsSideMenu({ )}
    - +
    diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 90f25fde788..dbc4c213f08 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -53,6 +53,7 @@ import { type UserWithDashboardPreferences } from "~/models/user.server"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { type FeedbackType } from "~/routes/resources.feedback"; import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents"; +import { NotificationPanel } from "./NotificationPanel"; import { cn } from "~/utils/cn"; import { accountPath, @@ -463,10 +464,7 @@ export function SideMenu({ title="AI" isSideMenuCollapsed={isCollapsed} itemSpacingClassName="space-y-0" - initialCollapsed={getSectionCollapsed( - user.dashboardPreferences.sideMenu, - "ai" - )} + initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")} onCollapseToggle={handleSectionToggle("ai")} > - {(user.admin || user.isImpersonating || featureFlags.hasAiModelsAccess) && ( + {(user.admin || user.isImpersonating || featureFlags.hasAiAccess) && ( + - + {isFreeUser && (
    @@ -1158,7 +1163,7 @@ function CollapsibleHeight({ ); } -function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) { +function HelpAndAI({ isCollapsed, organizationId, projectId }: { isCollapsed: boolean; organizationId: string; projectId: string }) { return (
    - +
    diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 844782ed615..26aad3908df 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -10,6 +10,7 @@ export function SideMenuItem({ icon, activeIconColor, inactiveIconColor, + iconClassName, trailingIcon, trailingIconClassName, name, @@ -22,6 +23,7 @@ export function SideMenuItem({ icon?: RenderIcon; activeIconColor?: string; inactiveIconColor?: string; + iconClassName?: string; trailingIcon?: RenderIcon; trailingIconClassName?: string; name: string; @@ -47,7 +49,8 @@ export function SideMenuItem({ icon={icon} className={cn( "size-5 shrink-0", - isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed" + isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed", + iconClassName )} /> -
    +
    {icon} {label && (
    diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 8ba196b5dc2..86e23801e72 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -341,7 +341,7 @@ export const Button = forwardRef( disabled: isDisabled || !props.shortcut, }); - return ( + const buttonElement = ( ); + + if (props.tooltip) { + return ( + + + + + {buttonElement} + + + + {props.tooltip} {props.shortcut && !props.hideShortcutKey && ( + + )} + + + + ); + } + + return buttonElement; } ); diff --git a/apps/webapp/app/components/primitives/Checkbox.tsx b/apps/webapp/app/components/primitives/Checkbox.tsx index 59f12a40481..3b9ca1c532d 100644 --- a/apps/webapp/app/components/primitives/Checkbox.tsx +++ b/apps/webapp/app/components/primitives/Checkbox.tsx @@ -184,8 +184,8 @@ export const Checkbox = forwardRef( + {copied ? "Copied" : "Copy"} + {copied ? ( + + ) : ( + + )} + + ); +} diff --git a/apps/webapp/app/components/primitives/DateTime.tsx b/apps/webapp/app/components/primitives/DateTime.tsx index 27dc75415b9..4dae92731af 100644 --- a/apps/webapp/app/components/primitives/DateTime.tsx +++ b/apps/webapp/app/components/primitives/DateTime.tsx @@ -361,33 +361,35 @@ function formatDateTimeAccurate( type RelativeDateTimeProps = { date: Date | string; timeZone?: string; + capitalize?: boolean; }; -function getRelativeText(date: Date): string { +function getRelativeText(date: Date, capitalize = true): string { const text = formatDistanceToNow(date, { addSuffix: true }); + if (!capitalize) return text; return text.charAt(0).toUpperCase() + text.slice(1); } -export const RelativeDateTime = ({ date, timeZone }: RelativeDateTimeProps) => { +export const RelativeDateTime = ({ date, timeZone, capitalize = true }: RelativeDateTimeProps) => { const locales = useLocales(); const userTimeZone = useUserTimeZone(); const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]); - const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate)); + const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate, capitalize)); // Every 60s refresh useEffect(() => { const interval = setInterval(() => { - setRelativeText(getRelativeText(realDate)); + setRelativeText(getRelativeText(realDate, capitalize)); }, 60_000); return () => clearInterval(interval); - }, [realDate]); + }, [realDate, capitalize]); // On first render useEffect(() => { - setRelativeText(getRelativeText(realDate)); - }, [realDate]); + setRelativeText(getRelativeText(realDate, capitalize)); + }, [realDate, capitalize]); return ( ["type"]; + danger?: boolean; } >( ( @@ -80,18 +84,26 @@ const PopoverMenuItem = React.forwardRef< onClick, disabled, openInNewTab = false, + name, + value, + type, + danger = false, }, ref ) => { const contentProps = { variant: variant.variant, LeadingIcon: icon, - leadingIconClassName, + leadingIconClassName: danger + ? cn(leadingIconClassName, "transition-colors group-hover/button:text-error") + : leadingIconClassName, fullWidth: true, textAlignLeft: true, TrailingIcon: isSelected ? CheckIcon : undefined, className: cn( - "group-hover:bg-charcoal-700", + danger + ? "transition-colors group-hover/button:bg-error/10 group-hover/button:text-error [&_span]:transition-colors [&_span]:group-hover/button:text-error" + : "group-hover:bg-charcoal-700", isSelected ? "bg-charcoal-750 group-hover:bg-charcoal-600/50" : undefined, className ), @@ -114,7 +126,6 @@ const PopoverMenuItem = React.forwardRef< return ( @@ -197,6 +211,18 @@ const popoverArrowTriggerVariants = { text: "group-hover:text-text-bright", icon: "text-text-dimmed group-hover:text-text-bright", }, + primary: { + trigger: + "bg-indigo-600 border border-indigo-500 text-text-bright hover:bg-indigo-500 hover:border-indigo-400 disabled:opacity-50 disabled:pointer-events-none", + text: "text-text-bright hover:text-white", + icon: "text-text-bright", + }, + secondary: { + trigger: + "bg-secondary border border-charcoal-600 text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550 disabled:opacity-60 disabled:pointer-events-none", + text: "text-text-bright", + icon: "text-text-bright", + }, tertiary: { trigger: "bg-tertiary text-text-bright hover:bg-charcoal-600", text: "text-text-bright", @@ -245,8 +271,7 @@ function PopoverArrowTrigger({ const popoverVerticalEllipseVariants = { minimal: { - trigger: - "size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright", + trigger: "size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright", icon: "size-5", }, secondary: { diff --git a/apps/webapp/app/components/primitives/Resizable.tsx b/apps/webapp/app/components/primitives/Resizable.tsx index abb0b00cbb5..2efaae4258e 100644 --- a/apps/webapp/app/components/primitives/Resizable.tsx +++ b/apps/webapp/app/components/primitives/Resizable.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useRef } from "react"; import { PanelGroup, Panel, PanelResizer } from "react-window-splitter"; import { cn } from "~/utils/cn"; @@ -10,7 +10,6 @@ const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps ); @@ -70,6 +69,30 @@ const ResizableHandle = ({ ); -export { ResizableHandle, ResizablePanel, ResizablePanelGroup }; +const RESIZABLE_PANEL_ANIMATION = { + easing: "ease-in-out" as const, + duration: 200, +}; + +const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200"; + +function collapsibleHandleClassName(show: boolean) { + return cn(COLLAPSIBLE_HANDLE_CLASSNAME, !show && "pointer-events-none opacity-0"); +} + +function useFrozenValue(value: T | null | undefined): T | null | undefined { + const ref = useRef(value); + if (value != null) ref.current = value; + return ref.current; +} + +export { + RESIZABLE_PANEL_ANIMATION, + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, + collapsibleHandleClassName, + useFrozenValue, +}; export type ResizableSnapshot = React.ComponentProps["snapshot"]; diff --git a/apps/webapp/app/components/primitives/SearchInput.tsx b/apps/webapp/app/components/primitives/SearchInput.tsx index ea9156839a3..639a4d1a737 100644 --- a/apps/webapp/app/components/primitives/SearchInput.tsx +++ b/apps/webapp/app/components/primitives/SearchInput.tsx @@ -10,11 +10,13 @@ export type SearchInputProps = { placeholder?: string; /** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */ resetParams?: string[]; + autoFocus?: boolean; }; export function SearchInput({ placeholder = "Search logs…", resetParams = ["cursor", "direction"], + autoFocus, }: SearchInputProps) { const inputRef = useRef(null); @@ -71,6 +73,7 @@ export function SearchInput({ value={text} onChange={(e) => setText(e.target.value)} fullWidth + autoFocus={autoFocus} className={cn("", isFocused && "placeholder:text-text-dimmed/70")} onKeyDown={(e) => { if (e.key === "Enter") { diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index dfff784853d..1a30bc82b8a 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -431,7 +431,7 @@ export const TableCellMenu = forwardRef< onClick?: (event: React.MouseEvent) => void; visibleButtons?: ReactNode; hiddenButtons?: ReactNode; - popoverContent?: ReactNode; + popoverContent?: ReactNode | ((close: () => void) => ReactNode); children?: ReactNode; isSelected?: boolean; } @@ -451,6 +451,8 @@ export const TableCellMenu = forwardRef< ) => { const [isOpen, setIsOpen] = useState(false); const { variant } = useContext(TableContext); + const resolvedContent = + typeof popoverContent === "function" ? popoverContent(() => setIsOpen(false)) : popoverContent; return ( setIsOpen(open)}> + {resolvedContent && ( + setIsOpen(open)}> -
    {popoverContent}
    + {typeof popoverContent === "function" ? ( + resolvedContent + ) : ( +
    {resolvedContent}
    + )}
    )} diff --git a/apps/webapp/app/components/primitives/Toast.tsx b/apps/webapp/app/components/primitives/Toast.tsx index 742715fa6ad..175d5ccb604 100644 --- a/apps/webapp/app/components/primitives/Toast.tsx +++ b/apps/webapp/app/components/primitives/Toast.tsx @@ -1,7 +1,7 @@ import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/24/solid"; import { useSearchParams } from "@remix-run/react"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useTypedLoaderData } from "remix-typedjson"; import { Toaster, toast } from "sonner"; import { type ToastMessageAction } from "~/models/message.server"; @@ -43,6 +43,32 @@ export function Toast() { return ; } +export function useToast() { + return useMemo( + () => ({ + success(message: string, options?: { title?: string; ephemeral?: boolean }) { + const ephemeral = options?.ephemeral ?? true; + toast.custom( + (t) => ( + + ), + { duration: ephemeral ? defaultToastDuration : permanentToastDuration } + ); + }, + error(message: string, options?: { title?: string; ephemeral?: boolean }) { + const ephemeral = options?.ephemeral ?? true; + toast.custom( + (t) => ( + + ), + { duration: ephemeral ? defaultToastDuration : permanentToastDuration } + ); + }, + }), + [] + ); +} + export function ToastUI({ variant, message, diff --git a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx index d1e002abb58..5f720c24fe9 100644 --- a/apps/webapp/app/components/primitives/TreeView/TreeView.tsx +++ b/apps/webapp/app/components/primitives/TreeView/TreeView.tsx @@ -197,6 +197,18 @@ export function useTree({ concreteStateFromInput({ tree, selectedId, collapsedIds, filter }) ); + //sync external selectedId prop into internal state + useEffect(() => { + const internalSelectedId = selectedIdFromState(state.nodes); + if (selectedId !== internalSelectedId) { + if (selectedId === undefined) { + dispatch({ type: "DESELECT_ALL_NODES" }); + } else { + dispatch({ type: "SELECT_NODE", payload: { id: selectedId, scrollToNode: false, scrollToNodeFn } }); + } + } + }, [selectedId]); + //fire onSelectedIdChanged() useEffect(() => { const selectedId = selectedIdFromState(state.nodes); diff --git a/apps/webapp/app/components/primitives/UnorderedList.tsx b/apps/webapp/app/components/primitives/UnorderedList.tsx new file mode 100644 index 00000000000..e65dfe6673f --- /dev/null +++ b/apps/webapp/app/components/primitives/UnorderedList.tsx @@ -0,0 +1,129 @@ +import { cn } from "~/utils/cn"; +import { type ParagraphVariant } from "./Paragraph"; + +const listVariants: Record< + ParagraphVariant, + { text: string; spacing: string; items: string } +> = { + base: { + text: "font-sans text-base font-normal text-text-dimmed", + spacing: "mb-3", + items: "space-y-1 [&>li]:gap-1.5", + }, + "base/bright": { + text: "font-sans text-base font-normal text-text-bright", + spacing: "mb-3", + items: "space-y-1 [&>li]:gap-1.5", + }, + small: { + text: "font-sans text-sm font-normal text-text-dimmed", + spacing: "mb-2", + items: "space-y-0.5 [&>li]:gap-1", + }, + "small/bright": { + text: "font-sans text-sm font-normal text-text-bright", + spacing: "mb-2", + items: "space-y-0.5 [&>li]:gap-1", + }, + "small/dimmed": { + text: "font-sans text-sm font-normal text-text-dimmed", + spacing: "mb-2", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small": { + text: "font-sans text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/bright": { + text: "font-sans text-xs font-normal text-text-bright", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/dimmed": { + text: "font-sans text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/dimmed/mono": { + text: "font-mono text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/mono": { + text: "font-mono text-xs font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/bright/mono": { + text: "font-mono text-xs text-text-bright", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/caps": { + text: "font-sans text-xs uppercase tracking-wider font-normal text-text-dimmed", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-small/bright/caps": { + text: "font-sans text-xs uppercase tracking-wider font-normal text-text-bright", + spacing: "mb-1.5", + items: "space-y-0.5 [&>li]:gap-1", + }, + "extra-extra-small": { + text: "font-sans text-xxs font-normal text-text-dimmed", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/bright": { + text: "font-sans text-xxs font-normal text-text-bright", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/caps": { + text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/bright/caps": { + text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-bright", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, + "extra-extra-small/dimmed/caps": { + text: "font-sans text-xxs uppercase tracking-wider font-normal text-text-dimmed", + spacing: "mb-1", + items: "space-y-0.5 [&>li]:gap-0.5", + }, +}; + +type UnorderedListProps = { + variant?: ParagraphVariant; + className?: string; + spacing?: boolean; + children: React.ReactNode; +} & React.HTMLAttributes; + +export function UnorderedList({ + variant = "base", + className, + spacing = false, + children, + ...props +}: UnorderedListProps) { + const v = listVariants[variant]; + return ( +
      li]:flex [&>li]:items-baseline [&>li]:before:shrink-0 [&>li]:before:content-['•']", + v.text, + v.items, + spacing && v.spacing, + className + )} + {...props} + > + {children} +
    + ); +} diff --git a/apps/webapp/app/components/primitives/charts/ChartBar.tsx b/apps/webapp/app/components/primitives/charts/ChartBar.tsx index 28493070c06..0b560747297 100644 --- a/apps/webapp/app/components/primitives/charts/ChartBar.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartBar.tsx @@ -174,6 +174,7 @@ export function ChartBarRenderer({ } labelFormatter={tooltipLabelFormatter} allowEscapeViewBox={{ x: false, y: true }} + animationDuration={0} /> {/* Zoom selection area - rendered before bars to appear behind them */} diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index 6cf3f7d7f24..7fe77d97e81 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -180,7 +180,7 @@ export function ChartLegendCompound({ )} > {currentTotalLabel} - + {currentTotal != null ? ( valueFormatter ? ( valueFormatter(currentTotal) @@ -253,7 +253,7 @@ export function ChartLegendCompound({ /> @@ -350,7 +350,7 @@ function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: H {item.label} {remainingCount > 0 && +{remainingCount} more}
    - + {value != null ? ( valueFormatter ? ( valueFormatter(value) diff --git a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx index 9a366c9789d..3b2a2c6a3c1 100644 --- a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx @@ -40,6 +40,8 @@ export type ChartRootProps = { onViewAllLegendItems?: () => void; /** When true, constrains legend to max 50% height with scrolling */ legendScrollable?: boolean; + /** Additional className for the legend */ + legendClassName?: string; /** When true, chart fills its parent container height and distributes space between chart and legend */ fillContainer?: boolean; /** Content rendered between the chart and the legend */ @@ -87,6 +89,7 @@ export function ChartRoot({ legendValueFormatter, onViewAllLegendItems, legendScrollable = false, + legendClassName, fillContainer = false, beforeLegend, children, @@ -114,6 +117,7 @@ export function ChartRoot({ legendValueFormatter={legendValueFormatter} onViewAllLegendItems={onViewAllLegendItems} legendScrollable={legendScrollable} + legendClassName={legendClassName} fillContainer={fillContainer} beforeLegend={beforeLegend} > @@ -133,6 +137,7 @@ type ChartRootInnerProps = { legendValueFormatter?: (value: number) => string; onViewAllLegendItems?: () => void; legendScrollable?: boolean; + legendClassName?: string; fillContainer?: boolean; beforeLegend?: React.ReactNode; children: React.ComponentProps["children"]; @@ -148,6 +153,7 @@ function ChartRootInner({ legendValueFormatter, onViewAllLegendItems, legendScrollable = false, + legendClassName, fillContainer = false, beforeLegend, children, @@ -193,6 +199,7 @@ function ChartRootInner({ valueFormatter={legendValueFormatter} onViewAllLegendItems={onViewAllLegendItems} scrollable={legendScrollable} + className={legendClassName} /> )}
    diff --git a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx index 9e1f7163239..ff902147f19 100644 --- a/apps/webapp/app/components/runs/v3/EnabledStatus.tsx +++ b/apps/webapp/app/components/runs/v3/EnabledStatus.tsx @@ -1,4 +1,4 @@ -import { BoltSlashIcon, CheckCircleIcon } from "@heroicons/react/20/solid"; +import { NoSymbolIcon, CheckIcon } from "@heroicons/react/20/solid"; type EnabledStatusProps = { enabled: boolean; @@ -8,8 +8,8 @@ type EnabledStatusProps = { export function EnabledStatus({ enabled, - enabledIcon = CheckCircleIcon, - disabledIcon = BoltSlashIcon, + enabledIcon = CheckIcon, + disabledIcon = NoSymbolIcon, }: EnabledStatusProps) { const EnabledIcon = enabledIcon; const DisabledIcon = disabledIcon; diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index f643209b8cb..dc3657b42a9 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1216,7 +1216,7 @@ function AppliedMachinesFilter() { ); } -function VersionsDropdown({ +export function VersionsDropdown({ trigger, clearSearchValue, searchValue, diff --git a/apps/webapp/app/components/runs/v3/SpanTitle.tsx b/apps/webapp/app/components/runs/v3/SpanTitle.tsx index c54b93cb690..4c25fc7b9ae 100644 --- a/apps/webapp/app/components/runs/v3/SpanTitle.tsx +++ b/apps/webapp/app/components/runs/v3/SpanTitle.tsx @@ -1,5 +1,5 @@ import { ChevronRightIcon } from "@heroicons/react/20/solid"; -import { type TaskEventStyle } from "@trigger.dev/core/v3"; +import { TaskEventStyle } from "@trigger.dev/core/v3"; import type { TaskEventLevel } from "@trigger.dev/database"; import { Fragment } from "react"; import { cn } from "~/utils/cn"; @@ -14,12 +14,17 @@ type SpanTitleProps = { isPartial: boolean; size: "small" | "large"; hideAccessory?: boolean; + overrideDimmed?: boolean; }; export function SpanTitle(event: SpanTitleProps) { + const textClass = eventTextClassName(event); + const finalTextClass = + event.overrideDimmed && textClass === "text-text-dimmed" ? "text-text-bright" : textClass; + return ( - - {event.message}{" "} + + {event.message}{" "} {!event.hideAccessory && ( )} diff --git a/apps/webapp/app/db.server.ts b/apps/webapp/app/db.server.ts index 47b67a1a406..4668b58fb02 100644 --- a/apps/webapp/app/db.server.ts +++ b/apps/webapp/app/db.server.ts @@ -113,6 +113,7 @@ function getClient() { connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + application_name: env.SERVICE_NAME, }); console.log(`🔌 setting up prisma client to ${redactUrlSecrets(databaseUrl)}`); @@ -236,6 +237,7 @@ function getReplicaClient() { connection_limit: env.DATABASE_CONNECTION_LIMIT.toString(), pool_timeout: env.DATABASE_POOL_TIMEOUT.toString(), connection_timeout: env.DATABASE_CONNECTION_TIMEOUT.toString(), + application_name: env.SERVICE_NAME, }); console.log(`🔌 setting up read replica connection to ${redactUrlSecrets(replicaUrl)}`); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 4b4f22623b0..7f72337b9fa 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -333,7 +333,17 @@ const EnvironmentSchema = z .optional() .transform((v) => v ?? process.env.DEPLOY_REGISTRY_ECR_ASSUME_ROLE_EXTERNAL_ID), + // Compute gateway (template creation during deploy finalize) + COMPUTE_GATEWAY_URL: z.string().optional(), + COMPUTE_GATEWAY_AUTH_TOKEN: z.string().optional(), + COMPUTE_TEMPLATE_SHADOW_ROLLOUT_PCT: z.string().optional(), + DEPLOY_IMAGE_PLATFORM: z.string().default("linux/amd64"), + DEPLOY_VERSION_SUFFIX: z.string().optional(), + // Full image reference override - bypasses auto-generation of image tags + // When set, all deployments will use this exact image reference + // Example: "myregistry.com/myorg/myapp:1.0.5" + DEPLOY_IMAGE_OVERRIDE: z.string().optional(), DEPLOY_TIMEOUT_MS: z.coerce .number() .int() @@ -344,11 +354,18 @@ const EnvironmentSchema = z .default(60 * 1000 * 15), // 15 minutes OBJECT_STORE_BASE_URL: z.string().optional(), + OBJECT_STORE_BUCKET: z.string().optional(), OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), OBJECT_STORE_SECRET_ACCESS_KEY: z.string().optional(), OBJECT_STORE_REGION: z.string().optional(), OBJECT_STORE_SERVICE: z.string().default("s3"), + // Protocol to use for new uploads (e.g., "s3", "r2"). Data without protocol uses default provider above. + // If specified, you must configure the corresponding provider using OBJECT_STORE_{PROTOCOL}_* env vars. + // Example: OBJECT_STORE_DEFAULT_PROTOCOL=s3 requires OBJECT_STORE_S3_BASE_URL, OBJECT_STORE_S3_ACCESS_KEY_ID, etc. + // Enables zero-downtime migration between providers (old data keeps working, new data uses new provider). + OBJECT_STORE_DEFAULT_PROTOCOL: z.string().regex(/^[a-z0-9]+$/).optional(), + ARTIFACTS_OBJECT_STORE_BUCKET: z.string().optional(), ARTIFACTS_OBJECT_STORE_BASE_URL: z.string().optional(), ARTIFACTS_OBJECT_STORE_ACCESS_KEY_ID: z.string().optional(), @@ -1225,9 +1242,6 @@ const EnvironmentSchema = z // AI features (Prompts, Models, AI Metrics sidebar section) AI_FEATURES_ENABLED: z.string().default("0"), - // AI Models feature (Models sidebar item within AI section) - AI_MODELS_ENABLED: z.string().default("0"), - // Logs page ClickHouse URL (for logs queries) LOGS_CLICKHOUSE_URL: z .string() @@ -1385,6 +1399,10 @@ const EnvironmentSchema = z REALTIME_STREAMS_S2_WAIT_SECONDS: z.coerce.number().int().default(60), REALTIME_STREAMS_DEFAULT_VERSION: z.enum(["v1", "v2"]).default("v1"), WAIT_UNTIL_TIMEOUT_MS: z.coerce.number().int().default(600_000), + + // Private connections + PRIVATE_CONNECTIONS_ENABLED: z.string().optional(), + PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(), }) .and(GithubAppEnvSchema) .and(S2EnvSchema); diff --git a/apps/webapp/app/features.server.ts b/apps/webapp/app/features.server.ts index e55f91e213f..36a134dfe99 100644 --- a/apps/webapp/app/features.server.ts +++ b/apps/webapp/app/features.server.ts @@ -1,7 +1,9 @@ +import { env } from "./env.server"; import { requestUrl } from "./utils/requestUrl.server"; export type TriggerFeatures = { isManagedCloud: boolean; + hasPrivateConnections: boolean; }; function isManagedCloud(host: string): boolean { @@ -13,9 +15,17 @@ function isManagedCloud(host: string): boolean { ); } +function hasPrivateConnections(host: string): boolean { + if (env.PRIVATE_CONNECTIONS_ENABLED === "1") { + return isManagedCloud(host); + } + return false; +} + function featuresForHost(host: string): TriggerFeatures { return { isManagedCloud: isManagedCloud(host), + hasPrivateConnections: hasPrivateConnections(host), }; } diff --git a/apps/webapp/app/hooks/useFeatures.ts b/apps/webapp/app/hooks/useFeatures.ts index d2e1ac699f5..5ae3dfbf34c 100644 --- a/apps/webapp/app/hooks/useFeatures.ts +++ b/apps/webapp/app/hooks/useFeatures.ts @@ -5,5 +5,5 @@ import type { TriggerFeatures } from "~/features.server"; export function useFeatures(): TriggerFeatures { const routeMatch = useTypedRouteLoaderData("root"); - return routeMatch?.features ?? { isManagedCloud: false }; + return routeMatch?.features ?? { isManagedCloud: false, hasPrivateConnections: false }; } diff --git a/apps/webapp/app/hooks/useFuzzyFilter.ts b/apps/webapp/app/hooks/useFuzzyFilter.ts index 1c0f6048268..3f0797179f2 100644 --- a/apps/webapp/app/hooks/useFuzzyFilter.ts +++ b/apps/webapp/app/hooks/useFuzzyFilter.ts @@ -8,7 +8,7 @@ import { matchSorter } from "match-sorter"; * * @param params - The parameters object * @param params.items - Array of objects to filter - * @param params.keys - Array of object keys to perform the fuzzy search on + * @param params.keys - Array of object keys to perform the fuzzy search on (supports dot-notation for nested properties) * @returns An object containing: * - filterText: The current filter text * - setFilterText: Function to update the filter text @@ -28,7 +28,7 @@ export function useFuzzyFilter({ keys, }: { items: T[]; - keys: Extract[]; + keys: (Extract | (string & {}))[]; }) { const [filterText, setFilterText] = useState(""); diff --git a/apps/webapp/app/models/projectAlert.server.ts b/apps/webapp/app/models/projectAlert.server.ts index d2ab0be1d1a..dbcb672ad7d 100644 --- a/apps/webapp/app/models/projectAlert.server.ts +++ b/apps/webapp/app/models/projectAlert.server.ts @@ -32,3 +32,9 @@ export const ProjectAlertSlackStorage = z.object({ }); export type ProjectAlertSlackStorage = z.infer; + +export const ErrorAlertConfig = z.object({ + evaluationIntervalMs: z.number().min(60_000).default(300_000), +}); + +export type ErrorAlertConfig = z.infer; diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 52e629ffedb..99ced5e3efb 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -10,7 +10,9 @@ import { } from "./SelectBestEnvironmentPresenter.server"; import { sortEnvironments } from "~/utils/environmentSort"; import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar"; -import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server"; +import { env } from "~/env.server"; +import { flags } from "~/v3/featureFlags.server"; +import { validatePartialFeatureFlags } from "~/v3/featureFlags"; export class OrganizationsPresenter { #prismaClient: PrismaClient; @@ -154,8 +156,13 @@ export class OrganizationsPresenter { }, }); - // Get global feature flags (no overrides or defaults) - const globalFlags = await flags(); + // Get global feature flags with env-var-based defaults + const globalFlags = await flags({ + defaultValues: { + hasAiAccess: env.AI_FEATURES_ENABLED === "1", + hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1", + }, + }); return orgs.map((org) => { const orgFlagsResult = org.featureFlags @@ -210,7 +217,11 @@ export class OrganizationsPresenter { })[]; }) { if (environmentSlug) { - const env = environments.find((e) => e.slug === environmentSlug); + const env = environments.find( + (e) => + e.slug === environmentSlug && + (e.type !== "DEVELOPMENT" || e.orgMember?.userId === user.id) + ); if (env) { return env; } diff --git a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts index 4bc4c776e85..83ab09c177c 100644 --- a/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiAlertChannelPresenter.server.ts @@ -17,6 +17,7 @@ export const ApiAlertType = z.enum([ "attempt_failure", "deployment_failure", "deployment_success", + "error_group", ]); export type ApiAlertType = z.infer; @@ -85,6 +86,8 @@ export class ApiAlertChannelPresenter { return "deployment_failure"; case "DEPLOYMENT_SUCCESS": return "deployment_success"; + case "ERROR_GROUP": + return "error_group"; default: assertNever(alertType); } @@ -100,6 +103,8 @@ export class ApiAlertChannelPresenter { return "DEPLOYMENT_FAILURE"; case "deployment_success": return "DEPLOYMENT_SUCCESS"; + case "error_group": + return "ERROR_GROUP"; default: assertNever(alertType); } diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts index 8d1a312c5d7..dc19457cdd1 100644 --- a/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts @@ -15,7 +15,7 @@ import assertNever from "assert-never"; import { API_VERSIONS, CURRENT_API_VERSION, RunStatusUnspecifiedApiVersion } from "~/api/versions"; import { $replica, prisma } from "~/db.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; -import { generatePresignedUrl } from "~/v3/r2.server"; +import { generatePresignedUrl } from "~/v3/objectStore.server"; import { tracer } from "~/v3/tracer.server"; import { startSpanWithEnv } from "~/v3/tracing.server"; diff --git a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts index 9abc0ed0ab9..f6b9aea322f 100644 --- a/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/DeploymentListPresenter.server.ts @@ -202,7 +202,8 @@ WHERE wd."projectId" = ${project.id} AND wd."environmentId" = ${environment.id} ORDER BY - string_to_array(wd."version", '.')::int[] DESC + string_to_array(split_part(wd."version", '-', 1), '.')::int[] DESC, + split_part(wd."version", '-', 2) DESC LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; const { connectedGithubRepository } = project; @@ -319,7 +320,13 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`; FROM ${sqlDatabaseSchema}."WorkerDeployment" WHERE "projectId" = ${project.id} AND "environmentId" = ${environment.id} - AND string_to_array(version, '.')::int[] > string_to_array(${version}, '.')::int[] + AND ( + string_to_array(split_part(version, '-', 1), '.')::int[] > string_to_array(split_part(${version}, '-', 1), '.')::int[] + OR ( + string_to_array(split_part(version, '-', 1), '.')::int[] = string_to_array(split_part(${version}, '-', 1), '.')::int[] + AND split_part(version, '-', 2) > split_part(${version}, '-', 2) + ) + ) `; const count = Number(deploymentsSinceVersion[0].count); diff --git a/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts new file mode 100644 index 00000000000..e2d207555fe --- /dev/null +++ b/apps/webapp/app/presenters/v3/ErrorAlertChannelPresenter.server.ts @@ -0,0 +1,73 @@ +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { + ProjectAlertEmailProperties, + ProjectAlertSlackProperties, + ProjectAlertWebhookProperties, +} from "~/models/projectAlert.server"; +import { BasePresenter } from "./basePresenter.server"; +import { NewAlertChannelPresenter } from "./NewAlertChannelPresenter.server"; +import { env } from "~/env.server"; + +export type ErrorAlertChannelData = Awaited>; + +export class ErrorAlertChannelPresenter extends BasePresenter { + public async call(projectId: string, environmentType: RuntimeEnvironmentType) { + const channels = await this._prisma.projectAlertChannel.findMany({ + where: { + projectId, + alertTypes: { has: "ERROR_GROUP" }, + environmentTypes: { has: environmentType }, + }, + orderBy: { createdAt: "asc" }, + }); + + const emails: Array<{ id: string; email: string }> = []; + const webhooks: Array<{ id: string; url: string }> = []; + let slackChannel: { id: string; channelId: string; channelName: string } | null = null; + + for (const channel of channels) { + switch (channel.type) { + case "EMAIL": { + const parsed = ProjectAlertEmailProperties.safeParse(channel.properties); + if (parsed.success) { + emails.push({ id: channel.id, email: parsed.data.email }); + } + break; + } + case "SLACK": { + if (!channel.enabled) break; + const parsed = ProjectAlertSlackProperties.safeParse(channel.properties); + if (parsed.success) { + slackChannel = { + id: channel.id, + channelId: parsed.data.channelId, + channelName: parsed.data.channelName, + }; + } + break; + } + case "WEBHOOK": { + const parsed = ProjectAlertWebhookProperties.safeParse(channel.properties); + if (parsed.success) { + webhooks.push({ id: channel.id, url: parsed.data.url }); + } + break; + } + } + } + + const slackPresenter = new NewAlertChannelPresenter(this._prisma, this._replica); + const slackResult = await slackPresenter.call(projectId); + + const emailAlertsEnabled = + env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined; + + return { + emails, + webhooks, + slackChannel, + slack: slackResult.slack, + emailAlertsEnabled, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index 024ac1e95ea..5e9df362e4c 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -2,7 +2,7 @@ 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 PrismaClientOrTransaction } from "@trigger.dev/database"; +import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { type Direction, DirectionSchema } from "~/components/ListPagination"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; @@ -27,6 +27,7 @@ export type ErrorGroupOptions = { userId?: string; projectId: string; fingerprint: string; + versions?: string[]; runsPageSize?: number; period?: string; from?: number; @@ -39,6 +40,7 @@ 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(), @@ -59,6 +61,21 @@ function parseClickHouseDateTime(value: string): Date { return new Date(value.replace(" ", "T") + "Z"); } +export type ErrorGroupState = { + status: ErrorGroupStatus; + resolvedAt: Date | null; + resolvedInVersion: string | null; + resolvedBy: string | null; + ignoredAt: Date | null; + ignoredUntil: Date | null; + ignoredReason: string | null; + ignoredByUserId: string | null; + ignoredByUserDisplayName: string | null; + ignoredUntilOccurrenceRate: number | null; + ignoredUntilTotalOccurrences: number | null; + ignoredAtOccurrenceCount: number | null; +}; + export type ErrorGroupSummary = { fingerprint: string; errorType: string; @@ -68,10 +85,12 @@ export type ErrorGroupSummary = { firstSeen: Date; lastSeen: Date; affectedVersions: string[]; + state: ErrorGroupState; }; export type ErrorGroupOccurrences = Awaited>; export type ErrorGroupActivity = ErrorGroupOccurrences["data"]; +export type ErrorGroupActivityVersions = ErrorGroupOccurrences["versions"]; export class ErrorGroupPresenter extends BasePresenter { constructor( @@ -89,6 +108,7 @@ export class ErrorGroupPresenter extends BasePresenter { userId, projectId, fingerprint, + versions, runsPageSize = DEFAULT_RUNS_PAGE_SIZE, period, from, @@ -110,23 +130,40 @@ export class ErrorGroupPresenter extends BasePresenter { defaultPeriod: "7d", }); - const [summary, affectedVersions, runList] = await Promise.all([ - this.getSummary(organizationId, projectId, environmentId, fingerprint), + const summary = await this.getSummary(organizationId, projectId, environmentId, fingerprint); + + const [affectedVersions, runList, stateRow] = await Promise.all([ this.getAffectedVersions(organizationId, projectId, environmentId, fingerprint), this.getRunList(organizationId, environmentId, { userId, projectId, fingerprint, + versions, pageSize: runsPageSize, from: time.from.getTime(), to: time.to.getTime(), cursor, direction, }), + this.getState(environmentId, summary?.taskIdentifier, fingerprint), ]); if (summary) { summary.affectedVersions = affectedVersions; + summary.state = stateRow ?? { + status: "UNRESOLVED", + resolvedAt: null, + resolvedInVersion: null, + resolvedBy: null, + ignoredAt: null, + ignoredUntil: null, + ignoredReason: null, + ignoredByUserId: null, + ignoredByUserDisplayName: null, + ignoredUntilOccurrenceRate: null, + ignoredUntilTotalOccurrences: null, + ignoredAtOccurrenceCount: null, + }; } return { @@ -140,8 +177,8 @@ export class ErrorGroupPresenter extends BasePresenter { } /** - * Returns bucketed occurrence counts for a single fingerprint over a time range. - * Granularity is determined automatically from the range span. + * Returns bucketed occurrence counts for a single fingerprint over a time range, + * grouped by task_version for stacked charts. */ public async getOccurrences( organizationId: string, @@ -149,14 +186,17 @@ export class ErrorGroupPresenter extends BasePresenter { environmentId: string, fingerprint: string, from: Date, - to: Date + to: Date, + versions?: string[] ): Promise<{ - data: Array<{ date: Date; count: number }>; + data: Array>; + versions: string[]; }> { const granularityMs = errorGroupGranularity.getTimeGranularityMs(from, to); const intervalExpr = msToClickHouseInterval(granularityMs); - const queryBuilder = this.logsClickhouse.errors.createOccurrencesQueryBuilder(intervalExpr); + const queryBuilder = + this.logsClickhouse.errors.createOccurrencesByVersionQueryBuilder(intervalExpr); queryBuilder.where("organization_id = {organizationId: String}", { organizationId }); queryBuilder.where("project_id = {projectId: String}", { projectId }); @@ -169,7 +209,11 @@ export class ErrorGroupPresenter extends BasePresenter { toTimeMs: to.getTime(), }); - queryBuilder.groupBy("error_fingerprint, bucket_epoch"); + if (versions && versions.length > 0) { + queryBuilder.where("task_version IN {versions: Array(String)}", { versions }); + } + + queryBuilder.groupBy("error_fingerprint, task_version, bucket_epoch"); queryBuilder.orderBy("bucket_epoch ASC"); const [queryError, records] = await queryBuilder.execute(); @@ -186,17 +230,27 @@ export class ErrorGroupPresenter extends BasePresenter { buckets.push(epoch); } - const byBucket = new Map(); + // Collect distinct versions and index results by (epoch, version) + const versionSet = new Set(); + const byBucketVersion = new Map(); for (const row of records ?? []) { - byBucket.set(row.bucket_epoch, (byBucket.get(row.bucket_epoch) ?? 0) + row.count); + const version = row.task_version || "unknown"; + versionSet.add(version); + const key = `${row.bucket_epoch}:${version}`; + byBucketVersion.set(key, (byBucketVersion.get(key) ?? 0) + row.count); } - return { - data: buckets.map((epoch) => ({ - date: new Date(epoch * 1000), - count: byBucket.get(epoch) ?? 0, - })), - }; + const sortedVersions = sortVersionsDescending([...versionSet]); + + const data = buckets.map((epoch) => { + const point: Record = { date: new Date(epoch * 1000) }; + for (const version of sortedVersions) { + point[version] = byBucketVersion.get(`${epoch}:${version}`) ?? 0; + } + return point; + }); + + return { data, versions: sortedVersions }; } private async getSummary( @@ -235,6 +289,20 @@ export class ErrorGroupPresenter extends BasePresenter { firstSeen: parseClickHouseDateTime(record.first_seen), lastSeen: parseClickHouseDateTime(record.last_seen), affectedVersions: [], + state: { + status: "UNRESOLVED" as const, + resolvedAt: null, + resolvedInVersion: null, + resolvedBy: null, + ignoredAt: null, + ignoredUntil: null, + ignoredReason: null, + ignoredByUserId: null, + ignoredByUserDisplayName: null, + ignoredUntilOccurrenceRate: null, + ignoredUntilTotalOccurrences: null, + ignoredAtOccurrenceCount: null, + }, }; } @@ -268,6 +336,65 @@ export class ErrorGroupPresenter extends BasePresenter { return sortVersionsDescending(versions).slice(0, 5); } + private async getState( + environmentId: string, + taskIdentifier: string | undefined, + fingerprint: string + ): Promise { + const row = await this.replica.errorGroupState.findFirst({ + where: { + environmentId, + ...(taskIdentifier ? { taskIdentifier } : {}), + errorFingerprint: fingerprint, + }, + select: { + status: true, + resolvedAt: true, + resolvedInVersion: true, + resolvedBy: true, + ignoredAt: true, + ignoredUntil: true, + ignoredReason: true, + ignoredByUserId: true, + ignoredUntilOccurrenceRate: true, + ignoredUntilTotalOccurrences: true, + ignoredAtOccurrenceCount: true, + }, + }); + + if (!row) { + return null; + } + + let ignoredByUserDisplayName: string | null = null; + if (row.ignoredByUserId) { + const user = await this.replica.user.findFirst({ + where: { id: row.ignoredByUserId }, + select: { displayName: true, name: true, email: true }, + }); + if (user) { + ignoredByUserDisplayName = user.displayName ?? user.name ?? user.email; + } + } + + return { + status: row.status, + resolvedAt: row.resolvedAt, + resolvedInVersion: row.resolvedInVersion, + resolvedBy: row.resolvedBy, + ignoredAt: row.ignoredAt, + ignoredUntil: row.ignoredUntil, + ignoredReason: row.ignoredReason, + ignoredByUserId: row.ignoredByUserId, + ignoredByUserDisplayName, + ignoredUntilOccurrenceRate: row.ignoredUntilOccurrenceRate, + ignoredUntilTotalOccurrences: row.ignoredUntilTotalOccurrences, + ignoredAtOccurrenceCount: row.ignoredAtOccurrenceCount + ? Number(row.ignoredAtOccurrenceCount) + : null, + }; + } + private async getRunList( organizationId: string, environmentId: string, @@ -275,6 +402,7 @@ export class ErrorGroupPresenter extends BasePresenter { userId?: string; projectId: string; fingerprint: string; + versions?: string[]; pageSize: number; from?: number; to?: number; @@ -289,6 +417,7 @@ export class ErrorGroupPresenter extends BasePresenter { projectId: options.projectId, rootOnly: false, errorId: ErrorId.toFriendlyId(options.fingerprint), + versions: options.versions, pageSize: options.pageSize, from: options.from, to: options.to, diff --git a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts index 89832b28340..13da4ff91f8 100644 --- a/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorsListPresenter.server.ts @@ -9,7 +9,7 @@ const errorsListGranularity = new TimeGranularity([ { max: "3 months", granularity: "1w" }, { max: "Infinity", granularity: "30d" }, ]); -import { type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { type ErrorGroupStatus, type PrismaClientOrTransaction } from "@trigger.dev/database"; import { type Direction } from "~/components/ListPagination"; import { timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; @@ -22,6 +22,8 @@ export type ErrorsListOptions = { projectId: string; // filters tasks?: string[]; + versions?: string[]; + statuses?: ErrorGroupStatus[]; period?: string; from?: number; to?: number; @@ -39,6 +41,8 @@ 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(), @@ -88,7 +92,11 @@ function decodeCursor(cursor: string): ErrorGroupCursor | null { } } -function cursorFromRow(row: { occurrence_count: number; error_fingerprint: string; task_identifier: string }): string { +function cursorFromRow(row: { + occurrence_count: number; + error_fingerprint: string; + task_identifier: string; +}): string { return encodeCursor({ occurrenceCount: row.occurrence_count, fingerprint: row.error_fingerprint, @@ -123,6 +131,8 @@ export class ErrorsListPresenter extends BasePresenter { userId, projectId, tasks, + versions, + statuses, period, search, from, @@ -156,20 +166,49 @@ export class ErrorsListPresenter extends BasePresenter { const hasFilters = (tasks !== undefined && tasks.length > 0) || + (versions !== undefined && versions.length > 0) || (search !== undefined && search !== "") || - !time.isDefault; + (statuses !== undefined && statuses.length > 0); const possibleTasksAsync = getAllTaskIdentifiers(this.replica, environmentId); - const [possibleTasks, displayableEnvironment] = await Promise.all([ + // Pre-filter by status: since status lives in Postgres (ErrorGroupState) and the error + // list comes from ClickHouse, we resolve inclusion/exclusion sets upfront so that + // ClickHouse pagination operates on the correctly filtered dataset. + const statusFilterAsync = this.resolveStatusFilter(environmentId, statuses); + + const [possibleTasks, displayableEnvironment, statusFilter] = await Promise.all([ possibleTasksAsync, findDisplayableEnvironment(environmentId, userId), + statusFilterAsync, ]); if (!displayableEnvironment) { throw new ServiceValidationError("No environment found"); } + if (statusFilter.empty) { + return { + errorGroups: [], + pagination: { + next: undefined, + previous: undefined, + }, + filters: { + tasks, + versions, + statuses, + search, + period: time, + from: effectiveFrom, + to: effectiveTo, + hasFilters, + possibleTasks, + wasClampedByRetention, + }, + }; + } + // Query the per-minute error_occurrences_v1 table for time-scoped counts const queryBuilder = this.clickhouse.errors.occurrencesListQueryBuilder(); @@ -189,6 +228,23 @@ export class ErrorsListPresenter extends BasePresenter { queryBuilder.where("task_identifier IN {tasks: Array(String)}", { tasks }); } + if (versions && versions.length > 0) { + queryBuilder.where("task_version IN {versions: Array(String)}", { versions }); + } + + if (statusFilter.includeKeys) { + queryBuilder.where( + "concat(task_identifier, '::', error_fingerprint) IN {statusIncludeKeys: Array(String)}", + { statusIncludeKeys: statusFilter.includeKeys } + ); + } + if (statusFilter.excludeKeys) { + queryBuilder.where( + "concat(task_identifier, '::', error_fingerprint) NOT IN {statusExcludeKeys: Array(String)}", + { statusExcludeKeys: statusFilter.excludeKeys } + ); + } + queryBuilder.groupBy("error_fingerprint, task_identifier"); // Text search via HAVING (operates on aggregated values) @@ -254,15 +310,14 @@ export class ErrorsListPresenter extends BasePresenter { // Fetch global first_seen / last_seen from the errors_v1 summary table const fingerprints = errorGroups.map((e) => e.error_fingerprint); - const globalSummaryMap = await this.getGlobalSummary( - organizationId, - projectId, - environmentId, - fingerprints - ); + const [globalSummaryMap, stateMap] = await Promise.all([ + this.getGlobalSummary(organizationId, projectId, environmentId, fingerprints), + this.getErrorGroupStates(environmentId, errorGroups), + ]); - const transformedErrorGroups = errorGroups.map((error) => { + let transformedErrorGroups = errorGroups.map((error) => { const global = globalSummaryMap.get(error.error_fingerprint); + const state = stateMap.get(`${error.task_identifier}:${error.error_fingerprint}`); return { errorType: error.error_type, errorMessage: error.error_message, @@ -271,6 +326,9 @@ export class ErrorsListPresenter extends BasePresenter { firstSeen: global?.firstSeen ?? new Date(), lastSeen: global?.lastSeen ?? new Date(), count: error.occurrence_count, + status: state?.status ?? "UNRESOLVED", + resolvedAt: state?.resolvedAt ?? null, + ignoredUntil: state?.ignoredUntil ?? null, }; }); @@ -282,6 +340,8 @@ export class ErrorsListPresenter extends BasePresenter { }, filters: { tasks, + versions, + statuses, search, period: time, from: effectiveFrom, @@ -367,6 +427,106 @@ export class ErrorsListPresenter extends BasePresenter { return { data }; } + /** + * Determines which (task, fingerprint) pairs to include or exclude from the ClickHouse + * query based on the requested status filter. Since status lives in Postgres and errors + * live in ClickHouse, we resolve the filter set here so ClickHouse pagination is correct. + * + * - UNRESOLVED is the default (no ErrorGroupState row), so filtering FOR it means + * excluding groups with non-matching explicit statuses. + * - RESOLVED/IGNORED are explicit, so filtering for them means including only matching groups. + */ + private async resolveStatusFilter( + environmentId: string, + statuses?: ErrorGroupStatus[] + ): Promise<{ + includeKeys?: string[]; + excludeKeys?: string[]; + empty: boolean; + }> { + if (!statuses || statuses.length === 0) { + return { empty: false }; + } + + const allStatuses: ErrorGroupStatus[] = ["UNRESOLVED", "RESOLVED", "IGNORED"]; + const excludedStatuses = allStatuses.filter((s) => !statuses.includes(s)); + + if (excludedStatuses.length === 0) { + return { empty: false }; + } + + if (statuses.includes("UNRESOLVED")) { + const excluded = await this.replica.errorGroupState.findMany({ + where: { environmentId, status: { in: excludedStatuses } }, + select: { taskIdentifier: true, errorFingerprint: true }, + }); + if (excluded.length === 0) { + return { empty: false }; + } + return { + excludeKeys: excluded.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`), + empty: false, + }; + } + + const included = await this.replica.errorGroupState.findMany({ + where: { environmentId, status: { in: statuses } }, + select: { taskIdentifier: true, errorFingerprint: true }, + }); + if (included.length === 0) { + return { empty: true }; + } + return { + includeKeys: included.map((g) => `${g.taskIdentifier}::${g.errorFingerprint}`), + empty: false, + }; + } + + /** + * Batch-fetch ErrorGroupState rows from Postgres for the given ClickHouse error groups. + * Returns a map keyed by `${taskIdentifier}:${errorFingerprint}`. + */ + private async getErrorGroupStates( + environmentId: string, + errorGroups: Array<{ task_identifier: string; error_fingerprint: string }> + ) { + type StateValue = { + status: ErrorGroupStatus; + resolvedAt: Date | null; + ignoredUntil: Date | null; + }; + + const result = new Map(); + if (errorGroups.length === 0) return result; + + const states = await this.replica.errorGroupState.findMany({ + where: { + environmentId, + OR: errorGroups.map((e) => ({ + taskIdentifier: e.task_identifier, + errorFingerprint: e.error_fingerprint, + })), + }, + select: { + taskIdentifier: true, + errorFingerprint: true, + status: true, + resolvedAt: true, + ignoredUntil: true, + }, + }); + + for (const state of states) { + result.set(`${state.taskIdentifier}:${state.errorFingerprint}`, { + status: state.status, + resolvedAt: state.resolvedAt, + ignoredUntil: state.ignoredUntil, + }); + } + + return result; + } + /** * Fetches global first_seen / last_seen for a set of fingerprints from errors_v1. */ diff --git a/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts b/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts index 9096ab67b71..16a0aa75046 100644 --- a/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ModelRegistryPresenter.server.ts @@ -64,14 +64,12 @@ export type ModelCatalogItem = { description: string | null; contextWindow: number | null; maxOutputTokens: number | null; - capabilities: string[]; + /** Combined capabilities (from DB) and boolean feature flags (from catalog) as slug strings. */ + features: string[]; inputPrice: number | null; outputPrice: number | null; /** When the model was publicly released (from startDate on LlmModel). */ releaseDate: string | null; - supportsStructuredOutput: boolean; - supportsParallelToolCalls: boolean; - supportsStreamingToolCalls: boolean; /** Dated variants of this model (only populated on base models). */ variants: ModelVariant[]; }; @@ -98,6 +96,17 @@ export type ModelDetail = ModelCatalogItem & { }>; }; +function buildFeatures( + capabilities: string[], + catalogEntry: { supportsStructuredOutput: boolean; supportsParallelToolCalls: boolean; supportsStreamingToolCalls: boolean } | undefined +): string[] { + const features = new Set(capabilities); + if (catalogEntry?.supportsStructuredOutput) features.add("structured_output"); + if (catalogEntry?.supportsParallelToolCalls) features.add("parallel_tool_calls"); + if (catalogEntry?.supportsStreamingToolCalls) features.add("streaming_tool_calls"); + return Array.from(features); +} + export type ModelMetricsPoint = { minute: string; callCount: number; @@ -214,13 +223,10 @@ export class ModelRegistryPresenter extends BasePresenter { description: m.description, contextWindow: m.contextWindow, maxOutputTokens: m.maxOutputTokens, - capabilities: m.capabilities, + features: buildFeatures(m.capabilities, catalogEntry), inputPrice: inputPrice ? Number(inputPrice.price) : null, outputPrice: outputPrice ? Number(outputPrice.price) : null, releaseDate: m.startDate ? m.startDate.toISOString().split("T")[0] : null, - supportsStructuredOutput: catalogEntry?.supportsStructuredOutput ?? false, - supportsParallelToolCalls: catalogEntry?.supportsParallelToolCalls ?? false, - supportsStreamingToolCalls: catalogEntry?.supportsStreamingToolCalls ?? false, variants: [], _baseModelName: m.baseModelName, }; @@ -304,7 +310,7 @@ export class ModelRegistryPresenter extends BasePresenter { /** Get a single model with full pricing details. */ async getModelDetail(friendlyId: string): Promise { - const model = await this._replica.llmModel.findUnique({ + const model = await this._replica.llmModel.findFirst({ where: { friendlyId }, include: { pricingTiers: { @@ -331,13 +337,10 @@ export class ModelRegistryPresenter extends BasePresenter { description: model.description, contextWindow: model.contextWindow, maxOutputTokens: model.maxOutputTokens, - capabilities: model.capabilities, + features: buildFeatures(model.capabilities, catalogEntry), inputPrice: inputPrice ? Number(inputPrice.price) : null, outputPrice: outputPrice ? Number(outputPrice.price) : null, releaseDate: model.startDate ? model.startDate.toISOString().split("T")[0] : null, - supportsStructuredOutput: catalogEntry?.supportsStructuredOutput ?? false, - supportsParallelToolCalls: catalogEntry?.supportsParallelToolCalls ?? false, - supportsStreamingToolCalls: catalogEntry?.supportsStreamingToolCalls ?? false, variants: [], matchPattern: model.matchPattern, source: model.source, diff --git a/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts b/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts index 08bccc66ef7..bde51bda91f 100644 --- a/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NewAlertChannelPresenter.server.ts @@ -20,6 +20,7 @@ export class NewAlertChannelPresenter extends BasePresenter { where: { service: "SLACK", organizationId: project.organizationId, + deletedAt: null, }, orderBy: { createdAt: "desc", diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index 7a35fb6fb9b..55bd30e33be 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -1,6 +1,8 @@ import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; -import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG } from "~/v3/featureFlags"; +import { makeFlag } from "~/v3/featureFlags.server"; +import { defaultVisibilityFilter, resolveComputeAccess } from "~/v3/regionAccess.server"; import { BasePresenter } from "./basePresenter.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; @@ -31,6 +33,9 @@ export class RegionsPresenter extends BasePresenter { organizationId: true, defaultWorkerGroupId: true, allowedWorkerQueues: true, + organization: { + select: { featureFlags: true }, + }, }, where: { slug: projectSlug, @@ -57,6 +62,11 @@ export class RegionsPresenter extends BasePresenter { throw new Error("Default worker instance group not found"); } + const hasComputeAccess = await resolveComputeAccess( + this._replica, + project.organization.featureFlags + ); + const visibleRegions = await this._replica.workerInstanceGroup.findMany({ select: { id: true, @@ -74,9 +84,7 @@ export class RegionsPresenter extends BasePresenter { ? { masterQueue: { in: project.allowedWorkerQueues }, } - : { - hidden: false, - }, + : defaultVisibilityFilter(hasComputeAccess), orderBy: { name: "asc", }, diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index af5bb93a7e7..438d6da987b 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -45,7 +45,11 @@ export class TestPresenter extends BasePresenter { >`WITH workers AS ( SELECT bw.*, - ROW_NUMBER() OVER(ORDER BY string_to_array(bw.version, '.')::int[] DESC) AS rn + ROW_NUMBER() OVER( + ORDER BY + string_to_array(split_part(bw.version, '-', 1), '.')::int[] DESC, + split_part(bw.version, '-', 2) DESC + ) AS rn FROM ${sqlDatabaseSchema}."BackgroundWorker" bw WHERE "runtimeEnvironmentId" = ${envId} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx index 5d6a947a424..2cf8b844a9e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx @@ -6,16 +6,18 @@ import { ExclamationTriangleIcon, LightBulbIcon, MagnifyingGlassIcon, + XMarkIcon, UserPlusIcon, VideoCameraIcon, } from "@heroicons/react/20/solid"; import { json, type MetaFunction } from "@remix-run/node"; -import { Link, useRevalidator, useSubmit } from "@remix-run/react"; +import { Link, useFetcher, useRevalidator } from "@remix-run/react"; import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { DiscordIcon } from "@trigger.dev/companyicons"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; import type { TaskRunStatus } from "@trigger.dev/database"; -import { Fragment, Suspense, useEffect, useState } from "react"; +import { Fragment, Suspense, useCallback, useEffect, useRef, useState } from "react"; +import type { PanelHandle } from "react-window-splitter"; import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { ExitIcon } from "~/assets/icons/ExitIcon"; @@ -42,9 +44,11 @@ import { Paragraph } from "~/components/primitives/Paragraph"; import { PopoverMenuItem } from "~/components/primitives/Popover"; import * as Property from "~/components/primitives/PropertyTable"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Spinner } from "~/components/primitives/Spinner"; import { StepNumber } from "~/components/primitives/StepNumber"; @@ -84,6 +88,7 @@ import { uiPreferencesStorage, } from "~/services/preferences/uiPreferences.server"; import { requireUserId } from "~/services/session.server"; +import { motion } from "framer-motion"; import { cn } from "~/utils/cn"; import { docsPath, @@ -192,14 +197,20 @@ export default function Page() { }, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true); + const usefulLinksPanelRef = useRef(null); + const fetcher = useFetcher(); + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; - // Create a submit handler to save the preference - const submit = useSubmit(); - - const handleUsefulLinksToggle = (show: boolean) => { + const toggleUsefulLinks = useCallback((show: boolean) => { setShowUsefulLinks(show); - submit({ showUsefulLinks: show.toString() }, { method: "post" }); - }; + if (show) { + usefulLinksPanelRef.current?.expand(); + } else { + usefulLinksPanelRef.current?.collapse(); + } + fetcherRef.current.submit({ showUsefulLinks: show.toString() }, { method: "post" }); + }, []); return ( @@ -226,27 +237,24 @@ export default function Page() { - +
    {hasTasks ? (
    {tasks.length === 0 ? : null}
    -
    - + setFilterText(e.target.value)} + onChange={setFilterText} + placeholder="Search tasks…" autoFocus /> - {!showUsefulLinks && ( + {!showUsefulLinks && (
    - {hasTasks && showUsefulLinks ? ( - <> - - - handleUsefulLinksToggle(false)} /> - - - ) : null} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {hasTasks && ( + toggleUsefulLinks(false)} /> + )} +
    +
    @@ -850,3 +867,54 @@ function FailedToLoadStats() { /> ); } + +function AnimatedSearchField({ + value, + onChange, + placeholder, + autoFocus, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + autoFocus?: boolean; +}) { + const [isFocused, setIsFocused] = useState(false); + + return ( + 0 ? "24rem" : "auto" }} + transition={{ type: "spring", stiffness: 300, damping: 30 }} + className="relative h-6 min-w-52" + > + onChange(e.target.value)} + fullWidth + autoFocus={autoFocus} + className={cn(isFocused && "placeholder:text-text-dimmed/70")} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + onKeyDown={(e) => { + if (e.key === "Escape") e.currentTarget.blur(); + }} + icon={} + accessory={ + value.length > 0 ? ( + + ) : undefined + } + /> + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts index 6800ab2ed88..ddd1bf646b7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new.connect-to-slack.ts @@ -28,6 +28,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { where: { service: "SLACK", organizationId: project.organizationId, + deletedAt: null, }, }); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx index 1bedd30d0f9..9b888a43624 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts/route.tsx @@ -63,6 +63,7 @@ import { v3NewProjectAlertPath, v3ProjectAlertsPath, } from "~/utils/pathBuilder"; +import { alertsWorker } from "~/v3/alertsWorker.server"; export const meta: MetaFunction = () => { return [ @@ -156,6 +157,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { data: { enabled: true }, }); + if (alertChannel.alertTypes.includes("ERROR_GROUP")) { + await alertsWorker.enqueue({ + id: `evaluateErrorAlerts:${project.id}`, + job: "v3.evaluateErrorAlerts", + payload: { + projectId: project.id, + scheduledAt: Date.now(), + }, + }); + } + return redirectWithSuccessMessage( v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }), request, @@ -555,8 +567,10 @@ export function alertTypeTitle(alertType: ProjectAlertType): string { return "Deployment failure"; case "DEPLOYMENT_SUCCESS": return "Deployment success"; + case "ERROR_GROUP": + return "Error group"; default: { - assertNever(alertType); + throw new Error(`Unknown alertType: ${alertType}`); } } } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx index 91403f4597d..6f5fc89341f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam/route.tsx @@ -1,4 +1,4 @@ -import { ArrowRightIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core"; import { motion } from "framer-motion"; @@ -12,17 +12,14 @@ import { DateTime } from "~/components/primitives/DateTime"; import { Header2, Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; -import { - BatchStatusCombo, - descriptionForBatchStatus, -} from "~/components/runs/v3/BatchStatus"; +import { BatchStatusCombo, descriptionForBatchStatus } from "~/components/runs/v3/BatchStatus"; import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; -import { BatchPresenter, type BatchPresenterData } from "~/presenters/v3/BatchPresenter.server"; +import { BatchPresenter } from "~/presenters/v3/BatchPresenter.server"; import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatNumber } from "~/utils/numberFormatter"; @@ -35,8 +32,7 @@ const BatchParamSchema = EnvironmentParamSchema.extend({ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); - const { organizationSlug, projectParam, envParam, batchParam } = - BatchParamSchema.parse(params); + const { organizationSlug, projectParam, envParam, batchParam } = BatchParamSchema.parse(params); const project = await findProjectBySlug(organizationSlug, projectParam, userId); if (!project) { @@ -85,7 +81,8 @@ export default function Page() { disabled: batch.hasFinished, }); - const showProgressMeter = batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED"); + const showProgressMeter = + batch.isV2 && (batch.status === "PROCESSING" || batch.status === "PARTIAL_FAILED"); return (
    @@ -141,9 +138,7 @@ export default function Page() { Version - - {batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"} - + {batch.isV2 ? "v2 (Run Engine)" : "v1 (Legacy)"} Total runs @@ -243,11 +238,11 @@ export default function Page() { {/* Footer */}
    View runs @@ -304,4 +299,3 @@ function BatchProgressMeter({ successCount, failureCount, totalCount }: BatchPro
    ); } - diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx index a66e85c0f86..eaa040c4081 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches/route.tsx @@ -1,9 +1,10 @@ -import { ArrowRightIcon, ExclamationCircleIcon } from "@heroicons/react/20/solid"; +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { type MetaFunction, Outlet, useNavigation, useParams, useLocation } from "@remix-run/react"; +import { type MetaFunction, Outlet, useLocation, useNavigation, useParams } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDuration } from "@trigger.dev/core/v3/utils/durations"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; import { BatchesNone } from "~/components/BlankStatePanels"; import { ListPagination } from "~/components/ListPagination"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; @@ -13,6 +14,8 @@ import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + collapsibleHandleClassName, + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, @@ -143,14 +146,25 @@ export default function Page() { />
    - {isShowingInspector && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    )} @@ -287,8 +301,14 @@ function BatchActionsCell({ runsPath }: { runsPath: string }) { - View runs + + View runs } /> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx index f44ce5904dc..a17f3e7d99e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions/route.tsx @@ -13,9 +13,11 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -170,14 +172,26 @@ export default function Page() { )}
    - {isShowingInspector && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx index 245f117ffdb..051ea7a8a28 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx @@ -5,7 +5,6 @@ import { Form, useNavigation } from "@remix-run/react"; import { IconChartHistogram, IconEdit, IconTypography } from "@tabler/icons-react"; import { useCallback, useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { toast } from "sonner"; import { z } from "zod"; import { defaultChartConfig } from "~/components/code/ChartConfigPanel"; import { Feedback } from "~/components/Feedback"; @@ -33,7 +32,7 @@ import { PopoverVerticalEllipseTrigger, } from "~/components/primitives/Popover"; import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; -import { ToastUI } from "~/components/primitives/Toast"; +import { useToast } from "~/components/primitives/Toast"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor"; import { $replica, prisma } from "~/db.server"; @@ -206,7 +205,8 @@ export default function Page() { const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`; const layoutActionUrl = widgetActionUrl; - // Handle sync errors by showing a toast + const toast = useToast(); + const handleSyncError = useCallback((error: Error, action: string) => { const actionMessages: Record = { add: "Failed to add widget", @@ -218,15 +218,8 @@ export default function Page() { const message = actionMessages[action] || "Failed to save changes"; - toast.custom((t) => ( - - )); - }, []); + toast.error(`${message}. Your changes may not be saved.`, { title: "Sync Error" }); + }, [toast]); // Add title dialog state const [showAddTitleDialog, setShowAddTitleDialog] = useState(false); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index a42b39c4573..9dbac88c51a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -42,9 +42,11 @@ import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/Page import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -255,7 +257,7 @@ export default function Page() {
    - {deployment.shortCode} + {deployment.shortCode} {deployment.label && ( {titleCase(deployment.label)} )} @@ -388,14 +390,26 @@ export default function Page() { )} - {deploymentParam && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    @@ -405,8 +419,8 @@ export default function Page() { export function UserTag({ name, avatarUrl }: { name: string; avatarUrl?: string }) { return (
    - - {name} + + {name}
    ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx index 2670f0188df..f7f91f33274 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx @@ -256,7 +256,7 @@ export default function Page() { const { filterText, setFilterText, filteredItems } = useFuzzyFilter({ items: environmentVariables, - keys: ["key", "value"], + keys: ["key", "value", "environment.type", "environment.branchName"], }); // Add isFirst and isLast to each environment variable diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 0ff8594fa36..f42c73b5ea3 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -1,8 +1,13 @@ -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs, type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { type MetaFunction, useFetcher, useRevalidator } from "@remix-run/react"; +import { BellAlertIcon } from "@heroicons/react/20/solid"; +import { IconAlarmSnooze as IconAlarmSnoozeBase, IconCircleDotted } from "@tabler/icons-react"; +import { parse } from "@conform-to/zod"; +import { z } from "zod"; +import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; -import { requireUser } from "~/services/session.server"; +import { requireUser, requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema, v3CreateBulkActionPath, @@ -14,38 +19,69 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { ErrorGroupPresenter, type ErrorGroupActivity, + type ErrorGroupActivityVersions, type ErrorGroupOccurrences, type ErrorGroupSummary, + type ErrorGroupState, } from "~/presenters/v3/ErrorGroupPresenter.server"; import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; import { $replica } from "~/db.server"; import { logsClickhouseClient, clickhouseClient } from "~/services/clickhouseInstance.server"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PageBody } from "~/components/layout/AppLayout"; -import { Suspense, useMemo } from "react"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { AnimatePresence, motion } from "framer-motion"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { Spinner } from "~/components/primitives/Spinner"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Callout } from "~/components/primitives/Callout"; -import { Header1, Header2, Header3 } from "~/components/primitives/Headers"; -import { formatDistanceToNow } from "date-fns"; -import { formatNumberCompact } from "~/utils/numberFormatter"; +import { Header2, Header3 } from "~/components/primitives/Headers"; + +import { formatDistanceToNow, isPast } from "date-fns"; + import * as Property from "~/components/primitives/PropertyTable"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { DateTime, RelativeDateTime } from "~/components/primitives/DateTime"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; -import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + type TooltipProps, + XAxis, + YAxis, +} from "recharts"; +import TooltipPortal from "~/components/primitives/TooltipPortal"; import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { useEnvironment } from "~/hooks/useEnvironment"; import { RunsIcon } from "~/assets/icons/RunsIcon"; -import { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; import { useSearchParams } from "~/hooks/useSearchParam"; import { CopyableText } from "~/components/primitives/CopyableText"; +import { cn } from "~/utils/cn"; +import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter"; +import { CodeBlock } from "~/components/code/CodeBlock"; + +import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover"; +import { ErrorGroupActions } from "~/v3/services/errorGroupActions.server"; +import { + ErrorStatusMenuItems, + CustomIgnoreDialog, + statusActionToastMessage, +} from "~/components/errors/ErrorStatusMenu"; +import { useToast } from "~/components/primitives/Toast"; export const meta: MetaFunction = ({ data }) => { return [ @@ -55,6 +91,119 @@ export const meta: MetaFunction = ({ data }) => { ]; }; +const emptyStringToUndefined = z.preprocess( + (v) => (v === "" ? undefined : v), + z.coerce.number().positive().optional() +); + +const actionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("resolve"), + taskIdentifier: z.string().min(1), + resolvedInVersion: z.string().optional(), + }), + z.object({ + action: z.literal("ignore"), + taskIdentifier: z.string().min(1), + duration: emptyStringToUndefined, + occurrenceRate: emptyStringToUndefined, + totalOccurrences: emptyStringToUndefined, + reason: z.preprocess((v) => (v === "" ? undefined : v), z.string().optional()), + }), + z.object({ + action: z.literal("unresolve"), + taskIdentifier: z.string().min(1), + }), +]); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const fingerprint = params.fingerprint; + + if (!fingerprint) { + return json({ error: "Fingerprint parameter is required" }, { status: 400 }); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + return json({ error: "Environment not found" }, { status: 404 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: actionSchema }); + + if (!submission.value) { + return json(submission); + } + + const actions = new ErrorGroupActions(); + const identifier = { + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + taskIdentifier: submission.value.taskIdentifier, + errorFingerprint: fingerprint, + }; + + switch (submission.value.action) { + case "resolve": { + await actions.resolveError(identifier, { + userId, + resolvedInVersion: submission.value.resolvedInVersion, + }); + return json({ ok: true }); + } + case "ignore": { + let occurrenceCountAtIgnoreTime: number | undefined; + + if (submission.value.totalOccurrences) { + const qb = clickhouseClient.errors.listQueryBuilder(); + qb.where("organization_id = {organizationId: String}", { + organizationId: project.organizationId, + }); + qb.where("project_id = {projectId: String}", { projectId: project.id }); + qb.where("environment_id = {environmentId: String}", { + environmentId: environment.id, + }); + qb.where("error_fingerprint = {fingerprint: String}", { fingerprint }); + qb.where("task_identifier = {taskIdentifier: String}", { + taskIdentifier: submission.value.taskIdentifier, + }); + qb.groupBy("error_fingerprint, task_identifier"); + + const [err, results] = await qb.execute(); + if (err || !results || results.length === 0) { + return json( + { error: "Failed to fetch current occurrence count. Please try again." }, + { status: 500 } + ); + } + occurrenceCountAtIgnoreTime = results[0].occurrence_count; + } + + await actions.ignoreError(identifier, { + userId, + duration: submission.value.duration, + occurrenceRateThreshold: submission.value.occurrenceRate, + totalOccurrencesThreshold: submission.value.totalOccurrences, + occurrenceCountAtIgnoreTime, + reason: submission.value.reason, + }); + return json({ ok: true }); + } + case "unresolve": { + await actions.unresolveError(identifier); + return json({ ok: true }); + } + } +}; + export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const userId = user.id; @@ -82,6 +231,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const toStr = url.searchParams.get("to"); const from = fromStr ? parseInt(fromStr, 10) : undefined; const to = toStr ? parseInt(toStr, 10) : undefined; + const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0); const cursor = url.searchParams.get("cursor") ?? undefined; const directionRaw = url.searchParams.get("direction") ?? undefined; const direction = directionRaw ? DirectionSchema.parse(directionRaw) : undefined; @@ -93,6 +243,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { userId, projectId: project.id, fingerprint, + versions: versions.length > 0 ? versions : undefined, period, from, to, @@ -115,9 +266,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { environment.id, fingerprint, time.from, - time.to + time.to, + versions.length > 0 ? versions : undefined ) - .catch(() => ({ data: [] as ErrorGroupActivity })); + .catch(() => ({ data: [] as ErrorGroupActivity, versions: [] as string[] })); return typeddefer({ data: detailPromise, @@ -149,10 +301,19 @@ export default function Page() { if (period) carry.set("period", period); if (from) carry.set("from", from); if (to) carry.set("to", to); + for (const v of searchParams.getAll("versions")) { + if (v) carry.append("versions", v); + } const qs = carry.toString(); return qs ? `${base}?${qs}` : base; }, [organizationSlug, projectParam, envParam, searchParams.toString()]); + const alertsHref = useMemo(() => { + const params = new URLSearchParams(location.search); + params.set("alerts", "true"); + return `?${params.toString()}`; + }, [location.search]); + return ( <> @@ -205,6 +366,7 @@ export default function Page() { projectParam={projectParam} envParam={envParam} fingerprint={fingerprint} + alertsHref={alertsHref} /> ); }} @@ -223,6 +385,7 @@ function ErrorGroupDetail({ projectParam, envParam, fingerprint, + alertsHref, }: { errorGroup: ErrorGroupSummary | undefined; runList: NextRunList | undefined; @@ -231,8 +394,9 @@ function ErrorGroupDetail({ projectParam: string; envParam: string; fingerprint: string; + alertsHref: string; }) { - const { value } = useSearchParams(); + const { value, values } = useSearchParams(); const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); @@ -252,26 +416,181 @@ function ErrorGroupDetail({ const fromValue = value("from") ?? undefined; const toValue = value("to") ?? undefined; + const selectedVersions = values("versions").filter((v) => v !== ""); const filters: TaskRunListSearchFilters = { period: value("period") ?? undefined, from: fromValue ? parseInt(fromValue, 10) : undefined, to: toValue ? parseInt(toValue, 10) : undefined, + versions: selectedVersions.length > 0 ? selectedVersions : undefined, rootOnly: false, errorId: ErrorId.toFriendlyId(fingerprint), }; return ( -
    - {/* Error Summary */} -
    -
    - {errorGroup.errorMessage} - {formatNumberCompact(errorGroup.count)} total occurrences + + {/* Main content: chart + runs */} + +
    + {/* Activity chart */} +
    +
    + + +
    + + }> + }> + {(result) => { + if (result.data.length > 0 && result.versions.length > 0) { + return ; + } + return ; + }} + + +
    + + {/* Runs Table */} +
    +
    + Runs + {runList && ( +
    + + View all runs + + + Bulk replay… + + +
    + )} +
    + {runList ? ( + 0} + filters={{ + tasks: [], + versions: selectedVersions, + statuses: [], + from: undefined, + to: undefined, + }} + runs={runList.runs} + isLoading={false} + variant="dimmed" + additionalTableState={{ errorId: ErrorId.toFriendlyId(fingerprint) }} + /> + ) : ( + + No runs found for this error. + + )} +
    +
    + + {/* Right-hand detail sidebar */} + + + + +
    + ); +} -
    +function ErrorDetailSidebar({ + errorGroup, + fingerprint, + alertsHref, +}: { + errorGroup: ErrorGroupSummary; + fingerprint: string; + alertsHref: string; +}) { + return ( +
    +
    + Details + + Configure alerts + +
    +
    +
    + {/* Status */} + + Error status + +
    + + +
    + + + {errorGroup.state.status === "IGNORED" && ( + + + + )} + +
    +
    + + {/* Error message */} + + Error + + + + ID @@ -284,9 +603,12 @@ function ErrorGroupDetail({ -
    - - + + Occurrences + + {errorGroup.count.toLocaleString()} + + First seen @@ -299,14 +621,11 @@ function ErrorGroupDetail({ - - - {errorGroup.affectedVersions.length > 0 && ( - Affected versions + Versions - + {errorGroup.affectedVersions.join(", ")} @@ -315,91 +634,170 @@ function ErrorGroupDetail({
    +
    + ); +} - {/* Activity chart */} -
    -
    - -
    +function IgnoredDetails({ + state, + totalOccurrences, + className, +}: { + state: ErrorGroupState; + totalOccurrences: number; + className?: string; +}) { + if (state.status !== "IGNORED") { + return null; + } - }> - }> - {(result) => - result.data.length > 0 ? ( - - ) : ( - - ) - } - - -
    + const hasConditions = + state.ignoredUntil || state.ignoredUntilOccurrenceRate || state.ignoredUntilTotalOccurrences; - {/* Runs Table */} -
    -
    - Runs - {runList && ( -
    - - View all runs - - - Bulk replay… - - -
    - )} + const ignoredForever = !hasConditions; + + const occurrencesSinceIgnore = + state.ignoredUntilTotalOccurrences && state.ignoredAtOccurrenceCount !== null + ? totalOccurrences - state.ignoredAtOccurrenceCount + : null; + + return ( +
    +
    +
    + + + {ignoredForever ? "Ignored permanently" : "Ignored with conditions"} +
    - {runList ? ( - - ) : ( - - No runs found for this error. + {(state.ignoredByUserDisplayName || state.ignoredAt) && ( + + {state.ignoredByUserDisplayName && <>Configured by {state.ignoredByUserDisplayName}} + {state.ignoredByUserDisplayName && state.ignoredAt && " "} + {state.ignoredAt && } )}
    + + {state.ignoredReason && ( + Reason: {state.ignoredReason} + )} + + {hasConditions && ( +
    + {state.ignoredUntil && ( + + Will revert to "Unresolved" at:{" "} + + + + {isPast(state.ignoredUntil) && (expired)} + + )} + {state.ignoredUntilOccurrenceRate !== null && state.ignoredUntilOccurrenceRate > 0 && ( + + Will revert to "Unresolved" when: Occurrence rate exceeds{" "} + + {state.ignoredUntilOccurrenceRate}/min + + + )} + {state.ignoredUntilTotalOccurrences !== null && + state.ignoredUntilTotalOccurrences > 0 && ( + + Will revert to "Unresolved" when: Total occurrences exceed{" "} + + {state.ignoredUntilTotalOccurrences.toLocaleString()} + + {occurrencesSinceIgnore !== null && ( + + ({occurrencesSinceIgnore.toLocaleString()} since ignored) + + )} + + )} +
    + )}
    ); } -const activityChartConfig: ChartConfig = { - count: { - label: "Occurrences", - color: "#6366F1", - }, -}; +function ErrorStatusDropdown({ + state, + taskIdentifier, +}: { + state: ErrorGroupState; + taskIdentifier: string; +}) { + const fetcher = useFetcher<{ ok?: boolean }>(); + const revalidator = useRevalidator(); + const [popoverOpen, setPopoverOpen] = useState(false); + const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false); + const isSubmitting = fetcher.state !== "idle"; + const toast = useToast(); + const pendingToast = useRef(); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) { + toast.success(pendingToast.current); + pendingToast.current = undefined; + revalidator.revalidate(); + } + }, [fetcher.state, fetcher.data, toast, revalidator]); + + const act = (data: Record) => { + setPopoverOpen(false); + pendingToast.current = statusActionToastMessage(data); + fetcher.submit(data, { method: "post" }); + }; + + return ( + <> + + + + Mark error as… + + + { + setPopoverOpen(false); + setCustomIgnoreOpen(true); + }} + /> + + + + + + ); +} + +function ActivityChart({ + activity, + versions, +}: { + activity: ErrorGroupActivity; + versions: ErrorGroupActivityVersions; +}) { + const ERROR_CHART_COLORS = ["#6c5ce7", "#ec4899"]; + const colors = useMemo( + () => versions.map((_, i) => ERROR_CHART_COLORS[i % ERROR_CHART_COLORS.length]), + [versions] + ); -function ActivityChart({ activity }: { activity: ErrorGroupActivity }) { const data = useMemo( () => activity.map((d) => ({ @@ -433,48 +831,91 @@ function ActivityChart({ activity }: { activity: ErrorGroupActivity }) { }; }, []); - const tooltipLabelFormatter = useMemo(() => { - return (_label: string, payload: Array<{ payload?: Record }>) => { - const timestamp = payload[0]?.payload?.__timestamp as number | undefined; - if (timestamp) { - const date = new Date(timestamp); - return date.toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); - } - return _label; - }; - }, []); - return ( - - - + + + + + dataMax * 1.15]} + /> + } + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 1000 }} + animationDuration={0} + /> + {versions.map((version, i) => ( + + ))} + + ); } +const ActivityTooltip = ({ + active, + payload, + versions, + colors, +}: TooltipProps & { versions: string[]; colors: string[] }) => { + if (!active || !payload?.length) return null; + + const timestamp = payload[0]?.payload?.__timestamp as number | undefined; + if (!timestamp) return null; + + const date = new Date(timestamp); + const formattedDate = date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + return ( + +
    + {formattedDate} +
    + {payload.map((entry, i) => { + const value = (entry.value as number) ?? 0; + return ( +
    +
    + {entry.dataKey} + {value} +
    + ); + })} +
    +
    + + ); +}; + function ActivityChartBlankState() { return (
    diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx index 2459a067902..e92b5b34644 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors._index/route.tsx @@ -1,8 +1,11 @@ -import { XMarkIcon } from "@heroicons/react/20/solid"; -import { Form, type MetaFunction } from "@remix-run/react"; +import * as Ariakit from "@ariakit/react"; +import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { IconBugFilled } from "@tabler/icons-react"; import { ErrorId } from "@trigger.dev/core/v3/isomorphic"; -import { Suspense, useMemo } from "react"; +import { type ErrorGroupStatus } from "@trigger.dev/database"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Bar, BarChart, @@ -13,30 +16,51 @@ import { type TooltipProps, } from "recharts"; import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { ErrorStatusBadge } from "~/components/errors/ErrorStatusBadge"; import { PageBody } from "~/components/layout/AppLayout"; -import { SearchInput } from "~/components/primitives/SearchInput"; +import { ListPagination } from "~/components/ListPagination"; import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter"; -import { Button } from "~/components/primitives/Buttons"; +import { LogsVersionFilter } from "~/components/logs/LogsVersionFilter"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { formatDateTime, RelativeDateTime } from "~/components/primitives/DateTime"; import { Header3 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { SearchInput } from "~/components/primitives/SearchInput"; +import { + ComboBox, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + SelectTrigger, +} from "~/components/primitives/Select"; import { Spinner } from "~/components/primitives/Spinner"; import { CopyableTableCell, Table, TableBody, TableCell, - TableCellChevron, + TableCellMenu, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; +import { PopoverSectionHeader } from "~/components/primitives/Popover"; +import { + ErrorStatusMenuItems, + CustomIgnoreDialog, + statusActionToastMessage, +} from "~/components/errors/ErrorStatusMenu"; +import { useToast } from "~/components/primitives/Toast"; import TooltipPortal from "~/components/primitives/TooltipPortal"; -import { TimeFilter } from "~/components/runs/v3/SharedFilters"; +import { appliedSummary, FilterMenuProvider, TimeFilter } from "~/components/runs/v3/SharedFilters"; import { $replica } from "~/db.server"; +import { useInterval } from "~/hooks/useInterval"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { @@ -49,7 +73,6 @@ import { import { logsClickhouseClient } from "~/services/clickhouseInstance.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; import { requireUser } from "~/services/session.server"; -import { ListPagination } from "~/components/ListPagination"; import { formatNumberCompact } from "~/utils/numberFormatter"; import { EnvironmentParamSchema, v3ErrorPath } from "~/utils/pathBuilder"; import { ServiceValidationError } from "~/v3/services/baseService.server"; @@ -80,6 +103,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const url = new URL(request.url); const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0); + const versions = url.searchParams.getAll("versions").filter((v) => v.length > 0); + const statuses = url.searchParams + .getAll("status") + .filter( + (s): s is ErrorGroupStatus => s === "UNRESOLVED" || s === "RESOLVED" || s === "IGNORED" + ); const search = url.searchParams.get("search") ?? undefined; const period = url.searchParams.get("period") ?? undefined; const fromStr = url.searchParams.get("from"); @@ -101,6 +130,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { userId, projectId: project.id, tasks: tasks.length > 0 ? tasks : undefined, + versions: versions.length > 0 ? versions : undefined, + statuses: statuses.length > 0 ? statuses : undefined, search, period, from, @@ -153,6 +184,24 @@ export default function Page() { envParam, } = useTypedLoaderData(); + const revalidator = useRevalidator(); + useInterval({ + interval: 60_000, + onLoad: false, + callback: useCallback(() => { + if (revalidator.state === "idle") { + revalidator.revalidate(); + } + }, [revalidator]), + }); + + const location = useOptimisticLocation(); + const alertsHref = useMemo(() => { + const params = new URLSearchParams(location.search); + params.set("alerts", "true"); + return `?${params.toString()}`; + }, [location.search]); + return ( <> @@ -177,7 +226,11 @@ export default function Page() { resolve={data} errorElement={
    - +
    Unable to load errors. Please refresh the page or try again in a moment. @@ -193,6 +246,7 @@ export default function Page() {
    @@ -208,6 +262,7 @@ export default function Page() { list={result} defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} + alertsHref={alertsHref} /> ; +const statusShortcut = { key: "s" }; + +function StatusFilter() { + const { values, del } = useSearchParams(); + const selectedStatuses = values("status"); + + if (selectedStatuses.length === 0 || selectedStatuses.every((v) => v === "")) { + return ( + + {(search, setSearch) => ( + + Status + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); + } + + return ( + + {(search, setSearch) => ( + }> + { + const opt = errorStatusOptions.find((o) => o.value === s); + return opt ? opt.label : s; + }) + )} + onRemove={() => del(["status", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function ErrorStatusDropdown({ + trigger, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ + status: values.length > 0 ? values : undefined, + cursor: undefined, + direction: undefined, + }); + }; + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + {errorStatusOptions.map((item) => ( + + + + ))} + + + + ); +} + function FiltersBar({ list, defaultPeriod, retentionLimitDays, + alertsHref, }: { list?: ErrorsListData; defaultPeriod?: string; retentionLimitDays: number; + alertsHref: string; }) { const location = useOptimisticLocation(); const searchParams = new URLSearchParams(location.search); const hasFilters = + searchParams.has("status") || searchParams.has("tasks") || + searchParams.has("versions") || searchParams.has("search") || searchParams.has("period") || searchParams.has("from") || @@ -246,10 +415,12 @@ function FiltersBar({ return (
    -
    +
    {list ? ( <> + + ) : ( <> + + {hasFilters && ( @@ -283,7 +456,17 @@ function FiltersBar({ )}
    - {list && } +
    + + Configure alerts + + {list && } +
    ); } @@ -303,22 +486,21 @@ function ErrorsList({ }) { if (errorGroups.length === 0) { return ( -
    -
    - No errors found - - No errors have been recorded in the selected time period. - -
    +
    + + + No errors found for this time period. +
    ); } return ( - +
    ID + Status Task Error Occurrences @@ -330,7 +512,7 @@ function ErrorsList({ {errorGroups.map((errorGroup) => ( {errorGroup.fingerprint.slice(-8)} + + + {errorGroup.taskIdentifier} - {errorMessage} + {errorMessage.length > 128 ? `${errorMessage.slice(0, 128)}…` : errorMessage} - {errorGroup.count.toLocaleString()} + + {errorGroup.count.toLocaleString()} + }> }> @@ -403,33 +593,112 @@ function ErrorGroupRow({ - + - + + ); } +function ErrorActionsCell({ + errorGroup, + organizationSlug, + projectParam, + envParam, +}: { + errorGroup: ErrorGroup; + organizationSlug: string; + projectParam: string; + envParam: string; +}) { + const fetcher = useFetcher<{ ok?: boolean }>(); + const revalidator = useRevalidator(); + const [customIgnoreOpen, setCustomIgnoreOpen] = useState(false); + const toast = useToast(); + const pendingToast = useRef(); + + useEffect(() => { + if (fetcher.state === "idle" && fetcher.data?.ok && pendingToast.current) { + toast.success(pendingToast.current); + pendingToast.current = undefined; + revalidator.revalidate(); + } + }, [fetcher.state, fetcher.data, toast, revalidator]); + + const actionUrl = v3ErrorPath( + { slug: organizationSlug }, + { slug: projectParam }, + { slug: envParam }, + { fingerprint: errorGroup.fingerprint } + ); + + return ( + <> + ( + <> + +
    + { + close(); + pendingToast.current = statusActionToastMessage(data); + fetcher.submit(data, { method: "post", action: actionUrl }); + }} + onCustomIgnore={() => { + close(); + setCustomIgnoreOpen(true); + }} + /> +
    + + )} + /> + + + ); +} + function ErrorActivityGraph({ activity }: { activity: ErrorOccurrenceActivity }) { const maxCount = Math.max(...activity.map((d) => d.count)); return (
    -
    +
    } allowEscapeViewBox={{ x: true, y: true }} wrapperStyle={{ zIndex: 1000 }} animationDuration={0} /> - + {maxCount > 0 && ( @@ -470,7 +739,7 @@ const ErrorActivityTooltip = ({ active, payload }: TooltipProps) function ErrorActivityBlankState() { return ( -
    +
    {[...Array(24)].map((_, i) => (
    ))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts new file mode 100644 index 00000000000..b8bed6b631d --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.connect-to-slack.ts @@ -0,0 +1,48 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { prisma } from "~/db.server"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { OrgIntegrationRepository } from "~/models/orgIntegration.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { requireUserId } from "~/services/session.server"; +import { + EnvironmentParamSchema, + v3ErrorsPath, + v3ErrorsConnectToSlackPath, +} from "~/utils/pathBuilder"; + +export async function loader({ request, params }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + + const url = new URL(request.url); + const shouldReinstall = url.searchParams.get("reinstall") === "true"; + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const integration = await prisma.organizationIntegration.findFirst({ + where: { + service: "SLACK", + organizationId: project.organizationId, + deletedAt: null, + }, + }); + + if (integration && !shouldReinstall) { + return redirectWithSuccessMessage( + `${v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam })}?alerts`, + request, + "Successfully connected your Slack workspace" + ); + } + + return await OrgIntegrationRepository.redirectToAuthService( + "SLACK", + project.organizationId, + request, + v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { slug: envParam }) + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx index f6723ddebaa..dd9a5f6d593 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx @@ -1,10 +1,207 @@ -import { Outlet } from "@remix-run/react"; +import { parse } from "@conform-to/zod"; +import { Outlet, useNavigate } from "@remix-run/react"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { useCallback } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { PageContainer } from "~/components/layout/AppLayout"; +import { + ConfigureErrorAlerts, + ErrorAlertsFormSchema, +} from "~/components/errors/ConfigureErrorAlerts"; +import { Sheet, SheetContent } from "~/components/primitives/SheetV3"; +import { prisma } from "~/db.server"; +import { ErrorAlertChannelPresenter } from "~/presenters/v3/ErrorAlertChannelPresenter.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { requireUserId } from "~/services/session.server"; +import { env } from "~/env.server"; +import { + EnvironmentParamSchema, + v3ErrorsConnectToSlackPath, + v3ErrorsPath, +} from "~/utils/pathBuilder"; +import { + type CreateAlertChannelOptions, + CreateAlertChannelService, +} from "~/v3/services/alerts/createAlertChannel.server"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + throw new Response("Project not found", { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + throw new Response("Environment not found", { status: 404 }); + } + + const presenter = new ErrorAlertChannelPresenter(); + const alertData = await presenter.call(project.id, environment.type); + + const connectToSlackHref = v3ErrorsConnectToSlackPath({ slug: organizationSlug }, project, { + slug: envParam, + }); + + const errorsPath = v3ErrorsPath({ slug: organizationSlug }, project, { slug: envParam }); + + return typedjson({ + alertData, + projectRef: project.externalRef, + projectId: project.id, + environmentType: environment.type, + connectToSlackHref, + errorsPath, + }); +}; + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); + + if (request.method.toUpperCase() !== "POST") { + return json({ status: 405, error: "Method Not Allowed" }, { status: 405 }); + } + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + if (!project) { + return json({ error: "Project not found" }, { status: 404 }); + } + + const environment = await findEnvironmentBySlug(project.id, envParam, userId); + if (!environment) { + return json({ error: "Environment not found" }, { status: 404 }); + } + + const formData = await request.formData(); + const submission = parse(formData, { schema: ErrorAlertsFormSchema }); + + if (!submission.value) { + return json(submission); + } + + const { emails, webhooks, slackChannel, slackIntegrationId } = submission.value; + + const emailEnabled = env.ALERT_FROM_EMAIL !== undefined && env.ALERT_RESEND_API_KEY !== undefined; + const slackEnabled = !!slackIntegrationId; + + const existingChannels = await prisma.projectAlertChannel.findMany({ + where: { + projectId: project.id, + alertTypes: { has: "ERROR_GROUP" }, + environmentTypes: { has: environment.type }, + }, + }); + + const service = new CreateAlertChannelService(); + const environmentTypes = [environment.type]; + const processedChannelIds = new Set(); + + if (emailEnabled) { + for (const email of emails) { + const options: CreateAlertChannelOptions = { + name: `Error alert to ${email}`, + alertTypes: ["ERROR_GROUP"], + environmentTypes, + deduplicationKey: `error-email:${email}:${environment.type}`, + channel: { type: "EMAIL", email }, + }; + const channel = await service.call(project.externalRef, userId, options); + processedChannelIds.add(channel.id); + } + } + + if (slackEnabled && slackChannel) { + const [channelId, channelName] = slackChannel.split("/"); + if (channelId && channelName) { + const options: CreateAlertChannelOptions = { + name: `Error alert to #${channelName}`, + alertTypes: ["ERROR_GROUP"], + environmentTypes, + deduplicationKey: `error-slack:${environment.type}`, + channel: { + type: "SLACK", + channelId, + channelName, + integrationId: slackIntegrationId, + }, + }; + const channel = await service.call(project.externalRef, userId, options); + processedChannelIds.add(channel.id); + } + } + + for (const url of webhooks) { + const options: CreateAlertChannelOptions = { + name: `Error alert to ${new URL(url).hostname}`, + alertTypes: ["ERROR_GROUP"], + environmentTypes, + deduplicationKey: `error-webhook:${url}:${environment.type}`, + channel: { type: "WEBHOOK", url }, + }; + const channel = await service.call(project.externalRef, userId, options); + processedChannelIds.add(channel.id); + } + + const editableTypes = new Set(["WEBHOOK"]); + if (emailEnabled) { + editableTypes.add("EMAIL"); + } + if (slackEnabled) { + editableTypes.add("SLACK"); + } + + const channelsToDelete = existingChannels.filter( + (ch) => + !processedChannelIds.has(ch.id) && + editableTypes.has(ch.type) && + ch.alertTypes.length === 1 && + ch.alertTypes[0] === "ERROR_GROUP" + ); + + for (const ch of channelsToDelete) { + await prisma.projectAlertChannel.delete({ where: { id: ch.id } }); + } + + return json({ ok: true }); +}; export default function Page() { + const { alertData, connectToSlackHref, errorsPath } = useTypedLoaderData(); + const { has } = useSearchParams(); + const showAlerts = has("alerts") ?? false; + const navigate = useNavigate(); + const location = useOptimisticLocation(); + + const closeAlerts = useCallback(() => { + const params = new URLSearchParams(location.search); + params.delete("alerts"); + const qs = params.toString(); + navigate(qs ? `?${qs}` : location.pathname, { replace: true }); + }, [location.search, location.pathname, navigate]); + return ( + + !open && closeAlerts()}> + e.preventDefault()} + > + + + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx index 0c7f75fe262..af3cc30a246 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs/route.tsx @@ -32,12 +32,15 @@ import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter"; import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter"; import { TimeFilter } from "~/components/runs/v3/SharedFilters"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, + useFrozenValue, } from "~/components/primitives/Resizable"; import { Button } from "~/components/primitives/Buttons"; -import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags"; // Valid log levels for filtering const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]; @@ -148,7 +151,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { from, to, defaultPeriod: "1h", - retentionLimitDays + retentionLimitDays, }) .catch((error) => { if (error instanceof ServiceValidationError) { @@ -165,8 +168,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }; export default function Page() { - const { data, defaultPeriod, retentionLimitDays } = - useTypedLoaderData(); + const { data, defaultPeriod, retentionLimitDays } = useTypedLoaderData(); return ( @@ -192,10 +194,7 @@ export default function Page() { resolve={data} errorElement={
    - +
    Unable to load your logs. Please refresh the page or try again in a moment. @@ -228,10 +227,7 @@ export default function Page() { defaultPeriod={defaultPeriod} retentionLimitDays={retentionLimitDays} /> - +
    ); }} @@ -409,6 +405,11 @@ function LogsList({ return accumulatedLogs.find((log) => log.id === selectedLogId); }, [selectedLogId, accumulatedLogs]); + const frozenLogId = useFrozenValue(selectedLogId); + const frozenLog = useFrozenValue(selectedLog); + const displayLogId = selectedLogId ?? frozenLogId; + const displayLog = selectedLog ?? frozenLog ?? undefined; + const updateUrlWithLog = useCallback((logId: string | undefined) => { const url = new URL(window.location.href); if (logId) { @@ -464,11 +465,21 @@ function LogsList({ onLogSelect={handleLogSelect} /> - {/* Side panel for log details */} - {selectedLogId && ( - <> - - + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {displayLogId && ( @@ -477,15 +488,15 @@ function LogsList({ } > - - - )} + )} +
    +
    ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx index 5b8fc9170db..7a25f996d4d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models.$modelId/route.tsx @@ -42,7 +42,7 @@ import { formatModelPrice, formatTokenCount, formatModelCost, - formatCapability, + formatFeature, formatProviderName, } from "~/utils/modelFormatters"; @@ -112,8 +112,8 @@ type Tab = "overview" | "global" | "usage"; const TAB_CONFIG: { id: Tab; label: string }[] = [ { id: "overview", label: "Overview" }, - { id: "global", label: "Global Metrics" }, - { id: "usage", label: "Your Usage" }, + { id: "usage", label: "Metrics" }, + { id: "global", label: "Global metrics" }, ]; export default function ModelDetailPage() { @@ -312,14 +312,14 @@ function OverviewTab({ )} - {model.capabilities.length > 0 && ( + {model.features.length > 0 && ( - Capabilities + Features
    - {model.capabilities.map((cap) => ( - - {formatCapability(cap)} + {model.features.map((f) => ( + + {formatFeature(f)} ))}
    @@ -425,16 +425,7 @@ function GlobalMetricsTab({ return (
    {/* Big numbers */} -
    -
    - -
    +
    {/* Charts */} -
    -
    - -
    -
    - -
    +
    +
    @@ -523,7 +503,7 @@ function YourUsageTab({
    { return [{ title: "Models | Trigger.dev" }]; @@ -84,32 +117,37 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const popularModels = await presenter.getPopularModels(sevenDaysAgo, now, 50); const allProviders = catalog.map((g) => g.provider); - const allCapabilities = Array.from( - new Set(catalog.flatMap((g) => g.models.flatMap((m) => m.capabilities))) + const allFeatures = Array.from( + new Set(catalog.flatMap((g) => g.models.flatMap((m) => m.features))) ).sort(); - return typedjson({ catalog, popularModels, allProviders, allCapabilities }); + return typedjson({ + catalog, + popularModels, + allProviders, + allFeatures, + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + }); }; -// --- Helpers --- - -const FEATURE_OPTIONS = [ - { value: "structuredOutput", label: "Structured Output" }, - { value: "parallelToolCalls", label: "Parallel Tool Calls" }, - { value: "streamingToolCalls", label: "Streaming Tool Calls" }, -] as const; - -type FeatureKey = (typeof FEATURE_OPTIONS)[number]["value"]; +const providerIcons: Record JSX.Element> = { + openai: OpenAIIcon, + anthropic: AnthropicIcon, + google: GeminiIcon, + meta: LlamaIcon, + mistral: MistralIcon, + deepseek: DeepseekIcon, + xai: XAIIcon, + perplexity: PerplexityIcon, + cerebras: CerebrasIcon, + azure: AzureIcon, +}; -function modelMatchesFeature(model: ModelCatalogItem, feature: FeatureKey): boolean { - switch (feature) { - case "structuredOutput": - return model.supportsStructuredOutput; - case "parallelToolCalls": - return model.supportsParallelToolCalls; - case "streamingToolCalls": - return model.supportsStreamingToolCalls; - } +function providerIcon(slug: string) { + const Icon = providerIcons[slug] ?? CubeIcon; + return ; } // --- Filter Components --- @@ -117,245 +155,177 @@ function modelMatchesFeature(model: ModelCatalogItem, feature: FeatureKey): bool function ProviderFilter({ providers }: { providers: string[] }) { const { values, replace, del } = useSearchParams(); const selected = values("providers"); + const hasFilter = selected.length > 0; return ( - <> - replace({ providers: v })}> - - {selected.length === 0 ? ( - - - Provider - - ) : null} - - - - {providers.map((p) => ( - - {formatProviderName(p)} - - ))} - - - - {selected.length > 0 && ( + replace({ providers: v })}> + }> } + label={hasFilter ? "Provider" : undefined} + value={hasFilter ? appliedSummary(selected.map(formatProviderName))! : "Provider"} + valueClassName={hasFilter ? undefined : "text-text-bright"} + removable={hasFilter} onRemove={() => del("providers")} /> - )} - - ); -} - -function CapabilityFilter({ capabilities }: { capabilities: string[] }) { - const { values, replace, del } = useSearchParams(); - const selected = values("capabilities"); - - return ( - <> - replace({ capabilities: v })}> - - {selected.length === 0 ? ( - - - Capability - - ) : null} - - - - {capabilities.map((c) => ( - - {formatCapability(c)} - - ))} - - - - {selected.length > 0 && ( - del("capabilities")} - /> - )} - + + + + {providers.map((p) => ( + + {formatProviderName(p)} + + ))} + + + ); } -function FeaturesFilter() { +function FeaturesFilter({ features }: { features: string[] }) { const { values, replace, del } = useSearchParams(); const selected = values("features"); + const hasFilter = selected.length > 0; return ( - <> - replace({ features: v })}> - - {selected.length === 0 ? ( - - - Features - - ) : null} - - - - {FEATURE_OPTIONS.map((f) => ( - - {f.label} - - ))} - - - - {selected.length > 0 && ( + replace({ features: v })}> + }> FEATURE_OPTIONS.find((f) => f.value === s)?.label ?? s) - )! - } + icon={} + label={hasFilter ? "Features" : undefined} + value={hasFilter ? appliedSummary(selected.map(formatFeature))! : "Features"} + valueClassName={hasFilter ? undefined : "text-text-bright"} + removable={hasFilter} onRemove={() => del("features")} /> - )} - + + + + {features.map((f) => ( + + {formatFeature(f)} + + ))} + + + ); } -// --- Model Card --- +// --- Filters Bar --- -function ModelCard({ - model, - popular, - onToggleCompare, - isSelected, +function FiltersBar({ + allProviders, + allFeatures, + compareSet, + onCompare, + showAllDetails, + onToggleAllDetails, }: { - model: ModelCatalogItem; - popular?: PopularModel; - onToggleCompare: (modelName: string) => void; - isSelected: boolean; + allProviders: string[]; + allFeatures: string[]; + compareSet: Set; + onCompare: () => void; + showAllDetails: boolean; + onToggleAllDetails: (checked: boolean) => void; }) { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); - const detailPath = v3ModelDetailPath(organization, project, environment, model.friendlyId); - - return ( -
    -
    e.stopPropagation()}> - onToggleCompare(model.modelName)} - title="Select for comparison" - /> -
    + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const hasFilters = + searchParams.has("providers") || searchParams.has("features") || searchParams.has("search"); - - {model.displayId} - - - {model.description && ( -

    {model.description}

    - )} + const compareDisabled = compareSet.size < 2; -
    - - {formatModelPrice(model.inputPrice)}/1M in - - - {formatModelPrice(model.outputPrice)}/1M out - - {model.contextWindow && ( - {formatTokenCount(model.contextWindow)} ctx + return ( +
    +
    + + + + {hasFilters && ( +
    +
    - - {model.capabilities.length > 0 && ( -
    - {model.capabilities.map((cap) => ( - - {formatCapability(cap)} - - ))} -
    - )} - -
    - {popular && popular.callCount > 0 && ( - {formatNumberCompact(popular.callCount)} calls (7d) - )} - {popular && popular.ttfcP50 > 0 && ( - {popular.ttfcP50.toFixed(0)}ms TTFC - )} +
    + +
    - - {model.variants.length > 0 && }
    ); } -function VariantsDropdown({ variants }: { variants: ModelCatalogItem["variants"] }) { - const [isOpen, setIsOpen] = useState(false); - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); +// --- Models Table --- +function BooleanCell({ value, onClick }: { value: boolean; onClick: () => void }) { return ( -
    - - {isOpen && ( -
    - {variants.map((v) => ( - - {v.modelName} - {v.releaseDate && ( - {v.releaseDate} - )} - - ))} -
    - )} -
    + + {value ? ( + + ) : null} + ); } -// --- Models Table --- - -function ModelsTable({ +function ModelsList({ models, popularMap, compareSet, onToggleCompare, + showAllDetails, + allFeatures, + selectedModelId, + onSelectModel, }: { models: ModelCatalogItem[]; popularMap: Map; compareSet: Set; onToggleCompare: (modelName: string) => void; + showAllDetails: boolean; + allFeatures: string[]; + selectedModelId: string | null; + onSelectModel: (model: ModelCatalogItem) => void; }) { - const organization = useOrganization(); - const project = useProject(); - const environment = useEnvironment(); + if (models.length === 0) { + return ( +
    +

    No models match your filters.

    +
    + ); + } return ( -
    +
    @@ -364,43 +334,71 @@ function ModelsTable({ Input $/1M Output $/1M Context + {showAllDetails && ( + <> + Max output + Release date + {allFeatures.map((f) => ( + + {formatFeature(f)} + + ))} + + )} p50 TTFC - Calls (7d) {models.map((model) => { - const path = v3ModelDetailPath(organization, project, environment, model.friendlyId); const popular = popularMap.get(model.modelName); + const select = () => onSelectModel(model); return ( - + onToggleCompare(model.modelName)} + disabled={compareSet.size >= 4 && !compareSet.has(model.modelName)} /> - - {model.displayId} + + {model.displayId} + + + + {providerIcon(model.provider)} + {formatProviderName(model.provider)} + - {formatProviderName(model.provider)} - + {formatModelPrice(model.inputPrice)} - + {formatModelPrice(model.outputPrice)} - + {formatTokenCount(model.contextWindow)} - + {showAllDetails && ( + <> + + {formatTokenCount(model.maxOutputTokens)} + + + {model.releaseDate ? ( + + ) : ( + "—" + )} + + {allFeatures.map((f) => ( + + ))} + + )} + {popular && popular.ttfcP50 > 0 ? `${popular.ttfcP50.toFixed(0)}ms` : "—"} - - {popular && popular.callCount > 0 - ? formatNumberCompact(popular.callCount) - : "—"} - ); })} @@ -409,30 +407,673 @@ function ModelsTable({ ); } -// --- Main Page --- +// --- Compare Dialog --- -export default function ModelsPage() { - const { catalog, popularModels, allProviders, allCapabilities } = - useTypedLoaderData(); +type ComparisonRow = { + label: string; + values: React.ReactNode[]; + bestIndex?: number; +}; + +function buildComparisonRows( + models: string[], + catalogModels: ModelCatalogItem[], + comparison: ModelComparisonItem[] +): ComparisonRow[] { + const catalogMap = new Map(); + for (const item of catalogModels) { + catalogMap.set(item.modelName, item); + } + + const dataMap = new Map(); + for (const item of comparison) { + dataMap.set(item.responseModel, item); + } + + const allFeatures = Array.from( + new Set(models.flatMap((m) => catalogMap.get(m)?.features ?? [])) + ).sort(); + + const getCatalog = (model: string) => catalogMap.get(model); + const getMetric = (model: string, key: keyof ModelComparisonItem) => { + const d = dataMap.get(model); + return d ? d[key] : 0; + }; + + const findBest = (values: number[], lowerIsBetter: boolean) => { + if (values.every((v) => v === 0)) return undefined; + const filtered = values.map((v, i) => ({ v, i })).filter(({ v }) => v > 0); + if (filtered.length === 0) return undefined; + const best = lowerIsBetter + ? filtered.reduce((a, b) => (a.v < b.v ? a : b)) + : filtered.reduce((a, b) => (a.v > b.v ? a : b)); + return best.i; + }; + + const inputPrices = models.map((m) => getCatalog(m)?.inputPrice ?? 0); + const outputPrices = models.map((m) => getCatalog(m)?.outputPrice ?? 0); + const contextWindows = models.map((m) => getCatalog(m)?.contextWindow ?? 0); + const maxOutputs = models.map((m) => getCatalog(m)?.maxOutputTokens ?? 0); + const callValues = models.map((m) => Number(getMetric(m, "callCount"))); + const ttfcP50Values = models.map((m) => Number(getMetric(m, "ttfcP50"))); + const ttfcP90Values = models.map((m) => Number(getMetric(m, "ttfcP90"))); + const tpsP50Values = models.map((m) => Number(getMetric(m, "tpsP50"))); + const tpsP90Values = models.map((m) => Number(getMetric(m, "tpsP90"))); + const costValues = models.map((m) => Number(getMetric(m, "totalCost"))); + + return [ + { + label: "Provider", + values: models.map((m) => { + const c = getCatalog(m); + const slug = c?.provider ?? dataMap.get(m)?.genAiSystem; + if (!slug) return "—"; + return ( + + {providerIcon(slug)} + {formatProviderName(slug)} + + ); + }), + }, + { + label: "Input $/1M", + values: models.map((m) => formatModelPrice(getCatalog(m)?.inputPrice ?? null)), + bestIndex: findBest(inputPrices, true), + }, + { + label: "Output $/1M", + values: models.map((m) => formatModelPrice(getCatalog(m)?.outputPrice ?? null)), + bestIndex: findBest(outputPrices, true), + }, + { + label: "Context window", + values: models.map((m) => formatTokenCount(getCatalog(m)?.contextWindow ?? null)), + bestIndex: findBest(contextWindows, false), + }, + { + label: "Max output", + values: models.map((m) => formatTokenCount(getCatalog(m)?.maxOutputTokens ?? null)), + bestIndex: findBest(maxOutputs, false), + }, + { + label: "Release date", + values: models.map((m) => { + const c = getCatalog(m); + return c?.releaseDate + ? new Date(c.releaseDate).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }) + : "—"; + }), + }, + ...allFeatures.map((feature) => ({ + label: formatFeature(feature), + values: models.map((m) => + getCatalog(m)?.features.includes(feature) ? ( + + ) : ( + "—" + ) + ), + })), + { + label: "Total calls (7d)", + values: callValues.map((v) => formatNumberCompact(v)), + bestIndex: findBest(callValues, false), + }, + { + label: "p50 TTFC", + values: ttfcP50Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")), + bestIndex: findBest(ttfcP50Values, true), + }, + { + label: "p90 TTFC", + values: ttfcP90Values.map((v) => (v > 0 ? `${v.toFixed(0)}ms` : "—")), + bestIndex: findBest(ttfcP90Values, true), + }, + { + label: "Tokens/sec (p50)", + values: tpsP50Values.map((v) => (v > 0 ? v.toFixed(0) : "—")), + bestIndex: findBest(tpsP50Values, false), + }, + { + label: "Tokens/sec (p90)", + values: tpsP90Values.map((v) => (v > 0 ? v.toFixed(0) : "—")), + bestIndex: findBest(tpsP90Values, false), + }, + { + label: "Total cost (7d)", + values: costValues.map((v) => (v > 0 ? formatModelCost(v) : "—")), + bestIndex: findBest(costValues, true), + }, + ]; +} + +function CompareDialog({ + open, + onOpenChange, + models, + catalogModels, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + models: string[]; + catalogModels: ModelCatalogItem[]; +}) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const navigate = useNavigate(); - const searchParams = useSearchParams(); - - const view = searchParams.value("view") ?? "cards"; - const search = searchParams.value("search") ?? ""; - const selectedProviders = searchParams.values("providers"); - const selectedCapabilities = searchParams.values("capabilities"); - const selectedFeatures = searchParams.values("features") as FeatureKey[]; + const fetcher = useFetcher(); + + const comparison = (fetcher.data as { comparison?: ModelComparisonItem[] } | undefined) + ?.comparison; + const rows = useMemo( + () => buildComparisonRows(models, catalogModels, comparison ?? []), + [comparison, models, catalogModels] + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only fires on open; other deps are stable per dialog mount + useEffect(() => { + if (open && models.length >= 2) { + const params = models.join(","); + fetcher.load(`${v3ModelComparePath(organization, project, environment)}?models=${params}`); + } + }, [open]); + + return ( + + + + Compare models + + {rows.length > 0 ? ( +
    +
    + + + Metric + {models.map((model) => ( + + {model} + + ))} + + + + {rows.map((row) => ( + + + + {row.label} + + + {row.values.map((value, i) => ( + +
    + {value} +
    +
    + ))} +
    + ))} +
    +
    +
    + ) : ( +
    + No comparison data available for these models. +
    + )} + + + ); +} + +// --- Model Detail Panel --- + +function escapeTSQL(value: string): string { + return value.replace(/'/g, "''"); +} + +function bignumberConfig( + column: string, + opts?: { aggregation?: "sum" | "avg" | "first"; suffix?: string; abbreviate?: boolean } +): QueryWidgetConfig { + return { + type: "bignumber", + column, + aggregation: opts?.aggregation ?? "sum", + abbreviate: opts?.abbreviate ?? false, + suffix: opts?.suffix, + }; +} + +function chartConfig(opts: { + chartType: "bar" | "line"; + xAxisColumn: string; + yAxisColumns: string[]; + aggregation?: "sum" | "avg"; +}): QueryWidgetConfig { + return { + type: "chart", + chartType: opts.chartType, + xAxisColumn: opts.xAxisColumn, + yAxisColumns: opts.yAxisColumns, + groupByColumn: null, + stacked: false, + sortByColumn: null, + sortDirection: "asc", + aggregation: opts.aggregation ?? "sum", + }; +} + +type DetailTab = "overview" | "global" | "usage"; + +function ModelDetailPanel({ + model, + organizationId, + projectId, + environmentId, + onClose, +}: { + model: ModelCatalogItem; + organizationId: string; + projectId: string; + environmentId: string; + onClose: () => void; +}) { + const [tab, setTab] = useState("overview"); + + return ( +
    +
    + {model.displayId} +
    +
    + + setTab("overview")} + shortcut={{ key: "o" }} + > + Overview + + setTab("usage")} + shortcut={{ key: "u" }} + > + Metrics + + setTab("global")} + shortcut={{ key: "g" }} + > + Global metrics + + +
    +
    + {tab === "overview" && } + {tab === "global" && ( + + )} + {tab === "usage" && ( + + )} +
    +
    + ); +} + +function DetailOverviewTab({ model }: { model: ModelCatalogItem }) { + return ( +
    + + + Provider + {formatProviderName(model.provider)} + + + Model name + + {model.modelName} + + + {model.description && ( + + Description + {model.description} + + )} + + Input price + + {formatModelPrice(model.inputPrice)} / 1M tokens + + + + Output price + + {formatModelPrice(model.outputPrice)} / 1M tokens + + + {model.contextWindow && ( + + Context window + + {formatTokenCount(model.contextWindow)} tokens + + + )} + {model.maxOutputTokens && ( + + Max output tokens + + {formatTokenCount(model.maxOutputTokens)} tokens + + + )} + {model.releaseDate && ( + + Release date + + + + + )} + + + {model.features.length > 0 && ( + + + Features + +
    + {model.features.map((f) => ( +
    + + {formatFeature(f)} +
    + ))} +
    +
    +
    +
    + )} + + {model.variants.length > 0 && ( + <> + Variants + + {model.variants.map((v) => ( + + {v.displayId} + + {v.releaseDate ? : "—"} + + + ))} + + + )} +
    + ); +} + +function DetailGlobalMetricsTab({ + modelName, + organizationId, + projectId, + environmentId, +}: { + modelName: string; + organizationId: string; + projectId: string; + environmentId: string; +}) { + const widgetProps = { + organizationId, + projectId, + environmentId, + scope: "environment" as const, + period: "7d", + from: null, + to: null, + }; + + return ( +
    +
    + +
    +
    + +
    +
    + +
    + +
    + +
    + + + Aggregated across all Trigger.dev users. No tenant-specific data is exposed. + +
    + ); +} + +function DetailYourUsageTab({ + modelName, + organizationId, + projectId, + environmentId, +}: { + modelName: string; + organizationId: string; + projectId: string; + environmentId: string; +}) { + const widgetProps = { + organizationId, + projectId, + environmentId, + scope: "environment" as const, + period: "7d", + from: null, + to: null, + }; + + return ( +
    +
    + +
    +
    + +
    +
    + 0`} + config={bignumberConfig("avg_ttfc", { aggregation: "avg", suffix: "ms" })} + {...widgetProps} + /> +
    +
    + 0`} + config={bignumberConfig("avg_tps", { aggregation: "avg" })} + {...widgetProps} + /> +
    + +
    + +
    +
    + +
    +
    + +
    +
    + ); +} + +// --- Main Page --- + +export default function ModelsPage() { + const { + catalog, + popularModels, + allProviders, + allFeatures, + organizationId, + projectId, + environmentId, + } = useTypedLoaderData(); + const { values: searchValues, value: searchValue } = useSearchParams(); + + const search = searchValue("search") ?? ""; + const selectedProviders = searchValues("providers"); + const selectedFeatures = searchValues("features"); const [compareSet, setCompareSet] = useState>(new Set()); + const [showAllDetails, setShowAllDetails] = useState(false); + const [compareOpen, setCompareOpen] = useState(false); + const [selectedModel, setSelectedModel] = useState(null); + const frozenModel = useFrozenValue(selectedModel); + const displayModel = selectedModel ?? frozenModel; const popularMap = useMemo(() => { const map = new Map(); for (const m of popularModels) { - // Index by raw response_model map.set(m.responseModel, m); - // Also index by model name without provider prefix (e.g. "openai/gpt-4o" → "gpt-4o") if (m.responseModel.includes("/")) { map.set(m.responseModel.split("/").slice(1).join("/"), m); } @@ -440,33 +1081,17 @@ export default function ModelsPage() { return map; }, [popularModels]); - const filteredCatalog = useMemo(() => { + const filteredModels = useMemo(() => { return catalog - .map((group) => ({ - ...group, - models: group.models.filter((m) => { - if (search && !m.displayId.toLowerCase().includes(search.toLowerCase())) return false; - if (selectedProviders.length > 0 && !selectedProviders.includes(m.provider)) return false; - if ( - selectedCapabilities.length > 0 && - !selectedCapabilities.every((c) => m.capabilities.includes(c)) - ) - return false; - if ( - selectedFeatures.length > 0 && - !selectedFeatures.every((f) => modelMatchesFeature(m, f)) - ) - return false; - return true; - }), - })) - .filter((group) => group.models.length > 0); - }, [catalog, search, selectedProviders, selectedCapabilities, selectedFeatures]); - - const allFilteredModels = useMemo( - () => filteredCatalog.flatMap((g) => g.models), - [filteredCatalog] - ); + .flatMap((group) => group.models) + .filter((m) => { + if (search && !m.displayId.toLowerCase().includes(search.toLowerCase())) return false; + if (selectedProviders.length > 0 && !selectedProviders.includes(m.provider)) return false; + if (selectedFeatures.length > 0 && !selectedFeatures.every((f) => m.features.includes(f))) + return false; + return true; + }); + }, [catalog, search, selectedProviders, selectedFeatures]); const toggleCompare = (modelName: string) => { setCompareSet((prev) => { @@ -480,118 +1105,77 @@ export default function ModelsPage() { }); }; - const hasActiveFilters = - selectedProviders.length > 0 || - selectedCapabilities.length > 0 || - selectedFeatures.length > 0; + const compareModels = useMemo(() => Array.from(compareSet), [compareSet]); + const allModels = useMemo(() => catalog.flatMap((g) => g.models), [catalog]); return ( - -
    -
    - - searchParams.replace({ search: e.target.value || undefined })} - variant="small" - className="pl-8" - fullWidth={false} - /> -
    - -
    - - -
    -
    -
    - - {/* Filter bar */} -
    - - - - {hasActiveFilters && ( - - )} -
    - - {/* Compare bar */} - {compareSet.size >= 2 && ( -
    - {compareSet.size} models selected -
    - - + + + +
    + setCompareOpen(true)} + showAllDetails={showAllDetails} + onToggleAllDetails={(checked) => setShowAllDetails(checked)} + /> +
    -
    - )} - - {view === "cards" ? ( -
    - {filteredCatalog.map((group) => ( -
    - {formatProviderName(group.provider)} -
    - {group.models.map((model) => ( - - ))} -
    -
    - ))} - {filteredCatalog.length === 0 && ( -

    - No models match your filters. -

    - )} -
    - ) : ( - + - )} + { + if (isCollapsed) setSelectedModel(null); + }} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {displayModel && ( + setSelectedModel(null)} + /> + )} +
    +
    + + ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx index 3484e1378b4..26daa24df34 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions/route.tsx @@ -262,7 +262,7 @@ export default function Page() { } /> @@ -309,12 +309,9 @@ export default function Page() { security portal or{" "} + get in touch - + } defaultValue="help" /> @@ -345,20 +342,21 @@ function SetDefaultDialog({ Set as default region - + +
    Are you sure you want to set {newDefaultRegion.name} as your new default region? @@ -441,6 +439,7 @@ function SetDefaultDialog({ Runs triggered from now on will execute in "{newDefaultRegion.name}", unless you{" "} override when triggering. +
    - {isShowingBulkActionInspector && ( - <> - - + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + {isShowingBulkActionInspector && ( 0} /> - - - )} + )} +
    +
    ); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx index d32f9ecb5b4..769aa10cd75 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules/route.tsx @@ -25,9 +25,11 @@ import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -304,14 +306,25 @@ export default function Page() { )}
    - {(isShowingNewPane || isShowingSchedule) && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx index b3233abb858..782f3b132ff 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens/route.tsx @@ -13,9 +13,11 @@ import { DateTime } from "~/components/primitives/DateTime"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { + RESIZABLE_PANEL_ANIMATION, ResizableHandle, ResizablePanel, ResizablePanelGroup, + collapsibleHandleClassName, } from "~/components/primitives/Resizable"; import { Table, @@ -240,14 +242,25 @@ export default function Page() {
    - {isShowingWaitpoint && ( - <> - - - - - - )} + + {}} + collapsedSize="0px" + collapseAnimation={RESIZABLE_PANEL_ANIMATION} + > +
    + +
    +
    )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx index c954a6fe697..ba11cf8f8a1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.integrations.slack.tsx @@ -1,13 +1,14 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; +import { type ActionFunctionArgs, type LoaderFunctionArgs, json, redirect } from "@remix-run/node"; import { fromPromise } from "neverthrow"; import { Form, useActionData, useNavigation } from "@remix-run/react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { DialogClose } from "@radix-ui/react-dialog"; -import { SlackIcon } from "@trigger.dev/companyicons"; import { TrashIcon } from "@heroicons/react/20/solid"; +import { IconBugFilled } from "@tabler/icons-react"; +import { SlackMonoIcon } from "~/assets/icons/SlackMonoIcon"; import { Button } from "~/components/primitives/Buttons"; +import { DateTime } from "~/components/primitives/DateTime"; import { Dialog, DialogContent, @@ -17,8 +18,14 @@ import { DialogTrigger, } from "~/components/primitives/Dialog"; import { FormButtons } from "~/components/primitives/FormButtons"; -import { Header1 } from "~/components/primitives/Headers"; -import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Table, @@ -31,21 +38,9 @@ import { import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import { $transaction, prisma } from "~/db.server"; import { requireOrganization } from "~/services/org.server"; -import { OrganizationParamsSchema, organizationSettingsPath } from "~/utils/pathBuilder"; +import { OrganizationParamsSchema, organizationSlackIntegrationPath } from "~/utils/pathBuilder"; import { logger } from "~/services/logger.server"; -function formatDate(date: Date): string { - return new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }).format(date); -} - export const loader = async ({ request, params }: LoaderFunctionArgs) => { const { organizationSlug } = OrganizationParamsSchema.parse(params); const { organization } = await requireOrganization(request, organizationSlug); @@ -183,12 +178,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { integrationId: slackIntegration.id, }); - return redirect(organizationSettingsPath({ slug: organizationSlug })); + return redirect(organizationSlackIntegrationPath({ slug: organizationSlug })); }; export default function SlackIntegrationPage() { - const { slackIntegration, alertChannels, teamName } = - useTypedLoaderData(); + const { slackIntegration, alertChannels, teamName } = useTypedLoaderData(); const actionData = useActionData(); const navigation = useNavigation(); const isUninstalling = @@ -197,12 +191,18 @@ export default function SlackIntegrationPage() { if (!slackIntegration) { return ( + + + -
    - No Slack Integration Found - - This organization doesn't have a Slack integration configured. You can connect Slack - when setting up alert channels in your project settings. +
    + + No Slack integration found + + Your organization doesn't have a Slack integration configured. You can connect Slack + when setting up alerts from the{" "} + + Errors page.
    @@ -212,114 +212,131 @@ export default function SlackIntegrationPage() { return ( + + + -
    - Slack Integration - - Manage your organization's Slack integration and connected alert channels. - -
    - - {/* Integration Info Section */} -
    -
    + +
    -

    Integration Details

    -
    +
    + Integration details +
    +
    {teamName && ( -
    - Slack Workspace: {teamName} -
    + + Workspace:{" "} + {teamName} + )} -
    - Installed:{" "} - {formatDate(new Date(slackIntegration.createdAt))} -
    + + Installed:{" "} + + + +
    -
    - - - - - - - Remove Slack Integration - - - This will remove the Slack integration and disable all connected alert channels. - This action cannot be undone. - - - + +
    + + Connected alert channels + ({alertChannels.length}) + + {alertChannels.length === 0 ? ( + + No alert channels are currently connected to this Slack integration. + + ) : ( + + + + Channel + Project + Status + Created + + + + {alertChannels.map((channel) => ( + + {channel.name} + {channel.project.name} + + + + + + + + ))} + +
    + )} +
    + +
    + Danger zone +
    + Remove integration + + This will remove the Slack integration and disable all connected alert channels. + This action cannot be undone. + + {actionData?.error && ( + + {actionData.error} + + )} + + - - } - cancelButton={ - - - - } - /> - -
    - {actionData?.error && ( - - {actionData.error} - - )} + + + + Remove Slack integration + + + This will remove the Slack integration and disable all connected alert + channels. This action cannot be undone. + + + + + + } + cancelButton={ + + + + } + /> + + + } + /> +
    -
    - - {/* Connected Alert Channels Section */} -
    -

    - Connected Alert Channels ({alertChannels.length}) -

    - - {alertChannels.length === 0 ? ( -
    - - No alert channels are currently connected to this Slack integration. - -
    - ) : ( - - - - Channel Name - Project - Status - Created - - - - {alertChannels.map((channel) => ( - - {channel.name} - {channel.project.name} - - - - {formatDate(new Date(channel.createdAt))} - - ))} - -
    - )} -
    + ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx new file mode 100644 index 00000000000..7c82333ccc0 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections._index/route.tsx @@ -0,0 +1,307 @@ +import { LinkButton } from "~/components/primitives/Buttons"; +import { Form, useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { Header2 } from "~/components/primitives/Headers"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { prisma } from "~/db.server"; +import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server"; +import { logger } from "~/services/logger.server"; +import { getPrivateLinks } from "~/services/platform.v3.server"; +import { requireUserId } from "~/services/session.server"; +import { + OrganizationParamsSchema, + organizationPath, + v3PrivateConnectionsPath, +} from "~/utils/pathBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import type { PrivateLinkConnectionStatus } from "@trigger.dev/platform"; +import { Button } from "~/components/primitives/Buttons"; +import { type ActionFunctionArgs, json } from "@remix-run/server-runtime"; +import { deletePrivateLink } from "~/services/platform.v3.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { + ClipboardDocumentIcon, + PlusIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; +import { useMemo, useState } from "react"; +import { useInterval } from "~/hooks/useInterval"; + +export const meta: MetaFunction = () => { + return [{ title: `Private Connections | Trigger.dev` }]; +}; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const canAccess = await canAccessPrivateConnections({ organizationSlug, userId }); + if (!canAccess) { + return redirect(organizationPath({ slug: organizationSlug })); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } + + const [error, connections] = await tryCatch(getPrivateLinks(organization.id)); + if (error) { + logger.error("Error loading private link connections", { error, organizationId: organization.id }); + } + + return typedjson({ + connections: connections?.connections ?? [], + organizationId: organization.id, + }); +} + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + if (request.method !== "DELETE" && request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const formData = await request.formData(); + const connectionId = formData.get("connectionId"); + const intent = formData.get("intent"); + + if (intent !== "delete" || typeof connectionId !== "string") { + return json({ error: "Invalid request" }, { status: 400 }); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + "Organization not found" + ); + } + + const [error] = await tryCatch(deletePrivateLink(organization.id, connectionId)); + if (error) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + `Failed to delete connection: ${error.message}` + ); + } + + return redirectWithSuccessMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + "Connection deletion initiated" + ); +}; + +const STATUS_COLORS: Record = { + PENDING: "bg-amber-500/20 text-amber-400", + PROVISIONING: "bg-blue-500/20 text-blue-400", + ACTIVE: "bg-emerald-500/20 text-emerald-400", + ERROR: "bg-rose-500/20 text-rose-400", + DELETING: "bg-charcoal-500/20 text-charcoal-400", +}; + +function StatusBadge({ status }: { status: PrivateLinkConnectionStatus }) { + return ( + + {status} + + ); +} + +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +const TERMINAL_STATUSES: PrivateLinkConnectionStatus[] = ["ACTIVE", "ERROR"]; + +export default function Page() { + const { connections } = useTypedLoaderData(); + const plan = useCurrentPlan(); + const revalidator = useRevalidator(); + + const hasInProgressConnections = useMemo( + () => connections.some((c) => !TERMINAL_STATUSES.includes(c.status)), + [connections] + ); + + useInterval({ + interval: 3_000, + onLoad: false, + callback: () => { + if (revalidator.state === "idle") { + revalidator.revalidate(); + } + }, + disabled: !hasInProgressConnections, + }); + + const hasPrivateNetworking = plan?.v3Subscription?.plan?.limits?.hasPrivateNetworking ?? false; + const limit = plan?.v3Subscription?.plan?.limits?.privateLinkConnectionLimit ?? 2; + const canAdd = connections.filter((c) => c.status !== "DELETING").length < limit; + + return ( + + + + + {hasPrivateNetworking && canAdd && ( + + Add Connection + + )} + + + + +
    +
    + Private Connections + + Connect your AWS resources (databases, caches, APIs) to your Trigger.dev tasks via + AWS PrivateLink. Connections are organization-wide and work across all projects and + environments. + +
    + + {!hasPrivateNetworking ? ( +
    + + Private Connections require upgrading to Pro or an Enterprise plan. + +
    + ) : connections.length === 0 ? ( +
    + + No private connections yet. Add your first connection to securely reach your AWS + resources from task pods. + + + Add Connection + +
    + ) : ( +
    + {connections.map((connection) => ( +
    +
    +
    + + {connection.name} + + + {connection.status !== "DELETING" && ( +
    + + + +
    + )} +
    +
    +
    + Service: + + {connection.endpointServiceName} + + +
    +
    + Region: + {connection.targetRegion} +
    + {connection.endpointIps && connection.endpointIps.length > 0 && ( +
    + IPs: + + {connection.endpointIps.join(", ")} + + +
    + )} + {connection.statusMessage && ( +
    + Error: + {connection.statusMessage} +
    + )} +
    + Created: + + {new Date(connection.createdAt).toLocaleDateString()} + +
    +
    +
    +
    + ))} + + {!canAdd && ( + + Connection limit reached ({limit}). Delete an existing connection to add a new + one. + + )} +
    + )} +
    +
    +
    +
    + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx new file mode 100644 index 00000000000..649419b9c65 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.private-connections.new/route.tsx @@ -0,0 +1,769 @@ +import { conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData, useParams, type MetaFunction } from "@remix-run/react"; +import { json, type ActionFunction, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { useState } from "react"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { + MainHorizontallyCenteredContainer, + PageBody, + PageContainer, +} from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Header2, Header3 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { canAccessPrivateConnections } from "~/v3/canAccessPrivateConnections.server"; +import { + redirectWithErrorMessage, + redirectWithSuccessMessage, +} from "~/models/message.server"; +import type { CreatePrivateLinkConnectionBody } from "@trigger.dev/platform"; +import { + createPrivateLink, + getPrivateLinkRegions, +} from "~/services/platform.v3.server"; +import { requireUserId } from "~/services/session.server"; +import { + OrganizationParamsSchema, + organizationPath, + v3PrivateConnectionsPath, +} from "~/utils/pathBuilder"; +import { + CommandLineIcon, + DocumentTextIcon, + PencilSquareIcon, + SparklesIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; + +export const meta: MetaFunction = () => { + return [{ title: `Add Private Connection | Trigger.dev` }]; +}; + +export async function loader({ params, request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const canAccess = await canAccessPrivateConnections({ organizationSlug, userId }); + if (!canAccess) { + return redirect(organizationPath({ slug: organizationSlug })); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + throw new Response(null, { status: 404, statusText: "Organization not found" }); + } + + const [error, regions] = await tryCatch(getPrivateLinkRegions(organization.id)); + + const awsAccountIds = env.PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS?.split(",").filter(Boolean) ?? []; + + return typedjson({ + availableRegions: regions?.availableRegions ?? ["us-east-1", "eu-central-1"], + activeRegions: regions?.activeRegions ?? [], + awsAccountIds, + }); +} + +const schema = z.object({ + name: z.string().min(1, "Name is required").max(100, "Name must be 100 characters or less"), + endpointServiceName: z + .string() + .min(1, "VPC Endpoint Service name is required") + .regex( + /^com\.amazonaws\.vpce\..+\.vpce-svc-.+$/, + "Must be a valid VPC Endpoint Service name (com.amazonaws.vpce..vpce-svc-*)" + ), + targetRegion: z.string().min(1, "Region is required"), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { organizationSlug } = OrganizationParamsSchema.parse(params); + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + + const organization = await prisma.organization.findFirst({ + where: { slug: organizationSlug, members: { some: { userId } } }, + }); + + if (!organization) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + "Organization not found" + ); + } + + // Fetch available regions dynamically (same call the loader makes) + const [, fetchedRegions] = await tryCatch(getPrivateLinkRegions(organization.id)); + const availableRegions = fetchedRegions?.availableRegions ?? ["us-east-1", "eu-central-1"]; + + const { targetRegion: selectedRegion, ...rest } = submission.value; + + if (!availableRegions.includes(selectedRegion)) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + `Invalid region: ${selectedRegion}` + ); + } + + const [error] = await tryCatch( + createPrivateLink(organization.id, { + ...rest, + targetRegion: selectedRegion as CreatePrivateLinkConnectionBody["targetRegion"], + }) + ); + + if (error) { + return redirectWithErrorMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + error.message + ); + } + + const message = "Connection created! Provisioning will begin shortly."; + + return redirectWithSuccessMessage( + v3PrivateConnectionsPath({ slug: organizationSlug }), + request, + message + ); +}; + + +type SetupMethod = "manual" | "ai" | "terraform" | "docs"; + +type PortEntry = { port: string; protocol: "TCP" | "UDP" }; + +const AWS_REGIONS = [ + { value: "us-east-1", label: "US East (N. Virginia)" }, + { value: "us-east-2", label: "US East (Ohio)" }, + { value: "us-west-1", label: "US West (N. California)" }, + { value: "us-west-2", label: "US West (Oregon)" }, + { value: "af-south-1", label: "Africa (Cape Town)" }, + { value: "ap-east-1", label: "Asia Pacific (Hong Kong)" }, + { value: "ap-south-1", label: "Asia Pacific (Mumbai)" }, + { value: "ap-south-2", label: "Asia Pacific (Hyderabad)" }, + { value: "ap-southeast-1", label: "Asia Pacific (Singapore)" }, + { value: "ap-southeast-2", label: "Asia Pacific (Sydney)" }, + { value: "ap-southeast-3", label: "Asia Pacific (Jakarta)" }, + { value: "ap-southeast-4", label: "Asia Pacific (Melbourne)" }, + { value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" }, + { value: "ap-northeast-2", label: "Asia Pacific (Seoul)" }, + { value: "ap-northeast-3", label: "Asia Pacific (Osaka)" }, + { value: "ca-central-1", label: "Canada (Central)" }, + { value: "ca-west-1", label: "Canada West (Calgary)" }, + { value: "eu-central-1", label: "Europe (Frankfurt)" }, + { value: "eu-central-2", label: "Europe (Zurich)" }, + { value: "eu-west-1", label: "Europe (Ireland)" }, + { value: "eu-west-2", label: "Europe (London)" }, + { value: "eu-west-3", label: "Europe (Paris)" }, + { value: "eu-south-1", label: "Europe (Milan)" }, + { value: "eu-south-2", label: "Europe (Spain)" }, + { value: "eu-north-1", label: "Europe (Stockholm)" }, + { value: "il-central-1", label: "Israel (Tel Aviv)" }, + { value: "me-south-1", label: "Middle East (Bahrain)" }, + { value: "me-central-1", label: "Middle East (UAE)" }, + { value: "sa-east-1", label: "South America (São Paulo)" }, +]; + +function TerraformWizard({ awsAccountIds }: { awsAccountIds: string[] }) { + const [hostname, setHostname] = useState(""); + const [ports, setPorts] = useState([{ port: "5432", protocol: "TCP" }]); + const [region, setRegion] = useState("us-east-1"); + + const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]); + const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index)); + const updatePort = (index: number, field: keyof PortEntry, value: string) => + setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + + const validPorts = ports.filter((p) => p.port !== ""); + + const terraformScript = `# Trigger.dev Private Networking - Terraform Configuration +# Creates an NLB and VPC Endpoint Service for your resource + +variable "vpc_id" { + description = "Your VPC ID" + type = string +} + +variable "subnet_ids" { + description = "Private subnet IDs in your VPC" + type = list(string) +} + +variable "target_ip" { + description = "IP address of the target resource" + type = string${hostname ? `\n default = "${hostname}"` : ""} +} + +# Network Load Balancer +resource "aws_lb" "trigger_privatelink" { + name = "trigger-privatelink" + internal = true + load_balancer_type = "network" + subnets = var.subnet_ids +} +${validPorts + .map( + (p, i) => ` +resource "aws_lb_target_group" "port_${p.port}" { + name = "trigger-pl-${p.port}" + port = ${p.port} + protocol = "${p.protocol}" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + protocol = "TCP" + port = ${p.port} + } +} + +resource "aws_lb_target_group_attachment" "port_${p.port}" { + target_group_arn = aws_lb_target_group.port_${p.port}.arn + target_id = var.target_ip + port = ${p.port} +} + +resource "aws_lb_listener" "port_${p.port}" { + load_balancer_arn = aws_lb.trigger_privatelink.arn + port = ${p.port} + protocol = "${p.protocol}" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.port_${p.port}.arn + } +}` + ) + .join("\n")} + +# VPC Endpoint Service +resource "aws_vpc_endpoint_service" "trigger_privatelink" { + acceptance_required = false + network_load_balancer_arns = [aws_lb.trigger_privatelink.arn] + supported_regions = ["us-east-1", "eu-central-1"] + + allowed_principals = [ +${awsAccountIds.map((id) => ` "arn:aws:iam::${id}:root",`).join("\n")} + ] +} + +output "endpoint_service_name" { + description = "Paste this into the Trigger.dev dashboard" + value = aws_vpc_endpoint_service.trigger_privatelink.service_name +} +`; + + return ( +
    + + + setHostname(e.target.value)} + placeholder="my-database.abc123.us-east-1.rds.amazonaws.com" + fullWidth + /> + + +
    + +
    + {ports.map((entry, index) => ( +
    + updatePort(index, "port", e.target.value)} + placeholder="Port" + className="w-24" + /> + + {ports.length > 1 && ( + + )} +
    + ))} + +
    +
    + + + + + + +
    +
    + main.tf + +
    +
    +          {terraformScript}
    +        
    +
    +
    + ); +} + +function AIPromptWizard({ awsAccountIds }: { awsAccountIds: string[] }) { + const [hostname, setHostname] = useState(""); + const [ports, setPorts] = useState([{ port: "5432", protocol: "TCP" }]); + const [region, setRegion] = useState("us-east-1"); + + const addPort = () => setPorts([...ports, { port: "", protocol: "TCP" }]); + const removePort = (index: number) => setPorts(ports.filter((_, i) => i !== index)); + const updatePort = (index: number, field: keyof PortEntry, value: string) => + setPorts(ports.map((p, i) => (i === index ? { ...p, [field]: value } : p))); + + const validPorts = ports.filter((p) => p.port !== ""); + const regionLabel = AWS_REGIONS.find((r) => r.value === region)?.label ?? region; + + const portsDescription = validPorts.length > 0 + ? validPorts.map((p) => `${p.port} (${p.protocol})`).join(", ") + : "5432 (TCP)"; + + const prompt = `I need to set up AWS PrivateLink so that Trigger.dev can connect to my resource. Please create the following in my AWS account in the ${region} (${regionLabel}) region: + +1. A Network Load Balancer (NLB): + - Name: trigger-privatelink + - Internal: yes + - Type: network + - Place it in my private subnets + +2. For each of the following ports, create a target group, target group attachment, and listener: +${validPorts.length > 0 ? validPorts.map((p) => ` - Port ${p.port} (${p.protocol})`).join("\n") : " - Port 5432 (TCP)"} + + Each target group should: + - Target type: ip + - Target IP: ${hostname || ""} + - Have a TCP health check on the same port + +3. A VPC Endpoint Service: + - Acceptance required: no + - Attach the NLB created above + - Supported regions: us-east-1, eu-central-1 + - Allowed principals: +${awsAccountIds.map((id) => ` - arn:aws:iam::${id}:root`).join("\n") || " - "} + +After creating everything, give me the VPC Endpoint Service name (it looks like com.amazonaws.vpce..vpce-svc-*) so I can paste it into the Trigger.dev dashboard.`; + + return ( +
    + + + setHostname(e.target.value)} + placeholder="my-database.abc123.us-east-1.rds.amazonaws.com" + fullWidth + /> + + +
    + +
    + {ports.map((entry, index) => ( +
    + updatePort(index, "port", e.target.value)} + placeholder="Port" + className="w-24" + /> + + {ports.length > 1 && ( + + )} +
    + ))} + +
    +
    + + + + + + +
    +
    + AI Prompt + +
    +
    +          {prompt}
    +        
    +
    +
    + ); +} + +export default function Page() { + const { availableRegions, activeRegions, awsAccountIds } = useTypedLoaderData(); + const { organizationSlug } = useParams(); + const lastSubmission = useActionData(); + const [setupMethod, setSetupMethod] = useState(null); + + const defaultRegion = "us-east-1"; + + const [form, { name, endpointServiceName, targetRegion }] = useForm({ + id: "create-private-connection", + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + }); + + return ( + + + + + + +
    +
    + Add Private Connection + + Connect your AWS resources to Trigger.dev task pods via AWS PrivateLink. You'll need + to create a VPC Endpoint Service on your AWS account first. + +
    + + {/* Setup method cards */} +
    + + + + +
    + + {/* AI prompt wizard */} + {setupMethod === "ai" && ( +
    + AI-Assisted Setup + + Fill in your resource details below and we'll generate a prompt you can paste into + Claude, ChatGPT, or any AI assistant with AWS access. After it creates the + resources, paste the VPC Endpoint Service name below. + + +
    + )} + + {/* Terraform wizard (expandable) */} + {setupMethod === "terraform" && ( +
    + Terraform Configuration + + Fill in your resource details below and we'll generate a Terraform script. Run{" "} + + terraform apply + {" "} + to create the VPC Endpoint Service, then paste the output service name below. + + +
    + )} + + {/* Docs iframe */} + {setupMethod === "docs" && ( +
    + Setup Guide + {awsAccountIds.length > 0 && ( + <> + + When adding allowed principals to your VPC Endpoint Service, use the following + AWS account ID(s): + +
    + {awsAccountIds.map((id) => ( + + {id} + + ))} +
    + + )} +