Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46bceb0064 | |||
| c00dae006b | |||
| 774007e9b9 | |||
| f86e492c31 | |||
| d4772b5f60 | |||
| 88f755082f | |||
| 54d95ee4b9 | |||
| 35298ac357 | |||
| 1cfc296c6b | |||
| 411803e49e | |||
| dbbe9f77f9 | |||
| f98e274b06 | |||
| cf0e1ff91a | |||
| 2406e850eb | |||
| 7672e8d998 | |||
| 2c688ca3d4 | |||
| 59ff68b00c | |||
| 440c16c4aa | |||
| d4d8d9fabc | |||
| 8108683073 | |||
| fea8ae4e53 | |||
| 21fdb528f5 | |||
| b8766e7a27 | |||
| fef6d18e8f | |||
| c0b6309c72 | |||
| 436f20efc6 | |||
| d1ea8d8f74 | |||
| e64b101138 | |||
| 6f6523ff78 | |||
| d30ed3421e | |||
| 5f359be286 | |||
| e49ccc1226 | |||
| c01332297b | |||
| c5ce2976be | |||
| e954a2c97a | |||
| e36b75785e | |||
| dee6f1d09e | |||
| b192b71b93 | |||
| a9163dfd9d | |||
| 540e1c86a4 | |||
| 2af5c860de | |||
| a09038b066 | |||
| 2135dc56d6 | |||
| 4a0fb2fc06 | |||
| e1f8134f44 | |||
| 10d6f01843 | |||
| 24b92d3b68 | |||
| 8003923598 | |||
| cff45664fc | |||
| 51b6c3a580 | |||
| d5a27f08ed | |||
| 719a44da01 | |||
| 92dfeb37b3 | |||
| b1e78a6590 | |||
| 5612383684 | |||
| 4451fcb84c | |||
| 863dbe8d60 | |||
| 39dd91b098 | |||
| cf6b6e7063 | |||
| bed3789c31 | |||
| a482153365 | |||
| fe193418d0 | |||
| e9fb8e3b52 | |||
| c05b30adfe | |||
| f37bdaac84 | |||
| 3c0644a3b8 | |||
| 19733c8338 | |||
| 9ba608d2cf | |||
| 89c73ed8ba | |||
| 97bf89873e | |||
| b60788df82 | |||
| 6409fea6ac | |||
| ae46e3f7c8 | |||
| 69dc7bcde8 | |||
| 3a7054628f | |||
| 676525279a | |||
| 79f8cdef72 | |||
| 98bf706437 | |||
| 354363e408 | |||
| 525145def0 | |||
| 23c327ea16 | |||
| d794101c67 | |||
| f325638892 | |||
| 68e3f8c9db | |||
| 2071090042 | |||
| 469b039090 | |||
| 38981f5e70 | |||
| 22505e9803 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"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).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix `list_deploys` MCP tool failing when deployments have null `runtime` or `runtimeVersion` fields.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Propagate run tags to span attributes so they can be extracted server-side for LLM cost attribution metadata.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"@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
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
"@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
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@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.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
paths:
|
||||
- "internal-packages/database/**"
|
||||
---
|
||||
|
||||
# Database Migration Safety
|
||||
|
||||
- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file).
|
||||
- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY.
|
||||
- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file.
|
||||
- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes.
|
||||
- Never drop columns or tables without explicit approval.
|
||||
- New code should target `RunEngineVersion.V2` only.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
paths:
|
||||
- "docs/**"
|
||||
---
|
||||
|
||||
# Documentation Writing Rules
|
||||
|
||||
- Use Mintlify MDX format. Frontmatter: `title`, `description`, `sidebarTitle` (optional).
|
||||
- After creating a new page, add it to `docs.json` navigation under the correct group.
|
||||
- Use Mintlify components: `<Note>`, `<Warning>`, `<Info>`, `<Tip>`, `<CodeGroup>`, `<Expandable>`, `<Steps>`/`<Step>`.
|
||||
- Code examples should be complete and runnable where possible.
|
||||
- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`.
|
||||
- Keep paragraphs short. Use headers to break up content.
|
||||
- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`).
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/webapp/app/v3/**"
|
||||
---
|
||||
|
||||
# Legacy V1 Engine Code in `app/v3/`
|
||||
|
||||
The `v3/` directory name is misleading - most code here is actively used by the current V2 engine. Only the specific files below are legacy V1-only code.
|
||||
|
||||
## V1-Only Files - Never Modify
|
||||
|
||||
- `marqs/` directory (entire MarQS queue system: sharedQueueConsumer, devQueueConsumer, fairDequeuingStrategy, devPubSub)
|
||||
- `legacyRunEngineWorker.server.ts` (V1 background job worker)
|
||||
- `services/triggerTaskV1.server.ts` (deprecated V1 task triggering)
|
||||
- `services/cancelTaskRunV1.server.ts` (deprecated V1 cancellation)
|
||||
- `authenticatedSocketConnection.server.ts` (V1 dev WebSocket using DevQueueConsumer)
|
||||
- `sharedSocketConnection.ts` (V1 shared queue socket using SharedQueueConsumer)
|
||||
|
||||
## V1/V2 Branching Pattern
|
||||
|
||||
Some services act as routers that branch on `RunEngineVersion`:
|
||||
- `services/cancelTaskRun.server.ts` - calls V1 service or `engine.cancelRun()` for V2
|
||||
- `services/batchTriggerV3.server.ts` - uses marqs for V1 path, run-engine for V2
|
||||
|
||||
When editing these shared services, only modify V2 code paths.
|
||||
|
||||
## V2 Modern Stack
|
||||
|
||||
- **Run lifecycle**: `@internal/run-engine` (internal-packages/run-engine)
|
||||
- **Background jobs**: `@trigger.dev/redis-worker` (not graphile-worker/zodworker)
|
||||
- **Queue operations**: RunQueue inside run-engine (not MarQS)
|
||||
- **V2 engine singleton**: `runEngine.server.ts`, `runEngineHandlers.server.ts`
|
||||
- **V2 workers**: `commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts`
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
paths:
|
||||
- "packages/**"
|
||||
---
|
||||
|
||||
# Public Package Rules
|
||||
|
||||
- Changes to `packages/` are **customer-facing**. Always add a changeset: `pnpm run changeset:add`
|
||||
- Default to **patch**. Get maintainer approval for minor. Never select major without explicit approval.
|
||||
- `@trigger.dev/core`: **Never import the root**. Always use subpath imports (e.g., `@trigger.dev/core/v3`).
|
||||
- Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes.
|
||||
- Test changes using `references/hello-world` reference project.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/**"
|
||||
---
|
||||
|
||||
# Server App Changes
|
||||
|
||||
When modifying server apps (webapp, supervisor, coordinator, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset:
|
||||
|
||||
```bash
|
||||
cat > .server-changes/descriptive-name.md << 'EOF'
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Brief description of what changed and why.
|
||||
EOF
|
||||
```
|
||||
|
||||
- **area**: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- **type**: `feature` | `fix` | `improvement` | `breaking`
|
||||
- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed).
|
||||
+5
-1
@@ -13,4 +13,8 @@ samejr
|
||||
isshaddad
|
||||
# Outside contributors
|
||||
gautamsi
|
||||
capaj
|
||||
capaj
|
||||
chengzp
|
||||
bharathkumar39293
|
||||
bhekanik
|
||||
jrossi
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
paths:
|
||||
- "packages/**"
|
||||
- ".changeset/**"
|
||||
- ".server-changes/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
|
||||
@@ -50,7 +51,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update PR title with version
|
||||
- name: Update PR title and enhance body
|
||||
if: steps.changesets.outputs.published != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -61,6 +62,15 @@ jobs:
|
||||
# we arbitrarily reference the version of the cli package here; it is the same for all package releases
|
||||
VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version')
|
||||
gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION"
|
||||
|
||||
# Enhance the PR body with a clean, deduplicated summary
|
||||
RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body')
|
||||
ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION")
|
||||
if [ -n "$ENHANCED_BODY" ]; then
|
||||
gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \
|
||||
-X PATCH \
|
||||
-f body="$ENHANCED_BODY"
|
||||
fi
|
||||
fi
|
||||
|
||||
update-lockfile:
|
||||
@@ -88,15 +98,26 @@ jobs:
|
||||
- name: Install and update lockfile
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
|
||||
- name: Commit and push lockfile
|
||||
- name: Clean up consumed .server-changes/ files
|
||||
run: |
|
||||
set -e
|
||||
if git diff --quiet pnpm-lock.yaml; then
|
||||
echo "No lockfile changes"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "chore: update lockfile for release"
|
||||
shopt -s nullglob
|
||||
files=(.server-changes/*.md)
|
||||
for f in "${files[@]}"; do
|
||||
if [ "$(basename "$f")" != "README.md" ]; then
|
||||
git rm --ignore-unmatch "$f"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push lockfile + server-changes cleanup
|
||||
run: |
|
||||
set -e
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: update lockfile and clean up .server-changes/ for release"
|
||||
git push origin changeset-release/main
|
||||
else
|
||||
echo "No changes to commit"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
name: 📝 CLAUDE.md Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, synchronize]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- ".changeset/**"
|
||||
- ".server-changes/**"
|
||||
- "**/*.md"
|
||||
- "references/**"
|
||||
|
||||
concurrency:
|
||||
group: claude-md-audit-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
use_sticky_comment: true
|
||||
|
||||
claude_args: |
|
||||
--max-turns 15
|
||||
--allowedTools "Read,Glob,Grep,Bash(git diff:*)"
|
||||
|
||||
prompt: |
|
||||
You are reviewing a PR to check whether any CLAUDE.md files or .claude/rules/ files need updating.
|
||||
|
||||
## Your task
|
||||
|
||||
1. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR.
|
||||
2. For each changed directory, check if there's a CLAUDE.md in that directory or a parent directory.
|
||||
3. Determine if any CLAUDE.md or .claude/rules/ file should be updated based on the changes. Consider:
|
||||
- New files/directories that aren't covered by existing documentation
|
||||
- Changed architecture or patterns that contradict current CLAUDE.md guidance
|
||||
- New dependencies, services, or infrastructure that Claude should know about
|
||||
- Renamed or moved files that are referenced in CLAUDE.md
|
||||
- Changes to build commands, test patterns, or development workflows
|
||||
|
||||
## Response format
|
||||
|
||||
If NO updates are needed, respond with exactly:
|
||||
✅ CLAUDE.md files look current for this PR.
|
||||
|
||||
If updates ARE needed, respond with a short list:
|
||||
📝 **CLAUDE.md updates suggested:**
|
||||
- `path/to/CLAUDE.md`: [what should be added/changed]
|
||||
- `.claude/rules/file.md`: [what should be added/changed]
|
||||
|
||||
Keep suggestions specific and brief. Only flag things that would actually mislead Claude in future sessions.
|
||||
Do NOT suggest updates for trivial changes (bug fixes, small refactors within existing patterns).
|
||||
Do NOT suggest creating new CLAUDE.md files - only updates to existing ones.
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: pnpm run changeset:release
|
||||
createGithubReleases: true
|
||||
createGithubReleases: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -122,6 +122,19 @@ jobs:
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create unified GitHub release
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
VERSION="${{ steps.get_version.outputs.package_version }}"
|
||||
node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md
|
||||
gh release create "v${VERSION}" \
|
||||
--title "trigger.dev v${VERSION}" \
|
||||
--notes-file /tmp/release-body.md \
|
||||
--target main
|
||||
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
@@ -140,6 +153,62 @@ jobs:
|
||||
with:
|
||||
image_tag: v${{ needs.release.outputs.published_package_version }}
|
||||
|
||||
# After Docker images are published, update the GitHub release with the exact GHCR tag URL.
|
||||
# The GHCR package version ID is only known after the image is pushed, so we query for it here.
|
||||
update-release:
|
||||
name: 🔗 Update release Docker link
|
||||
needs: [release, publish-docker]
|
||||
if: needs.release.outputs.published == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
steps:
|
||||
- name: Update GitHub release with Docker image link
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
VERSION="${{ needs.release.outputs.published_package_version }}"
|
||||
TAG="v${VERSION}"
|
||||
|
||||
# Query GHCR for the version ID matching this tag
|
||||
VERSION_ID=$(gh api --paginate -H "Accept: application/vnd.github+json" \
|
||||
/orgs/triggerdotdev/packages/container/trigger.dev/versions \
|
||||
--jq ".[] | select(.metadata.container.tags[] == \"${TAG}\") | .id" \
|
||||
| head -1)
|
||||
|
||||
if [ -z "$VERSION_ID" ]; then
|
||||
echo "Warning: Could not find GHCR version ID for tag ${TAG}, skipping update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DOCKER_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/${VERSION_ID}?tag=${TAG}"
|
||||
GENERIC_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev"
|
||||
|
||||
# Get current release body and replace the generic link with the tag-specific one.
|
||||
# Use word boundary after GENERIC_URL (closing paren) to avoid matching URLs that
|
||||
# already have a version ID appended (idempotent on re-runs).
|
||||
gh release view "${TAG}" --repo triggerdotdev/trigger.dev --json body --jq '.body' > /tmp/release-body.md
|
||||
sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md
|
||||
|
||||
gh release edit "${TAG}" --repo triggerdotdev/trigger.dev --notes-file /tmp/release-body.md
|
||||
|
||||
# Dispatch changelog entry creation to the marketing site repo.
|
||||
# Runs after update-release so the GitHub release body already has the exact Docker image URL.
|
||||
dispatch-changelog:
|
||||
name: 📝 Dispatch changelog PR
|
||||
needs: [release, update-release]
|
||||
if: needs.release.outputs.published == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.CROSS_REPO_PAT }}
|
||||
repository: triggerdotdev/trigger.dev-site-v3
|
||||
event-type: new-release
|
||||
client-payload: '{"version": "${{ needs.release.outputs.published_package_version }}"}'
|
||||
|
||||
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
|
||||
prerelease:
|
||||
name: 🧪 Prerelease
|
||||
|
||||
@@ -10,14 +10,35 @@ permissions:
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
check-vouch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mitchellh/vouch/action/check-pr@main
|
||||
- uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
|
||||
with:
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
auto-close: true
|
||||
require-vouch: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
require-draft:
|
||||
needs: check-vouch
|
||||
if: >
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.author_association != 'MEMBER' &&
|
||||
github.event.pull_request.author_association != 'OWNER' &&
|
||||
github.event.pull_request.author_association != 'COLLABORATOR'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-draft PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
STATE=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json state -q '.state')
|
||||
if [ "$STATE" != "OPEN" ]; then
|
||||
echo "PR is already closed, skipping."
|
||||
exit 0
|
||||
fi
|
||||
gh pr close ${{ github.event.pull_request.number }} \
|
||||
--repo ${{ github.repository }} \
|
||||
--comment "Thanks for your contribution! We require all external PRs to be opened in **draft** status first so you can address CodeRabbit review comments and ensure CI passes before requesting a review. Please re-open this PR as a draft. See [CONTRIBUTING.md](https://github.com/${{ github.repository }}/blob/main/CONTRIBUTING.md#pr-workflow) for details."
|
||||
|
||||
@@ -16,8 +16,7 @@ jobs:
|
||||
contains(github.event.comment.body, 'denounce') ||
|
||||
contains(github.event.comment.body, 'unvouch')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@main
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
|
||||
with:
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
|
||||
+2
-1
@@ -67,4 +67,5 @@ apps/**/public/build
|
||||
**/.claude/settings.local.json
|
||||
.mcp.log
|
||||
.mcp.json
|
||||
.cursor/debug.log
|
||||
.cursor/debug.log
|
||||
ailogger-output.log
|
||||
@@ -0,0 +1,81 @@
|
||||
# Server Changes
|
||||
|
||||
This directory tracks changes to server-only components (webapp, supervisor, coordinator, etc.) that are not captured by changesets. Changesets only track published npm packages — server changes would otherwise go undocumented.
|
||||
|
||||
## When to add a file
|
||||
|
||||
**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file.
|
||||
|
||||
**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed — the changeset covers it.
|
||||
|
||||
**Package-only PRs**: Just add a changeset as usual.
|
||||
|
||||
## File format
|
||||
|
||||
Create a markdown file with a descriptive name:
|
||||
|
||||
```
|
||||
.server-changes/fix-batch-queue-stalls.md
|
||||
```
|
||||
|
||||
With this format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Speed up batch queue processing by removing stalls and fixing retry race
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- **area** (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- **type** (required): `feature` | `fix` | `improvement` | `breaking`
|
||||
|
||||
### Description
|
||||
|
||||
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. Engineer adds a `.server-changes/` file in their PR
|
||||
2. Files accumulate on `main` as PRs merge
|
||||
3. The changeset release PR includes these in its summary
|
||||
4. After the release merges, CI cleans up the consumed files
|
||||
|
||||
## Examples
|
||||
|
||||
**New feature:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
TRQL query language and the Query page
|
||||
```
|
||||
|
||||
**Bug fix:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Fix schedule limit counting for orgs with custom limits
|
||||
```
|
||||
|
||||
**Improvement:**
|
||||
|
||||
```markdown
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Use the replica for API auth queries to reduce primary load
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
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 `<div>` inside `<p>` DOM nesting warnings in span titles and chat messages
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Add allowRollbacks query param to the promote deployment API to enable version downgrades
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Added `/engine/v1/dev/disconnect` endpoint to auto-cancel runs when the CLI disconnects. Maximum of 500 runs can be cancelled. Uses the bulk action system when there are more than 25 runs to cancel.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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`.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,2 @@
|
||||
vouch:
|
||||
- github: edosrecki
|
||||
+34
-9
@@ -1,24 +1,49 @@
|
||||
# Changesets
|
||||
# Changesets and Server Changes
|
||||
|
||||
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage updated our packages and releasing them to npm.
|
||||
Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage package versions and releasing them to npm. For server-only changes, we use a lightweight `.server-changes/` convention.
|
||||
|
||||
## Adding a changeset
|
||||
## Adding a changeset (package changes)
|
||||
|
||||
To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset.
|
||||
|
||||
## Release instructions (local only)
|
||||
## Adding a server change (server-only changes)
|
||||
|
||||
Based on the instructions [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md)
|
||||
If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset:
|
||||
|
||||
1. Run `pnpm run changeset:version`
|
||||
2. Run `pnpm run changeset:release`
|
||||
```sh
|
||||
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Speed up batch queue processing by removing stalls and fixing retry race
|
||||
EOF
|
||||
```
|
||||
|
||||
- `area`: `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- `type`: `feature` | `fix` | `improvement` | `breaking`
|
||||
|
||||
For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed.
|
||||
|
||||
See `.server-changes/README.md` for full documentation.
|
||||
|
||||
## When to add which
|
||||
|
||||
| PR changes | What to add |
|
||||
|---|---|
|
||||
| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) |
|
||||
| Only server (`apps/`) | `.server-changes/` file |
|
||||
| Both packages and server | Just the changeset |
|
||||
|
||||
## Release instructions (CI)
|
||||
|
||||
Please follow the best-practice of adding changesets in the same commit as the code making the change with `pnpm run changeset:add`, as it will allow our release.yml CI workflow to function properly:
|
||||
|
||||
- Anytime new changesets are added in a commit in the `main` branch, the [release.yml](./.github/workflows/release.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
|
||||
- When the version PR is merged into `main`, the release.yml workflow will automatically run `pnpm run changeset:release` to build and release packages to npm.
|
||||
- Anytime new changesets are added in a commit in the `main` branch, the [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`.
|
||||
- The release PR body is automatically enhanced with a clean, deduplicated summary that includes both package changes and `.server-changes/` entries.
|
||||
- Consumed `.server-changes/` files are removed on the `changeset-release/main` branch — the same way changesets deletes `.changeset/*.md` files. When the release PR merges, they're gone from main.
|
||||
- When the version PR is merged into `main`, the [release.yml](./.github/workflows/release.yml) workflow will automatically build, release packages to npm, and create a single unified GitHub release.
|
||||
|
||||
## Pre-release instructions
|
||||
|
||||
|
||||
@@ -1,73 +1,72 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
This file provides guidance to Claude Code when working with this repository. Subdirectory CLAUDE.md files provide deeper context when you navigate into specific areas.
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
This is a pnpm 10.23.0 monorepo using Turborepo. Run commands from root with `pnpm run`.
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
# Start Docker services (PostgreSQL, Redis, Electric)
|
||||
pnpm run docker
|
||||
|
||||
# Run database migrations
|
||||
pnpm run db:migrate
|
||||
|
||||
# Seed the database (required for reference projects)
|
||||
pnpm run db:seed
|
||||
pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric)
|
||||
pnpm run db:migrate # Run database migrations
|
||||
pnpm run db:seed # Seed the database (required for reference projects)
|
||||
|
||||
# Build packages (required before running)
|
||||
pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
# Run webapp in development mode (http://localhost:3030)
|
||||
pnpm run dev --filter webapp
|
||||
|
||||
# Build and watch for changes (CLI and packages)
|
||||
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*"
|
||||
pnpm run dev --filter webapp # Run webapp (http://localhost:3030)
|
||||
pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" # Watch CLI and packages
|
||||
```
|
||||
|
||||
### Testing
|
||||
### Verifying Changes
|
||||
|
||||
The verification command depends on where the change lives:
|
||||
|
||||
- **Apps and internal packages** (`apps/*`, `internal-packages/*`): Use `typecheck`. **Never use `build`** for these — building proves almost nothing about correctness.
|
||||
- **Public packages** (`packages/*`): Use `build`.
|
||||
|
||||
```bash
|
||||
# Apps and internal packages — use typecheck
|
||||
pnpm run typecheck --filter webapp # ~1-2 minutes
|
||||
pnpm run typecheck --filter @internal/run-engine
|
||||
|
||||
# Public packages — use build
|
||||
pnpm run build --filter @trigger.dev/sdk
|
||||
pnpm run build --filter @trigger.dev/core
|
||||
```
|
||||
|
||||
Only run typecheck/build after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues.
|
||||
|
||||
## Testing
|
||||
|
||||
We use vitest exclusively. **Never mock anything** - use testcontainers instead.
|
||||
|
||||
```bash
|
||||
# Run all tests for a package
|
||||
pnpm run test --filter webapp
|
||||
|
||||
# Run a single test file (preferred - cd into directory first)
|
||||
pnpm run test --filter webapp # All tests for a package
|
||||
cd internal-packages/run-engine
|
||||
pnpm run test ./src/engine/tests/ttl.test.ts --run
|
||||
|
||||
# May need to build dependencies first
|
||||
pnpm run build --filter @internal/run-engine
|
||||
pnpm run test ./src/engine/tests/ttl.test.ts --run # Single test file
|
||||
pnpm run build --filter @internal/run-engine # May need to build deps first
|
||||
```
|
||||
|
||||
Test files go next to source files (e.g., `MyService.ts` → `MyService.test.ts`).
|
||||
Test files go next to source files (e.g., `MyService.ts` -> `MyService.test.ts`).
|
||||
|
||||
#### Testcontainers for Redis/PostgreSQL
|
||||
### Testcontainers for Redis/PostgreSQL
|
||||
|
||||
```typescript
|
||||
import { redisTest, postgresTest, containerTest } from "@internal/testcontainers";
|
||||
|
||||
// Redis only
|
||||
redisTest("should use redis", async ({ redisOptions }) => {
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// PostgreSQL only
|
||||
postgresTest("should use postgres", async ({ prisma }) => {
|
||||
/* ... */
|
||||
});
|
||||
|
||||
// Both Redis and PostgreSQL
|
||||
containerTest("should use both", async ({ prisma, redisOptions }) => {
|
||||
/* ... */
|
||||
});
|
||||
```
|
||||
|
||||
### Changesets
|
||||
## Changesets and Server Changes
|
||||
|
||||
When modifying any public package (`packages/*` or `integrations/*`), add a changeset:
|
||||
|
||||
@@ -77,227 +76,168 @@ pnpm run changeset:add
|
||||
|
||||
- Default to **patch** for bug fixes and minor changes
|
||||
- Confirm with maintainers before selecting **minor** (new features)
|
||||
- **Never** select major (breaking changes) without explicit approval
|
||||
- **Never** select major without explicit approval
|
||||
|
||||
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.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Request Flow
|
||||
|
||||
User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Supervisor -> Container execution -> Results back through RunEngine -> ClickHouse (analytics) + PostgreSQL (state)
|
||||
|
||||
### Apps
|
||||
|
||||
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, and Docker image. Uses Express server.
|
||||
- **apps/supervisor**: Node.js app handling task execution, interfacing with Docker/Kubernetes.
|
||||
- **apps/webapp**: Remix 2.1.0 app - main API, dashboard, orchestration. Uses Express server.
|
||||
- **apps/supervisor**: Manages task execution containers (Docker/Kubernetes).
|
||||
|
||||
### Public Packages
|
||||
|
||||
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK
|
||||
- **packages/cli-v3** (`trigger.dev`): CLI package
|
||||
- **packages/core** (`@trigger.dev/core`): Shared code between SDK and webapp. Import subpaths only (never root).
|
||||
- **packages/build**: Build extensions and types
|
||||
- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK for writing tasks
|
||||
- **packages/cli-v3** (`trigger.dev`): CLI - also bundles code that goes into customer task images
|
||||
- **packages/core** (`@trigger.dev/core`): Shared types. **Import subpaths only** (never root).
|
||||
- **packages/build** (`@trigger.dev/build`): Build extensions and types
|
||||
- **packages/react-hooks**: React hooks for realtime and triggering
|
||||
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Custom Redis-based background job system
|
||||
- **packages/redis-worker** (`@trigger.dev/redis-worker`): Redis-based background job system
|
||||
|
||||
### Internal Packages
|
||||
|
||||
- **internal-packages/database** (`@trigger.dev/database`): Prisma 6.14.0 client and schema
|
||||
- **internal-packages/clickhouse** (`@internal/clickhouse`): ClickHouse client and schema migrations
|
||||
- **internal-packages/run-engine** (`@internal/run-engine`): "Run Engine 2.0" - run lifecycle management
|
||||
- **internal-packages/redis** (`@internal/redis`): Redis client creation utilities
|
||||
- **internal-packages/testcontainers** (`@internal/testcontainers`): Test helpers for Redis/PostgreSQL containers
|
||||
- **internal-packages/zodworker** (`@internal/zodworker`): Graphile-worker wrapper (being replaced by redis-worker)
|
||||
- **internal-packages/database**: Prisma 6.14.0 client and schema (PostgreSQL)
|
||||
- **internal-packages/clickhouse**: ClickHouse client, schema migrations, analytics queries
|
||||
- **internal-packages/run-engine**: "Run Engine 2.0" - core run lifecycle management
|
||||
- **internal-packages/redis**: Redis client creation utilities (ioredis)
|
||||
- **internal-packages/testcontainers**: Test helpers for Redis/PostgreSQL containers
|
||||
- **internal-packages/schedule-engine**: Durable cron scheduling
|
||||
- **internal-packages/zodworker**: Graphile-worker wrapper (DEPRECATED - use redis-worker)
|
||||
|
||||
### Legacy V1 Engine Code
|
||||
|
||||
The `apps/webapp/app/v3/` directory name is misleading - most code there is actively used by V2. Only specific files are V1-only legacy (MarQS queue, triggerTaskV1, cancelTaskRunV1, etc.). See `apps/webapp/CLAUDE.md` for the exact list. When you encounter V1/V2 branching in services, only modify V2 code paths. All new work uses Run Engine 2.0 (`@internal/run-engine`) and redis-worker.
|
||||
|
||||
### Documentation
|
||||
|
||||
Docs live in `docs/` as a Mintlify site (MDX format). See `docs/CLAUDE.md` for conventions.
|
||||
|
||||
### Reference Projects
|
||||
|
||||
The `references/` directory contains test workspaces for developing and testing new SDK and platform features. Use these projects (e.g., `references/hello-world`) to manually test changes to the CLI, SDK, core packages, and webapp before submitting PRs.
|
||||
|
||||
## Webapp Development
|
||||
|
||||
### Key Locations
|
||||
|
||||
- Trigger API: `apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts`
|
||||
- Batch trigger: `apps/webapp/app/routes/api.v1.tasks.batch.ts`
|
||||
- Prisma setup: `apps/webapp/app/db.server.ts`
|
||||
- Run engine config: `apps/webapp/app/v3/runEngine.server.ts`
|
||||
- Services: `apps/webapp/app/v3/services/**/*.server.ts`
|
||||
- Presenters: `apps/webapp/app/v3/presenters/**/*.server.ts`
|
||||
- OTEL endpoints: `apps/webapp/app/routes/otel.v1.logs.ts`, `otel.v1.traces.ts`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Access via `env` export from `apps/webapp/app/env.server.ts`, never `process.env` directly.
|
||||
|
||||
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead. Example pattern:
|
||||
|
||||
- `realtimeClient.server.ts` (testable service)
|
||||
- `realtimeClientGlobal.server.ts` (configuration)
|
||||
|
||||
### Legacy vs Run Engine 2.0
|
||||
|
||||
The codebase is transitioning from the "legacy run engine" (spread across codebase) to "Run Engine 2.0" (`@internal/run-engine`). Focus on Run Engine 2.0 for new work.
|
||||
The `references/` directory contains test workspaces for testing SDK and platform features. Use `references/hello-world` to manually test changes before submitting PRs.
|
||||
|
||||
## Docker Image Guidelines
|
||||
|
||||
When updating Docker image references in `docker/Dockerfile` or other container files:
|
||||
When updating Docker image references:
|
||||
|
||||
- **Always use multiplatform/index digests**, not architecture-specific digests
|
||||
- Architecture-specific digests (e.g., for `linux/amd64` only) will cause CI failures on different build environments
|
||||
- On Docker Hub, the multiplatform digest is shown on the main image page, while architecture-specific digests are listed under "OS/ARCH"
|
||||
- Example: Use `node:20.20-bullseye-slim@sha256:abc123...` where the digest is from the multiplatform index, not from a specific OS/ARCH variant
|
||||
|
||||
## Database Migrations (PostgreSQL)
|
||||
|
||||
1. Edit `internal-packages/database/prisma/schema.prisma`
|
||||
2. Create migration:
|
||||
```bash
|
||||
cd internal-packages/database
|
||||
pnpm run db:migrate:dev:create --name "add_new_column"
|
||||
```
|
||||
3. **Important**: Generated migration includes extraneous changes. Remove lines related to:
|
||||
- `_BackgroundWorkerToBackgroundWorkerFile`
|
||||
- `_BackgroundWorkerToTaskQueue`
|
||||
- `_TaskRunToTaskRunTag`
|
||||
- `_WaitpointRunConnections`
|
||||
- `_completedWaitpoints`
|
||||
- `SecretStore_key_idx`
|
||||
- Various `TaskRun` indexes unless you added them
|
||||
4. Apply migration:
|
||||
```bash
|
||||
pnpm run db:migrate:deploy && pnpm run generate
|
||||
```
|
||||
|
||||
### Index Migration Rules
|
||||
|
||||
- Indexes **must use CONCURRENTLY** to avoid table locks
|
||||
- **CONCURRENTLY indexes must be in their own separate migration file** - they cannot be combined with other schema changes
|
||||
|
||||
## ClickHouse Migrations
|
||||
|
||||
ClickHouse migrations use Goose format and live in `internal-packages/clickhouse/schema/`.
|
||||
|
||||
1. Create a new numbered SQL file (e.g., `010_add_new_column.sql`)
|
||||
2. Use Goose markers:
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
ADD COLUMN new_column String DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE trigger_dev.your_table
|
||||
DROP COLUMN new_column;
|
||||
```
|
||||
|
||||
Follow naming conventions in `internal-packages/clickhouse/README.md`:
|
||||
|
||||
- `raw_` prefix for input tables
|
||||
- `_v1`, `_v2` suffixes for versioning
|
||||
- `_mv_v1` suffix for materialized views
|
||||
- Architecture-specific digests cause CI failures on different build environments
|
||||
- Use the digest from the main Docker Hub page, not from a specific OS/ARCH variant
|
||||
|
||||
## Writing Trigger.dev Tasks
|
||||
|
||||
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern.
|
||||
Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`.
|
||||
|
||||
```typescript
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
// Every task must be exported
|
||||
export const myTask = task({
|
||||
id: "my-task", // Unique ID
|
||||
id: "my-task",
|
||||
run: async (payload: { message: string }) => {
|
||||
// Task logic - no timeouts
|
||||
// Task logic
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### SDK Documentation Rules
|
||||
|
||||
The `rules/` directory contains versioned documentation for writing Trigger.dev tasks, distributed to users via the SDK installer. Current version is defined in `rules/manifest.json`.
|
||||
|
||||
- `rules/4.3.0/` - Latest: batch trigger v2 (1,000 items, 3MB payloads), debouncing
|
||||
- `rules/4.1.0/` - Realtime streams v2, updated config
|
||||
- `rules/4.0.0/` - Base v4 SDK documentation
|
||||
|
||||
When adding new SDK features, create a new version directory with only the files that changed from the previous version. Update `manifest.json` to point unchanged files to previous versions.
|
||||
|
||||
### Claude Code Skill
|
||||
|
||||
The `.claude/skills/trigger-dev-tasks/` skill provides Claude Code with Trigger.dev task expertise. It includes:
|
||||
|
||||
- `SKILL.md` - Core instructions and patterns
|
||||
- Reference files for basic tasks, advanced tasks, scheduled tasks, realtime, and config
|
||||
|
||||
Keep the skill in sync with the latest rules version when SDK features change.
|
||||
The `rules/` directory contains versioned SDK documentation distributed via the SDK installer. Current version: `rules/manifest.json`. Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked - these are maintained in separate dedicated passes.
|
||||
|
||||
## Testing with hello-world Reference Project
|
||||
|
||||
First-time setup:
|
||||
|
||||
1. Run `pnpm run db:seed` to seed the database (creates the hello-world project)
|
||||
1. `pnpm run db:seed` to seed the database
|
||||
2. Build CLI: `pnpm run build --filter trigger.dev && pnpm i`
|
||||
3. Authorize CLI: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
|
||||
3. Authorize: `cd references/hello-world && pnpm exec trigger login -a http://localhost:3030`
|
||||
|
||||
Running:
|
||||
|
||||
```bash
|
||||
cd references/hello-world
|
||||
pnpm exec trigger dev # or with --log-level debug
|
||||
```
|
||||
Running: `cd references/hello-world && pnpm exec trigger dev`
|
||||
|
||||
## Local Task Testing Workflow
|
||||
|
||||
This workflow enables Claude Code to run the webapp and trigger dev simultaneously, trigger tasks, and inspect results for testing code changes.
|
||||
|
||||
### Step 1: Start Webapp in Background
|
||||
|
||||
```bash
|
||||
# Run from repo root with run_in_background: true
|
||||
pnpm run dev --filter webapp
|
||||
```
|
||||
|
||||
Verify webapp is running:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:3030/healthcheck # Should return 200
|
||||
curl -s http://localhost:3030/healthcheck # Verify running
|
||||
```
|
||||
|
||||
### Step 2: Start Trigger Dev in Background
|
||||
|
||||
```bash
|
||||
# Run from hello-world directory with run_in_background: true
|
||||
cd references/hello-world && pnpm exec trigger dev
|
||||
# Wait for "Local worker ready [node]"
|
||||
```
|
||||
|
||||
The worker will build and register tasks. Check output for "Local worker ready [node]" message.
|
||||
|
||||
### Step 3: Trigger and Monitor Tasks via MCP
|
||||
|
||||
Use the Trigger.dev MCP tools to interact with tasks:
|
||||
|
||||
```
|
||||
# Get current worker and registered tasks
|
||||
mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev")
|
||||
|
||||
# Trigger a task
|
||||
mcp__trigger__trigger_task(
|
||||
projectRef: "proj_rrkpdguyagvsoktglnod",
|
||||
environment: "dev",
|
||||
taskId: "hello-world",
|
||||
payload: {"message": "Hello from Claude"}
|
||||
)
|
||||
|
||||
# List runs to see status
|
||||
mcp__trigger__list_runs(
|
||||
projectRef: "proj_rrkpdguyagvsoktglnod",
|
||||
environment: "dev",
|
||||
taskIdentifier: "hello-world",
|
||||
limit: 5
|
||||
)
|
||||
mcp__trigger__trigger_task(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskId: "hello-world", payload: {"message": "Hello"})
|
||||
mcp__trigger__list_runs(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskIdentifier: "hello-world", limit: 5)
|
||||
```
|
||||
|
||||
### Step 4: Monitor Execution
|
||||
Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
|
||||
|
||||
- Check trigger dev output file for real-time execution logs
|
||||
- Successful runs show: `Task | Run ID | Success (Xms)`
|
||||
- Dashboard available at: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
|
||||
<!-- intent-skills:start -->
|
||||
|
||||
### Key Project Refs
|
||||
# Skill mappings — when working in these areas, load the linked skill file into context.
|
||||
|
||||
- hello-world: `proj_rrkpdguyagvsoktglnod`
|
||||
skills:
|
||||
|
||||
- task: "Using agentcrumbs for debug tracing, adding crumbs, trails, markers, querying traces, or stripping debug code before merge"
|
||||
load: "node_modules/agentcrumbs/skills/agentcrumbs/SKILL.md"
|
||||
- task: "Setting up agentcrumbs in the project, initializing namespace catalog, running crumbs init"
|
||||
load: "node_modules/agentcrumbs/skills/agentcrumbs/init/SKILL.md"
|
||||
<!-- intent-skills:end -->
|
||||
|
||||
## agentcrumbs
|
||||
|
||||
Add crumbs as you write code — not just when debugging. Mark lines with
|
||||
`// @crumbs` or wrap blocks in `// #region @crumbs`. They stay on the
|
||||
branch throughout development and are stripped by `agentcrumbs strip`
|
||||
before merge.
|
||||
|
||||
### Namespaces
|
||||
|
||||
| Namespace | Description | Path |
|
||||
| ----------------- | ---------------------------------------------- | ----------------------------------- |
|
||||
| `webapp` | Main Remix app — API, dashboard, orchestration | `apps/webapp` |
|
||||
| `supervisor` | Task execution container manager | `apps/supervisor` |
|
||||
| `sdk` | Main SDK for writing tasks | `packages/trigger-sdk` |
|
||||
| `cli` | CLI and task image bundling | `packages/cli-v3` |
|
||||
| `core` | Shared types and subpath exports | `packages/core` |
|
||||
| `build` | Build extensions and types | `packages/build` |
|
||||
| `react-hooks` | React hooks for realtime/triggering | `packages/react-hooks` |
|
||||
| `redis-worker` | Redis-based background job system | `packages/redis-worker` |
|
||||
| `run-engine` | Run Engine 2.0 — core run lifecycle | `internal-packages/run-engine` |
|
||||
| `database` | Prisma client and schema | `internal-packages/database` |
|
||||
| `clickhouse` | ClickHouse client, migrations, analytics | `internal-packages/clickhouse` |
|
||||
| `schedule-engine` | Durable cron scheduling | `internal-packages/schedule-engine` |
|
||||
| `run-queue` | Run queue management | `internal-packages/run-queue` |
|
||||
|
||||
Do not invent new namespaces — pick from this table or ask first.
|
||||
|
||||
### For PR reviewers
|
||||
|
||||
Do not flag `// @crumbs` markers, `#region @crumbs` blocks, or agentcrumbs
|
||||
imports in reviews. These are temporary debug instrumentation stripped
|
||||
before merge. Data logged in crumbs (IDs, names, values) never reaches
|
||||
production.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
pnpm exec agentcrumbs collect # start collector (multi-service)
|
||||
pnpm exec agentcrumbs tail --app trigger # live tail
|
||||
pnpm exec agentcrumbs clear --app trigger # remove crumbs before merge
|
||||
```
|
||||
|
||||
The preferred way to query for crumbs is to use `pnpm exec agentcrumbs query --app trigger` with the `--limit` option and cursor pagination, and clear existing crumbs before reproducing a bug via `pnpm exec agentcrumbs clear --app trigger`.
|
||||
|
||||
+50
-3
@@ -242,9 +242,23 @@ See the [Job Catalog](./references/job-catalog/README.md) file for more.
|
||||
|
||||
**If you get errors, be sure to fix them before committing.**
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue
|
||||
](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
> **Note:** We may close PRs if we decide that the cost of integrating the change outweighs the benefits. To improve the chances of your PR getting accepted, follow the guidelines below.
|
||||
|
||||
### PR workflow
|
||||
|
||||
1. **Always open your PR in draft status first.** Do not mark it as "Ready for Review" until the steps below are complete.
|
||||
2. **Address all CodeRabbit code review comments.** Our CI runs an automated code review via CodeRabbit. Go through each comment and either fix the issue or resolve it with a comment explaining why no change is needed.
|
||||
3. **Wait for all CI checks to pass.** Do not mark the PR as "Ready for Review" until every check is green.
|
||||
4. **Then mark the PR as "Ready for Review"** so a maintainer can take a look.
|
||||
|
||||
### Cost/benefit analysis for risky changes
|
||||
|
||||
If your change touches core infrastructure, modifies widely-used code paths, or could introduce regressions, consider doing a brief cost/benefit analysis and including it in the PR description. Explain what the benefit is to users and why the risk is worth it. This goes a long way toward helping maintainers evaluate your contribution.
|
||||
|
||||
### General guidelines
|
||||
|
||||
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
|
||||
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
|
||||
- Be sure to fill the PR Template accordingly.
|
||||
|
||||
## Adding changesets
|
||||
@@ -267,6 +281,39 @@ You will be prompted to select which packages to include in the changeset. Only
|
||||
|
||||
Most of the time the changes you'll make are likely to be categorized as patch releases. If you feel like there is the need for a minor or major release of the package based on the changes being made, add the changeset as such and it will be discussed during PR review.
|
||||
|
||||
## Adding server changes
|
||||
|
||||
Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, `apps/coordinator/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes.
|
||||
|
||||
Create a markdown file with a descriptive name:
|
||||
|
||||
```sh
|
||||
cat > .server-changes/fix-batch-queue-stalls.md << 'EOF'
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Speed up batch queue processing by removing stalls and fixing retry race
|
||||
EOF
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `area` (required): `webapp` | `supervisor` | `coordinator` | `kubernetes-provider` | `docker-provider`
|
||||
- `type` (required): `feature` | `fix` | `improvement` | `breaking`
|
||||
|
||||
The body text (below the frontmatter) is a one-line description of the change. Keep it concise — it will appear in release notes.
|
||||
|
||||
**When to add which:**
|
||||
|
||||
| PR changes | What to add |
|
||||
|---|---|
|
||||
| Only packages (`packages/`) | Changeset |
|
||||
| Only server (`apps/`) | `.server-changes/` file |
|
||||
| Both packages and server | Just the changeset |
|
||||
|
||||
See `.server-changes/README.md` for more details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### EADDRINUSE: address already in use :::3030
|
||||
|
||||
+23
@@ -1,5 +1,28 @@
|
||||
## Guide on releasing a new version
|
||||
|
||||
### Automated release (v4+)
|
||||
|
||||
Releases are fully automated via CI:
|
||||
|
||||
1. PRs merge to `main` with changesets (for package changes) and/or `.server-changes/` files (for server-only changes).
|
||||
2. The [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow automatically creates/updates the `changeset-release/main` PR with version bumps and an enhanced summary of all changes. Consumed `.server-changes/` files are removed on the release branch (same approach changesets uses for `.changeset/` files — they're deleted on the branch, so merging the PR cleans them up).
|
||||
3. When ready to release, merge the changeset release PR into `main`.
|
||||
4. The [release.yml](./.github/workflows/release.yml) workflow automatically:
|
||||
- Publishes all packages to npm
|
||||
- Creates a single unified GitHub release (e.g., "trigger.dev v4.3.4")
|
||||
- Tags and triggers Docker image builds
|
||||
- After Docker images are pushed, updates the GitHub release with the exact GHCR tag link
|
||||
|
||||
### What engineers need to do
|
||||
|
||||
- **Package changes**: Add a changeset with `pnpm run changeset:add`
|
||||
- **Server-only changes**: Add a `.server-changes/` file (see `.server-changes/README.md`)
|
||||
- **Mixed PRs**: Just the changeset is enough
|
||||
|
||||
See `CHANGESETS.md` for full details on changesets and server changes.
|
||||
|
||||
### Legacy release (v3)
|
||||
|
||||
1. Merge in the changeset PR into main, making sure to cancel both the release and publish github actions from that merge.
|
||||
2. Pull the changes locally into main
|
||||
3. Run `pnpm i` which will update the pnpm lock file with the new versions
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Supervisor
|
||||
|
||||
Node.js app that manages task execution containers. Receives work from the platform, starts Docker/Kubernetes containers, monitors execution, and reports results.
|
||||
|
||||
## Key Directories
|
||||
|
||||
- `src/services/` - Core service logic
|
||||
- `src/workloadManager/` - Container orchestration abstraction (Docker or Kubernetes)
|
||||
- `src/workloadServer/` - HTTP server for workload communication (heartbeats, snapshots)
|
||||
- `src/clients/` - Platform communication (webapp/coordinator)
|
||||
- `src/env.ts` - Environment configuration
|
||||
|
||||
## Architecture
|
||||
|
||||
- **WorkloadManager**: Abstracts Docker vs Kubernetes execution
|
||||
- **SupervisorSession**: Manages the dequeue loop with EWMA-based dynamic scaling
|
||||
- **ResourceMonitor**: Tracks CPU/memory during execution
|
||||
- **PodCleaner/FailedPodHandler**: Kubernetes-specific cleanup
|
||||
|
||||
Communicates with the platform via Socket.io and HTTP. Receives task assignments through the dequeue protocol from the webapp.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Webapp
|
||||
|
||||
Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).
|
||||
|
||||
## Verifying Changes
|
||||
|
||||
**Never run `pnpm run build --filter webapp` to verify changes.** Building proves almost nothing about correctness. The webapp is an app, not a public package — use typecheck from the repo root:
|
||||
|
||||
```bash
|
||||
pnpm run typecheck --filter webapp # ~1-2 minutes
|
||||
```
|
||||
|
||||
Only run typecheck after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues.
|
||||
|
||||
Note: Public packages (`packages/*`) use `build` instead. See the root CLAUDE.md for details.
|
||||
|
||||
## Testing Dashboard Changes with Chrome DevTools MCP
|
||||
|
||||
Use the `chrome-devtools` MCP server to visually verify local dashboard changes. The webapp must be running (`pnpm run dev --filter webapp` from repo root).
|
||||
|
||||
### Login
|
||||
|
||||
```
|
||||
1. mcp__chrome-devtools__new_page(url: "http://localhost:3030")
|
||||
→ Redirects to /login
|
||||
2. mcp__chrome-devtools__click the "Continue with Email" link
|
||||
3. mcp__chrome-devtools__fill the email field with "local@trigger.dev"
|
||||
4. mcp__chrome-devtools__click "Send a magic link"
|
||||
→ Auto-logs in and redirects to the dashboard (no email verification needed locally)
|
||||
```
|
||||
|
||||
### Navigating and Verifying
|
||||
|
||||
- **take_snapshot**: Get an a11y tree of the page (text content, element UIDs for interaction). Prefer this over screenshots for understanding page structure.
|
||||
- **take_screenshot**: Capture what the page looks like visually. Use to verify styling, layout, and visual changes.
|
||||
- **navigate_page**: Go to specific URLs, e.g. `http://localhost:3030/orgs/references-bc08/projects/hello-world-SiWs/env/dev/runs`
|
||||
- **click / fill**: Interact with elements using UIDs from `take_snapshot`.
|
||||
- **evaluate_script**: Run JS in the browser console for debugging.
|
||||
- **list_console_messages**: Check for console errors after navigating.
|
||||
|
||||
### Tips
|
||||
|
||||
- Snapshots can be very large on complex pages (200K+ chars). Use `take_screenshot` first to orient, then `take_snapshot` only when you need element UIDs to interact.
|
||||
- The local seeded user email is `local@trigger.dev`.
|
||||
- Dashboard URL pattern: `http://localhost:3030/orgs/{orgSlug}/projects/{projectSlug}/env/{envSlug}/{section}`
|
||||
|
||||
## Key File Locations
|
||||
|
||||
- **Trigger API**: `app/routes/api.v1.tasks.$taskId.trigger.ts`
|
||||
- **Batch trigger**: `app/routes/api.v1.tasks.batch.ts`
|
||||
- **OTEL endpoints**: `app/routes/otel.v1.logs.ts`, `app/routes/otel.v1.traces.ts`
|
||||
- **Prisma setup**: `app/db.server.ts`
|
||||
- **Run engine config**: `app/v3/runEngine.server.ts`
|
||||
- **Services**: `app/v3/services/**/*.server.ts`
|
||||
- **Presenters**: `app/v3/presenters/**/*.server.ts`
|
||||
|
||||
## Route Convention
|
||||
|
||||
Routes use Remix flat-file convention with dot-separated segments:
|
||||
`api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.**
|
||||
|
||||
For testable code, **never import env.server.ts** in test files. Pass configuration as options instead:
|
||||
- `realtimeClient.server.ts` (testable service, takes config as constructor arg)
|
||||
- `realtimeClientGlobal.server.ts` (creates singleton with env config)
|
||||
|
||||
## Run Engine 2.0
|
||||
|
||||
The webapp integrates `@internal/run-engine` via `app/v3/runEngine.server.ts`. This is the singleton engine instance. Services in `app/v3/services/` call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.).
|
||||
|
||||
The `engineVersion.server.ts` file determines V1 vs V2 for a given environment. New code should always target V2.
|
||||
|
||||
## Background Workers
|
||||
|
||||
Background job workers use `@trigger.dev/redis-worker`:
|
||||
- `app/v3/commonWorker.server.ts`
|
||||
- `app/v3/alertsWorker.server.ts`
|
||||
- `app/v3/batchTriggerWorker.server.ts`
|
||||
|
||||
Do NOT add new jobs using zodworker/graphile-worker (legacy).
|
||||
|
||||
## Real-time
|
||||
|
||||
- Socket.io: `app/v3/handleSocketIo.server.ts`, `app/v3/handleWebsockets.server.ts`
|
||||
- Electric SQL: Powers real-time data sync for the dashboard
|
||||
|
||||
## Legacy V1 Code
|
||||
|
||||
The `app/v3/` directory name is misleading - most code is actively used by V2. Only these specific files are V1-only legacy:
|
||||
- `app/v3/marqs/` (old MarQS queue system)
|
||||
- `app/v3/legacyRunEngineWorker.server.ts`
|
||||
- `app/v3/services/triggerTaskV1.server.ts`
|
||||
- `app/v3/services/cancelTaskRunV1.server.ts`
|
||||
- `app/v3/authenticatedSocketConnection.server.ts`
|
||||
- `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.
|
||||
@@ -0,0 +1,177 @@
|
||||
type IconProps = { className?: string };
|
||||
|
||||
export function OpenAIIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.648 9.27004V7.36778C9.648 7.20757 9.70764 7.08738 9.84657 7.00738L13.6396 4.80477C14.1559 4.50443 14.7715 4.36434 15.4069 4.36434C17.7899 4.36434 19.2992 6.22658 19.2992 8.20884C19.2992 8.34898 19.2992 8.50919 19.2793 8.6694L15.3473 6.3466C15.1091 6.20651 14.8707 6.20651 14.6324 6.3466L9.648 9.27004ZM18.5048 16.6789V12.1334C18.5048 11.853 18.3855 11.6528 18.1473 11.5126L13.1629 8.58919L14.7913 7.64801C14.9303 7.56801 15.0495 7.56801 15.1884 7.64801L18.9814 9.85062C20.0737 10.4915 20.8084 11.853 20.8084 13.1745C20.8084 14.6962 19.9148 16.098 18.5048 16.6787V16.6789ZM8.47638 12.6742L6.848 11.7131C6.70907 11.6331 6.64943 11.5128 6.64943 11.3526V6.94746C6.64943 4.80498 8.2778 3.18295 10.4821 3.18295C11.3163 3.18295 12.0906 3.46334 12.7461 3.96392L8.834 6.24669C8.59578 6.38679 8.47658 6.58702 8.47658 6.86746V12.6743L8.47638 12.6742ZM11.9814 14.7165L9.648 13.395V10.5918L11.9814 9.27025L14.3146 10.5918V13.395L11.9814 14.7165ZM13.4807 20.8038C12.6466 20.8038 11.8723 20.5234 11.2168 20.0229L15.1288 17.7401C15.3671 17.6 15.4863 17.3997 15.4863 17.1193V11.3124L17.1346 12.2735C17.2735 12.3535 17.3331 12.4737 17.3331 12.634V17.0391C17.3331 19.1816 15.6848 20.8036 13.4807 20.8036V20.8038ZM8.77424 16.3385L4.9812 14.136C3.88892 13.4951 3.15426 12.1336 3.15426 10.8121C3.15426 9.27025 4.06775 7.88863 5.4776 7.30789V11.8733C5.4776 12.1537 5.59683 12.3539 5.83506 12.494L10.7997 15.3974L9.17134 16.3385C9.03241 16.4185 8.91317 16.4185 8.77424 16.3385ZM8.55592 19.6224C6.31192 19.6224 4.66364 17.9204 4.66364 15.8179C4.66364 15.6577 4.68355 15.4975 4.70329 15.3373L8.61535 17.62C8.85358 17.7602 9.09201 17.7602 9.33023 17.62L14.3146 14.7167V16.619C14.3146 16.7792 14.255 16.8994 14.1161 16.9794L10.3231 19.182C9.80672 19.4823 9.19109 19.6224 8.55571 19.6224H8.55592ZM13.4807 22.0052C15.8836 22.0052 17.8891 20.2832 18.3461 18.0004C20.5701 17.4197 21.9999 15.3172 21.9999 13.1747C21.9999 11.773 21.4043 10.4115 20.3319 9.43025C20.4312 9.00972 20.4908 8.58919 20.4908 8.16886C20.4908 5.30551 18.1872 3.16283 15.5261 3.16283C14.9901 3.16283 14.4737 3.24283 13.9574 3.42316C13.0636 2.54207 11.8324 1.98145 10.4821 1.98145C8.07927 1.98145 6.07369 3.70339 5.61678 5.98616C3.39269 6.5669 1.96289 8.6694 1.96289 10.8119C1.96289 12.2136 2.55857 13.5751 3.63095 14.5563C3.53166 14.9768 3.47207 15.3974 3.47207 15.8177C3.47207 18.6811 5.77567 20.8237 8.43668 20.8237C8.97277 20.8237 9.48911 20.7437 10.0055 20.5634C10.899 21.4445 12.1302 22.0052 13.4807 22.0052Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnthropicIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function GeminiIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LlamaIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M3.4485 2C4.406 2 5.2065 2.466 6.1635 3.688L6.3045 3.5015C6.3995 3.3785 6.496 3.2595 6.5945 3.1465L6.751 2.9715C7.294 2.394 7.896 2 8.6125 2C9.249 2 9.847 2.2785 10.358 2.758L10.467 2.8645C11.332 3.747 11.9255 5.2195 11.9935 6.8775L11.999 7.0735L12 7.1985C12 7.949 11.86 8.578 11.591 9.0485L11.521 9.1635L11.467 9.24C11.3165 9.45 11.135 9.619 10.924 9.7445L10.7915 9.8155L10.748 9.8355C10.6986 9.85749 10.6482 9.87718 10.597 9.8945C10.3825 9.96542 10.1579 10.0006 9.932 9.9985C9.67 9.9985 9.434 9.965 9.213 9.891C8.906 9.789 8.6315 9.611 8.35 9.333L8.2365 9.2155C7.86 8.8095 7.4695 8.2275 6.99 7.4225L6.275 6.2175L6.003 5.77L5.12 7.335L4.9485 7.631C3.7985 9.578 3.1135 10 2.178 10C1.573 10 1.0755 9.79 0.71 9.409L0.626 9.317C0.384 9.0305 0.2075 8.6615 0.1045 8.2225L0.071 8.0625C0.0323456 7.84982 0.00961585 7.63456 0.003 7.4185L0 7.234C0.001 6.8615 0.03 6.489 0.087 6.119L0.137 5.8325C0.286 5.0675 0.551 4.3535 0.905 3.754L1.0095 3.584C1.598 2.669 2.404 2.0575 3.317 2.004L3.4485 2ZM3.432 3.3075L3.3315 3.3125C2.9165 3.354 2.5285 3.649 2.2055 4.101L2.1365 4.2005L2.1315 4.2095C1.7965 4.718 1.539 5.3985 1.4035 6.132L1.4015 6.143C1.33323 6.5148 1.29859 6.89199 1.298 7.27L1.299 7.364C1.301 7.454 1.3075 7.544 1.319 7.634L1.3405 7.7795C1.3865 8.031 1.469 8.2335 1.5835 8.3835L1.642 8.452C1.7935 8.6135 1.991 8.698 2.227 8.698C2.777 8.698 3.125 8.36 4.075 6.8775L5.1625 5.1775L5.3895 4.827L5.32 4.728C4.555 3.65 4.042 3.3075 3.432 3.3075ZM8.53 3.0315L8.442 3.035C8.1245 3.059 7.8305 3.2145 7.532 3.5015L7.434 3.6005C7.2145 3.8315 6.9905 4.1325 6.7505 4.504L6.8835 4.703C6.9735 4.84 7.0645 4.983 7.1585 5.132L7.305 5.3695L8.003 6.537L8.3505 7.094C8.642 7.557 8.8655 7.894 9.0545 8.135L9.161 8.266C9.302 8.429 9.4255 8.536 9.5495 8.6025L9.6005 8.6275C9.714 8.6775 9.829 8.6965 9.9595 8.6965C10.0475 8.6975 10.1345 8.685 10.2185 8.66C10.3875 8.608 10.5235 8.5 10.625 8.3415L10.6725 8.26L10.711 8.179C10.808 7.9495 10.856 7.649 10.856 7.2865L10.853 7.062C10.813 5.6265 10.384 4.376 9.753 3.663L9.665 3.5685C9.33 3.227 8.943 3.0315 8.53 3.0315Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeepseekIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_20374_57805)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M23.7479 4.48176C23.4939 4.35776 23.384 4.59476 23.236 4.71576C23.185 4.75476 23.142 4.80576 23.099 4.85176C22.727 5.24876 22.293 5.50876 21.726 5.47776C20.897 5.43176 20.189 5.69176 19.563 6.32576C19.43 5.54376 18.988 5.07776 18.316 4.77776C17.964 4.62176 17.608 4.46676 17.361 4.12776C17.189 3.88676 17.142 3.61776 17.056 3.35376C17.001 3.19376 16.946 3.03076 16.763 3.00376C16.563 2.97276 16.4849 3.13976 16.4069 3.27976C16.094 3.85176 15.9729 4.48176 15.9849 5.11976C16.0119 6.55576 16.618 7.69976 17.823 8.51276C17.96 8.60576 17.995 8.69976 17.952 8.83576C17.87 9.11576 17.772 9.38776 17.686 9.66876C17.631 9.84776 17.549 9.88576 17.357 9.80876C16.7082 9.52995 16.1189 9.12939 15.6209 8.62876C14.7639 7.80076 13.9899 6.88676 13.0239 6.17076C12.8001 6.00537 12.5703 5.84827 12.3349 5.69976C11.3499 4.74276 12.4649 3.95676 12.7229 3.86376C12.9929 3.76576 12.8159 3.43176 11.9439 3.43576C11.0719 3.43976 10.2739 3.73076 9.25695 4.11976C9.10582 4.17767 8.95033 4.22348 8.79195 4.25676C7.84158 4.07769 6.8696 4.0433 5.90895 4.15476C4.02395 4.36476 2.51895 5.25676 1.41195 6.77776C0.0819496 8.60576 -0.23105 10.6838 0.15195 12.8498C0.55495 15.1338 1.72095 17.0248 3.51195 18.5028C5.36995 20.0358 7.50895 20.7868 9.94995 20.6428C11.4319 20.5578 13.0829 20.3588 14.9439 18.7828C15.4139 19.0168 15.906 19.1098 16.724 19.1798C17.354 19.2388 17.96 19.1498 18.429 19.0518C19.164 18.8958 19.1129 18.2148 18.8479 18.0908C16.693 17.0868 17.166 17.4958 16.735 17.1648C17.831 15.8688 19.481 14.5228 20.127 10.1618C20.177 9.81476 20.134 9.59676 20.127 9.31676C20.123 9.14676 20.162 9.07976 20.357 9.06076C20.898 9.00463 21.4228 8.84327 21.902 8.58576C23.298 7.82276 23.862 6.57076 23.995 5.06876C24.015 4.83876 23.9909 4.60276 23.7479 4.48176ZM11.5809 17.9998C9.49195 16.3578 8.47895 15.8168 8.06095 15.8398C7.66895 15.8638 7.73995 16.3108 7.82595 16.6028C7.91595 16.8908 8.03295 17.0888 8.19695 17.3418C8.31095 17.5088 8.38895 17.7578 8.08395 17.9448C7.41095 18.3608 6.24195 17.8048 6.18695 17.7778C4.82595 16.9758 3.68695 15.9178 2.88595 14.4708C2.11195 13.0778 1.66195 11.5838 1.58795 9.98876C1.56795 9.60276 1.68095 9.46676 2.06495 9.39676C2.56906 9.30029 3.08558 9.28711 3.59395 9.35776C5.72595 9.66976 7.53995 10.6228 9.06195 12.1318C9.92995 12.9918 10.5869 14.0188 11.2639 15.0228C11.9839 16.0888 12.7579 17.1048 13.7439 17.9368C14.0919 18.2288 14.3689 18.4508 14.6349 18.6138C13.8329 18.7038 12.4949 18.7238 11.5809 17.9998ZM12.5809 11.5598C12.5808 11.5101 12.5927 11.4611 12.6157 11.4171C12.6387 11.373 12.672 11.3353 12.7129 11.307C12.7538 11.2787 12.8009 11.2609 12.8502 11.2549C12.8995 11.2489 12.9495 11.2551 12.9959 11.2728C13.0551 11.294 13.1062 11.3331 13.142 11.3848C13.1779 11.4364 13.1967 11.4979 13.1959 11.5608C13.1961 11.6014 13.1881 11.6416 13.1726 11.6791C13.157 11.7166 13.1341 11.7506 13.1053 11.7792C13.0764 11.8078 13.0422 11.8303 13.0045 11.8455C12.9669 11.8607 12.9266 11.8683 12.8859 11.8678C12.8457 11.8679 12.8057 11.86 12.7685 11.8445C12.7313 11.829 12.6976 11.8063 12.6693 11.7776C12.641 11.7489 12.6186 11.7149 12.6037 11.6775C12.5887 11.6401 12.5803 11.6 12.5809 11.5598ZM15.6909 13.1558C15.4909 13.2368 15.2919 13.3068 15.1009 13.3158C14.8136 13.3258 14.5316 13.236 14.3029 13.0618C14.0289 12.8318 13.8329 12.7038 13.7509 12.3038C13.7227 12.1083 13.7281 11.9094 13.7669 11.7158C13.8369 11.3888 13.7589 11.1788 13.5279 10.9888C13.3409 10.8328 13.1019 10.7898 12.8399 10.7898C12.7502 10.7845 12.6631 10.7578 12.5859 10.7118C12.4759 10.6578 12.3859 10.5218 12.4719 10.3538C12.4999 10.2998 12.6319 10.1678 12.6639 10.1438C13.0199 9.94176 13.4309 10.0078 13.8099 10.1598C14.1619 10.3038 14.4279 10.5678 14.8109 10.9418C15.2019 11.3928 15.2729 11.5178 15.4959 11.8558C15.6719 12.1208 15.8319 12.3928 15.9409 12.7038C16.0079 12.8988 15.9219 13.0578 15.6909 13.1558Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_20374_57805">
|
||||
<rect width="24" height="24" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XAIIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M17.7329 8.44672L18.0784 22.0139H20.8452L21.1911 3.50781L17.7329 8.44672Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M21.1911 2H16.9692L10.3442 11.4621L12.4552 14.4768L21.1911 2Z" fill="currentColor" />
|
||||
<path
|
||||
d="M2.95508 22.0136H7.17691L9.28824 18.9989L7.17691 15.9839L2.95508 22.0136Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M2.95508 8.44629L12.4546 22.0134H16.6764L7.17691 8.44629H2.95508Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PerplexityIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M18.4875 2V8.06H20.75V16.6833H18.3042V22L12.44 16.8383V21.9592H11.5308V16.8325L5.66 22V16.6125H3.25V7.99H5.65333V2L11.5308 7.41167V2.15833H12.4392V7.56667L18.4875 2ZM12.44 9.53667V15.6358L17.395 19.9975V14.0333L12.44 9.53667ZM11.5242 9.47L6.56917 13.9683V19.9975L11.5242 15.6358V9.47083V9.47ZM18.3042 15.7867H19.8408V8.9575H13.2167L18.3042 13.5742V15.7867ZM10.8192 8.88667H4.15833V15.7158H5.65833V13.5692L10.8183 8.88583L10.8192 8.88667ZM6.5625 4.06333V7.98833H10.825L6.5625 4.06333ZM17.5783 4.06333L13.3158 7.98833H17.5783V4.06333Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CerebrasIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.6535 20.6834C10.4382 20.6834 9.28717 20.4401 8.23625 20.0036C6.66345 19.3453 5.31944 18.2433 4.36862 16.8551C3.4178 15.4669 2.86732 13.7997 2.86732 11.9964C2.86732 10.7943 3.11039 9.65655 3.56078 8.61182C4.22564 7.0519 5.3409 5.72809 6.7421 4.7907C8.1433 3.85331 9.83047 3.30948 11.6535 3.30948V2C10.2594 2 8.92972 2.27907 7.71437 2.78712C5.8985 3.54562 4.35432 4.81217 3.26767 6.40788C2.17386 8.00356 1.5376 9.92844 1.5376 11.9964C1.5376 13.3774 1.82356 14.6941 2.33114 15.8891C3.09609 17.6851 4.38291 19.2093 5.99144 20.2898C7.60713 21.3703 9.55167 22 11.6463 22V20.6834H11.6535Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7.14949 17.3272C6.32735 16.6403 5.71253 15.8102 5.2979 14.9014C4.88323 13.9927 4.66877 13.0124 4.66877 12.0249C4.66877 11.2378 4.8046 10.4506 5.06911 9.69931C5.34079 8.94794 5.74113 8.23241 6.29159 7.58122C6.9779 6.7655 7.81433 6.15012 8.72225 5.73508C9.63021 5.32005 10.6239 5.11254 11.6105 5.11254C12.3969 5.11254 13.1904 5.24849 13.9411 5.51325C14.6989 5.78516 15.4138 6.18588 16.0643 6.7297L16.9151 5.72077C16.143 5.07676 15.2851 4.59018 14.3843 4.27533C13.4835 3.95332 12.547 3.7959 11.6105 3.7959C10.4309 3.7959 9.25846 4.04634 8.17178 4.54009C7.08514 5.03382 6.09141 5.77087 5.27643 6.73687C4.62587 7.50967 4.14689 8.36119 3.82518 9.25563C3.50347 10.1501 3.34619 11.0875 3.34619 12.0249C3.34619 13.1984 3.59641 14.3719 4.08969 15.4524C4.58298 16.5329 5.32649 17.5276 6.29876 18.3362L7.14949 17.3272Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M9.18714 16.4758C8.32211 16.0179 7.64298 15.3524 7.17829 14.5724C6.7136 13.7925 6.47052 12.8909 6.47052 11.9821C6.47052 11.1807 6.65641 10.3721 7.06388 9.62074C7.52144 8.75492 8.19345 8.08228 8.97983 7.6243C9.76624 7.1592 10.667 6.9159 11.5821 6.9159C12.3828 6.9159 13.1978 7.10194 13.9556 7.50269L14.5704 6.33631C13.6196 5.83541 12.5901 5.59212 11.5749 5.59927C10.424 5.59927 9.28725 5.90697 8.30069 6.48655C7.31412 7.06618 6.46339 7.92487 5.89146 9.00535C5.39101 9.95706 5.14795 10.9803 5.14795 11.9821C5.14795 13.127 5.45536 14.2576 6.04159 15.2379C6.62782 16.2254 7.48568 17.0626 8.57236 17.635L9.18714 16.4758Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M11.6608 15.2165C11.2104 15.2165 10.7815 15.1235 10.3955 14.9589C9.80924 14.7156 9.31596 14.3005 8.96564 13.7782C8.61536 13.2558 8.40804 12.6333 8.40804 11.9606C8.40804 11.5098 8.50095 11.0805 8.66537 10.6941C8.90845 10.1145 9.32309 9.61359 9.84496 9.26297C10.3669 8.91235 10.9888 8.70484 11.6608 8.70484V7.38818C11.0317 7.38818 10.4312 7.517 9.88072 7.74597C9.05858 8.09659 8.36511 8.66905 7.87183 9.39892C7.37142 10.136 7.08545 11.0233 7.08545 11.9678C7.08545 12.5975 7.21412 13.1986 7.4429 13.7496C7.79322 14.5725 8.37228 15.2666 9.10147 15.7603C9.83067 16.2469 10.71 16.5331 11.6608 16.5331V15.2165Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12.7332 10.9234C12.5831 10.766 12.4187 10.6372 12.2542 10.5442C12.0898 10.4511 11.9183 10.401 11.7395 10.401C11.4965 10.401 11.2891 10.444 11.0961 10.5299C10.9102 10.6157 10.7458 10.7302 10.61 10.8805C10.4741 11.0236 10.374 11.1953 10.3026 11.3814C10.2311 11.5674 10.2025 11.7678 10.2025 11.9681C10.2025 12.1685 10.2382 12.3689 10.3026 12.5549C10.374 12.7409 10.4741 12.9127 10.61 13.0558C10.7458 13.1989 10.9031 13.3206 11.0961 13.4064C11.282 13.4923 11.4965 13.5352 11.7395 13.5352C11.9397 13.5352 12.1327 13.4923 12.3043 13.4136C12.4759 13.3277 12.626 13.2061 12.7475 13.0486L13.6197 13.986C13.491 14.1148 13.3409 14.2293 13.1693 14.3223C12.9978 14.4154 12.8262 14.4941 12.6546 14.5513C12.483 14.6086 12.3114 14.6515 12.1542 14.673C11.9969 14.7016 11.8539 14.7087 11.7395 14.7087C11.3463 14.7087 10.9746 14.6443 10.6314 14.5155C10.2811 14.3868 9.98084 14.2007 9.73064 13.9574C9.47326 13.7213 9.27311 13.4279 9.12298 13.0916C8.97285 12.7553 8.90137 12.376 8.90137 11.9681C8.90137 11.5531 8.97285 11.181 9.12298 10.8447C9.27311 10.5084 9.47326 10.2222 9.73064 9.97887C9.98801 9.74274 10.2883 9.55669 10.6314 9.42075C10.9817 9.29193 11.3535 9.22754 11.7395 9.22754C12.0755 9.22754 12.4115 9.29193 12.7475 9.42075C13.0835 9.54953 13.3838 9.7499 13.634 10.0218L12.7332 10.9234Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MistralIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.28516 3.74658H4.1416V6.8809H7.28516V3.74658Z" />
|
||||
<path d="M19.8579 3.74658H16.7144V6.8809H19.8579V3.74658Z" />
|
||||
<path d="M10.4277 6.88086H4.1416V10.0152H10.4277V6.88086Z" />
|
||||
<path d="M19.8588 6.88086H13.5728V10.0152H19.8588V6.88086Z" />
|
||||
<path d="M19.8564 10.0137H4.1416V13.148H19.8564V10.0137Z" />
|
||||
<path d="M7.28516 13.1484H4.1416V16.2828H7.28516V13.1484Z" />
|
||||
<path d="M13.5723 13.1484H10.4287V16.2828H13.5723V13.1484Z" />
|
||||
<path d="M19.8579 13.1484H16.7144V16.2828H19.8579V13.1484Z" />
|
||||
<path d="M10.4286 16.2812H1V19.4157H10.4286V16.2812Z" />
|
||||
<path d="M23.0024 16.2812H13.5728V19.4157H23.0024V16.2812Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AzureIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M13.05 4.24L6.56 18.05l2.77-.46 1.89-4.55 4.2 5.19.03-.01 2.99.53L13.05 4.24zm-4.1 7.51L2.49 20.21h5.23l1.23-3.04v-5.42z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function AnthropicLogoIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ClockIcon,
|
||||
DocumentTextIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
RectangleGroupIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
SparklesIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useLocation } from "react-use";
|
||||
@@ -686,3 +688,55 @@ function DeploymentOnboardingSteps() {
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptsNone() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Define your first prompt"
|
||||
icon={SparklesIcon}
|
||||
iconClassName="text-purple-500"
|
||||
panelClassName="max-w-lg"
|
||||
accessory={
|
||||
<LinkButton to={docsPath("prompt-management")} variant="docs/small" LeadingIcon={BookOpenIcon}>
|
||||
Prompt docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Managed prompts let you define AI prompts in code with typesafe variables, then edit and
|
||||
version them from the dashboard without redeploying.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
Add a prompt to your project using <InlineCode variant="small">prompts.define()</InlineCode>:
|
||||
</Paragraph>
|
||||
<div className="rounded border border-grid-dimmed bg-charcoal-900 p-3">
|
||||
<pre className="text-xs leading-relaxed text-text-dimmed">
|
||||
<span className="text-purple-400">import</span>
|
||||
{" { prompts } "}
|
||||
<span className="text-purple-400">from</span>
|
||||
{' "@trigger.dev/sdk";\n'}
|
||||
<span className="text-purple-400">import</span>
|
||||
{" { z } "}
|
||||
<span className="text-purple-400">from</span>
|
||||
{' "zod";\n\n'}
|
||||
<span className="text-purple-400">export const</span>
|
||||
{" myPrompt = "}
|
||||
<span className="text-blue-400">prompts.define</span>
|
||||
{"({\n"}
|
||||
{" id: "}
|
||||
<span className="text-green-400">"my-prompt"</span>
|
||||
{",\n"}
|
||||
{" variables: z.object({\n"}
|
||||
{" name: z.string(),\n"}
|
||||
{" }),\n"}
|
||||
{" content: "}
|
||||
<span className="text-green-400">{"`Hello {{name}}!`"}</span>
|
||||
{",\n"});</pre>
|
||||
</div>
|
||||
<Paragraph variant="small" className="mt-2">
|
||||
Deploy your project and your prompts will appear here with version history and a live
|
||||
editor.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,6 +228,18 @@ export function BulkActionFilterSummary({
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "errorId": {
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={"Error ID"}
|
||||
icon={filterIcon(key)}
|
||||
value={value}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
assertNever(typedKey);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { BarChart3, LineChart } from "lucide-react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { createValueFormatter } from "~/utils/columnFormat";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import type { ChartConfig } from "~/components/primitives/charts/Chart";
|
||||
import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
@@ -855,8 +858,24 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
};
|
||||
}, [isDateBased, timeGranularity]);
|
||||
|
||||
// Create dynamic Y-axis formatter based on data range
|
||||
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
|
||||
// Resolve the Y-axis column format for formatting
|
||||
const yAxisFormat = useMemo(() => {
|
||||
if (yAxisColumns.length === 0) return undefined;
|
||||
const col = columns.find((c) => c.name === yAxisColumns[0]);
|
||||
return (col?.format ?? col?.customRenderType) as ColumnFormatType | undefined;
|
||||
}, [yAxisColumns, columns]);
|
||||
|
||||
// Create dynamic Y-axis formatter based on data range and format
|
||||
const yAxisFormatter = useMemo(
|
||||
() => createYAxisFormatter(data, series, yAxisFormat),
|
||||
[data, series, yAxisFormat]
|
||||
);
|
||||
|
||||
// Create value formatter for tooltips and legend based on column format
|
||||
const tooltipValueFormatter = useMemo(
|
||||
() => createValueFormatter(yAxisFormat),
|
||||
[yAxisFormat]
|
||||
);
|
||||
|
||||
// Check if the group-by column has a runStatus customRenderType
|
||||
const groupByIsRunStatus = useMemo(() => {
|
||||
@@ -870,13 +889,15 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
const cfg: ChartConfig = {};
|
||||
sortedSeries.forEach((s, i) => {
|
||||
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
|
||||
const originalIndex = config.yAxisColumns.indexOf(s);
|
||||
const colorIndex = originalIndex >= 0 ? originalIndex : i;
|
||||
cfg[s] = {
|
||||
label: s,
|
||||
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
|
||||
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex),
|
||||
};
|
||||
});
|
||||
return cfg;
|
||||
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
|
||||
}, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
|
||||
|
||||
// Custom tooltip label formatter for better date display
|
||||
const tooltipLabelFormatter = useMemo(() => {
|
||||
@@ -1081,6 +1102,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
legendValueFormatter={tooltipValueFormatter}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
@@ -1093,6 +1115,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
yAxisProps={yAxisProps}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
tooltipValueFormatter={tooltipValueFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
@@ -1110,6 +1133,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
legendValueFormatter={tooltipValueFormatter}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
@@ -1122,6 +1146,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
yAxisProps={yAxisProps}
|
||||
stacked={stacked && visibleSeries.length > 1}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
tooltipValueFormatter={tooltipValueFormatter}
|
||||
lineType="linear"
|
||||
/>
|
||||
</Chart.Root>
|
||||
@@ -1129,9 +1154,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a Y-axis value formatter based on the data range
|
||||
* Creates a Y-axis value formatter based on the data range and optional format hint
|
||||
*/
|
||||
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
|
||||
function createYAxisFormatter(
|
||||
data: Record<string, unknown>[],
|
||||
series: string[],
|
||||
format?: ColumnFormatType
|
||||
) {
|
||||
// Find min and max values across all series
|
||||
let minVal = Infinity;
|
||||
let maxVal = -Infinity;
|
||||
@@ -1148,6 +1177,56 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
|
||||
|
||||
const range = maxVal - minVal;
|
||||
|
||||
// Format-aware formatters
|
||||
if (format === "bytes" || format === "decimalBytes") {
|
||||
const divisor = format === "bytes" ? 1024 : 1000;
|
||||
const units =
|
||||
format === "bytes"
|
||||
? ["B", "KiB", "MiB", "GiB", "TiB"]
|
||||
: ["B", "KB", "MB", "GB", "TB"];
|
||||
return (value: number): string => {
|
||||
if (value === 0) return "0 B";
|
||||
// Use consistent unit for all ticks based on max value
|
||||
const i = Math.min(
|
||||
Math.max(0, Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor))),
|
||||
units.length - 1
|
||||
);
|
||||
const scaled = value / Math.pow(divisor, i);
|
||||
return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[i]}`;
|
||||
};
|
||||
}
|
||||
|
||||
if (format === "percent") {
|
||||
return (value: number): string => `${value.toFixed(range < 1 ? 2 : 1)}%`;
|
||||
}
|
||||
|
||||
if (format === "duration") {
|
||||
return (value: number): string => formatDurationMilliseconds(value, { style: "short" });
|
||||
}
|
||||
|
||||
if (format === "durationSeconds") {
|
||||
return (value: number): string =>
|
||||
formatDurationMilliseconds(value * 1000, { style: "short" });
|
||||
}
|
||||
|
||||
if (format === "durationNs") {
|
||||
return (value: number): string =>
|
||||
formatDurationMilliseconds(value / 1_000_000, { style: "short" });
|
||||
}
|
||||
|
||||
if (format === "costInDollars" || format === "cost") {
|
||||
return (value: number): string => {
|
||||
const dollars = format === "cost" ? value / 100 : value;
|
||||
if (dollars === 0) return "$0";
|
||||
if (Math.abs(dollars) >= 1000) return `$${(dollars / 1000).toFixed(1)}K`;
|
||||
if (Math.abs(dollars) >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (Math.abs(dollars) >= 0.01) return `$${dollars.toFixed(4)}`;
|
||||
if (Math.abs(dollars) >= 0.0001) return `$${dollars.toFixed(6)}`;
|
||||
return formatCurrencyAccurate(dollars);
|
||||
};
|
||||
}
|
||||
|
||||
// Default formatter
|
||||
return (value: number): string => {
|
||||
// Use abbreviations for large numbers
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useCopy } from "~/hooks/useCopy";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatBytes, formatDecimalBytes, formatQuantity } from "~/utils/columnFormat";
|
||||
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
|
||||
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
@@ -66,9 +67,10 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
if (value === null) return "NULL";
|
||||
if (value === undefined) return "";
|
||||
|
||||
// Handle custom render types
|
||||
if (column.customRenderType) {
|
||||
switch (column.customRenderType) {
|
||||
// Handle format hints (from prettyFormat() or auto-populated from customRenderType)
|
||||
const formatType = column.format ?? column.customRenderType;
|
||||
if (formatType) {
|
||||
switch (formatType) {
|
||||
case "duration":
|
||||
if (typeof value === "number") {
|
||||
return formatDurationMilliseconds(value, { style: "short" });
|
||||
@@ -79,6 +81,11 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
return formatDurationMilliseconds(value * 1000, { style: "short" });
|
||||
}
|
||||
break;
|
||||
case "durationNs":
|
||||
if (typeof value === "number") {
|
||||
return formatDurationMilliseconds(value / 1_000_000, { style: "short" });
|
||||
}
|
||||
break;
|
||||
case "cost":
|
||||
if (typeof value === "number") {
|
||||
return formatCurrencyAccurate(value / 100);
|
||||
@@ -95,6 +102,26 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
return value;
|
||||
}
|
||||
break;
|
||||
case "bytes":
|
||||
if (typeof value === "number") {
|
||||
return formatBytes(value);
|
||||
}
|
||||
break;
|
||||
case "decimalBytes":
|
||||
if (typeof value === "number") {
|
||||
return formatDecimalBytes(value);
|
||||
}
|
||||
break;
|
||||
case "percent":
|
||||
if (typeof value === "number") {
|
||||
return `${value.toFixed(2)}%`;
|
||||
}
|
||||
break;
|
||||
case "quantity":
|
||||
if (typeof value === "number") {
|
||||
return formatQuantity(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +138,7 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
} catch {
|
||||
return String(value);
|
||||
@@ -222,6 +250,21 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
|
||||
if (value === null) return 4; // "NULL"
|
||||
if (value === undefined) return 9; // "UNDEFINED"
|
||||
|
||||
// Handle format hint types - estimate their rendered width
|
||||
const fmt = column.format;
|
||||
if (fmt === "bytes" || fmt === "decimalBytes") {
|
||||
// e.g., "1.50 GiB" or "256.00 MB"
|
||||
return 12;
|
||||
}
|
||||
if (fmt === "percent") {
|
||||
// e.g., "45.23%"
|
||||
return 8;
|
||||
}
|
||||
if (fmt === "quantity") {
|
||||
// e.g., "1.50M"
|
||||
return 8;
|
||||
}
|
||||
|
||||
// Handle custom render types - estimate their rendered width
|
||||
if (column.customRenderType) {
|
||||
switch (column.customRenderType) {
|
||||
@@ -244,6 +287,12 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
|
||||
return formatted.length;
|
||||
}
|
||||
return 10;
|
||||
case "durationNs":
|
||||
if (typeof value === "number") {
|
||||
const formatted = formatDurationMilliseconds(value / 1_000_000, { style: "short" });
|
||||
return formatted.length;
|
||||
}
|
||||
return 10;
|
||||
case "cost":
|
||||
case "costInDollars":
|
||||
// Currency format: "$1,234.56"
|
||||
@@ -263,6 +312,8 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
|
||||
return typeof value === "string" ? Math.min(value.length, 20) : 12;
|
||||
case "queue":
|
||||
return typeof value === "string" ? Math.min(value.length, 25) : 15;
|
||||
case "deploymentId":
|
||||
return typeof value === "string" ? Math.min(value.length, 25) : 20;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +445,10 @@ function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const fmt = column.format;
|
||||
if (fmt === "bytes" || fmt === "decimalBytes" || fmt === "percent" || fmt === "quantity") {
|
||||
return true;
|
||||
}
|
||||
return isNumericType(column.type);
|
||||
}
|
||||
|
||||
@@ -405,10 +460,12 @@ function CellValueWrapper({
|
||||
value,
|
||||
column,
|
||||
prettyFormatting,
|
||||
row,
|
||||
}: {
|
||||
value: unknown;
|
||||
column: OutputColumnMetadata;
|
||||
prettyFormatting: boolean;
|
||||
row?: Record<string, unknown>;
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
@@ -423,6 +480,7 @@ function CellValueWrapper({
|
||||
column={column}
|
||||
prettyFormatting={prettyFormatting}
|
||||
hovered={hovered}
|
||||
row={row}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
@@ -436,11 +494,13 @@ function CellValue({
|
||||
column,
|
||||
prettyFormatting = true,
|
||||
hovered = false,
|
||||
row,
|
||||
}: {
|
||||
value: unknown;
|
||||
column: OutputColumnMetadata;
|
||||
prettyFormatting?: boolean;
|
||||
hovered?: boolean;
|
||||
row?: Record<string, unknown>;
|
||||
}) {
|
||||
// Plain text mode - render everything as monospace text with truncation
|
||||
if (!prettyFormatting) {
|
||||
@@ -476,17 +536,51 @@ function CellValue({
|
||||
return <pre className="text-text-dimmed">UNDEFINED</pre>;
|
||||
}
|
||||
|
||||
// Check format hint for new format types (from prettyFormat())
|
||||
if (column.format && !column.customRenderType) {
|
||||
switch (column.format) {
|
||||
case "bytes":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatBytes(value)}</span>;
|
||||
}
|
||||
break;
|
||||
case "decimalBytes":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatDecimalBytes(value)}</span>;
|
||||
}
|
||||
break;
|
||||
case "percent":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{value.toFixed(2)}%</span>;
|
||||
}
|
||||
break;
|
||||
case "quantity":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatQuantity(value)}</span>;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// First check customRenderType for special rendering
|
||||
if (column.customRenderType) {
|
||||
switch (column.customRenderType) {
|
||||
case "runId": {
|
||||
if (typeof value === "string") {
|
||||
const spanId = row?.["span_id"];
|
||||
const runPath = v3RunPathFromFriendlyId(value);
|
||||
const href = typeof spanId === "string" && spanId
|
||||
? `${runPath}?span=${spanId}`
|
||||
: runPath;
|
||||
const tooltip = typeof spanId === "string" && spanId
|
||||
? "Jump to span"
|
||||
: "Jump to run";
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content="Jump to run"
|
||||
content={tooltip}
|
||||
disableHoverableContent
|
||||
hidden={!hovered}
|
||||
button={<TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>}
|
||||
button={<TextLink to={href}>{value}</TextLink>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -528,6 +622,15 @@ function CellValue({
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
case "durationNs":
|
||||
if (typeof value === "number") {
|
||||
return (
|
||||
<span className="tabular-nums">
|
||||
{formatDurationMilliseconds(value / 1_000_000, { style: "short" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
case "cost":
|
||||
if (typeof value === "number") {
|
||||
return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
|
||||
@@ -577,6 +680,19 @@ function CellValue({
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
case "deploymentId": {
|
||||
if (typeof value === "string" && value.startsWith("deployment_")) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content="Jump to deployment"
|
||||
disableHoverableContent
|
||||
hidden={!hovered}
|
||||
button={<TextLink to={`/deployments/${value}`}>{value}</TextLink>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -585,7 +701,7 @@ function CellValue({
|
||||
|
||||
if (isDateTimeType(type)) {
|
||||
if (typeof value === "string") {
|
||||
return <DateTimeAccurate date={value} showTooltip={hovered} />;
|
||||
return <DateTimeAccurate date={value} showTooltip={hovered} timeZone="UTC" />;
|
||||
}
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
@@ -907,6 +1023,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
prettyFormatting = true,
|
||||
sorting: defaultSorting = [],
|
||||
showHeaderOnEmpty = false,
|
||||
hiddenColumns,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
@@ -914,6 +1031,8 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
sorting?: SortingState;
|
||||
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
|
||||
showHeaderOnEmpty?: boolean;
|
||||
/** Column names to hide from display but keep in row data (useful for linking) */
|
||||
hiddenColumns?: string[];
|
||||
}) {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -927,9 +1046,13 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
|
||||
// Create TanStack Table column definitions from OutputColumnMetadata
|
||||
// Calculate column widths based on content
|
||||
const visibleColumns = useMemo(
|
||||
() => hiddenColumns?.length ? columns.filter((col) => !hiddenColumns.includes(col.name)) : columns,
|
||||
[columns, hiddenColumns]
|
||||
);
|
||||
const columnDefs = useMemo<ColumnDef<RowData, unknown>[]>(
|
||||
() =>
|
||||
columns.map((col) => ({
|
||||
visibleColumns.map((col) => ({
|
||||
id: col.name,
|
||||
accessorKey: col.name,
|
||||
header: () => col.name,
|
||||
@@ -938,6 +1061,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
value={info.getValue()}
|
||||
column={col}
|
||||
prettyFormatting={prettyFormatting}
|
||||
row={info.row.original}
|
||||
/>
|
||||
),
|
||||
meta: {
|
||||
@@ -947,7 +1071,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
size: calculateColumnWidth(col.name, rows, col),
|
||||
filterFn: fuzzyFilter,
|
||||
})),
|
||||
[columns, rows, prettyFormatting]
|
||||
[visibleColumns, rows, prettyFormatting]
|
||||
);
|
||||
|
||||
// Initialize TanStack Table
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { EditorView, lineNumbers } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon } from "@heroicons/react/20/solid";
|
||||
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
|
||||
export interface TextEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
defaultValue?: string;
|
||||
readOnly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
showCopyButton?: boolean;
|
||||
additionalActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TextEditor(opts: TextEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
additionalActions,
|
||||
} = opts;
|
||||
|
||||
// Don't use default line numbers from setup — add our own with proper sizing
|
||||
const extensions = getEditorSetup(false);
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
extensions.push(
|
||||
lineNumbers({
|
||||
formatNumber: (n) => String(n),
|
||||
})
|
||||
);
|
||||
extensions.push(
|
||||
EditorView.theme({
|
||||
".cm-lineNumbers": {
|
||||
minWidth: "40px",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup: false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
};
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [view]);
|
||||
|
||||
const showToolbar = showCopyButton || additionalActions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
showToolbar ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]",
|
||||
opts.className
|
||||
)}
|
||||
>
|
||||
{showToolbar && (
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<div className="flex items-center">{additionalActions}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 min-w-0 overflow-auto" ref={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Switch } from "~/components/primitives/Switch";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
@@ -18,6 +19,8 @@ type BuildSettingsFieldsProps = {
|
||||
atomicBuilds: EnvSlug[];
|
||||
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
|
||||
envVarsConfigLink?: string;
|
||||
/** Slugs that should be forced off and disabled, with tooltip reason. */
|
||||
disabledEnvSlugs?: Partial<Record<EnvSlug, string>>;
|
||||
};
|
||||
|
||||
export function BuildSettingsFields({
|
||||
@@ -29,42 +32,51 @@ export function BuildSettingsFields({
|
||||
atomicBuilds,
|
||||
onAtomicBuildsChange,
|
||||
envVarsConfigLink,
|
||||
disabledEnvSlugs,
|
||||
}: BuildSettingsFieldsProps) {
|
||||
const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug];
|
||||
const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull env vars before build */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Pull env vars before build</Label>
|
||||
<Hint>
|
||||
Select which environments should pull environment variables from Vercel before each
|
||||
build.{" "}
|
||||
{envVarsConfigLink && (
|
||||
<>
|
||||
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Hint>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
enabledSlugs.length > 0 &&
|
||||
enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Hint className="pr-6">
|
||||
Select which environments should pull environment variables from Vercel before each
|
||||
build.{" "}
|
||||
{envVarsConfigLink && (
|
||||
<>
|
||||
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Hint>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
return (
|
||||
<div key={slug} className="flex items-center justify-between">
|
||||
const disabled = isSlugDisabled(slug);
|
||||
const disabledReason = disabledEnvSlugs?.[slug];
|
||||
const row = (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
@@ -73,7 +85,8 @@ export function BuildSettingsFields({
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={pullEnvVarsBeforeBuild.includes(slug)}
|
||||
checked={disabled ? false : pullEnvVarsBeforeBuild.includes(slug)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(
|
||||
checked
|
||||
@@ -84,49 +97,57 @@ export function BuildSettingsFields({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (disabled && disabledReason) {
|
||||
return (
|
||||
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
|
||||
);
|
||||
}
|
||||
return row;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discover new env vars */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Discover new env vars</Label>
|
||||
<Hint>
|
||||
Select which environments should automatically discover and create new environment
|
||||
variables from Vercel during builds.
|
||||
</Hint>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
enabledSlugs.length > 0 &&
|
||||
enabledSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
enabledSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
availableEnvSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!availableEnvSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? availableEnvSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Hint className="pr-6">
|
||||
Select which environments should automatically discover and create new environment
|
||||
variables from Vercel during builds.
|
||||
</Hint>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-charcoal-800 p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
const disabled = isSlugDisabled(slug);
|
||||
const disabledReason = disabledEnvSlugs?.[slug];
|
||||
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
|
||||
return (
|
||||
const row = (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
|
||||
className={`flex items-center justify-between ${disabled || isPullDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
@@ -136,8 +157,8 @@ export function BuildSettingsFields({
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={discoverEnvVars.includes(slug)}
|
||||
disabled={isPullDisabled}
|
||||
checked={disabled ? false : discoverEnvVars.includes(slug)}
|
||||
disabled={disabled || isPullDisabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
@@ -148,6 +169,12 @@ export function BuildSettingsFields({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (disabled && disabledReason) {
|
||||
return (
|
||||
<SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />
|
||||
);
|
||||
}
|
||||
return row;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,13 +182,7 @@ export function BuildSettingsFields({
|
||||
{/* Atomic deployments */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Atomic deployments</Label>
|
||||
<Hint>
|
||||
When enabled, production deployments wait for Vercel deployment to complete before
|
||||
promoting the Trigger.dev deployment.
|
||||
</Hint>
|
||||
</div>
|
||||
<Label>Atomic deployments</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={atomicBuilds.includes("prod")}
|
||||
@@ -170,6 +191,16 @@ export function BuildSettingsFields({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Hint className="pr-6">
|
||||
When enabled, production deployments wait for Vercel deployment to complete before
|
||||
promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom
|
||||
Production Domains" option in your Vercel project settings to perform staged
|
||||
deployments.{" "}
|
||||
<TextLink href="https://trigger.dev/docs/vercel-integration#atomic-deployments" target="_blank">
|
||||
Learn more
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { VercelLogo } from "./VercelLogo";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
|
||||
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<VercelLogo className="size-3.5" />}
|
||||
iconSpacing="gap-x-1"
|
||||
to={vercelDeploymentUrl}
|
||||
className="pl-1"
|
||||
>
|
||||
Vercel
|
||||
</LinkButton>
|
||||
}
|
||||
content="View on Vercel"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -44,10 +44,11 @@ import {
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { type VercelCustomEnvironment } from "~/models/vercelIntegration.server";
|
||||
import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresenter.server";
|
||||
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import { vercelAppInstallPath, v3ProjectSettingsIntegrationsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
|
||||
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { usePostHogTracking } from "~/hooks/usePostHog";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
|
||||
function safeRedirectUrl(url: string): string | null {
|
||||
try {
|
||||
@@ -102,6 +103,7 @@ export function VercelOnboardingModal({
|
||||
hasOrgIntegration,
|
||||
nextUrl,
|
||||
onDataReload,
|
||||
vercelManageAccessUrl,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -114,6 +116,7 @@ export function VercelOnboardingModal({
|
||||
hasOrgIntegration: boolean;
|
||||
nextUrl?: string;
|
||||
onDataReload?: (vercelStagingEnvironment?: string) => void;
|
||||
vercelManageAccessUrl?: string;
|
||||
}) {
|
||||
const { capture, startSessionRecording } = usePostHogTracking();
|
||||
const navigation = useNavigation();
|
||||
@@ -122,7 +125,8 @@ export function VercelOnboardingModal({
|
||||
const completeOnboardingFetcher = useFetcher();
|
||||
const { Form: CompleteOnboardingForm } = completeOnboardingFetcher;
|
||||
const [searchParams] = useSearchParams();
|
||||
const fromMarketplaceContext = searchParams.get("origin") === "marketplace";
|
||||
const origin = searchParams.get("origin");
|
||||
const fromMarketplaceContext = origin === "marketplace";
|
||||
|
||||
const availableProjects = onboardingData?.availableProjects || [];
|
||||
const hasProjectSelected = onboardingData?.hasProjectSelected ?? false;
|
||||
@@ -221,9 +225,10 @@ export function VercelOnboardingModal({
|
||||
() => availableEnvSlugsForOnboardingBuildSettings
|
||||
);
|
||||
|
||||
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true (once)
|
||||
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasStagingEnvironment becomes true
|
||||
// AND a custom Vercel environment is mapped (once)
|
||||
useEffect(() => {
|
||||
if (hasStagingEnvironment && !hasSyncedStagingRef.current) {
|
||||
if (hasStagingEnvironment && vercelStagingEnvironment && !hasSyncedStagingRef.current) {
|
||||
hasSyncedStagingRef.current = true;
|
||||
setPullEnvVarsBeforeBuild((prev) => {
|
||||
if (!prev.includes("stg")) {
|
||||
@@ -238,7 +243,15 @@ export function VercelOnboardingModal({
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [hasStagingEnvironment]);
|
||||
}, [hasStagingEnvironment, vercelStagingEnvironment]);
|
||||
|
||||
// Strip "stg" from build settings when the staging environment mapping is cleared
|
||||
useEffect(() => {
|
||||
if (!vercelStagingEnvironment) {
|
||||
setPullEnvVarsBeforeBuild((prev) => prev.filter((s) => s !== "stg"));
|
||||
setDiscoverEnvVars((prev) => prev.filter((s) => s !== "stg"));
|
||||
}
|
||||
}, [vercelStagingEnvironment]);
|
||||
|
||||
// Sync pullEnvVarsBeforeBuild and discoverEnvVars when hasPreviewEnvironment becomes true (once)
|
||||
useEffect(() => {
|
||||
@@ -528,6 +541,9 @@ export function VercelOnboardingModal({
|
||||
formData.append("atomicBuilds", JSON.stringify(atomicBuilds));
|
||||
formData.append("discoverEnvVars", JSON.stringify(discoverEnvVars));
|
||||
formData.append("syncEnvVarsMapping", JSON.stringify(syncEnvVarsMapping));
|
||||
if (fromMarketplaceContext) {
|
||||
formData.append("origin", "marketplace");
|
||||
}
|
||||
if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) {
|
||||
formData.append("next", nextUrl);
|
||||
}
|
||||
@@ -543,8 +559,15 @@ export function VercelOnboardingModal({
|
||||
|
||||
if (!isGitHubConnectedForOnboarding) {
|
||||
setState("github-connection");
|
||||
capture("vercel onboarding github step viewed", {
|
||||
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
|
||||
step: "github-connection",
|
||||
organization_slug: organizationSlug,
|
||||
project_slug: projectSlug,
|
||||
github_app_installed: gitHubAppInstallations.length > 0,
|
||||
});
|
||||
}
|
||||
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
|
||||
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding, capture, organizationSlug, projectSlug, gitHubAppInstallations.length]);
|
||||
|
||||
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -639,7 +662,7 @@ export function VercelOnboardingModal({
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-5" />
|
||||
@@ -660,6 +683,11 @@ export function VercelOnboardingModal({
|
||||
const showBuildSettings = state === "build-settings";
|
||||
const showGitHubConnection = state === "github-connection";
|
||||
|
||||
const disabledEnvSlugsForBuildSettings =
|
||||
hasStagingEnvironment && !vercelStagingEnvironment
|
||||
? ({ stg: "Map a custom Vercel environment to Staging to enable this" } as Partial<Record<EnvSlug, string>>)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
if (!open && !fromMarketplaceContext) {
|
||||
@@ -669,7 +697,7 @@ export function VercelOnboardingModal({
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-lg" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<VercelLogo className="size-5" />
|
||||
@@ -721,20 +749,31 @@ export function VercelOnboardingModal({
|
||||
)}
|
||||
|
||||
<Hint>
|
||||
Once connected, your <code className="text-xs">TRIGGER_SECRET_KEY</code> will be
|
||||
Once connected, your <code className="text-xs rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code> will be
|
||||
automatically synced to Vercel for each environment.
|
||||
</Hint>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={handleProjectSelection}
|
||||
disabled={!selectedVercelProject || fetcher.state !== "idle"}
|
||||
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
|
||||
>
|
||||
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{vercelManageAccessUrl && !origin && (
|
||||
<LinkButton
|
||||
to={vercelManageAccessUrl}
|
||||
variant="tertiary/medium"
|
||||
target="_self"
|
||||
>
|
||||
Manage access
|
||||
</LinkButton>
|
||||
)}
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={handleProjectSelection}
|
||||
disabled={!selectedVercelProject || fetcher.state !== "idle"}
|
||||
LeadingIcon={fetcher.state !== "idle" ? SpinnerWhite : undefined}
|
||||
>
|
||||
{fetcher.state !== "idle" ? "Connecting..." : "Connect Project"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
cancelButton={
|
||||
<Button
|
||||
@@ -754,6 +793,10 @@ export function VercelOnboardingModal({
|
||||
<Paragraph className="text-sm">
|
||||
Select which custom Vercel environment should map to Trigger.dev's Staging
|
||||
environment. Production and Preview environments are mapped automatically.
|
||||
If you skip this step, the{" "}
|
||||
<code className="rounded bg-charcoal-700 px-1 py-0.5 text-text-bright">TRIGGER_SECRET_KEY</code>{" "}
|
||||
will not be installed for the staging environment in Vercel. You can configure this later in
|
||||
project settings.
|
||||
</Paragraph>
|
||||
|
||||
<Select
|
||||
@@ -779,6 +822,11 @@ export function VercelOnboardingModal({
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Paragraph className="text-xs text-text-dimmed">
|
||||
Make sure the staging branch in your Vercel project's Git settings matches the staging branch
|
||||
configured in your GitHub integration.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
@@ -810,24 +858,13 @@ export function VercelOnboardingModal({
|
||||
|
||||
{showEnvVarSync && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Header3>Pull Environment Variables</Header3>
|
||||
<Paragraph className="text-sm">
|
||||
Select which environment variables to pull from Vercel now. This is a one-time pull.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex gap-4 text-sm">
|
||||
<div className="rounded border bg-charcoal-750 px-3 py-2">
|
||||
<span className="font-medium text-text-bright">{syncableEnvVars.length}</span>
|
||||
<span className="text-text-dimmed"> can be pulled</span>
|
||||
</div>
|
||||
{secretEnvVars.length > 0 && (
|
||||
<div className="rounded border bg-charcoal-750 px-3 py-2">
|
||||
<span className="font-medium text-amber-400">{secretEnvVars.length}</span>
|
||||
<span className="text-text-dimmed"> secret (cannot pull)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3>Pull Environment Variables</Header3>
|
||||
<Paragraph className="text-sm">
|
||||
Choose which environment variables to import from Vercel. This runs as a one time pull to prefill your project with the variables it needs. You’ll be able to pull again later, or enable automatic syncing before each build if you prefer.
|
||||
If you are using Supabase or Neon branching, <TextLink href="https://trigger.dev/docs/vercel-integration#supabase-and-neon-database-branching" target="_blank" rel="noopener noreferrer">read the docs</TextLink> for the recommended setup.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded border bg-charcoal-800 p-3">
|
||||
<div>
|
||||
<Label>Pull all environment variables now</Label>
|
||||
@@ -1016,6 +1053,7 @@ export function VercelOnboardingModal({
|
||||
onDiscoverEnvVarsChange={setDiscoverEnvVars}
|
||||
atomicBuilds={atomicBuilds}
|
||||
onAtomicBuildsChange={setAtomicBuilds}
|
||||
disabledEnvSlugs={disabledEnvSlugsForBuildSettings}
|
||||
/>
|
||||
|
||||
<FormButtons
|
||||
@@ -1057,7 +1095,7 @@ export function VercelOnboardingModal({
|
||||
</Callout>
|
||||
|
||||
{(() => {
|
||||
const baseSettingsPath = v3ProjectSettingsPath(
|
||||
const baseSettingsPath = v3ProjectSettingsIntegrationsPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectSlug },
|
||||
{ slug: environmentSlug }
|
||||
@@ -1081,6 +1119,7 @@ export function VercelOnboardingModal({
|
||||
)}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={OctoKitty}
|
||||
onClick={() => trackOnboarding("vercel onboarding github app install clicked")}
|
||||
>
|
||||
Install GitHub app
|
||||
</LinkButton>
|
||||
@@ -1110,6 +1149,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github completed");
|
||||
setState("completed");
|
||||
const validUrl = safeRedirectUrl(nextUrl);
|
||||
if (validUrl) {
|
||||
@@ -1123,6 +1163,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github skipped");
|
||||
setState("completed");
|
||||
if (fromMarketplaceContext && nextUrl) {
|
||||
const validUrl = safeRedirectUrl(nextUrl);
|
||||
@@ -1141,6 +1182,7 @@ export function VercelOnboardingModal({
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
trackOnboarding("vercel onboarding github skipped");
|
||||
setState("completed");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -186,7 +186,7 @@ function DetailsTab({
|
||||
<CopyableText value={log.runId} copyValue={log.runId} asChild />
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="tertiary/small"
|
||||
variant="secondary/small"
|
||||
shortcut={{ key: "v" }}
|
||||
className="mt-2"
|
||||
>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
TableRow,
|
||||
type TableVariant,
|
||||
} from "../primitives/Table";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogEntry[];
|
||||
@@ -124,6 +125,7 @@ export function LogsTable({
|
||||
<TableHeaderCell
|
||||
className="min-w-24 whitespace-nowrap"
|
||||
tooltip={<LogLevelTooltipInfo />}
|
||||
disableTooltipHoverableContent
|
||||
>
|
||||
Level
|
||||
</TableHeaderCell>
|
||||
@@ -165,7 +167,7 @@ export function LogsTable({
|
||||
>
|
||||
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-24">
|
||||
<TableCell className="min-w-24" onClick={handleRowClick} hasAction>
|
||||
<TruncatedCopyableValue value={log.runId} />
|
||||
</TableCell>
|
||||
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
|
||||
@@ -185,9 +187,11 @@ export function LogsTable({
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ArrowTopRightOnSquareIcon}
|
||||
TrailingIcon={RunsIcon}
|
||||
trailingIconClassName="text-text-bright"
|
||||
className="h-[1.375rem] pl-1.5 pr-2"
|
||||
>
|
||||
View run
|
||||
<span className="text-[0.6875rem] text-text-bright">View run</span>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { CubeIcon } from "@heroicons/react/20/solid";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
import { tablerIcons } from "~/utils/tablerIcons";
|
||||
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
|
||||
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
|
||||
|
||||
const shortcut = { key: "m" };
|
||||
|
||||
export type ModelOption = {
|
||||
model: string;
|
||||
system: string;
|
||||
};
|
||||
|
||||
interface ModelsFilterProps {
|
||||
possibleModels: ModelOption[];
|
||||
}
|
||||
|
||||
function modelIcon(system: string, model: string): ReactNode {
|
||||
// For gateway/openrouter, derive provider from model prefix
|
||||
let provider = system.split(".")[0];
|
||||
if (provider === "gateway" || provider === "openrouter") {
|
||||
if (model.includes("/")) {
|
||||
provider = model.split("/")[0].replace(/-/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: Anthropic uses a custom SVG icon
|
||||
if (provider === "anthropic") {
|
||||
return <AnthropicLogoIcon className="size-4 shrink-0" />;
|
||||
}
|
||||
|
||||
const iconName = `tabler-brand-${provider}`;
|
||||
if (tablerIcons.has(iconName)) {
|
||||
return (
|
||||
<svg className="size-4 shrink-0 stroke-[1.5]">
|
||||
<use xlinkHref={`${tablerSpritePath}#${iconName}`} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return <CubeIcon className="size-4 shrink-0" />;
|
||||
}
|
||||
|
||||
export function ModelsFilter({ possibleModels }: ModelsFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedModels = values("models");
|
||||
|
||||
if (selectedModels.length === 0 || selectedModels.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ModelsDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<CubeIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by model"
|
||||
>
|
||||
<span className="ml-0.5">Models</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleModels={possibleModels}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ModelsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Model"
|
||||
icon={<CubeIcon className="size-4" />}
|
||||
value={appliedSummary(selectedModels)}
|
||||
onRemove={() => del(["models"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleModels={possibleModels}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleModels,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleModels: ModelOption[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ models: values });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleModels.filter((m) => {
|
||||
return m.model?.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleModels]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("models")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by model..." value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((m) => (
|
||||
<SelectItem key={m.model} value={m.model} icon={modelIcon(m.system, m.model)}>
|
||||
{m.model}
|
||||
</SelectItem>
|
||||
))}
|
||||
{filtered.length === 0 && <SelectItem disabled>No models found</SelectItem>}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "n" };
|
||||
|
||||
interface OperationsFilterProps {
|
||||
possibleOperations: string[];
|
||||
}
|
||||
|
||||
/** Pretty-print an operation ID like "ai.generateText.doGenerate" → "generateText" */
|
||||
function formatOperation(op: string): string {
|
||||
const parts = op.split(".");
|
||||
// ai.generateText.doGenerate → generateText
|
||||
// ai.streamText.doStream → streamText
|
||||
if (parts.length >= 2 && parts[0] === "ai") {
|
||||
return parts[1];
|
||||
}
|
||||
return op;
|
||||
}
|
||||
|
||||
export function OperationsFilter({ possibleOperations }: OperationsFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedOperations = values("operations");
|
||||
|
||||
if (selectedOperations.length === 0 || selectedOperations.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<OperationsDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<CommandLineIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by operation"
|
||||
>
|
||||
<span className="ml-0.5">Operations</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleOperations={possibleOperations}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<OperationsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Operation"
|
||||
icon={<CommandLineIcon className="size-4" />}
|
||||
value={appliedSummary(selectedOperations.map(formatOperation))}
|
||||
onRemove={() => del(["operations"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleOperations={possibleOperations}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleOperations,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleOperations: string[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ operations: values });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = searchValue.toLowerCase();
|
||||
return possibleOperations.filter(
|
||||
(op) => op.toLowerCase().includes(q) || formatOperation(op).toLowerCase().includes(q)
|
||||
);
|
||||
}, [searchValue, possibleOperations]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("operations")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by operation..." value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((op) => (
|
||||
<SelectItem key={op} value={op} icon={<CommandLineIcon className="size-4" />}>
|
||||
{formatOperation(op)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{filtered.length === 0 && <SelectItem disabled>No operations found</SelectItem>}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { DocumentTextIcon } from "@heroicons/react/20/solid";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "p" };
|
||||
|
||||
interface PromptsFilterProps {
|
||||
possiblePrompts: string[];
|
||||
}
|
||||
|
||||
export function PromptsFilter({ possiblePrompts }: PromptsFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedPrompts = values("prompts");
|
||||
|
||||
if (selectedPrompts.length === 0 || selectedPrompts.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<PromptsDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<DocumentTextIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by prompt"
|
||||
>
|
||||
<span className="ml-0.5">Prompts</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possiblePrompts={possiblePrompts}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<PromptsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Prompt"
|
||||
icon={<DocumentTextIcon className="size-4" />}
|
||||
value={appliedSummary(selectedPrompts)}
|
||||
onRemove={() => del(["prompts"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possiblePrompts={possiblePrompts}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possiblePrompts,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possiblePrompts: string[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ prompts: values });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possiblePrompts.filter((p) => {
|
||||
return p.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possiblePrompts]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("prompts")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by prompt..." value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((slug) => (
|
||||
<SelectItem key={slug} value={slug} icon={<DocumentTextIcon className="size-4" />}>
|
||||
{slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
{filtered.length === 0 && <SelectItem disabled>No prompts found</SelectItem>}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { ServerIcon } from "@heroicons/react/20/solid";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "v" };
|
||||
|
||||
interface ProvidersFilterProps {
|
||||
possibleProviders: string[];
|
||||
}
|
||||
|
||||
export function ProvidersFilter({ possibleProviders }: ProvidersFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedProviders = values("providers");
|
||||
|
||||
if (selectedProviders.length === 0 || selectedProviders.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ProvidersDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<ServerIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by provider"
|
||||
>
|
||||
<span className="ml-0.5">Providers</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleProviders={possibleProviders}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ProvidersDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Provider"
|
||||
icon={<ServerIcon className="size-4" />}
|
||||
value={appliedSummary(selectedProviders)}
|
||||
onRemove={() => del(["providers"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleProviders={possibleProviders}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ProvidersDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleProviders,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleProviders: string[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ providers: values });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleProviders.filter((p) => p.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
}, [searchValue, possibleProviders]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("providers")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by provider..." value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((provider) => (
|
||||
<SelectItem key={provider} value={provider} icon={<ServerIcon className="size-4" />}>
|
||||
{provider}
|
||||
</SelectItem>
|
||||
))}
|
||||
{filtered.length === 0 && <SelectItem disabled>No providers found</SelectItem>}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -156,6 +156,8 @@ export type QueryWidgetProps = {
|
||||
onDuplicate?: (data: QueryWidgetData) => void;
|
||||
/** When true, show table column headers even when there are no rows */
|
||||
showTableHeaderOnEmpty?: boolean;
|
||||
/** Column names to hide from table display but keep in row data (useful for linking) */
|
||||
hiddenColumns?: string[];
|
||||
};
|
||||
|
||||
export function QueryWidget({
|
||||
@@ -406,6 +408,7 @@ type QueryWidgetBodyProps = {
|
||||
setIsFullscreen: (open: boolean) => void;
|
||||
isLoading: boolean;
|
||||
showTableHeaderOnEmpty?: boolean;
|
||||
hiddenColumns?: string[];
|
||||
};
|
||||
|
||||
function QueryWidgetBody({
|
||||
@@ -417,6 +420,7 @@ function QueryWidgetBody({
|
||||
setIsFullscreen,
|
||||
isLoading,
|
||||
showTableHeaderOnEmpty,
|
||||
hiddenColumns,
|
||||
}: QueryWidgetBodyProps) {
|
||||
const type = config.type;
|
||||
|
||||
@@ -436,6 +440,7 @@ function QueryWidgetBody({
|
||||
prettyFormatting={config.prettyFormatting}
|
||||
sorting={config.sorting}
|
||||
showHeaderOnEmpty={showTableHeaderOnEmpty}
|
||||
hiddenColumns={hiddenColumns}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent
|
||||
|
||||
@@ -26,6 +26,46 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../pri
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
|
||||
function useCreateDashboard({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const limits = useDashboardLimits();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
const isAtLimit = limits.used >= limits.limit;
|
||||
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
|
||||
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
|
||||
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
|
||||
const isFreePlan = plan?.v3Subscription?.isPaying === false;
|
||||
|
||||
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.formAction === formAction && navigation.state === "loading") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.formAction, navigation.state, formAction]);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
isAtLimit,
|
||||
canUpgrade: !!canUpgrade,
|
||||
isFreePlan,
|
||||
formAction,
|
||||
limits,
|
||||
organization,
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateDashboardButton({
|
||||
organization,
|
||||
project,
|
||||
@@ -37,29 +77,12 @@ export function CreateDashboardButton({
|
||||
environment: SideMenuEnvironment;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const limits = useDashboardLimits();
|
||||
const plan = useCurrentPlan();
|
||||
|
||||
const isAtLimit = limits.used >= limits.limit;
|
||||
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
|
||||
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
|
||||
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
|
||||
|
||||
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
|
||||
|
||||
// Close dialog when form submission starts (redirect is happening)
|
||||
useEffect(() => {
|
||||
if (navigation.formAction === formAction && navigation.state === "loading") {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}, [navigation.formAction, navigation.state, formAction]);
|
||||
const dashboard = useCreateDashboard({ organization, project, environment });
|
||||
|
||||
if (isCollapsed) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -77,15 +100,47 @@ export function CreateDashboardButton({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{isAtLimit ? (
|
||||
{dashboard.isAtLimit ? (
|
||||
<CreateDashboardUpgradeDialog
|
||||
limits={limits}
|
||||
canUpgrade={!!canUpgrade}
|
||||
isFreePlan={plan?.v3Subscription?.isPaying === false}
|
||||
organization={organization}
|
||||
limits={dashboard.limits}
|
||||
canUpgrade={dashboard.canUpgrade}
|
||||
isFreePlan={dashboard.isFreePlan}
|
||||
organization={dashboard.organization}
|
||||
/>
|
||||
) : (
|
||||
<CreateDashboardDialog formAction={formAction} limits={limits} />
|
||||
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateDashboardPageButton({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
}: {
|
||||
organization: { slug: string };
|
||||
project: { slug: string };
|
||||
environment: { slug: string };
|
||||
}) {
|
||||
const dashboard = useCreateDashboard({ organization, project, environment });
|
||||
|
||||
return (
|
||||
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small" LeadingIcon={PlusIcon}>
|
||||
Create custom dashboard
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
{dashboard.isAtLimit ? (
|
||||
<CreateDashboardUpgradeDialog
|
||||
limits={dashboard.limits}
|
||||
canUpgrade={dashboard.canUpgrade}
|
||||
isFreePlan={dashboard.isFreePlan}
|
||||
organization={dashboard.organization}
|
||||
/>
|
||||
) : (
|
||||
<CreateDashboardDialog formAction={dashboard.formAction} limits={dashboard.limits} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
@@ -105,7 +160,7 @@ function CreateDashboardUpgradeDialog({
|
||||
limits: { used: number; limit: number };
|
||||
canUpgrade: boolean;
|
||||
isFreePlan: boolean;
|
||||
organization: MatchedOrganization;
|
||||
organization: { slug: string };
|
||||
}) {
|
||||
|
||||
if (isFreePlan) {
|
||||
|
||||
@@ -3,15 +3,17 @@ import {
|
||||
ChartBarIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
PuzzlePieceIcon,
|
||||
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 { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
organizationSlackIntegrationPath,
|
||||
organizationTeamPath,
|
||||
organizationVercelIntegrationPath,
|
||||
rootPath,
|
||||
@@ -115,13 +117,25 @@ export function OrganizationSettingsSideMenu({
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1">
|
||||
<SideMenuHeader title="Integrations" />
|
||||
</div>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
name="Vercel"
|
||||
icon={VercelLogo}
|
||||
activeIconColor="text-white"
|
||||
to={organizationVercelIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Slack"
|
||||
icon={SlackIcon}
|
||||
activeIconColor="text-white"
|
||||
to={organizationSlackIntegrationPath(organization)}
|
||||
data-action="integrations"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="App version" />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PuzzlePieceIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeAmericasIcon,
|
||||
@@ -19,10 +20,13 @@ import {
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
DocumentTextIcon,
|
||||
ServerStackIcon,
|
||||
SparklesIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
UsersIcon,
|
||||
BugAntIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import { LayoutGroup, motion } from "framer-motion";
|
||||
@@ -72,9 +76,12 @@ import {
|
||||
v3EnvironmentPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3LogsPath,
|
||||
v3ErrorsPath,
|
||||
v3PromptsPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3ProjectSettingsGeneralPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
v3QueuesPath,
|
||||
v3RunsPath,
|
||||
v3SchedulesPath,
|
||||
@@ -110,6 +117,7 @@ import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import { type SideMenuSectionId } from "./sideMenuTypes";
|
||||
import { IconBugFilled } from "@tabler/icons-react";
|
||||
|
||||
/** Get the collapsed state for a specific side menu section from user preferences */
|
||||
function getSectionCollapsed(
|
||||
@@ -127,7 +135,7 @@ type SideMenuUser = Pick<
|
||||
};
|
||||
export type SideMenuProject = Pick<
|
||||
MatchedProject,
|
||||
"id" | "name" | "slug" | "version" | "environments" | "engine"
|
||||
"id" | "name" | "slug" | "version" | "environments" | "engine" | "createdAt"
|
||||
>;
|
||||
export type SideMenuEnvironment = MatchedEnvironment;
|
||||
|
||||
@@ -449,9 +457,39 @@ export function SideMenu({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SideMenuSection
|
||||
title="AI"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(
|
||||
user.dashboardPreferences.sideMenu,
|
||||
"ai"
|
||||
)}
|
||||
onCollapseToggle={handleSectionToggle("ai")}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Prompts"
|
||||
icon={DocumentTextIcon}
|
||||
activeIconColor="text-purple-500"
|
||||
inactiveIconColor="text-purple-500"
|
||||
to={v3PromptsPath(organization, project, environment)}
|
||||
data-action="prompts"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="AI Metrics"
|
||||
icon={SparklesIcon}
|
||||
activeIconColor="text-purple-500"
|
||||
inactiveIconColor="text-purple-500"
|
||||
to={v3BuiltInDashboardPath(organization, project, environment, "llm")}
|
||||
data-action="ai-metrics"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
||||
<SideMenuSection
|
||||
title="Insights"
|
||||
title="Observability"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(
|
||||
@@ -472,6 +510,17 @@ export function SideMenu({
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(user.admin || user.isImpersonating) && (
|
||||
<SideMenuItem
|
||||
name="Errors"
|
||||
icon={IconBugFilled}
|
||||
activeIconColor="text-amber-500"
|
||||
inactiveIconColor="text-amber-500"
|
||||
to={v3ErrorsPath(organization, project, environment)}
|
||||
data-action="errors"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Query"
|
||||
icon={TableCellsIcon}
|
||||
@@ -482,7 +531,7 @@ export function SideMenu({
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Metrics"
|
||||
name="Dashboards"
|
||||
icon={ChartBarIcon}
|
||||
activeIconColor="text-metrics"
|
||||
inactiveIconColor="text-metrics"
|
||||
@@ -589,13 +638,34 @@ export function SideMenu({
|
||||
data-action="limits"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection
|
||||
title="Project settings"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={getSectionCollapsed(
|
||||
user.dashboardPreferences.sideMenu,
|
||||
"project-settings"
|
||||
)}
|
||||
onCollapseToggle={handleSectionToggle("project-settings")}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
name="General"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
to={v3ProjectSettingsGeneralPath(organization, project, environment)}
|
||||
data-action="project-settings-general"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Integrations"
|
||||
icon={PuzzlePieceIcon}
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsIntegrationsPath(organization, project, environment)}
|
||||
data-action="project-settings-integrations"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
@@ -611,6 +681,7 @@ export function SideMenu({
|
||||
<V3DeprecationPanel
|
||||
isCollapsed={isCollapsed}
|
||||
isV3={isV3Project}
|
||||
projectCreatedAt={project.createdAt}
|
||||
hasIncident={incidentStatus.hasIncident}
|
||||
isManagedCloud={incidentStatus.isManagedCloud}
|
||||
/>
|
||||
@@ -641,15 +712,21 @@ export function SideMenu({
|
||||
function V3DeprecationPanel({
|
||||
isCollapsed,
|
||||
isV3,
|
||||
projectCreatedAt,
|
||||
hasIncident,
|
||||
isManagedCloud,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
isV3: boolean;
|
||||
projectCreatedAt: Date;
|
||||
hasIncident: boolean;
|
||||
isManagedCloud: boolean;
|
||||
}) {
|
||||
if (!isManagedCloud || !isV3 || hasIncident) {
|
||||
// Only show for projects created before v4 was released
|
||||
const V4_RELEASE_DATE = new Date("2025-09-01");
|
||||
const isLikelyV3 = isV3 && new Date(projectCreatedAt) < V4_RELEASE_DATE;
|
||||
|
||||
if (!isManagedCloud || !isLikelyV3 || hasIncident) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Valid section IDs that can have their collapsed state toggled
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
|
||||
export const SideMenuSectionIdSchema = z.enum(["ai", "manage", "metrics", "project-settings"]);
|
||||
|
||||
// Inferred type from the schema
|
||||
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
XMarkIcon,
|
||||
PlusIcon,
|
||||
CubeIcon,
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { CheckboxIndicator } from "~/components/primitives/CheckboxIndicator";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
|
||||
const pillColors = [
|
||||
"bg-green-800/40 border-green-600/50",
|
||||
"bg-teal-800/40 border-teal-600/50",
|
||||
"bg-blue-800/40 border-blue-600/50",
|
||||
"bg-indigo-800/40 border-indigo-600/50",
|
||||
"bg-violet-800/40 border-violet-600/50",
|
||||
"bg-purple-800/40 border-purple-600/50",
|
||||
"bg-fuchsia-800/40 border-fuchsia-600/50",
|
||||
"bg-pink-800/40 border-pink-600/50",
|
||||
"bg-rose-800/40 border-rose-600/50",
|
||||
"bg-orange-800/40 border-orange-600/50",
|
||||
"bg-amber-800/40 border-amber-600/50",
|
||||
"bg-yellow-800/40 border-yellow-600/50",
|
||||
"bg-lime-800/40 border-lime-600/50",
|
||||
"bg-emerald-800/40 border-emerald-600/50",
|
||||
"bg-cyan-800/40 border-cyan-600/50",
|
||||
"bg-sky-800/40 border-sky-600/50",
|
||||
];
|
||||
|
||||
function getPillColor(value: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return pillColors[Math.abs(hash) % pillColors.length];
|
||||
}
|
||||
|
||||
export const TECHNOLOGY_OPTIONS = [
|
||||
"Airflow",
|
||||
"Angular",
|
||||
"Anthropic",
|
||||
"Astro",
|
||||
"Auth0",
|
||||
"AWS",
|
||||
"AWS SQS",
|
||||
"Azure",
|
||||
"BigQuery",
|
||||
"BullMQ",
|
||||
"Bun",
|
||||
"Cassandra",
|
||||
"Celery",
|
||||
"ClickHouse",
|
||||
"Clerk",
|
||||
"Cloudflare",
|
||||
"CockroachDB",
|
||||
"Cohere",
|
||||
"Convex",
|
||||
"Databricks",
|
||||
"Datadog",
|
||||
"DeepSeek",
|
||||
"Deno",
|
||||
"DigitalOcean",
|
||||
"Django",
|
||||
"Docker",
|
||||
"Drizzle",
|
||||
"DynamoDB",
|
||||
"Elasticsearch",
|
||||
"Electron",
|
||||
"Elevenlabs",
|
||||
"Expo",
|
||||
"Express",
|
||||
"FastAPI",
|
||||
"Fastify",
|
||||
"Firebase",
|
||||
"Flask",
|
||||
"Fly.io",
|
||||
"Gatsby",
|
||||
"GCP",
|
||||
"Go",
|
||||
"Google Cloud Tasks",
|
||||
"Google Gemini",
|
||||
"GraphQL",
|
||||
"Groq",
|
||||
"Heroku",
|
||||
"Hono",
|
||||
"htmx",
|
||||
"Hugging Face",
|
||||
"Inngest",
|
||||
"Kafka",
|
||||
"Kubernetes",
|
||||
"LangChain",
|
||||
"Laravel",
|
||||
"LlamaIndex",
|
||||
"MariaDB",
|
||||
"Midjourney",
|
||||
"Mistral",
|
||||
"MongoDB",
|
||||
"Mongoose",
|
||||
"MySQL",
|
||||
"Neo4j",
|
||||
"Neon",
|
||||
"Nest.js",
|
||||
"Netlify",
|
||||
"Next.js",
|
||||
"Node.js",
|
||||
"Nuxt",
|
||||
"Ollama",
|
||||
"OpenAI",
|
||||
"Perplexity",
|
||||
"PHP",
|
||||
"Pinecone",
|
||||
"PlanetScale",
|
||||
"Python",
|
||||
"PostHog",
|
||||
"PostgreSQL",
|
||||
"Prisma",
|
||||
"Pulumi",
|
||||
"RabbitMQ",
|
||||
"Railway",
|
||||
"React",
|
||||
"React Native",
|
||||
"Redis",
|
||||
"Redshift",
|
||||
"Remix",
|
||||
"Render",
|
||||
"Replicate",
|
||||
"Resend",
|
||||
"Ruby on Rails",
|
||||
"Rust",
|
||||
"SendGrid",
|
||||
"Sentry",
|
||||
"Sidekiq",
|
||||
"Snowflake",
|
||||
"Solid.js",
|
||||
"Spring Boot",
|
||||
"SQLite",
|
||||
"Stability AI",
|
||||
"Stripe",
|
||||
"Supabase",
|
||||
"Svelte",
|
||||
"SvelteKit",
|
||||
"Tailwind CSS",
|
||||
"Temporal",
|
||||
"Terraform",
|
||||
"Together AI",
|
||||
"tRPC",
|
||||
"Turso",
|
||||
"Twilio",
|
||||
"TypeORM",
|
||||
"Upstash",
|
||||
"Vercel",
|
||||
"Vercel AI SDK",
|
||||
"Vite",
|
||||
"Vue",
|
||||
"Weaviate",
|
||||
] as const;
|
||||
|
||||
type TechnologyPickerProps = {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
customValues: string[];
|
||||
onCustomValuesChange: (values: string[]) => void;
|
||||
};
|
||||
|
||||
export function TechnologyPicker({
|
||||
value,
|
||||
onChange,
|
||||
customValues,
|
||||
onCustomValuesChange,
|
||||
}: TechnologyPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [otherInputValue, setOtherInputValue] = useState("");
|
||||
const [showOtherInput, setShowOtherInput] = useState(false);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const allSelected = useMemo(() => [...value, ...customValues], [value, customValues]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchValue) return TECHNOLOGY_OPTIONS;
|
||||
return matchSorter([...TECHNOLOGY_OPTIONS], searchValue);
|
||||
}, [searchValue]);
|
||||
|
||||
const toggleOption = useCallback(
|
||||
(option: string) => {
|
||||
if (value.includes(option)) {
|
||||
onChange(value.filter((v) => v !== option));
|
||||
} else {
|
||||
onChange([...value, option]);
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
const removeItem = useCallback(
|
||||
(item: string) => {
|
||||
if (value.includes(item)) {
|
||||
onChange(value.filter((v) => v !== item));
|
||||
} else {
|
||||
onCustomValuesChange(customValues.filter((v) => v !== item));
|
||||
}
|
||||
},
|
||||
[value, onChange, customValues, onCustomValuesChange]
|
||||
);
|
||||
|
||||
const addCustomValue = useCallback(() => {
|
||||
const trimmed = otherInputValue.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const matchedOption = TECHNOLOGY_OPTIONS.find(
|
||||
(opt) => opt.toLowerCase() === trimmed.toLowerCase()
|
||||
);
|
||||
|
||||
if (matchedOption) {
|
||||
if (!value.includes(matchedOption)) {
|
||||
onChange([...value, matchedOption]);
|
||||
}
|
||||
} else if (!customValues.includes(trimmed) && !value.includes(trimmed)) {
|
||||
onCustomValuesChange([...customValues, trimmed]);
|
||||
}
|
||||
|
||||
setOtherInputValue("");
|
||||
}, [otherInputValue, customValues, onCustomValuesChange, value, onChange]);
|
||||
|
||||
const handleOtherKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
addCustomValue();
|
||||
}
|
||||
},
|
||||
[addCustomValue]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{allSelected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{allSelected.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border py-0.5 pl-1.5 pr-1 text-xs font-medium text-text-bright",
|
||||
getPillColor(item)
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item)}
|
||||
aria-label={`Remove ${item}`}
|
||||
className="ml-0.5 flex items-center transition hover:text-text-bright/70"
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Ariakit.ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(val) => {
|
||||
setSearchValue(val);
|
||||
}}
|
||||
>
|
||||
<Ariakit.SelectProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
value={value}
|
||||
setValue={(v) => {
|
||||
if (Array.isArray(v)) {
|
||||
onChange(v);
|
||||
}
|
||||
}}
|
||||
virtualFocus
|
||||
>
|
||||
<Ariakit.Select className="group flex h-8 w-full items-center rounded bg-charcoal-750 pl-2 pr-2.5 text-sm text-text-dimmed ring-charcoal-600 transition focus-custom hover:bg-charcoal-650 hover:ring-1">
|
||||
<div className="flex grow items-center">
|
||||
<CubeIcon className="mr-1.5 size-4 flex-none text-text-dimmed" />
|
||||
<span>Select your technologies…</span>
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 flex-none text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</Ariakit.Select>
|
||||
|
||||
<Ariakit.SelectPopover
|
||||
gutter={5}
|
||||
unmountOnHide
|
||||
className={cn(
|
||||
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
|
||||
"min-w-[max(180px,var(--popover-anchor-width))]",
|
||||
"max-w-[min(480px,var(--popover-available-width))]",
|
||||
"max-h-[min(400px,var(--popover-available-height))]"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-full flex-none items-center gap-2 border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
|
||||
<MagnifyingGlassIcon className="size-3.5 flex-none text-text-dimmed" />
|
||||
<Ariakit.Combobox
|
||||
autoSelect
|
||||
placeholder="Search technologies…"
|
||||
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Ariakit.ComboboxList className="overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-custom">
|
||||
{filteredOptions.map((option) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={option}
|
||||
className="group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
toggleOption(option);
|
||||
}}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center gap-2 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary">
|
||||
<CheckboxIndicator checked={value.includes(option)} />
|
||||
<span className="grow truncate text-text-bright">{option}</span>
|
||||
</div>
|
||||
</Ariakit.ComboboxItem>
|
||||
))}
|
||||
|
||||
{filteredOptions.length === 0 && searchValue && (
|
||||
<div className="px-3 py-2 text-xs text-text-dimmed">
|
||||
No matches for “{searchValue}”
|
||||
</div>
|
||||
)}
|
||||
</Ariakit.ComboboxList>
|
||||
|
||||
<div className="sticky bottom-0 border-t border-charcoal-700 bg-background-bright px-1 py-1">
|
||||
{showOtherInput ? (
|
||||
<div className="flex h-8 w-full items-center rounded-sm bg-tertiary pl-0 pr-2 ring-1 ring-charcoal-650">
|
||||
<input
|
||||
ref={otherInputRef}
|
||||
type="text"
|
||||
value={otherInputValue}
|
||||
onChange={(e) => setOtherInputValue(e.target.value)}
|
||||
onKeyDown={handleOtherKeyDown}
|
||||
placeholder="Type and press Enter to add"
|
||||
className="flex-1 border-none bg-transparent pl-2 text-2sm text-text-bright shadow-none outline-none ring-0 placeholder:text-text-dimmed focus:border-none focus:outline-none focus:ring-0"
|
||||
autoFocus
|
||||
/>
|
||||
<ShortcutKey
|
||||
shortcut={{ key: "Enter" }}
|
||||
variant="small"
|
||||
className={cn(
|
||||
"mr-1.5 transition-opacity duration-150",
|
||||
otherInputValue.length > 0 ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOtherInputValue("");
|
||||
setShowOtherInput(false);
|
||||
}}
|
||||
className="flex items-center text-text-dimmed hover:text-text-bright"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-sm px-2 text-2sm text-text-dimmed hover:bg-tertiary"
|
||||
onClick={() => {
|
||||
setShowOtherInput(true);
|
||||
setTimeout(() => otherInputRef.current?.focus(), 0);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="size-4 flex-none" />
|
||||
<span>Other (not listed)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.SelectPopover>
|
||||
</Ariakit.SelectProvider>
|
||||
</Ariakit.ComboboxProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
BoltIcon,
|
||||
BuildingOffice2Icon,
|
||||
CodeBracketSquareIcon,
|
||||
FaceSmileIcon,
|
||||
FireIcon,
|
||||
GlobeAltIcon,
|
||||
RocketLaunchIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
@@ -25,7 +27,8 @@ export const AvatarData = z.discriminatedUnion("type", [
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(AvatarType.enum.image),
|
||||
url: z.string().url(),
|
||||
url: z.string(),
|
||||
lastIconHex: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -85,6 +88,7 @@ export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGS
|
||||
"hero:fire": FireIcon,
|
||||
"hero:star": StarIcon,
|
||||
"hero:face-smile": FaceSmileIcon,
|
||||
"hero:bolt": BoltIcon,
|
||||
};
|
||||
|
||||
export const defaultAvatarColors = [
|
||||
@@ -179,9 +183,21 @@ function AvatarIcon({
|
||||
}
|
||||
|
||||
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
|
||||
if (!avatar.url) {
|
||||
return (
|
||||
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
|
||||
<GlobeAltIcon className="size-[90%] text-text-dimmed" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="grid place-items-center" style={styleFromSize(size)}>
|
||||
<img src={avatar.url} alt="Organization avatar" className="size-6" />
|
||||
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
|
||||
<img
|
||||
src={avatar.url}
|
||||
alt="Organization avatar"
|
||||
className="size-full rounded-[10%] object-contain"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Link, type LinkProps, NavLink, type NavLinkProps } from "@remix-run/react";
|
||||
import React, { forwardRef, type ReactNode, useImperativeHandle, useRef } from "react";
|
||||
import React, {
|
||||
forwardRef,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
|
||||
import { Icon, type RenderIcon } from "./Icon";
|
||||
import { Spinner } from "./Spinner";
|
||||
|
||||
const sizes = {
|
||||
small: {
|
||||
@@ -180,6 +188,7 @@ export type ButtonContentPropsType = {
|
||||
tooltip?: ReactNode;
|
||||
iconSpacing?: string;
|
||||
hideShortcutKey?: boolean;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function ButtonContent(props: ButtonContentPropsType) {
|
||||
@@ -196,7 +205,19 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
tooltip,
|
||||
iconSpacing,
|
||||
hideShortcutKey,
|
||||
isLoading,
|
||||
} = props;
|
||||
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setShowSpinner(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setShowSpinner(true), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
const variation = allVariants.variant[props.variant];
|
||||
|
||||
const btnClassName = cn(allVariants.$all, variation.button);
|
||||
@@ -217,56 +238,64 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
|
||||
const buttonContent = (
|
||||
<div className={cn("flex", fullWidth ? "" : "w-fit text-xxs", btnClassName, className)}>
|
||||
<div
|
||||
className={cn(
|
||||
textAlignLeft ? "text-left" : "justify-center",
|
||||
"flex w-full items-center",
|
||||
iconSpacingClassName,
|
||||
iconSpacing
|
||||
<div className={cn("relative", "flex w-full items-center")}>
|
||||
<div
|
||||
className={cn(
|
||||
textAlignLeft ? "text-left" : "justify-center",
|
||||
"flex w-full items-center",
|
||||
iconSpacingClassName,
|
||||
iconSpacing,
|
||||
showSpinner && "invisible"
|
||||
)}
|
||||
>
|
||||
{LeadingIcon && (
|
||||
<Icon
|
||||
icon={LeadingIcon}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
variation.icon,
|
||||
leadingIconClassName,
|
||||
"shrink-0 justify-start"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{text &&
|
||||
(typeof text === "string" ? (
|
||||
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
|
||||
{text}
|
||||
</span>
|
||||
) : (
|
||||
<>{text}</>
|
||||
))}
|
||||
|
||||
{shortcut &&
|
||||
!tooltip &&
|
||||
props.shortcutPosition === "before-trailing-icon" &&
|
||||
renderShortcutKey()}
|
||||
|
||||
{TrailingIcon && (
|
||||
<Icon
|
||||
icon={TrailingIcon}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
variation.icon,
|
||||
trailingIconClassName,
|
||||
"shrink-0 justify-end"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shortcut &&
|
||||
!tooltip &&
|
||||
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
|
||||
renderShortcutKey()}
|
||||
</div>
|
||||
{showSpinner && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="size-3.5" color="white" />
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{LeadingIcon && (
|
||||
<Icon
|
||||
icon={LeadingIcon}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
variation.icon,
|
||||
leadingIconClassName,
|
||||
"shrink-0 justify-start"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{text &&
|
||||
(typeof text === "string" ? (
|
||||
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
|
||||
{text}
|
||||
</span>
|
||||
) : (
|
||||
<>{text}</>
|
||||
))}
|
||||
|
||||
{shortcut &&
|
||||
!tooltip &&
|
||||
props.shortcutPosition === "before-trailing-icon" &&
|
||||
renderShortcutKey()}
|
||||
|
||||
{TrailingIcon && (
|
||||
<Icon
|
||||
icon={TrailingIcon}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
variation.icon,
|
||||
trailingIconClassName,
|
||||
"shrink-0 justify-end"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shortcut &&
|
||||
!tooltip &&
|
||||
(!props.shortcutPosition || props.shortcutPosition === "after-trailing-icon") &&
|
||||
renderShortcutKey()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -298,6 +327,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
const innerRef = useRef<HTMLButtonElement>(null);
|
||||
useImperativeHandle(ref, () => innerRef.current as HTMLButtonElement);
|
||||
|
||||
const isDisabled = disabled || props.isLoading;
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: props.shortcut,
|
||||
action: (e) => {
|
||||
@@ -307,14 +338,14 @@ export const Button = forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
disabled: disabled || !props.shortcut,
|
||||
disabled: isDisabled || !props.shortcut,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn("group/button outline-none focus-custom", props.fullWidth ? "w-full" : "")}
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
name={props.name}
|
||||
value={props.value}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function CheckboxIndicator({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 flex-none items-center justify-center rounded border",
|
||||
checked ? "border-indigo-500 bg-indigo-600" : "border-charcoal-600 bg-charcoal-700"
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<svg className="size-3 text-white" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M2.5 6L5 8.5L9.5 3.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
|
||||
import { useRouteLoaderData } from "@remix-run/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Laptop } from "lucide-react";
|
||||
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
|
||||
import { memo, type ReactNode, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { useLocales } from "./LocaleProvider";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
@@ -357,6 +358,54 @@ function formatDateTimeAccurate(
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
type RelativeDateTimeProps = {
|
||||
date: Date | string;
|
||||
timeZone?: string;
|
||||
};
|
||||
|
||||
function getRelativeText(date: Date): string {
|
||||
const text = formatDistanceToNow(date, { addSuffix: true });
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export const RelativeDateTime = ({ date, timeZone }: RelativeDateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
|
||||
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
|
||||
|
||||
const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate));
|
||||
|
||||
// Every 60s refresh
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setRelativeText(getRelativeText(realDate));
|
||||
}, 60_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [realDate]);
|
||||
|
||||
// On first render
|
||||
useEffect(() => {
|
||||
setRelativeText(getRelativeText(realDate));
|
||||
}, [realDate]);
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={<span suppressHydrationWarning>{relativeText}</span>}
|
||||
content={
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={userTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
}
|
||||
side="right"
|
||||
asChild={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
|
||||
@@ -4,16 +4,19 @@ import React from "react";
|
||||
import { PanelGroup, Panel, PanelResizer } from "react-window-splitter";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof PanelGroup>) => (
|
||||
<PanelGroup
|
||||
className={cn(
|
||||
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
autosaveStrategy={props.autosaveId ? "cookie" : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
const ResizablePanelGroup = ({ className, snapshot, ...props }: React.ComponentProps<typeof PanelGroup>) => {
|
||||
return (
|
||||
<PanelGroup
|
||||
className={cn(
|
||||
"flex w-full overflow-hidden data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
autosaveStrategy={props.autosaveId ? "cookie" : undefined}
|
||||
snapshot={snapshot}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ResizablePanel = Panel;
|
||||
|
||||
|
||||
+21
-15
@@ -3,23 +3,28 @@ import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function LogsSearchInput() {
|
||||
const location = useOptimisticLocation();
|
||||
export type SearchInputProps = {
|
||||
placeholder?: string;
|
||||
/** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */
|
||||
resetParams?: string[];
|
||||
};
|
||||
|
||||
export function SearchInput({
|
||||
placeholder = "Search logs…",
|
||||
resetParams = ["cursor", "direction"],
|
||||
}: SearchInputProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { value, replace, del } = useSearchParams();
|
||||
|
||||
// Get initial search value from URL
|
||||
const initialSearch = value("search") ?? "";
|
||||
|
||||
const [text, setText] = useState(initialSearch);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
// Update text when URL search param changes (only when not focused to avoid overwriting user input)
|
||||
useEffect(() => {
|
||||
const urlSearch = value("search") ?? "";
|
||||
if (urlSearch !== text && !isFocused) {
|
||||
@@ -28,21 +33,22 @@ export function LogsSearchInput() {
|
||||
}, [value, text, isFocused]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const resetValues = Object.fromEntries(resetParams.map((p) => [p, undefined]));
|
||||
if (text.trim()) {
|
||||
replace({ search: text.trim() });
|
||||
replace({ search: text.trim(), ...resetValues });
|
||||
} else {
|
||||
del("search");
|
||||
del(["search", ...resetParams]);
|
||||
}
|
||||
}, [text, replace, del]);
|
||||
}, [text, replace, del, resetParams]);
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setText("");
|
||||
del(["search", "cursor", "direction"]);
|
||||
del(["search", ...resetParams]);
|
||||
},
|
||||
[del]
|
||||
[del, resetParams]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -61,7 +67,7 @@ export function LogsSearchInput() {
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
variant="secondary-small"
|
||||
placeholder="Search logs…"
|
||||
placeholder={placeholder}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
fullWidth
|
||||
@@ -80,12 +86,12 @@ export function LogsSearchInput() {
|
||||
icon={<MagnifyingGlassIcon className="size-4" />}
|
||||
accessory={
|
||||
text.length > 0 ? (
|
||||
<div className="-mr-1 flex items-center gap-1">
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
|
||||
<div className="-mr-1 flex items-center gap-1.5">
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="medium" className="border-none" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
|
||||
title="Clear search"
|
||||
>
|
||||
<XMarkIcon className="size-3" />
|
||||
@@ -338,9 +338,9 @@ export function SelectTrigger({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex grow items-center gap-0.5">
|
||||
{icon && <div className="-ml-1 flex-none">{icon}</div>}
|
||||
<div className="truncate">{content}</div>
|
||||
<div className="flex min-w-0 grow items-center gap-0.5 overflow-hidden">
|
||||
{icon && <div className="flex-none">{icon}</div>}
|
||||
<div className="min-w-0 truncate">{content}</div>
|
||||
</div>
|
||||
{dropdownIcon === true ? (
|
||||
<ChevronDown
|
||||
@@ -443,21 +443,33 @@ export function SelectList(props: SelectListProps) {
|
||||
export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
checkPosition?: "left" | "right";
|
||||
shortcut?: ShortcutDefinition;
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
"group cursor-pointer px-1 pt-1 text-2sm text-text-dimmed focus-custom last:pb-1";
|
||||
|
||||
import { CheckboxIndicator } from "./CheckboxIndicator";
|
||||
|
||||
export function SelectItem({
|
||||
icon,
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-text-bright" />,
|
||||
checkPosition = "right",
|
||||
shortcut,
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const select = Ariakit.useSelectContext();
|
||||
const selectValue = select?.useState("value");
|
||||
|
||||
const isChecked = React.useMemo(() => {
|
||||
if (!props.value || selectValue == null) return false;
|
||||
if (Array.isArray(selectValue)) return selectValue.includes(props.value);
|
||||
return selectValue === props.value;
|
||||
}, [selectValue, props.value]);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
@@ -484,10 +496,16 @@ export function SelectItem({
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex h-8 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center rounded-sm px-2 group-data-[active-item=true]:bg-tertiary hover:bg-tertiary",
|
||||
checkPosition === "left" ? "gap-2" : "gap-1"
|
||||
)}
|
||||
>
|
||||
{checkPosition === "left" && <CheckboxIndicator checked={isChecked} />}
|
||||
{icon}
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
{checkIcon}
|
||||
{checkPosition === "right" && checkIcon}
|
||||
{shortcut && (
|
||||
<ShortcutKey
|
||||
className={cn("size-4 flex-none transition duration-0 group-hover:border-charcoal-600")}
|
||||
|
||||
@@ -176,10 +176,22 @@ type TableCellBasicProps = {
|
||||
type TableHeaderCellProps = TableCellBasicProps & {
|
||||
hiddenLabel?: boolean;
|
||||
tooltip?: ReactNode;
|
||||
disableTooltipHoverableContent?: boolean;
|
||||
};
|
||||
|
||||
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
|
||||
({ className, alignment = "left", children, colSpan, hiddenLabel = false, tooltip }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
alignment = "left",
|
||||
children,
|
||||
colSpan,
|
||||
hiddenLabel = false,
|
||||
tooltip,
|
||||
disableTooltipHoverableContent = false,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { variant } = useContext(TableContext);
|
||||
let alignmentClassName = "text-left";
|
||||
switch (alignment) {
|
||||
@@ -222,6 +234,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isHovered}
|
||||
disableHoverableContent={disableTooltipHoverableContent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -69,21 +69,26 @@ export function ToastUI({
|
||||
width: toastWidth,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-start gap-2 rounded-lg p-3">
|
||||
<div
|
||||
className={cn("flex w-full gap-2 rounded-lg p-3", title ? "items-start" : "items-center")}
|
||||
>
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className="mt-1 size-4 min-w-4 text-success" />
|
||||
<CheckCircleIcon className={cn("size-4 min-w-4 text-success", title && "mt-1")} />
|
||||
) : (
|
||||
<ExclamationCircleIcon className="mt-1 size-4 min-w-4 text-error" />
|
||||
<ExclamationCircleIcon className={cn("size-4 min-w-4 text-error", title && "mt-1")} />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{title && <Header2 className="pt-0">{title}</Header2>}
|
||||
<Paragraph variant="small/dimmed" className="pb-1 pt-0.5">
|
||||
<Paragraph
|
||||
variant={title ? "small/dimmed" : "small/bright"}
|
||||
className={title ? "pb-1 pt-0.5" : ""}
|
||||
>
|
||||
{message}
|
||||
</Paragraph>
|
||||
<Action action={action} toastId={t} className="my-2" />
|
||||
</div>
|
||||
<button
|
||||
className="hover:bg-midnight-800 -mr-1 -mt-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright"
|
||||
className={cn("-mr-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright", title && "-mt-1")}
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { OutputColumnMetadata } from "@internal/tsql";
|
||||
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
BigNumberAggregationType,
|
||||
BigNumberConfiguration,
|
||||
} from "~/components/metrics/QueryWidget";
|
||||
import { createValueFormatter } from "~/utils/columnFormat";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import { ChartBlankState } from "./ChartBlankState";
|
||||
import { Spinner } from "../Spinner";
|
||||
@@ -130,6 +131,15 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
|
||||
return aggregateValues(values, aggregation);
|
||||
}, [rows, column, aggregation, sortDirection]);
|
||||
|
||||
// Look up column format for format-aware display
|
||||
const columnValueFormatter = useMemo(() => {
|
||||
const columnMeta = columns.find((c) => c.name === column);
|
||||
const formatType = (columnMeta?.format ?? columnMeta?.customRenderType) as
|
||||
| ColumnFormatType
|
||||
| undefined;
|
||||
return createValueFormatter(formatType);
|
||||
}, [columns, column]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid h-full place-items-center [container-type:size]">
|
||||
@@ -142,6 +152,21 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
|
||||
return <ChartBlankState icon={Hash} message="No data to display" />;
|
||||
}
|
||||
|
||||
// Use format-aware formatter when available
|
||||
if (columnValueFormatter) {
|
||||
return (
|
||||
<div className="h-full w-full [container-type:size]">
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
|
||||
{prefix && <span>{prefix}</span>}
|
||||
<span>{columnValueFormatter(result)}</span>
|
||||
{suffix && <span className="text-[0.4em] text-text-dimmed">{suffix}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
|
||||
? abbreviateValue(result)
|
||||
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
|
||||
@@ -149,7 +174,7 @@ export function BigNumberCard({ rows, columns, config, isLoading = false }: BigN
|
||||
return (
|
||||
<div className="h-full w-full [container-type:size]">
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap font-normal tabular-nums leading-none text-text-bright text-[clamp(24px,12cqw,96px)]">
|
||||
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
|
||||
{prefix && <span>{prefix}</span>}
|
||||
<AnimatedNumber value={displayValue} decimalPlaces={decimalPlaces} />
|
||||
{(unitSuffix || suffix) && (
|
||||
|
||||
@@ -104,6 +104,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
/** Optional formatter for numeric values (e.g. bytes, duration) */
|
||||
valueFormatter?: (value: number) => string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -121,6 +123,7 @@ const ChartTooltipContent = React.forwardRef<
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
valueFormatter,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -221,9 +224,11 @@ const ChartTooltipContent = React.forwardRef<
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
{item.value != null && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
{valueFormatter && typeof item.value === "number"
|
||||
? valueFormatter(item.value)
|
||||
: item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,8 @@ export type ChartBarRendererProps = {
|
||||
referenceLine?: ReferenceLineProps;
|
||||
/** Custom tooltip label formatter */
|
||||
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
|
||||
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
|
||||
tooltipValueFormatter?: (value: number) => string;
|
||||
/** Width injected by ResponsiveContainer */
|
||||
width?: number;
|
||||
/** Height injected by ResponsiveContainer */
|
||||
@@ -62,6 +64,7 @@ export function ChartBarRenderer({
|
||||
yAxisProps: yAxisPropsProp,
|
||||
referenceLine,
|
||||
tooltipLabelFormatter,
|
||||
tooltipValueFormatter,
|
||||
width,
|
||||
height,
|
||||
}: ChartBarRendererProps) {
|
||||
@@ -159,7 +162,7 @@ export function ChartBarRenderer({
|
||||
showLegend ? (
|
||||
() => null
|
||||
) : tooltipLabelFormatter ? (
|
||||
<ChartTooltipContent />
|
||||
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
|
||||
) : (
|
||||
<ZoomTooltip
|
||||
isSelecting={zoom?.isSelecting}
|
||||
|
||||
@@ -26,6 +26,8 @@ export type ChartLegendCompoundProps = {
|
||||
totalLabel?: string;
|
||||
/** Aggregation method – controls the header label and how totals are computed */
|
||||
aggregation?: AggregationType;
|
||||
/** Optional formatter for numeric values (e.g. bytes, duration) */
|
||||
valueFormatter?: (value: number) => string;
|
||||
/** Callback when "View all" button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
@@ -50,6 +52,7 @@ export function ChartLegendCompound({
|
||||
className,
|
||||
totalLabel,
|
||||
aggregation,
|
||||
valueFormatter,
|
||||
onViewAllLegendItems,
|
||||
scrollable = false,
|
||||
}: ChartLegendCompoundProps) {
|
||||
@@ -74,12 +77,12 @@ export function ChartLegendCompound({
|
||||
const currentTotal = useMemo((): number | null => {
|
||||
if (!activePayload?.length) return grandTotal;
|
||||
|
||||
// Collect all series values from the hovered data point, preserving nulls
|
||||
const rawValues = activePayload
|
||||
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
|
||||
.map((item) => item.value);
|
||||
// Use the full data row so the total covers ALL dataKeys, not just visibleSeries
|
||||
const dataRow = activePayload[0]?.payload;
|
||||
if (!dataRow) return grandTotal;
|
||||
|
||||
const rawValues = dataKeys.map((key) => dataRow[key]);
|
||||
|
||||
// Filter to non-null values only
|
||||
const values = rawValues
|
||||
.filter((v): v is number => v != null)
|
||||
.map((v) => Number(v) || 0);
|
||||
@@ -88,7 +91,6 @@ export function ChartLegendCompound({
|
||||
if (values.length === 0) return null;
|
||||
|
||||
if (!aggregation) {
|
||||
// Default: sum
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return aggregateValues(values, aggregation);
|
||||
@@ -113,24 +115,24 @@ export function ChartLegendCompound({
|
||||
const currentData = useMemo((): Record<string, number | null> => {
|
||||
if (!activePayload?.length) return totals;
|
||||
|
||||
// If we have activePayload data from hovering over a bar/line
|
||||
const hoverData = activePayload.reduce(
|
||||
(acc, item) => {
|
||||
if (item.dataKey && item.value !== undefined) {
|
||||
// Preserve null for gap-filled points instead of coercing to 0
|
||||
acc[item.dataKey] = item.value != null ? Number(item.value) || 0 : null;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number | null>
|
||||
);
|
||||
// Use the full data row so ALL dataKeys are resolved from the hovered point,
|
||||
// not just the visibleSeries present in activePayload.
|
||||
const dataRow = activePayload[0]?.payload;
|
||||
if (!dataRow) return totals;
|
||||
|
||||
const hoverData: Record<string, number | null> = {};
|
||||
for (const key of dataKeys) {
|
||||
const value = dataRow[key];
|
||||
if (value !== undefined) {
|
||||
hoverData[key] = value != null ? Number(value) || 0 : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Return a merged object - totals for keys not in the hover data
|
||||
return {
|
||||
...totals,
|
||||
...hoverData,
|
||||
};
|
||||
}, [activePayload, totals]);
|
||||
}, [activePayload, totals, dataKeys]);
|
||||
|
||||
// Prepare legend items with capped display
|
||||
const legendItems = useMemo(() => {
|
||||
@@ -180,7 +182,11 @@ export function ChartLegendCompound({
|
||||
<span className="font-medium">{currentTotalLabel}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{currentTotal != null ? (
|
||||
<AnimatedNumber value={currentTotal} duration={0.25} />
|
||||
valueFormatter ? (
|
||||
valueFormatter(currentTotal)
|
||||
) : (
|
||||
<AnimatedNumber value={currentTotal} duration={0.25} />
|
||||
)
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
@@ -252,7 +258,11 @@ export function ChartLegendCompound({
|
||||
)}
|
||||
>
|
||||
{total != null ? (
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
valueFormatter ? (
|
||||
valueFormatter(total)
|
||||
) : (
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
)
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
@@ -269,6 +279,7 @@ export function ChartLegendCompound({
|
||||
item={legendItems.hoveredHiddenItem}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? null}
|
||||
remainingCount={legendItems.remaining - 1}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow
|
||||
@@ -315,9 +326,10 @@ type HoveredHiddenItemRowProps = {
|
||||
item: { dataKey: string; color?: string; label: React.ReactNode };
|
||||
value: number | null;
|
||||
remainingCount: number;
|
||||
valueFormatter?: (value: number) => string;
|
||||
};
|
||||
|
||||
function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItemRowProps) {
|
||||
function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: HoveredHiddenItemRowProps) {
|
||||
return (
|
||||
<div className="relative flex w-full items-center justify-between gap-2 rounded px-2 py-1">
|
||||
{/* Active highlight background */}
|
||||
@@ -339,7 +351,15 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
|
||||
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
|
||||
</div>
|
||||
<span className="tabular-nums text-text-bright">
|
||||
{value != null ? <AnimatedNumber value={value} duration={0.25} /> : "\u2013"}
|
||||
{value != null ? (
|
||||
valueFormatter ? (
|
||||
valueFormatter(value)
|
||||
) : (
|
||||
<AnimatedNumber value={value} duration={0.25} />
|
||||
)
|
||||
) : (
|
||||
"\u2013"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,8 @@ export type ChartLineRendererProps = {
|
||||
stacked?: boolean;
|
||||
/** Custom tooltip label formatter */
|
||||
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
|
||||
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
|
||||
tooltipValueFormatter?: (value: number) => string;
|
||||
/** Width injected by ResponsiveContainer */
|
||||
width?: number;
|
||||
/** Height injected by ResponsiveContainer */
|
||||
@@ -75,6 +77,7 @@ export function ChartLineRenderer({
|
||||
yAxisProps: yAxisPropsProp,
|
||||
stacked = false,
|
||||
tooltipLabelFormatter,
|
||||
tooltipValueFormatter,
|
||||
width,
|
||||
height,
|
||||
}: ChartLineRendererProps) {
|
||||
@@ -157,7 +160,13 @@ export function ChartLineRenderer({
|
||||
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
|
||||
content={showLegend ? () => null : <ChartTooltipContent indicator="line" />}
|
||||
content={
|
||||
showLegend ? (
|
||||
() => null
|
||||
) : (
|
||||
<ChartTooltipContent indicator="line" valueFormatter={tooltipValueFormatter} />
|
||||
)
|
||||
}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
@@ -205,7 +214,13 @@ export function ChartLineRenderer({
|
||||
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
|
||||
content={showLegend ? () => null : <ChartTooltipContent />}
|
||||
content={
|
||||
showLegend ? (
|
||||
() => null
|
||||
) : (
|
||||
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
|
||||
)
|
||||
}
|
||||
labelFormatter={tooltipLabelFormatter}
|
||||
/>
|
||||
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
|
||||
|
||||
@@ -34,6 +34,8 @@ export type ChartRootProps = {
|
||||
legendTotalLabel?: string;
|
||||
/** Aggregation method used by the legend to compute totals (defaults to sum behavior) */
|
||||
legendAggregation?: AggregationType;
|
||||
/** Optional formatter for numeric legend values (e.g. bytes, duration) */
|
||||
legendValueFormatter?: (value: number) => string;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
@@ -82,6 +84,7 @@ export function ChartRoot({
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
legendAggregation,
|
||||
legendValueFormatter,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
@@ -108,6 +111,7 @@ export function ChartRoot({
|
||||
maxLegendItems={maxLegendItems}
|
||||
legendTotalLabel={legendTotalLabel}
|
||||
legendAggregation={legendAggregation}
|
||||
legendValueFormatter={legendValueFormatter}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
fillContainer={fillContainer}
|
||||
@@ -126,6 +130,7 @@ type ChartRootInnerProps = {
|
||||
maxLegendItems?: number;
|
||||
legendTotalLabel?: string;
|
||||
legendAggregation?: AggregationType;
|
||||
legendValueFormatter?: (value: number) => string;
|
||||
onViewAllLegendItems?: () => void;
|
||||
legendScrollable?: boolean;
|
||||
fillContainer?: boolean;
|
||||
@@ -140,6 +145,7 @@ function ChartRootInner({
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
legendAggregation,
|
||||
legendValueFormatter,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
@@ -184,6 +190,7 @@ function ChartRootInner({
|
||||
maxItems={maxLegendItems}
|
||||
totalLabel={legendTotalLabel}
|
||||
aggregation={legendAggregation}
|
||||
valueFormatter={legendValueFormatter}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
scrollable={legendScrollable}
|
||||
/>
|
||||
|
||||
@@ -945,11 +945,9 @@ export function QueryEditor({
|
||||
<ResizableHandle id="query-handle" />
|
||||
<ResizablePanel
|
||||
id="query-help"
|
||||
min="200px"
|
||||
collapsible
|
||||
collapsedSize="20px"
|
||||
min="380px"
|
||||
default="400px"
|
||||
max="500px"
|
||||
max="800px"
|
||||
className="w-full"
|
||||
>
|
||||
<QueryHelpSidebar
|
||||
@@ -1175,9 +1173,9 @@ function QueryResultsCallouts({
|
||||
<div className="flex flex-col gap-2 px-2 pt-2">
|
||||
{hiddenColumns && hiddenColumns.length > 0 && (
|
||||
<Callout variant="warning" className="shrink-0 text-sm">
|
||||
<code>SELECT *</code> doesn't return all columns because it's slow. The following columns
|
||||
are not shown: <span className="font-mono text-xs">{hiddenColumns.join(", ")}</span>.
|
||||
Specify them explicitly to include them.
|
||||
<code>SELECT *</code> returns core columns only. To include{" "}
|
||||
<span className="font-mono text-xs">{hiddenColumns.join(", ")}</span>, add them to your
|
||||
SELECT explicitly.
|
||||
</Callout>
|
||||
)}
|
||||
{periodClipped && (
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { tryPrettyJson } from "./ai/aiHelpers";
|
||||
import { SpanMetricRow as MetricRow } from "./ai/SpanMetricRow";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { v3PromptPath } from "~/utils/pathBuilder";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
|
||||
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children }: { children: string }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
type PromptTab = "overview" | "input" | "template";
|
||||
|
||||
export function PromptSpanDetails({ promptData }: { promptData: PromptSpanData }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const promptPath =
|
||||
organization && project && environment
|
||||
? v3PromptPath(organization, project, environment, promptData.slug, promptData.version)
|
||||
: undefined;
|
||||
|
||||
const hasInput = !!promptData.input;
|
||||
const hasTemplate = !!promptData.template;
|
||||
|
||||
const availableTabs: PromptTab[] = [
|
||||
"overview",
|
||||
...(hasInput ? (["input"] as const) : []),
|
||||
...(hasTemplate ? (["template"] as const) : []),
|
||||
];
|
||||
const [tab, setTab] = useState<PromptTab>("overview");
|
||||
|
||||
const labels = promptData.labels
|
||||
? promptData.labels
|
||||
.split(",")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="shrink-0 overflow-x-auto px-3 py-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
{availableTabs.map((t) => (
|
||||
<TabButton
|
||||
key={t}
|
||||
isActive={tab === t}
|
||||
layoutId="prompt-span"
|
||||
onClick={() => setTab(t)}
|
||||
shortcut={
|
||||
t === "overview" ? { key: "o" } : t === "input" ? { key: "i" } : { key: "t" }
|
||||
}
|
||||
>
|
||||
{t === "overview" ? "Overview" : t === "input" ? "Input" : "Template"}
|
||||
</TabButton>
|
||||
))}
|
||||
</TabContainer>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-gutter-stable min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{tab === "overview" && (
|
||||
<div className="flex flex-col px-3">
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<MetricRow
|
||||
label="Prompt"
|
||||
value={
|
||||
promptPath ? (
|
||||
<TextLink to={promptPath}>{promptData.slug}</TextLink>
|
||||
) : (
|
||||
promptData.slug
|
||||
)
|
||||
}
|
||||
/>
|
||||
<MetricRow label="Version" value={`v${promptData.version}`} />
|
||||
{labels.length > 0 && <MetricRow label="Labels" value={labels.join(", ")} />}
|
||||
{promptData.model && <MetricRow label="Model" value={promptData.model} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{promptData.text && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Resolved content</Header3>
|
||||
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense
|
||||
fallback={
|
||||
<span className="whitespace-pre-wrap">
|
||||
{promptData.text.length > 300
|
||||
? promptData.text.slice(0, 300) + "..."
|
||||
: promptData.text}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<StreamdownRenderer>{promptData.text}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "input" && hasInput && (
|
||||
<div className="px-3 py-2.5">
|
||||
<CodeBlock
|
||||
code={tryPrettyJson(promptData.input!)}
|
||||
maxLines={30}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "template" && hasTemplate && (
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense
|
||||
fallback={
|
||||
<span className="whitespace-pre-wrap">{promptData.template!}</span>
|
||||
}
|
||||
>
|
||||
<StreamdownRenderer>{promptData.template!}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, useFetcher } from "@remix-run/react";
|
||||
import { IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
|
||||
import { IconBugFilled, IconRotateClockwise2, IconToggleLeft } from "@tabler/icons-react";
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
@@ -181,6 +181,7 @@ export const TaskRunListSearchFilters = z.object({
|
||||
machines: MachinePresetOrMachinePresetArray.describe(
|
||||
`Machine presets to filter by (${machines.join(", ")})`
|
||||
),
|
||||
errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"),
|
||||
});
|
||||
|
||||
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
|
||||
@@ -220,6 +221,8 @@ export function filterTitle(filterKey: string) {
|
||||
return "Machine";
|
||||
case "versions":
|
||||
return "Version";
|
||||
case "errorId":
|
||||
return "Error ID";
|
||||
default:
|
||||
return filterKey;
|
||||
}
|
||||
@@ -258,6 +261,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined {
|
||||
return <MachineDefaultIcon className="size-4" />;
|
||||
case "versions":
|
||||
return <IconRotateClockwise2 className="size-4" />;
|
||||
case "errorId":
|
||||
return <IconBugFilled className="size-4" />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -304,6 +309,7 @@ export function getRunFiltersFromSearchParams(
|
||||
searchParams.getAll("versions").filter((v) => v.length > 0).length > 0
|
||||
? searchParams.getAll("versions")
|
||||
: undefined,
|
||||
errorId: searchParams.get("errorId") ?? undefined,
|
||||
};
|
||||
|
||||
const parsed = TaskRunListSearchFilters.safeParse(params);
|
||||
@@ -344,7 +350,8 @@ export function RunsFilters(props: RunFiltersProps) {
|
||||
searchParams.has("scheduleId") ||
|
||||
searchParams.has("queues") ||
|
||||
searchParams.has("machines") ||
|
||||
searchParams.has("versions");
|
||||
searchParams.has("versions") ||
|
||||
searchParams.has("errorId");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
@@ -380,6 +387,7 @@ const filterTypes = [
|
||||
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
|
||||
{ name: "schedule", title: "Schedule ID", icon: <ClockIcon className="size-4" /> },
|
||||
{ name: "bulk", title: "Bulk action", icon: <ListCheckedIcon className="size-4" /> },
|
||||
{ name: "error", title: "Error ID", icon: <IconBugFilled className="size-4" /> },
|
||||
] as const;
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
@@ -434,6 +442,7 @@ function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
|
||||
<AppliedBatchIdFilter />
|
||||
<AppliedScheduleIdFilter />
|
||||
<AppliedBulkActionsFilter bulkActions={bulkActions} />
|
||||
<AppliedErrorIdFilter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -470,6 +479,8 @@ function Menu(props: MenuProps) {
|
||||
return <ScheduleIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "versions":
|
||||
return <VersionsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "error":
|
||||
return <ErrorIdDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,7 +666,7 @@ function TasksDropdown({
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
<MiddleTruncate text={item.slug}/>
|
||||
<MiddleTruncate text={item.slug} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
@@ -1740,3 +1751,121 @@ function AppliedScheduleIdFilter() {
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorIdDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { value, replace } = useSearchParams();
|
||||
const errorIdValue = value("errorId");
|
||||
|
||||
const [errorId, setErrorId] = useState(errorIdValue);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
cursor: undefined,
|
||||
direction: undefined,
|
||||
errorId: errorId === "" ? undefined : errorId?.toString(),
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
}, [errorId, replace]);
|
||||
|
||||
let error: string | undefined = undefined;
|
||||
if (errorId) {
|
||||
if (!errorId.startsWith("error_")) {
|
||||
error = "Error IDs start with 'error_'";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true} open={open} setOpen={setOpen}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
className="max-w-[min(32ch,var(--popover-available-width))]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Error ID</Label>
|
||||
<Input
|
||||
placeholder="error_"
|
||||
value={errorId ?? ""}
|
||||
onChange={(e) => setErrorId(e.target.value)}
|
||||
variant="small"
|
||||
className="w-[29ch] font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error ? <FormError>{error}</FormError> : null}
|
||||
</div>
|
||||
<div className="flex justify-between gap-1 border-t border-grid-dimmed pt-3">
|
||||
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={error !== undefined || !errorId}
|
||||
variant="secondary/small"
|
||||
shortcut={{
|
||||
modifiers: ["mod"],
|
||||
key: "Enter",
|
||||
enabledOnInputElements: true,
|
||||
}}
|
||||
onClick={() => apply()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedErrorIdFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("errorId") === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorId = value("errorId");
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<ErrorIdDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Error ID"
|
||||
icon={filterIcon("errorId")}
|
||||
value={errorId}
|
||||
onRemove={() => del(["errorId", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,25 @@ import {
|
||||
HandRaisedIcon,
|
||||
InformationCircleIcon,
|
||||
RectangleStackIcon,
|
||||
SparklesIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
TagIcon,
|
||||
WrenchIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { AnthropicLogoIcon } from "~/assets/icons/AnthropicLogoIcon";
|
||||
import {
|
||||
AnthropicIcon,
|
||||
AzureIcon,
|
||||
CerebrasIcon,
|
||||
DeepseekIcon,
|
||||
GeminiIcon,
|
||||
LlamaIcon,
|
||||
MistralIcon,
|
||||
OpenAIIcon,
|
||||
PerplexityIcon,
|
||||
XAIIcon,
|
||||
} from "~/assets/icons/AiProviderIcons";
|
||||
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
@@ -112,6 +127,31 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
return <FunctionIcon className={cn(className, "text-error")} />;
|
||||
case "streams":
|
||||
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "hero-sparkles":
|
||||
return <SparklesIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "hero-wrench":
|
||||
return <WrenchIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "tabler-brand-anthropic":
|
||||
case "ai-provider-anthropic":
|
||||
return <AnthropicIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-openai":
|
||||
return <OpenAIIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-gemini":
|
||||
return <GeminiIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-llama":
|
||||
return <LlamaIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-deepseek":
|
||||
return <DeepseekIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-xai":
|
||||
return <XAIIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-perplexity":
|
||||
return <PerplexityIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-cerebras":
|
||||
return <CerebrasIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-mistral":
|
||||
return <MistralIcon className={cn(className, "text-text-dimmed")} />;
|
||||
case "ai-provider-azure":
|
||||
return <AzureIcon className={cn(className, "text-text-dimmed")} />;
|
||||
}
|
||||
|
||||
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { TaskEventStyle } from "@trigger.dev/core/v3";
|
||||
import type { TaskEventLevel } from "@trigger.dev/database";
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { tablerIcons } from "~/utils/tablerIcons";
|
||||
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
|
||||
|
||||
type SpanTitleProps = {
|
||||
message: string;
|
||||
@@ -45,20 +47,44 @@ function SpanAccessory({
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "pills": {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{accessory.items.map((item, index) => (
|
||||
<SpanPill key={index} text={item.text} icon={item.icon} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<div className={cn("flex gap-1")}>
|
||||
<span className={cn("flex gap-1")}>
|
||||
{accessory.items.map((item, index) => (
|
||||
<span key={index} className={cn("inline-flex items-center gap-1")}>
|
||||
{item.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function SpanPill({ text, icon }: { text: string; icon?: string }) {
|
||||
const hasIcon = icon && tablerIcons.has(icon);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-0.5 rounded-full border border-charcoal-700 bg-charcoal-850 px-1.5 py-px text-xxs text-text-dimmed">
|
||||
{hasIcon && (
|
||||
<svg className="size-3 stroke-[1.5] text-text-dimmed/70">
|
||||
<use xlinkHref={`${tablerSpritePath}#${icon}`} />
|
||||
</svg>
|
||||
)}
|
||||
<span className="truncate">{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpanCodePathAccessory({
|
||||
accessory,
|
||||
className,
|
||||
|
||||
@@ -69,6 +69,7 @@ type RunsTableProps = {
|
||||
allowSelection?: boolean;
|
||||
variant?: TableVariant;
|
||||
disableAdjacentRows?: boolean;
|
||||
additionalTableState?: Record<string, string>;
|
||||
};
|
||||
|
||||
export function TaskRunsTable({
|
||||
@@ -81,6 +82,7 @@ export function TaskRunsTable({
|
||||
isLoading = false,
|
||||
allowSelection = false,
|
||||
variant = "dimmed",
|
||||
additionalTableState,
|
||||
}: RunsTableProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
@@ -89,8 +91,16 @@ export function TaskRunsTable({
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const { value } = useSearchParams();
|
||||
const location = useOptimisticLocation();
|
||||
const rootOnly = value("rootOnly") ? `` : `rootOnly=${rootOnlyDefault}`;
|
||||
const search = rootOnly ? `${rootOnly}&${location.search}` : location.search;
|
||||
const params = new URLSearchParams(location.search || "");
|
||||
if (!value("rootOnly")) {
|
||||
params.set("rootOnly", String(rootOnlyDefault));
|
||||
}
|
||||
if (additionalTableState) {
|
||||
for (const [key, val] of Object.entries(additionalTableState)) {
|
||||
params.set(key, val);
|
||||
}
|
||||
}
|
||||
const search = params.toString();
|
||||
/** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */
|
||||
const tableStateParam = disableAdjacentRows ? '' : encodeURIComponent(search);
|
||||
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
ClipboardDocumentIcon,
|
||||
CodeBracketSquareIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
|
||||
import type { DisplayItem, ToolUse } from "./types";
|
||||
|
||||
// Lazy load streamdown to avoid SSR issues
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children }: { children: string }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
export type PromptLink = {
|
||||
slug: string;
|
||||
version?: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export function AIChatMessages({
|
||||
items,
|
||||
promptLink,
|
||||
}: {
|
||||
items: DisplayItem[];
|
||||
promptLink?: PromptLink;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((item, i) => {
|
||||
switch (item.type) {
|
||||
case "system":
|
||||
return <SystemSection key={i} text={item.text} promptLink={promptLink} />;
|
||||
case "user":
|
||||
return <UserSection key={i} text={item.text} />;
|
||||
case "tool-use":
|
||||
return <ToolUseSection key={i} tools={item.tools} />;
|
||||
case "assistant":
|
||||
return <AssistantResponse key={i} text={item.text} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section header (shared across all sections)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeader({ label, right }: { label: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Header3>{label}</Header3>
|
||||
{right && <div className="flex items-center gap-2">{right}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatBubble({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SystemSection({
|
||||
text,
|
||||
promptLink,
|
||||
}: {
|
||||
text: string;
|
||||
promptLink?: PromptLink;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isLong = text.length > 150;
|
||||
const preview = isLong ? text.slice(0, 150) + "..." : text;
|
||||
const displayText = expanded || !isLong ? text : preview;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Header3>System</Header3>
|
||||
{promptLink && (
|
||||
<LinkButton to={promptLink.path} variant="minimal/small">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="size-3.5 shrink-0 text-text-dimmed">
|
||||
<use xlinkHref={`${tablerSpritePath}#tabler-file-text-ai`} />
|
||||
</svg>
|
||||
{promptLink.slug}
|
||||
{promptLink.version ? ` v${promptLink.version}` : ""}
|
||||
</span>
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
{isLong && (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
LeadingIcon={expanded ? ChevronUpIcon : ChevronDownIcon}
|
||||
aria-label={expanded ? "Collapse" : "Expand"}
|
||||
aria-expanded={expanded}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChatBubble>
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{displayText}</span>}>
|
||||
<StreamdownRenderer>{displayText}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UserSection({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader label="User" />
|
||||
<ChatBubble>
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
|
||||
<StreamdownRenderer>{text}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assistant response (with markdown/raw toggle)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isJsonString(value: string): boolean {
|
||||
const trimmed = value.trimStart();
|
||||
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AssistantResponse({
|
||||
text,
|
||||
headerLabel = "Assistant",
|
||||
}: {
|
||||
text: string;
|
||||
headerLabel?: string;
|
||||
}) {
|
||||
const isJson = isJsonString(text);
|
||||
const [mode, setMode] = useState<"rendered" | "raw">("rendered");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader label={headerLabel} />
|
||||
<CodeBlock
|
||||
code={text}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader
|
||||
label={headerLabel}
|
||||
right={
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => setMode(mode === "rendered" ? "raw" : "rendered")}
|
||||
LeadingIcon={CodeBracketSquareIcon}
|
||||
>
|
||||
{mode === "rendered" ? "Raw" : "Rendered"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={handleCopy}
|
||||
LeadingIcon={copied ? CheckIcon : ClipboardDocumentIcon}
|
||||
leadingIconClassName={copied ? "text-green-500" : undefined}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{mode === "rendered" ? (
|
||||
<ChatBubble>
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense fallback={<span className="whitespace-pre-wrap">{text}</span>}>
|
||||
<StreamdownRenderer>{text}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</ChatBubble>
|
||||
) : (
|
||||
<CodeBlock
|
||||
code={text}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton={false}
|
||||
className="pl-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool use (merged calls + results)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToolUseSection({ tools }: { tools: ToolUse[] }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<SectionHeader label={tools.length === 1 ? "Tool call" : `Tool calls (${tools.length})`} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{tools.map((tool) => (
|
||||
<ToolUseRow key={tool.toolCallId} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolTab = "input" | "output" | "details";
|
||||
|
||||
function ToolUseRow({ tool }: { tool: ToolUse }) {
|
||||
const hasInput = tool.inputJson !== "{}";
|
||||
const hasResult = !!tool.resultOutput;
|
||||
const hasDetails = !!tool.description || !!tool.parametersJson;
|
||||
|
||||
const availableTabs: ToolTab[] = [
|
||||
...(hasInput ? (["input"] as const) : []),
|
||||
...(hasResult ? (["output"] as const) : []),
|
||||
...(hasDetails ? (["details"] as const) : []),
|
||||
];
|
||||
|
||||
const defaultTab: ToolTab | null = hasInput ? "input" : null;
|
||||
const [activeTab, setActiveTab] = useState<ToolTab | null>(defaultTab);
|
||||
|
||||
function handleTabClick(tab: ToolTab) {
|
||||
setActiveTab(activeTab === tab ? null : tab);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-grid-bright bg-charcoal-800/40">
|
||||
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||
<code className="font-mono text-xs text-text-bright">{tool.toolName}</code>
|
||||
{tool.resultSummary && (
|
||||
<span className="ml-auto text-[10px] text-text-dimmed">{tool.resultSummary}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{availableTabs.length > 0 && (
|
||||
<>
|
||||
<div className="flex gap-0 border-t border-grid-bright">
|
||||
{availableTabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className={`px-2.5 py-1 text-[11px] capitalize transition-colors ${
|
||||
activeTab === tab
|
||||
? "bg-charcoal-750 text-text-bright"
|
||||
: "text-text-dimmed hover:text-text-bright"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "input" && hasInput && (
|
||||
<div className="border-t border-grid-dimmed">
|
||||
<CodeBlock
|
||||
code={tool.inputJson}
|
||||
maxLines={12}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "output" && hasResult && (
|
||||
<div className="border-t border-grid-dimmed">
|
||||
<CodeBlock
|
||||
code={tool.resultOutput!}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "details" && hasDetails && (
|
||||
<div className="flex flex-col gap-2 border-t border-grid-dimmed px-2.5 py-2">
|
||||
{tool.description && (
|
||||
<p className="text-xs leading-relaxed text-text-dimmed">{tool.description}</p>
|
||||
)}
|
||||
{tool.parametersJson && (
|
||||
<div>
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-text-dimmed">
|
||||
Parameters schema
|
||||
</span>
|
||||
<CodeBlock
|
||||
code={tool.parametersJson}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { formatDuration } from "./aiHelpers";
|
||||
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
|
||||
|
||||
export type AIEmbedData = {
|
||||
model: string;
|
||||
provider: string;
|
||||
value?: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export function extractAIEmbedData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AIEmbedData | undefined {
|
||||
const ai = properties.ai;
|
||||
if (!ai || typeof ai !== "object") return undefined;
|
||||
|
||||
const a = ai as Record<string, unknown>;
|
||||
if (a.operationId !== "ai.embed") return undefined;
|
||||
|
||||
const aiModel = a.model;
|
||||
if (!aiModel || typeof aiModel !== "object") return undefined;
|
||||
|
||||
const m = aiModel as Record<string, unknown>;
|
||||
const model = typeof m.id === "string" ? m.id : undefined;
|
||||
if (!model) return undefined;
|
||||
|
||||
return {
|
||||
model,
|
||||
provider: typeof m.provider === "string" ? m.provider : "unknown",
|
||||
value: typeof a.value === "string" ? a.value : undefined,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function AIEmbedSpanDetails({ data }: { data: AIEmbedData }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="flex flex-col px-3">
|
||||
{/* Model info */}
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<MetricRow label="Model" value={data.model} />
|
||||
<MetricRow label="Provider" value={data.provider} />
|
||||
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input value */}
|
||||
{data.value && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
|
||||
<Paragraph variant="small/dimmed">{data.value}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { v3PromptPath } from "~/utils/pathBuilder";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import type { AISpanData } from "./types";
|
||||
|
||||
export function AITagsRow({ aiData }: { aiData: AISpanData }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const promptLink =
|
||||
aiData.promptSlug && organization && project && environment
|
||||
? v3PromptPath(organization, project, environment, aiData.promptSlug, aiData.promptVersion)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
{aiData.responseId && (
|
||||
<MetricRow label="Response ID" value={<TruncatedCopyableValue value={aiData.responseId} />} />
|
||||
)}
|
||||
<MetricRow label="Model" value={aiData.model} />
|
||||
{aiData.provider !== "unknown" && <MetricRow label="Provider" value={aiData.provider} />}
|
||||
{aiData.resolvedProvider && (
|
||||
<MetricRow label="Resolved provider" value={aiData.resolvedProvider} />
|
||||
)}
|
||||
{aiData.promptSlug && (
|
||||
<MetricRow
|
||||
label="Prompt"
|
||||
value={
|
||||
promptLink ? (
|
||||
<TextLink to={promptLink}>
|
||||
{aiData.promptSlug}
|
||||
{aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}
|
||||
</TextLink>
|
||||
) : (
|
||||
`${aiData.promptSlug}${aiData.promptVersion ? ` v${aiData.promptVersion}` : ""}`
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{aiData.finishReason && <MetricRow label="Finish reason" value={aiData.finishReason} />}
|
||||
{aiData.serviceTier && <MetricRow label="Service tier" value={aiData.serviceTier} />}
|
||||
{aiData.toolChoice && <MetricRow label="Tool choice" value={aiData.toolChoice} />}
|
||||
{aiData.toolCount != null && aiData.toolCount > 0 && (
|
||||
<MetricRow
|
||||
label="Tools provided"
|
||||
value={`${aiData.toolCount} ${aiData.toolCount === 1 ? "tool" : "tools"}`}
|
||||
/>
|
||||
)}
|
||||
{aiData.messageCount != null && (
|
||||
<MetricRow
|
||||
label="Messages"
|
||||
value={`${aiData.messageCount} ${aiData.messageCount === 1 ? "message" : "messages"}`}
|
||||
/>
|
||||
)}
|
||||
{aiData.telemetryMetadata &&
|
||||
Object.entries(aiData.telemetryMetadata)
|
||||
.filter(([key]) => key !== "prompt")
|
||||
.map(([key, value]) => <MetricRow key={key} label={key} value={value} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIStatsSummary({ aiData }: { aiData: AISpanData }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<Header3>Stats</Header3>
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<MetricRow label="Input" value={aiData.inputTokens.toLocaleString()} unit="tokens" />
|
||||
<MetricRow label="Output" value={aiData.outputTokens.toLocaleString()} unit="tokens" />
|
||||
{aiData.cachedTokens != null && aiData.cachedTokens > 0 && (
|
||||
<MetricRow
|
||||
label="Cache read"
|
||||
value={aiData.cachedTokens.toLocaleString()}
|
||||
unit="tokens"
|
||||
/>
|
||||
)}
|
||||
{aiData.cacheCreationTokens != null && aiData.cacheCreationTokens > 0 && (
|
||||
<MetricRow
|
||||
label="Cache write"
|
||||
value={aiData.cacheCreationTokens.toLocaleString()}
|
||||
unit="tokens"
|
||||
/>
|
||||
)}
|
||||
{aiData.reasoningTokens != null && aiData.reasoningTokens > 0 && (
|
||||
<MetricRow
|
||||
label="Reasoning"
|
||||
value={aiData.reasoningTokens.toLocaleString()}
|
||||
unit="tokens"
|
||||
/>
|
||||
)}
|
||||
<MetricRow label="Total" value={aiData.totalTokens.toLocaleString()} unit="tokens" bold />
|
||||
|
||||
{aiData.totalCost != null && (
|
||||
<MetricRow label="Cost" value={formatCurrencyAccurate(aiData.totalCost)} />
|
||||
)}
|
||||
{aiData.msToFirstChunk != null && (
|
||||
<MetricRow label="TTFC" value={formatTtfc(aiData.msToFirstChunk)} />
|
||||
)}
|
||||
{aiData.tokensPerSecond != null && (
|
||||
<MetricRow label="Speed" value={`${Math.round(aiData.tokensPerSecond)} tok/s`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRow({
|
||||
label,
|
||||
value,
|
||||
unit,
|
||||
bold,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
unit?: string;
|
||||
bold?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-charcoal-750/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/[0.04]">
|
||||
<span className="text-text-dimmed">{label}</span>
|
||||
<span
|
||||
className={`text-right @[28rem]:text-left ${
|
||||
bold ? "font-medium text-text-bright" : "text-text-bright"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
{unit && <span className="ml-1 text-text-dimmed">{unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTtfc(ms: number): string {
|
||||
if (ms >= 10_000) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
return `${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/20/solid";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { tryPrettyJson } from "./aiHelpers";
|
||||
import { SpanMetricRow as PromptMetricRow } from "./SpanMetricRow";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { v3PromptPath } from "~/utils/pathBuilder";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { AIChatMessages, AssistantResponse, ChatBubble } from "./AIChatMessages";
|
||||
import type { PromptLink } from "./AIChatMessages";
|
||||
import { AIStatsSummary, AITagsRow } from "./AIModelSummary";
|
||||
import { AIToolsInventory } from "./AIToolsInventory";
|
||||
import type { AISpanData, DisplayItem } from "./types";
|
||||
import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server";
|
||||
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
import("streamdown").then((mod) => ({
|
||||
default: ({ children }: { children: string }) => (
|
||||
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
|
||||
<mod.Streamdown isAnimating={false}>{children}</mod.Streamdown>
|
||||
</mod.ShikiThemeContext.Provider>
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
type AITab = "overview" | "messages" | "tools" | "prompt";
|
||||
|
||||
export function AISpanDetails({
|
||||
aiData,
|
||||
promptVersionData,
|
||||
rawProperties,
|
||||
}: {
|
||||
aiData: AISpanData;
|
||||
promptVersionData?: PromptSpanData;
|
||||
rawProperties?: string;
|
||||
}) {
|
||||
const [tab, setTab] = useState<AITab>("overview");
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const toolCount = aiData.toolCount ?? aiData.toolDefinitions?.length ?? 0;
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const promptLink: PromptLink | undefined =
|
||||
aiData.promptSlug && organization && project && environment
|
||||
? {
|
||||
slug: aiData.promptSlug,
|
||||
version: aiData.promptVersion,
|
||||
path: v3PromptPath(organization, project, environment, aiData.promptSlug, aiData.promptVersion),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Tab bar */}
|
||||
<div className="shrink-0 overflow-x-auto px-3 py-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={tab === "overview"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("overview")}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "messages"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("messages")}
|
||||
shortcut={{ key: "m" }}
|
||||
>
|
||||
Messages
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "tools"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("tools")}
|
||||
shortcut={{ key: "t" }}
|
||||
>
|
||||
<span className="inline-flex items-center whitespace-nowrap">
|
||||
Tools
|
||||
{toolCount > 0 && (
|
||||
<span className="ml-1 inline-flex min-w-4 -translate-y-px items-center justify-center rounded-full border border-charcoal-600 bg-charcoal-650 px-1 py-0.5 text-[0.625rem] font-medium leading-none text-text-bright">
|
||||
{toolCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TabButton>
|
||||
{promptVersionData && (
|
||||
<TabButton
|
||||
isActive={tab === "prompt"}
|
||||
layoutId="ai-span"
|
||||
onClick={() => setTab("prompt")}
|
||||
shortcut={{ key: "p" }}
|
||||
>
|
||||
Prompt
|
||||
</TabButton>
|
||||
)}
|
||||
</TabContainer>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="scrollbar-gutter-stable min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{tab === "overview" && <OverviewTab aiData={aiData} />}
|
||||
{tab === "messages" && <MessagesTab aiData={aiData} promptLink={promptLink} />}
|
||||
{tab === "tools" && <ToolsTab aiData={aiData} />}
|
||||
{tab === "prompt" && promptVersionData && (
|
||||
<PromptTab promptData={promptVersionData} promptLink={promptLink} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Copy raw (admin only) */}
|
||||
{isAdmin && rawProperties && <CopyRawFooter rawProperties={rawProperties} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ aiData }: { aiData: AISpanData }) {
|
||||
const { userText, outputText, outputObject, outputToolNames } = extractInputOutput(aiData);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3">
|
||||
<AITagsRow aiData={aiData} />
|
||||
<AIStatsSummary aiData={aiData} />
|
||||
|
||||
{userText && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<ChatBubble>
|
||||
<Paragraph variant="small/dimmed">{userText}</Paragraph>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outputText && <AssistantResponse text={outputText} headerLabel="Output" />}
|
||||
{!outputText && outputObject && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Output</Header3>
|
||||
<CodeBlock
|
||||
code={outputObject}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{outputToolNames.length > 0 && !outputText && !outputObject && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Output</Header3>
|
||||
<ChatBubble>
|
||||
<Paragraph variant="small/dimmed">
|
||||
Called {outputToolNames.length === 1 ? "tool" : "tools"}:{" "}
|
||||
<span className="font-mono text-text-bright">{outputToolNames.join(", ")}</span>
|
||||
</Paragraph>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagesTab({
|
||||
aiData,
|
||||
promptLink,
|
||||
}: {
|
||||
aiData: AISpanData;
|
||||
promptLink?: PromptLink;
|
||||
}) {
|
||||
const showFallbackText = aiData.responseText && !hasAssistantItem(aiData.items);
|
||||
const showFallbackObject =
|
||||
!showFallbackText && aiData.responseObject && !hasAssistantItem(aiData.items);
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<div className="flex flex-col">
|
||||
{aiData.items && aiData.items.length > 0 && (
|
||||
<AIChatMessages items={aiData.items} promptLink={promptLink} />
|
||||
)}
|
||||
{showFallbackText && <AssistantResponse text={aiData.responseText!} />}
|
||||
{showFallbackObject && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Assistant</Header3>
|
||||
<CodeBlock
|
||||
code={aiData.responseObject!}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolsTab({ aiData }: { aiData: AISpanData }) {
|
||||
return <AIToolsInventory aiData={aiData} />;
|
||||
}
|
||||
|
||||
function CopyRawFooter({ rawProperties }: { rawProperties: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
navigator.clipboard.writeText(rawProperties);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[3.25rem] shrink-0 items-center justify-end border-t border-grid-dimmed px-2">
|
||||
<Button
|
||||
variant="minimal/medium"
|
||||
onClick={handleCopy}
|
||||
LeadingIcon={copied ? CheckIcon : ClipboardDocumentIcon}
|
||||
leadingIconClassName={copied ? "text-green-500" : undefined}
|
||||
>
|
||||
Copy raw properties
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptTab({
|
||||
promptData,
|
||||
promptLink,
|
||||
}: {
|
||||
promptData: PromptSpanData;
|
||||
promptLink?: PromptLink;
|
||||
}) {
|
||||
const labels = promptData.labels
|
||||
? promptData.labels.split(",").map((l) => l.trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3">
|
||||
{/* Prompt properties */}
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<PromptMetricRow
|
||||
label="Prompt"
|
||||
value={
|
||||
promptLink ? (
|
||||
<TextLink to={promptLink.path}>{promptData.slug}</TextLink>
|
||||
) : (
|
||||
promptData.slug
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PromptMetricRow label="Version" value={`v${promptData.version}`} />
|
||||
{labels.length > 0 && <PromptMetricRow label="Labels" value={labels.join(", ")} />}
|
||||
{promptData.model && <PromptMetricRow label="Model" value={promptData.model} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prompt input (variables passed to resolve()) */}
|
||||
{promptData.input && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<CodeBlock
|
||||
code={tryPrettyJson(promptData.input)}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template (from the prompt version) */}
|
||||
{promptData.template && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Template</Header3>
|
||||
<div className="rounded-md border border-grid-bright bg-charcoal-750/50 px-3.5 py-2">
|
||||
<div className="font-sans text-sm font-normal text-text-dimmed streamdown-container">
|
||||
<Suspense
|
||||
fallback={<span className="whitespace-pre-wrap">{promptData.template}</span>}
|
||||
>
|
||||
<StreamdownRenderer>{promptData.template}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractInputOutput(aiData: AISpanData): {
|
||||
userText: string | undefined;
|
||||
outputText: string | undefined;
|
||||
outputObject: string | undefined;
|
||||
outputToolNames: string[];
|
||||
} {
|
||||
let userText: string | undefined;
|
||||
let outputText: string | undefined;
|
||||
const outputToolNames: string[] = [];
|
||||
|
||||
if (aiData.items) {
|
||||
for (let i = aiData.items.length - 1; i >= 0; i--) {
|
||||
if (aiData.items[i].type === "user") {
|
||||
userText = (aiData.items[i] as { type: "user"; text: string }).text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = aiData.items.length - 1; i >= 0; i--) {
|
||||
const item = aiData.items[i];
|
||||
if (item.type === "assistant") {
|
||||
outputText = item.text;
|
||||
break;
|
||||
}
|
||||
if (item.type === "tool-use") {
|
||||
for (const tool of item.tools) {
|
||||
outputToolNames.push(tool.toolName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!outputText && aiData.responseText) {
|
||||
outputText = aiData.responseText;
|
||||
}
|
||||
|
||||
return {
|
||||
userText,
|
||||
outputText,
|
||||
outputObject: aiData.responseObject ? tryPrettyJson(aiData.responseObject) : undefined,
|
||||
outputToolNames,
|
||||
};
|
||||
}
|
||||
|
||||
function hasAssistantItem(items: DisplayItem[] | undefined): boolean {
|
||||
if (!items) return false;
|
||||
return items.some((item) => item.type === "assistant");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import { formatDuration, tryPrettyJson } from "./aiHelpers";
|
||||
import { SpanMetricRow as MetricRow } from "./SpanMetricRow";
|
||||
|
||||
export type AIToolCallData = {
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
args?: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export function extractAIToolCallData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AIToolCallData | undefined {
|
||||
const ai = properties.ai;
|
||||
if (!ai || typeof ai !== "object") return undefined;
|
||||
|
||||
const a = ai as Record<string, unknown>;
|
||||
if (a.operationId !== "ai.toolCall") return undefined;
|
||||
|
||||
const toolCall = a.toolCall;
|
||||
if (!toolCall || typeof toolCall !== "object") return undefined;
|
||||
|
||||
const tc = toolCall as Record<string, unknown>;
|
||||
const toolName = typeof tc.name === "string" ? tc.name : undefined;
|
||||
if (!toolName) return undefined;
|
||||
|
||||
return {
|
||||
toolName,
|
||||
toolCallId: typeof tc.id === "string" ? tc.id : "",
|
||||
args: typeof tc.args === "string" ? tc.args : undefined,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function AIToolCallSpanDetails({ data }: { data: AIToolCallData }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="flex flex-col px-3">
|
||||
{/* Tool info */}
|
||||
<div className="flex flex-col gap-1 py-2.5">
|
||||
<div className="flex flex-col text-xs @container">
|
||||
<MetricRow label="Tool" value={data.toolName} />
|
||||
{data.toolCallId && (
|
||||
<MetricRow
|
||||
label="Call ID"
|
||||
value={<TruncatedCopyableValue value={data.toolCallId} />}
|
||||
/>
|
||||
)}
|
||||
<MetricRow label="Duration" value={formatDuration(data.durationMs)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input args */}
|
||||
{data.args && (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<Header3>Input</Header3>
|
||||
<CodeBlock
|
||||
code={tryPrettyJson(data.args)}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import type { AISpanData, ToolDefinition } from "./types";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
export function AIToolsInventory({ aiData }: { aiData: AISpanData }) {
|
||||
const defs = aiData.toolDefinitions ?? [];
|
||||
const calledNames = getCalledToolNames(aiData);
|
||||
|
||||
if (defs.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<Paragraph variant="small/dimmed">No tool definitions available for this span.</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-grid-bright px-3">
|
||||
{defs.map((def) => {
|
||||
const wasCalled = calledNames.has(def.name);
|
||||
return <ToolDefRow key={def.name} def={def} wasCalled={wasCalled} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolean }) {
|
||||
const [showSchema, setShowSchema] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`size-1.5 shrink-0 rounded-full ${
|
||||
wasCalled ? "bg-success" : "bg-charcoal-600"
|
||||
}`}
|
||||
/>
|
||||
<code className="font-mono text-xs text-text-bright">{def.name}</code>
|
||||
<span className="text-[10px] text-text-dimmed">{wasCalled ? "called" : "not called"}</span>
|
||||
</div>
|
||||
|
||||
{def.description && (
|
||||
<p className="pl-3.5 text-xs leading-relaxed text-text-dimmed">{def.description}</p>
|
||||
)}
|
||||
|
||||
{def.parametersJson && (
|
||||
<div className="pl-3.5">
|
||||
<button
|
||||
onClick={() => setShowSchema(!showSchema)}
|
||||
className="text-[10px] text-text-link hover:underline"
|
||||
>
|
||||
{showSchema ? "Hide schema" : "Show schema"}
|
||||
</button>
|
||||
{showSchema && (
|
||||
<div className="mt-1">
|
||||
<CodeBlock
|
||||
code={def.parametersJson}
|
||||
maxLines={16}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCalledToolNames(aiData: AISpanData): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (!aiData.items) return names;
|
||||
|
||||
for (const item of aiData.items) {
|
||||
if (item.type === "tool-use") {
|
||||
for (const tool of item.tools) {
|
||||
names.add(tool.toolName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SpanMetricRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="grid h-7 grid-cols-[1fr_auto] items-center gap-4 rounded-sm px-1.5 transition odd:bg-charcoal-750/40 @[28rem]:grid-cols-[8rem_1fr] hover:bg-white/[0.04]">
|
||||
<span className="text-text-dimmed">{label}</span>
|
||||
<span className="text-right text-text-bright @[28rem]:text-left">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Shared primitive helpers for AI span data extraction
|
||||
|
||||
export function rec(v: unknown): Record<string, unknown> {
|
||||
return v && typeof v === "object" ? (v as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
export function str(v: unknown): string | undefined {
|
||||
return typeof v === "string" ? v : undefined;
|
||||
}
|
||||
|
||||
export function num(v: unknown): number | undefined {
|
||||
return typeof v === "number" ? v : undefined;
|
||||
}
|
||||
|
||||
export function tryPrettyJson(value: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
const secs = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse provider metadata from a JSON string.
|
||||
* Handles Anthropic, Azure, OpenAI, Gateway, and OpenRouter formats.
|
||||
*/
|
||||
export function parseProviderMetadata(
|
||||
raw: unknown
|
||||
): {
|
||||
serviceTier?: string;
|
||||
resolvedProvider?: string;
|
||||
gatewayCost?: string;
|
||||
responseId?: string;
|
||||
} | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
|
||||
let serviceTier: string | undefined;
|
||||
let resolvedProvider: string | undefined;
|
||||
let gatewayCost: string | undefined;
|
||||
let responseId: string | undefined;
|
||||
|
||||
// Anthropic: { anthropic: { usage: { service_tier: "standard" } } }
|
||||
const anthropic = rec(parsed.anthropic);
|
||||
serviceTier = str(rec(anthropic.usage).service_tier);
|
||||
|
||||
// Azure/OpenAI: { azure: { serviceTier: "default" } } or { openai: { serviceTier: "..." } }
|
||||
const openai = rec(parsed.openai);
|
||||
if (!serviceTier) {
|
||||
serviceTier = str(rec(parsed.azure).serviceTier) ?? str(openai.serviceTier);
|
||||
}
|
||||
|
||||
// OpenAI response ID
|
||||
responseId = str(openai.responseId);
|
||||
|
||||
// Gateway: { gateway: { routing: { finalProvider, resolvedProvider }, cost } }
|
||||
const gateway = rec(parsed.gateway);
|
||||
const routing = rec(gateway.routing);
|
||||
resolvedProvider = str(routing.finalProvider) ?? str(routing.resolvedProvider);
|
||||
gatewayCost = str(gateway.cost);
|
||||
|
||||
// OpenRouter: { openrouter: { provider: "xAI" } }
|
||||
if (!resolvedProvider) {
|
||||
resolvedProvider = str(rec(parsed.openrouter).provider);
|
||||
}
|
||||
|
||||
if (!serviceTier && !resolvedProvider && !gatewayCost && !responseId) return undefined;
|
||||
return { serviceTier, resolvedProvider, gatewayCost, responseId };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract user-defined telemetry metadata, coercing non-string values.
|
||||
* Skips the "prompt" key which is handled separately.
|
||||
*/
|
||||
export function extractTelemetryMetadata(raw: unknown): Record<string, string> | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (key === "prompt") continue;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
result[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
|
||||
import type { AISpanData, DisplayItem, ToolDefinition, ToolUse } from "./types";
|
||||
|
||||
/**
|
||||
* Extracts structured AI span data from unflattened OTEL span properties.
|
||||
*
|
||||
* Works with the nested object produced by `unflattenAttributes()` — expects
|
||||
* keys like `gen_ai.response.model`, `ai.prompt.messages`, `trigger.llm.total_cost`, etc.
|
||||
*
|
||||
* @param properties Unflattened span properties object
|
||||
* @param durationMs Span duration in milliseconds
|
||||
* @returns Structured AI data, or undefined if this isn't an AI generation span
|
||||
*/
|
||||
export function extractAISpanData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AISpanData | undefined {
|
||||
const genAi = properties.gen_ai;
|
||||
if (!genAi || typeof genAi !== "object") return undefined;
|
||||
|
||||
const g = genAi as Record<string, unknown>;
|
||||
const ai = rec(properties.ai);
|
||||
const trigger = rec(properties.trigger);
|
||||
|
||||
const gResponse = rec(g.response);
|
||||
const gRequest = rec(g.request);
|
||||
const gUsage = rec(g.usage);
|
||||
const gOperation = rec(g.operation);
|
||||
const aiModel = rec(ai.model);
|
||||
const aiResponse = rec(ai.response);
|
||||
const aiPrompt = rec(ai.prompt);
|
||||
const aiUsage = rec(ai.usage);
|
||||
const triggerLlm = rec(trigger.llm);
|
||||
|
||||
const model = str(gResponse.model) ?? str(gRequest.model) ?? str(aiModel.id);
|
||||
if (!model) return undefined;
|
||||
|
||||
// Prefer ai.usage (richer) over gen_ai.usage.
|
||||
// Gateway/some providers emit promptTokens/completionTokens instead of inputTokens/outputTokens.
|
||||
const inputTokens =
|
||||
num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? num(gUsage.input_tokens) ?? 0;
|
||||
const outputTokens =
|
||||
num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? num(gUsage.output_tokens) ?? 0;
|
||||
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
|
||||
|
||||
const tokensPerSecond =
|
||||
num(aiResponse.avgOutputTokensPerSecond) ??
|
||||
(outputTokens > 0 && durationMs > 0
|
||||
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
|
||||
: undefined);
|
||||
|
||||
const toolDefs = parseToolDefinitions(aiPrompt.tools);
|
||||
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
|
||||
const aiTelemetry = rec(ai.telemetry);
|
||||
const telemetryMetaRaw = rec(aiTelemetry.metadata);
|
||||
const promptMeta = rec(telemetryMetaRaw.prompt);
|
||||
const promptSlug = str(promptMeta.slug);
|
||||
const promptVersion = str(promptMeta.version);
|
||||
const promptModel = str(promptMeta.model);
|
||||
const promptLabels = str(promptMeta.labels);
|
||||
const promptInput = str(promptMeta.input);
|
||||
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
|
||||
|
||||
return {
|
||||
model,
|
||||
provider: str(g.system) ?? "unknown",
|
||||
operationName: str(gOperation.name) ?? str(ai.operationId) ?? "",
|
||||
responseId: str(gResponse.id) || undefined,
|
||||
finishReason: str(aiResponse.finishReason),
|
||||
serviceTier: providerMeta?.serviceTier,
|
||||
resolvedProvider: providerMeta?.resolvedProvider,
|
||||
toolChoice: parseToolChoice(aiPrompt.toolChoice),
|
||||
toolCount: toolDefs?.length,
|
||||
messageCount: countMessages(aiPrompt.messages),
|
||||
telemetryMetadata: telemetryMeta,
|
||||
promptSlug: promptSlug || undefined,
|
||||
promptVersion: promptVersion || undefined,
|
||||
promptModel: promptModel || undefined,
|
||||
promptLabels: promptLabels || undefined,
|
||||
promptInput: promptInput || undefined,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
cachedTokens: num(aiUsage.cachedInputTokens) ?? num(gUsage.cache_read_input_tokens),
|
||||
cacheCreationTokens:
|
||||
num(aiUsage.cacheCreationInputTokens) ?? num(gUsage.cache_creation_input_tokens),
|
||||
reasoningTokens: num(aiUsage.reasoningTokens) ?? num(gUsage.reasoning_tokens),
|
||||
tokensPerSecond,
|
||||
msToFirstChunk: num(aiResponse.msToFirstChunk),
|
||||
durationMs,
|
||||
inputCost: num(triggerLlm.input_cost),
|
||||
outputCost: num(triggerLlm.output_cost),
|
||||
totalCost: num(triggerLlm.total_cost),
|
||||
responseText: str(aiResponse.text) || undefined,
|
||||
responseObject: str(aiResponse.object) || undefined,
|
||||
toolDefinitions: toolDefs,
|
||||
items: buildDisplayItems(aiPrompt.messages, aiResponse.toolCalls, toolDefs),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message → DisplayItem transformation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RawMessage = {
|
||||
role: string;
|
||||
content: unknown;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build display items from prompt messages and optionally response tool calls.
|
||||
* - Parses ai.prompt.messages and merges consecutive tool-call + tool-result pairs
|
||||
* - If ai.response.toolCalls is present (finishReason=tool-calls), appends those too
|
||||
*/
|
||||
function buildDisplayItems(
|
||||
messagesRaw: unknown,
|
||||
responseToolCallsRaw: unknown,
|
||||
toolDefs?: ToolDefinition[]
|
||||
): DisplayItem[] | undefined {
|
||||
const items = parseMessagesToDisplayItems(messagesRaw);
|
||||
const responseToolCalls = parseResponseToolCalls(responseToolCallsRaw);
|
||||
|
||||
if (!items && !responseToolCalls) return undefined;
|
||||
|
||||
const result = items ?? [];
|
||||
|
||||
if (responseToolCalls && responseToolCalls.length > 0) {
|
||||
result.push({ type: "tool-use", tools: responseToolCalls });
|
||||
}
|
||||
|
||||
if (toolDefs && toolDefs.length > 0) {
|
||||
const defsByName = new Map(toolDefs.map((d) => [d.name, d]));
|
||||
for (const item of result) {
|
||||
if (item.type === "tool-use") {
|
||||
for (const tool of item.tools) {
|
||||
const def = defsByName.get(tool.toolName);
|
||||
if (def) {
|
||||
tool.description = def.description;
|
||||
tool.parametersJson = def.parametersJson;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function parseMessagesToDisplayItems(raw: unknown): DisplayItem[] | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
|
||||
let messages: RawMessage[];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
messages = parsed.map((item: unknown) => {
|
||||
const m = rec(item);
|
||||
return {
|
||||
role: str(m.role) ?? "user",
|
||||
content: m.content,
|
||||
toolCallId: str(m.toolCallId),
|
||||
name: str(m.name),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < messages.length) {
|
||||
const msg = messages[i];
|
||||
|
||||
if (msg.role === "system") {
|
||||
items.push({ type: "system", text: extractTextContent(msg.content) });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "user") {
|
||||
items.push({ type: "user", text: extractTextContent(msg.content) });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assistant message — check if it contains tool calls
|
||||
if (msg.role === "assistant") {
|
||||
const toolCalls = extractToolCalls(msg.content);
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
// Collect subsequent tool result messages that match these tool calls
|
||||
const toolCallIds = new Set(toolCalls.map((tc) => tc.toolCallId));
|
||||
let j = i + 1;
|
||||
while (j < messages.length && messages[j].role === "tool") {
|
||||
j++;
|
||||
}
|
||||
// Gather tool result messages between i+1 and j
|
||||
const toolResultMsgs = messages.slice(i + 1, j);
|
||||
|
||||
// Build ToolUse entries by pairing calls with results
|
||||
const tools: ToolUse[] = toolCalls.map((tc) => {
|
||||
const resultMsg = toolResultMsgs.find((m) => {
|
||||
// Match by toolCallId in the message's content parts
|
||||
const results = extractToolResults(m.content);
|
||||
return results.some((r) => r.toolCallId === tc.toolCallId);
|
||||
});
|
||||
|
||||
const result = resultMsg
|
||||
? extractToolResults(resultMsg.content).find(
|
||||
(r) => r.toolCallId === tc.toolCallId
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
toolCallId: tc.toolCallId,
|
||||
toolName: tc.toolName,
|
||||
inputJson: JSON.stringify(tc.input, null, 2),
|
||||
resultSummary: result?.summary,
|
||||
resultOutput: result?.formattedOutput,
|
||||
};
|
||||
});
|
||||
|
||||
items.push({ type: "tool-use", tools });
|
||||
i = j; // skip past the tool result messages
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assistant message with just text
|
||||
const text = extractTextContent(msg.content);
|
||||
if (text) {
|
||||
items.push({ type: "assistant", text });
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip any other message types (tool messages that weren't consumed above)
|
||||
i++;
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response tool calls (from ai.response.toolCalls, used when finishReason=tool-calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse ai.response.toolCalls JSON string into ToolUse entries.
|
||||
* These are tool calls the model requested but haven't been executed yet in this span.
|
||||
*/
|
||||
function parseResponseToolCalls(raw: unknown): ToolUse[] | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
const tools: ToolUse[] = [];
|
||||
for (const item of parsed) {
|
||||
const tc = rec(item);
|
||||
if (tc.type === "tool-call" || tc.toolName || tc.toolCallId) {
|
||||
tools.push({
|
||||
toolCallId: str(tc.toolCallId) ?? "",
|
||||
toolName: str(tc.toolName) ?? "",
|
||||
inputJson: JSON.stringify(
|
||||
tc.input && typeof tc.input === "object" ? tc.input : {},
|
||||
null,
|
||||
2
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Content part extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const raw of content) {
|
||||
const p = rec(raw);
|
||||
if (p.type === "text" && typeof p.text === "string") {
|
||||
texts.push(p.text);
|
||||
} else if (typeof p.text === "string") {
|
||||
texts.push(p.text);
|
||||
}
|
||||
}
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
type ParsedToolCall = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function extractToolCalls(content: unknown): ParsedToolCall[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const calls: ParsedToolCall[] = [];
|
||||
for (const raw of content) {
|
||||
const p = rec(raw);
|
||||
if (p.type === "tool-call") {
|
||||
calls.push({
|
||||
toolCallId: str(p.toolCallId) ?? "",
|
||||
toolName: str(p.toolName) ?? "",
|
||||
input: p.input && typeof p.input === "object" ? (p.input as Record<string, unknown>) : {},
|
||||
});
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
type ParsedToolResult = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
summary: string;
|
||||
formattedOutput: string;
|
||||
};
|
||||
|
||||
function extractToolResults(content: unknown): ParsedToolResult[] {
|
||||
if (!Array.isArray(content)) return [];
|
||||
const results: ParsedToolResult[] = [];
|
||||
for (const raw of content) {
|
||||
const p = rec(raw);
|
||||
if (p.type === "tool-result") {
|
||||
const { summary, formattedOutput } = summarizeToolOutput(p.output);
|
||||
results.push({
|
||||
toolCallId: str(p.toolCallId) ?? "",
|
||||
toolName: str(p.toolName) ?? "",
|
||||
summary,
|
||||
formattedOutput,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a tool output into a short label and a formatted string for display.
|
||||
* Handles the AI SDK's `{ type: "json", value: { status, contentType, body, truncated } }` shape.
|
||||
*/
|
||||
function summarizeToolOutput(output: unknown): { summary: string; formattedOutput: string } {
|
||||
if (typeof output === "string") {
|
||||
return {
|
||||
summary: output.length > 80 ? output.slice(0, 80) + "..." : output,
|
||||
formattedOutput: output,
|
||||
};
|
||||
}
|
||||
|
||||
if (!output || typeof output !== "object") {
|
||||
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
|
||||
}
|
||||
|
||||
const o = output as Record<string, unknown>;
|
||||
|
||||
// AI SDK wraps tool results as { type: "json", value: { status, contentType, body, ... } }
|
||||
if (o.type === "json" && o.value && typeof o.value === "object") {
|
||||
const v = o.value as Record<string, unknown>;
|
||||
const parts: string[] = [];
|
||||
if (typeof v.status === "number") parts.push(`${v.status}`);
|
||||
if (typeof v.contentType === "string") parts.push(v.contentType);
|
||||
if (v.truncated === true) parts.push("truncated");
|
||||
return {
|
||||
summary: parts.length > 0 ? parts.join(" · ") : "json result",
|
||||
formattedOutput: JSON.stringify(v, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool definitions (from ai.prompt.tools)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse ai.prompt.tools — after the array fix, this arrives as a JSON array string
|
||||
* where each element is itself a JSON string of a tool definition.
|
||||
*/
|
||||
function parseToolDefinitions(raw: unknown): ToolDefinition[] | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
const defs: ToolDefinition[] = [];
|
||||
for (const item of parsed) {
|
||||
// Each item is either a JSON string or already an object
|
||||
const obj = typeof item === "string" ? JSON.parse(item) : item;
|
||||
if (!obj || typeof obj !== "object") continue;
|
||||
const o = obj as Record<string, unknown>;
|
||||
const name = str(o.name);
|
||||
if (!name) continue;
|
||||
const schema = o.parameters ?? o.inputSchema;
|
||||
defs.push({
|
||||
name,
|
||||
description: str(o.description),
|
||||
parametersJson:
|
||||
schema && typeof schema === "object"
|
||||
? JSON.stringify(schema, null, 2)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return defs.length > 0 ? defs : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool choice parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseToolChoice(raw: unknown): string | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "string") return parsed;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.type === "string") return obj.type;
|
||||
}
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message count
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function countMessages(raw: unknown): number | undefined {
|
||||
if (typeof raw !== "string") return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return undefined;
|
||||
return parsed.length > 0 ? parsed.length : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers";
|
||||
import type { AISpanData, DisplayItem } from "./types";
|
||||
|
||||
/**
|
||||
* Extracts structured AI data from top-level AI SDK parent spans.
|
||||
*
|
||||
* These spans (ai.generateText, ai.streamText, ai.generateObject, ai.streamObject)
|
||||
* use `ai.*` attributes instead of `gen_ai.*`. They contain the full prompt,
|
||||
* aggregated response, and total usage across all steps.
|
||||
*/
|
||||
export function extractAISummarySpanData(
|
||||
properties: Record<string, unknown>,
|
||||
durationMs: number
|
||||
): AISpanData | undefined {
|
||||
const ai = rec(properties.ai);
|
||||
if (!ai.operationId) return undefined;
|
||||
|
||||
// Skip child spans that have gen_ai.* (those use extractAISpanData)
|
||||
if (properties.gen_ai && typeof properties.gen_ai === "object") return undefined;
|
||||
|
||||
const aiModel = rec(ai.model);
|
||||
const aiResponse = rec(ai.response);
|
||||
const aiUsage = rec(ai.usage);
|
||||
const aiSettings = rec(ai.settings);
|
||||
const aiRequest = rec(ai.request);
|
||||
const aiTelemetry = rec(ai.telemetry);
|
||||
const trigger = rec(properties.trigger);
|
||||
const triggerLlm = rec(trigger.llm);
|
||||
|
||||
const model = str(aiModel.id);
|
||||
if (!model) return undefined;
|
||||
|
||||
const provider = str(aiModel.provider) ?? "unknown";
|
||||
const operationName = str(ai.operationId) ?? "";
|
||||
|
||||
// Token usage
|
||||
const inputTokens =
|
||||
num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? 0;
|
||||
const outputTokens =
|
||||
num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? 0;
|
||||
const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens;
|
||||
|
||||
const tokensPerSecond =
|
||||
outputTokens > 0 && durationMs > 0
|
||||
? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10
|
||||
: undefined;
|
||||
|
||||
// Provider metadata
|
||||
const providerMeta = parseProviderMetadata(aiResponse.providerMetadata);
|
||||
|
||||
// Response ID from provider metadata
|
||||
const responseId = providerMeta?.responseId;
|
||||
|
||||
// Telemetry metadata (prompt info, custom metadata)
|
||||
const telemetryMetaRaw = rec(aiTelemetry.metadata);
|
||||
const promptMeta = rec(telemetryMetaRaw.prompt);
|
||||
const promptSlug = str(promptMeta.slug);
|
||||
const promptVersion = str(promptMeta.version);
|
||||
const promptModel = str(promptMeta.model);
|
||||
const promptLabels = str(promptMeta.labels);
|
||||
const promptInput = str(promptMeta.input);
|
||||
const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata);
|
||||
|
||||
// Parse the prompt JSON to build display items
|
||||
const promptJson = str(ai.prompt);
|
||||
const items = promptJson ? parsePromptToDisplayItems(promptJson, str(aiResponse.text)) : undefined;
|
||||
|
||||
// Count messages from the parsed prompt
|
||||
let messageCount: number | undefined;
|
||||
if (promptJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
|
||||
if (parsed.messages && Array.isArray(parsed.messages)) {
|
||||
messageCount = parsed.messages.length;
|
||||
} else {
|
||||
// system + prompt = 2 messages
|
||||
messageCount = (parsed.system ? 1 : 0) + (parsed.prompt ? 1 : 0);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
provider,
|
||||
operationName,
|
||||
responseId,
|
||||
finishReason: str(aiResponse.finishReason),
|
||||
serviceTier: providerMeta?.serviceTier,
|
||||
resolvedProvider: providerMeta?.resolvedProvider,
|
||||
toolChoice: undefined,
|
||||
toolCount: undefined,
|
||||
messageCount,
|
||||
telemetryMetadata: telemetryMeta,
|
||||
promptSlug: promptSlug || undefined,
|
||||
promptVersion: promptVersion || undefined,
|
||||
promptModel: promptModel || undefined,
|
||||
promptLabels: promptLabels || undefined,
|
||||
promptInput: promptInput || undefined,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
cachedTokens: num(aiUsage.cachedInputTokens),
|
||||
cacheCreationTokens: num(aiUsage.cacheCreationInputTokens),
|
||||
reasoningTokens: num(aiUsage.reasoningTokens),
|
||||
tokensPerSecond,
|
||||
msToFirstChunk: undefined, // Only on child doStream spans
|
||||
durationMs,
|
||||
inputCost: num(triggerLlm.input_cost),
|
||||
outputCost: num(triggerLlm.output_cost),
|
||||
totalCost: num(triggerLlm.total_cost),
|
||||
responseText: str(aiResponse.text) || undefined,
|
||||
responseObject: str(aiResponse.object) || undefined,
|
||||
toolDefinitions: undefined,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prompt parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parses the `ai.prompt` JSON string into display items.
|
||||
* Parent spans store the prompt as a JSON object with either:
|
||||
* - { system: "...", prompt: "..." }
|
||||
* - { system: "...", messages: [...] }
|
||||
* - { messages: [...] }
|
||||
*/
|
||||
function parsePromptToDisplayItems(
|
||||
promptJson: string,
|
||||
responseText?: string
|
||||
): DisplayItem[] | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(promptJson) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object") return undefined;
|
||||
|
||||
const items: DisplayItem[] = [];
|
||||
|
||||
if (typeof parsed.system === "string" && parsed.system) {
|
||||
items.push({ type: "system", text: parsed.system });
|
||||
}
|
||||
|
||||
if (typeof parsed.prompt === "string" && parsed.prompt) {
|
||||
items.push({ type: "user", text: parsed.prompt });
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed.messages)) {
|
||||
for (const msg of parsed.messages) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
const m = msg as Record<string, unknown>;
|
||||
const role = m.role;
|
||||
const content = extractMessageContent(m.content);
|
||||
if (!content) continue;
|
||||
|
||||
switch (role) {
|
||||
case "system":
|
||||
items.push({ type: "system", text: content });
|
||||
break;
|
||||
case "user":
|
||||
items.push({ type: "user", text: content });
|
||||
break;
|
||||
case "assistant":
|
||||
items.push({ type: "assistant", text: content });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add response as assistant item if not already present
|
||||
if (responseText && !items.some((i) => i.type === "assistant")) {
|
||||
items.push({ type: "assistant", text: responseText });
|
||||
}
|
||||
|
||||
return items.length > 0 ? items : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageContent(content: unknown): string | undefined {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
// Extract text parts from content array [{type: "text", text: "..."}]
|
||||
return content
|
||||
.filter((p): p is { type: string; text: string } => {
|
||||
if (!p || typeof p !== "object") return false;
|
||||
const o = p as Record<string, unknown>;
|
||||
return o.type === "text" && typeof o.text === "string";
|
||||
})
|
||||
.map((p) => p.text)
|
||||
.join("\n");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { AISpanDetails } from "./AISpanDetails";
|
||||
export { extractAISpanData } from "./extractAISpanData";
|
||||
export { extractAISummarySpanData } from "./extractAISummarySpanData";
|
||||
export { AIToolCallSpanDetails, extractAIToolCallData } from "./AIToolCallSpanDetails";
|
||||
export type { AIToolCallData } from "./AIToolCallSpanDetails";
|
||||
export { AIEmbedSpanDetails, extractAIEmbedData } from "./AIEmbedSpanDetails";
|
||||
export type { AIEmbedData } from "./AIEmbedSpanDetails";
|
||||
export type { AISpanData, DisplayItem, ToolUse } from "./types";
|
||||
@@ -0,0 +1,113 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool use (merged assistant tool-call + tool result)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ToolDefinition = {
|
||||
name: string;
|
||||
description?: string;
|
||||
/** JSON schema as formatted string */
|
||||
parametersJson?: string;
|
||||
};
|
||||
|
||||
export type ToolUse = {
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
/** Tool description from the definition, if available */
|
||||
description?: string;
|
||||
/** JSON schema of the tool's parameters, pretty-printed */
|
||||
parametersJson?: string;
|
||||
/** Formatted input args as JSON string */
|
||||
inputJson: string;
|
||||
/** Short summary of the result (e.g. "200 · text/html · truncated") */
|
||||
resultSummary?: string;
|
||||
/** Full formatted result for display in a code block */
|
||||
resultOutput?: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display items — what the UI actually renders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** System prompt text (collapsible) */
|
||||
export type SystemItem = {
|
||||
type: "system";
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** User message text */
|
||||
export type UserItem = {
|
||||
type: "user";
|
||||
text: string;
|
||||
};
|
||||
|
||||
/** One or more tool calls with their results, grouped */
|
||||
export type ToolUseItem = {
|
||||
type: "tool-use";
|
||||
tools: ToolUse[];
|
||||
};
|
||||
|
||||
/** Final assistant text response */
|
||||
export type AssistantItem = {
|
||||
type: "assistant";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type DisplayItem = SystemItem | UserItem | ToolUseItem | AssistantItem;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Span-level AI data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AISpanData = {
|
||||
model: string;
|
||||
provider: string;
|
||||
operationName: string;
|
||||
responseId?: string;
|
||||
|
||||
// Categorical tags
|
||||
finishReason?: string;
|
||||
serviceTier?: string;
|
||||
/** Resolved downstream provider for gateway/openrouter spans (e.g. "xAI", "mistral") */
|
||||
resolvedProvider?: string;
|
||||
toolChoice?: string;
|
||||
toolCount?: number;
|
||||
messageCount?: number;
|
||||
/** User-defined telemetry metadata (from ai.telemetry.metadata) */
|
||||
telemetryMetadata?: Record<string, string>;
|
||||
|
||||
// Prompt metadata (from ai.telemetry.metadata.prompt)
|
||||
promptSlug?: string;
|
||||
promptVersion?: string;
|
||||
promptModel?: string;
|
||||
promptLabels?: string;
|
||||
promptInput?: string;
|
||||
|
||||
// Token counts
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
cachedTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
|
||||
// Performance
|
||||
tokensPerSecond?: number;
|
||||
msToFirstChunk?: number;
|
||||
durationMs: number;
|
||||
|
||||
// Cost
|
||||
inputCost?: number;
|
||||
outputCost?: number;
|
||||
totalCost?: number;
|
||||
|
||||
// Response text (final assistant output)
|
||||
responseText?: string;
|
||||
// Structured object response (JSON) — mutually exclusive with responseText
|
||||
responseObject?: string;
|
||||
|
||||
// Tool definitions (from ai.prompt.tools)
|
||||
toolDefinitions?: ToolDefinition[];
|
||||
|
||||
// Display-ready message items (system, user, tool-use groups, assistant text)
|
||||
items?: DisplayItem[];
|
||||
};
|
||||
@@ -39,6 +39,7 @@ const S2EnvSchema = z.preprocess(
|
||||
S2_ENABLED: z.literal("1"),
|
||||
S2_ACCESS_TOKEN: z.string(),
|
||||
S2_DEPLOYMENT_LOGS_BASIN_NAME: z.string(),
|
||||
S2_DEPLOYMENT_STREAMS_LOCAL: z.string().default("0"),
|
||||
}),
|
||||
z.object({
|
||||
S2_ENABLED: z.literal("0"),
|
||||
@@ -372,6 +373,7 @@ const EnvironmentSchema = z
|
||||
|
||||
// Development OTEL environment variables
|
||||
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
|
||||
DEV_OTEL_METRICS_ENDPOINT: z.string().optional(),
|
||||
// If this is set to 1, then the below variables are used to configure the batch processor for spans and logs
|
||||
DEV_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
|
||||
DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||||
@@ -382,6 +384,9 @@ const EnvironmentSchema = z
|
||||
DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||||
DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||||
DEV_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
|
||||
DEV_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
|
||||
DEV_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
|
||||
DEV_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
|
||||
|
||||
PROD_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
|
||||
PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
|
||||
@@ -392,6 +397,9 @@ const EnvironmentSchema = z
|
||||
PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
|
||||
PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
|
||||
PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
|
||||
PROD_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
|
||||
PROD_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
|
||||
PROD_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
|
||||
|
||||
TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
|
||||
TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
|
||||
@@ -540,6 +548,9 @@ const EnvironmentSchema = z
|
||||
|
||||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
QUEUE_SIZE_CACHE_TTL_MS: z.coerce.number().int().optional().default(1_000), // 1 second
|
||||
QUEUE_SIZE_CACHE_MAX_SIZE: z.coerce.number().int().optional().default(5_000),
|
||||
QUEUE_SIZE_CACHE_ENABLED: z.coerce.number().int().optional().default(1),
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
MAX_BATCH_AND_WAIT_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
@@ -549,7 +560,7 @@ const EnvironmentSchema = z
|
||||
BATCH_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(100),
|
||||
BATCH_RATE_LIMIT_MAX: z.coerce.number().int().default(1200),
|
||||
BATCH_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"),
|
||||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(1),
|
||||
BATCH_CONCURRENCY_LIMIT_DEFAULT: z.coerce.number().int().default(5),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
REALTIME_STREAM_MAX_LENGTH: z.coerce.number().int().default(1000),
|
||||
@@ -596,6 +607,20 @@ const EnvironmentSchema = z
|
||||
RUN_ENGINE_CONCURRENCY_SWEEPER_SCAN_JITTER_IN_MS: z.coerce.number().int().optional(),
|
||||
RUN_ENGINE_CONCURRENCY_SWEEPER_PROCESS_MARKED_JITTER_IN_MS: z.coerce.number().int().optional(),
|
||||
|
||||
// TTL System settings for automatic run expiration
|
||||
RUN_ENGINE_TTL_SYSTEM_DISABLED: BoolEnv.default(false),
|
||||
RUN_ENGINE_TTL_SYSTEM_SHARD_COUNT: z.coerce.number().int().optional(),
|
||||
RUN_ENGINE_TTL_SYSTEM_POLL_INTERVAL_MS: z.coerce.number().int().default(1_000),
|
||||
RUN_ENGINE_TTL_SYSTEM_BATCH_SIZE: z.coerce.number().int().default(100),
|
||||
RUN_ENGINE_TTL_WORKER_CONCURRENCY: z.coerce.number().int().default(1),
|
||||
RUN_ENGINE_TTL_WORKER_BATCH_MAX_SIZE: z.coerce.number().int().default(50),
|
||||
RUN_ENGINE_TTL_CONSUMERS_DISABLED: BoolEnv.default(false),
|
||||
RUN_ENGINE_TTL_WORKER_BATCH_MAX_WAIT_MS: z.coerce.number().int().default(5_000),
|
||||
|
||||
/** Optional maximum TTL for all runs (e.g. "14d"). If set, runs without an explicit TTL
|
||||
* will use this as their TTL, and runs with a TTL larger than this will be clamped. */
|
||||
RUN_ENGINE_DEFAULT_MAX_TTL: z.string().optional(),
|
||||
|
||||
RUN_ENGINE_RUN_LOCK_DURATION: z.coerce.number().int().default(5000),
|
||||
RUN_ENGINE_RUN_LOCK_AUTOMATIC_EXTENSION_THRESHOLD: z.coerce.number().int().default(1000),
|
||||
RUN_ENGINE_RUN_LOCK_MAX_RETRIES: z.coerce.number().int().default(10),
|
||||
@@ -968,6 +993,9 @@ const EnvironmentSchema = z
|
||||
// Global rate limit: max items processed per second across all consumers
|
||||
// If not set, no global rate limiting is applied
|
||||
BATCH_QUEUE_GLOBAL_RATE_LIMIT: z.coerce.number().int().positive().optional(),
|
||||
// Max items in the worker queue before claiming pauses (protects visibility timeouts)
|
||||
// If not set, no depth limit is applied
|
||||
BATCH_QUEUE_WORKER_QUEUE_MAX_DEPTH: z.coerce.number().int().positive().optional(),
|
||||
|
||||
ADMIN_WORKER_ENABLED: z.string().default(process.env.WORKER_ENABLED ?? "true"),
|
||||
ADMIN_WORKER_CONCURRENCY_WORKERS: z.coerce.number().int().default(2),
|
||||
@@ -1171,6 +1199,7 @@ const EnvironmentSchema = z
|
||||
RUN_REPLICATION_INSERT_MAX_DELAY_MS: z.coerce.number().int().default(2000),
|
||||
RUN_REPLICATION_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||||
RUN_REPLICATION_DISABLE_PAYLOAD_INSERT: z.string().default("0"),
|
||||
RUN_REPLICATION_DISABLE_ERROR_FINGERPRINTING: z.string().default("0"),
|
||||
|
||||
// Clickhouse
|
||||
CLICKHOUSE_URL: z.string(),
|
||||
@@ -1218,6 +1247,12 @@ const EnvironmentSchema = z
|
||||
// Metric widget concurrency limits
|
||||
METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(30),
|
||||
|
||||
// Admin ClickHouse URL (for admin dashboard queries like missing models)
|
||||
ADMIN_CLICKHOUSE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
|
||||
|
||||
EVENTS_CLICKHOUSE_URL: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -1229,6 +1264,9 @@ const EnvironmentSchema = z
|
||||
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
|
||||
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
|
||||
METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
|
||||
METRICS_CLICKHOUSE_MAX_CONCURRENCY: z.coerce.number().int().default(3),
|
||||
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
|
||||
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
|
||||
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
|
||||
@@ -1245,6 +1283,16 @@ const EnvironmentSchema = z
|
||||
EVENTS_CLICKHOUSE_MAX_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(5_000),
|
||||
EVENTS_CLICKHOUSE_MAX_LIVE_RELOADING_SETTING: z.coerce.number().int().default(2000),
|
||||
|
||||
// LLM cost tracking
|
||||
LLM_COST_TRACKING_ENABLED: BoolEnv.default(true),
|
||||
LLM_PRICING_RELOAD_INTERVAL_MS: z.coerce.number().int().default(5 * 60 * 1000), // 5 minutes
|
||||
LLM_PRICING_SEED_ON_STARTUP: BoolEnv.default(false),
|
||||
LLM_PRICING_READY_TIMEOUT_MS: z.coerce.number().int().default(500),
|
||||
LLM_METRICS_BATCH_SIZE: z.coerce.number().int().default(5000),
|
||||
LLM_METRICS_FLUSH_INTERVAL_MS: z.coerce.number().int().default(2000),
|
||||
LLM_METRICS_MAX_BATCH_SIZE: z.coerce.number().int().default(10000),
|
||||
LLM_METRICS_MAX_CONCURRENCY: z.coerce.number().int().default(2),
|
||||
|
||||
// Bootstrap
|
||||
TRIGGER_BOOTSTRAP_ENABLED: z.string().default("0"),
|
||||
TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME: z.string().optional(),
|
||||
@@ -1317,6 +1365,8 @@ const EnvironmentSchema = z
|
||||
|
||||
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_ENDPOINT: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: z.enum(["true", "false"]).default("false"),
|
||||
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { extractDomain, faviconUrl } from "~/utils/favicon";
|
||||
|
||||
function resolve(input: string, size: number): string | null {
|
||||
const domain = extractDomain(input);
|
||||
return domain && domain.includes(".") ? faviconUrl(domain, size) : null;
|
||||
}
|
||||
|
||||
export function useFaviconUrl(urlInput: string, size: number = 64) {
|
||||
const [url, setUrl] = useState<string | null>(() => resolve(urlInput, size));
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setUrl(resolve(urlInput, size));
|
||||
}, 400);
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, [urlInput, size]);
|
||||
|
||||
return url;
|
||||
}
|
||||
@@ -8,15 +8,13 @@ import {
|
||||
getImpersonationId,
|
||||
setImpersonationId,
|
||||
} from "~/services/impersonation.server";
|
||||
import { authenticator } from "~/services/auth.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { extractClientIp } from "~/utils/extractClientIp.server";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
export async function adminGetUsers(
|
||||
userId: string,
|
||||
{ page, search }: SearchParams,
|
||||
) {
|
||||
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
|
||||
page = page || 1;
|
||||
|
||||
search = search ? decodeURIComponent(search) : undefined;
|
||||
@@ -231,7 +229,11 @@ export async function redirectWithImpersonation(request: Request, userId: string
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId: userId });
|
||||
logger.error("Failed to create impersonation audit log", {
|
||||
error,
|
||||
adminId: user.id,
|
||||
targetId: userId,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await setImpersonationId(userId, request);
|
||||
@@ -242,10 +244,10 @@ export async function redirectWithImpersonation(request: Request, userId: string
|
||||
}
|
||||
|
||||
export async function clearImpersonation(request: Request, path: string) {
|
||||
const user = await requireUser(request);
|
||||
const authUser = await authenticator.isAuthenticated(request);
|
||||
const targetId = await getImpersonationId(request);
|
||||
|
||||
if (targetId) {
|
||||
if (targetId && authUser?.userId) {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
const ipAddress = extractClientIp(xff);
|
||||
|
||||
@@ -253,13 +255,17 @@ export async function clearImpersonation(request: Request, path: string) {
|
||||
await prisma.impersonationAuditLog.create({
|
||||
data: {
|
||||
action: "STOP",
|
||||
adminId: user.id,
|
||||
adminId: authUser.userId,
|
||||
targetId,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to create impersonation audit log", { error, adminId: user.id, targetId });
|
||||
logger.error("Failed to create impersonation audit log", {
|
||||
error,
|
||||
adminId: authUser.userId,
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
Organization,
|
||||
OrgMember,
|
||||
Prisma,
|
||||
Project,
|
||||
RuntimeEnvironment,
|
||||
User,
|
||||
@@ -22,8 +23,12 @@ export async function createOrganization(
|
||||
title,
|
||||
userId,
|
||||
companySize,
|
||||
onboardingData,
|
||||
avatar,
|
||||
}: Pick<Organization, "title" | "companySize"> & {
|
||||
userId: User["id"];
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
avatar?: Prisma.InputJsonValue;
|
||||
},
|
||||
attemptCount = 0
|
||||
): Promise<Organization> {
|
||||
@@ -47,6 +52,8 @@ export async function createOrganization(
|
||||
title,
|
||||
userId,
|
||||
companySize,
|
||||
onboardingData,
|
||||
avatar,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
@@ -59,6 +66,8 @@ export async function createOrganization(
|
||||
title,
|
||||
slug: uniqueOrgSlug,
|
||||
companySize,
|
||||
onboardingData: onboardingData ?? undefined,
|
||||
avatar: avatar ?? undefined,
|
||||
maximumConcurrencyLimit: env.DEFAULT_ORG_EXECUTION_CONCURRENCY_LIMIT,
|
||||
members: {
|
||||
create: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { nanoid, customAlphabet } from "nanoid";
|
||||
import slug from "slug";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import type { Project } from "@trigger.dev/database";
|
||||
import { Organization, createEnvironment } from "./organization.server";
|
||||
import type { Prisma, Project } from "@trigger.dev/database";
|
||||
import { type Organization, createEnvironment } from "./organization.server";
|
||||
import { env } from "~/env.server";
|
||||
import { projectCreated } from "~/services/platform.v3.server";
|
||||
export type { Project } from "@trigger.dev/database";
|
||||
@@ -14,6 +14,7 @@ type Options = {
|
||||
name: string;
|
||||
userId: string;
|
||||
version: "v2" | "v3";
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
};
|
||||
|
||||
export class ExceededProjectLimitError extends Error {
|
||||
@@ -24,7 +25,7 @@ export class ExceededProjectLimitError extends Error {
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
{ organizationSlug, name, userId, version }: Options,
|
||||
{ organizationSlug, name, userId, version, onboardingData }: Options,
|
||||
attemptCount = 0
|
||||
): Promise<Project & { organization: Organization }> {
|
||||
//check the user has permissions to do this
|
||||
@@ -84,6 +85,7 @@ export async function createProject(
|
||||
name,
|
||||
userId,
|
||||
version,
|
||||
onboardingData,
|
||||
},
|
||||
attemptCount + 1
|
||||
);
|
||||
@@ -100,6 +102,7 @@ export async function createProject(
|
||||
},
|
||||
externalRef: `proj_${externalRefGenerator()}`,
|
||||
version: version === "v3" ? "V3" : "V2",
|
||||
onboardingData,
|
||||
},
|
||||
include: {
|
||||
organization: {
|
||||
|
||||
@@ -332,13 +332,15 @@ export function updateUser({
|
||||
email,
|
||||
marketingEmails,
|
||||
referralSource,
|
||||
onboardingData,
|
||||
}: Pick<User, "id" | "name" | "email"> & {
|
||||
marketingEmails?: boolean;
|
||||
referralSource?: string;
|
||||
onboardingData?: Prisma.InputJsonValue;
|
||||
}) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: { name, email, marketingEmails, referralSource, confirmedBasicDetails: true },
|
||||
data: { name, email, marketingEmails, referralSource, onboardingData, confirmedBasicDetails: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
envTypeToVercelTarget,
|
||||
} from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import {
|
||||
callVercelWithRecovery,
|
||||
wrapVercelCallWithRecovery,
|
||||
VercelSchemas,
|
||||
} from "./vercelSdkRecovery.server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
@@ -314,17 +319,21 @@ export class VercelIntegrationRepository {
|
||||
teamId: string | null
|
||||
): ResultAsync<string, VercelApiError> {
|
||||
if (teamId) {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.teams.getTeam({ teamId }),
|
||||
VercelSchemas.getTeam,
|
||||
"Failed to fetch Vercel team",
|
||||
{ teamId }
|
||||
{ teamId },
|
||||
toVercelApiError
|
||||
).map((response) => response.slug);
|
||||
}
|
||||
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.user.getAuthUser(),
|
||||
VercelSchemas.getAuthUser,
|
||||
"Failed to fetch Vercel user",
|
||||
{}
|
||||
{},
|
||||
toVercelApiError
|
||||
).map((response) => response?.user.username ?? "unknown");
|
||||
}
|
||||
|
||||
@@ -333,10 +342,11 @@ export class VercelIntegrationRepository {
|
||||
): ResultAsync<{ isValid: boolean }, VercelApiError> {
|
||||
return this.getVercelClient(integration)
|
||||
.andThen((client) =>
|
||||
ResultAsync.fromPromise(
|
||||
callVercelWithRecovery(
|
||||
client.user.getAuthUser(),
|
||||
toVercelApiError
|
||||
)
|
||||
VercelSchemas.getAuthUser,
|
||||
{ context: "validateVercelToken" }
|
||||
).mapErr(toVercelApiError)
|
||||
)
|
||||
.map(() => ({ isValid: true }))
|
||||
.orElse((error) =>
|
||||
@@ -420,13 +430,15 @@ export class VercelIntegrationRepository {
|
||||
projectId: string,
|
||||
teamId?: string | null
|
||||
): ResultAsync<VercelCustomEnvironment[], VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.environment.getV9ProjectsIdOrNameCustomEnvironments({
|
||||
idOrName: projectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.getCustomEnvironments,
|
||||
"Failed to fetch Vercel custom environments",
|
||||
{ projectId, teamId }
|
||||
{ projectId, teamId },
|
||||
toVercelApiError
|
||||
).map((response) => (response.environments || []).map(toVercelCustomEnvironment));
|
||||
}
|
||||
|
||||
@@ -435,13 +447,15 @@ export class VercelIntegrationRepository {
|
||||
projectId: string,
|
||||
teamId?: string | null,
|
||||
): ResultAsync<VercelEnvironmentVariable[], VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: projectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
"Failed to fetch Vercel environment variables",
|
||||
{ projectId, teamId }
|
||||
{ projectId, teamId },
|
||||
toVercelApiError
|
||||
).map((response) => {
|
||||
// Warn if response is paginated (more data exists that we're not fetching)
|
||||
if (
|
||||
@@ -467,13 +481,15 @@ export class VercelIntegrationRepository {
|
||||
/** If provided, only include keys that pass this filter */
|
||||
shouldIncludeKey?: (key: string) => boolean
|
||||
): ResultAsync<VercelEnvironmentVariableValue[], VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: projectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
"Failed to fetch Vercel environment variable values",
|
||||
{ projectId, teamId, target }
|
||||
{ projectId, teamId, target },
|
||||
toVercelApiError
|
||||
).andThen((response) => {
|
||||
// Apply all filters BEFORE decryption to avoid unnecessary API calls
|
||||
const filteredEnvs = extractVercelEnvs(response).filter((env) => {
|
||||
@@ -510,13 +526,14 @@ export class VercelIntegrationRepository {
|
||||
|
||||
// Encrypted vars: fetch decrypted value via individual endpoint
|
||||
// (list endpoint's decrypt param is deprecated)
|
||||
const result = await ResultAsync.fromPromise(
|
||||
const result = await callVercelWithRecovery(
|
||||
client.projects.getProjectEnv({
|
||||
idOrName: projectId,
|
||||
id: env.id,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
(error) => error
|
||||
VercelSchemas.getProjectEnv,
|
||||
{ context: "resolveEnvVarValue" }
|
||||
);
|
||||
|
||||
if (result.isErr()) {
|
||||
@@ -552,13 +569,15 @@ export class VercelIntegrationRepository {
|
||||
isSecret: boolean;
|
||||
target: string[];
|
||||
}>, VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.environment.listSharedEnvVariable({
|
||||
teamId,
|
||||
...(projectId && { projectId }),
|
||||
}),
|
||||
VercelSchemas.listSharedEnvVariable,
|
||||
"Failed to fetch Vercel shared environment variables",
|
||||
{ teamId, projectId }
|
||||
{ teamId, projectId },
|
||||
toVercelApiError
|
||||
).map((response) => {
|
||||
const envVars = response.data || [];
|
||||
return envVars
|
||||
@@ -593,13 +612,15 @@ export class VercelIntegrationRepository {
|
||||
}>,
|
||||
VercelApiError
|
||||
> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.environment.listSharedEnvVariable({
|
||||
teamId,
|
||||
...(projectId && { projectId }),
|
||||
}),
|
||||
VercelSchemas.listSharedEnvVariable,
|
||||
"Failed to fetch Vercel shared environment variable values",
|
||||
{ teamId, projectId }
|
||||
{ teamId, projectId },
|
||||
toVercelApiError
|
||||
).andThen((listResponse) => {
|
||||
const envVars = listResponse.data || [];
|
||||
if (envVars.length === 0) {
|
||||
@@ -635,12 +656,13 @@ export class VercelIntegrationRepository {
|
||||
}
|
||||
|
||||
// Try to get the decrypted value for this shared env var
|
||||
const getResult = await ResultAsync.fromPromise(
|
||||
const getResult = await callVercelWithRecovery(
|
||||
client.environment.getSharedEnvVar({
|
||||
id: envId,
|
||||
teamId,
|
||||
}),
|
||||
(error) => error
|
||||
VercelSchemas.getSharedEnvVar,
|
||||
{ context: "getSharedEnvVar" }
|
||||
);
|
||||
|
||||
if (getResult.isOk()) {
|
||||
@@ -655,47 +677,12 @@ export class VercelIntegrationRepository {
|
||||
};
|
||||
}
|
||||
|
||||
// Workaround: Vercel SDK may throw ResponseValidationError even when the API response
|
||||
// is valid (e.g., deletedAt: null vs expected number). Extract value from rawValue.
|
||||
const error = getResult.error;
|
||||
let errorValue: string | undefined;
|
||||
if (error && typeof error === "object" && "rawValue" in error) {
|
||||
const rawValue = (error as any).rawValue;
|
||||
if (rawValue && typeof rawValue === "object" && "value" in rawValue) {
|
||||
errorValue = rawValue.value as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackValue = errorValue || listValue;
|
||||
|
||||
if (fallbackValue) {
|
||||
logger.warn("getSharedEnvVar failed validation, using value from error.rawValue or list response", {
|
||||
teamId,
|
||||
envId,
|
||||
envKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
hasErrorRawValue: !!errorValue,
|
||||
hasListValue: !!listValue,
|
||||
valueLength: fallbackValue.length,
|
||||
});
|
||||
return {
|
||||
key: envKey,
|
||||
value: fallbackValue,
|
||||
target: normalizeTarget(env.target),
|
||||
type,
|
||||
isSecret,
|
||||
applyToAllCustomEnvironments: applyToAllCustomEnvs,
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn("Failed to get decrypted value for shared env var, no fallback available", {
|
||||
logger.warn("Failed to get decrypted value for shared env var", {
|
||||
teamId,
|
||||
projectId,
|
||||
envId,
|
||||
envKey,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
hasRawValue: error && typeof error === "object" && "rawValue" in error,
|
||||
error: getResult.error instanceof Error ? getResult.error.message : String(getResult.error),
|
||||
});
|
||||
return null;
|
||||
})
|
||||
@@ -723,11 +710,18 @@ export class VercelIntegrationRepository {
|
||||
let from: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await client.projects.getProjects({
|
||||
...(teamId && { teamId }),
|
||||
limit: "100",
|
||||
...(from && { from }),
|
||||
});
|
||||
const response = await callVercelWithRecovery(
|
||||
client.projects.getProjects({
|
||||
...(teamId && { teamId }),
|
||||
limit: "100",
|
||||
...(from && { from }),
|
||||
}),
|
||||
VercelSchemas.getProjects,
|
||||
{ context: "getVercelProjects" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const projects = Array.isArray(response)
|
||||
? response
|
||||
@@ -975,6 +969,13 @@ export class VercelIntegrationRepository {
|
||||
return { created: 0, updated: 0, errors: [] as string[] };
|
||||
}
|
||||
|
||||
await this.removeAllVercelEnvVarsByKey({
|
||||
client,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
teamId: params.teamId,
|
||||
key: "TRIGGER_SECRET_KEY",
|
||||
});
|
||||
|
||||
const result = await this.batchUpsertVercelEnvVars({
|
||||
client,
|
||||
vercelProjectId: params.vercelProjectId,
|
||||
@@ -1081,6 +1082,111 @@ export class VercelIntegrationRepository {
|
||||
);
|
||||
}
|
||||
|
||||
static upsertEnvVarForCustomEnvironment(params: {
|
||||
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
|
||||
vercelProjectId: string;
|
||||
teamId: string | null;
|
||||
key: string;
|
||||
value: string;
|
||||
customEnvironmentId: string;
|
||||
type: "sensitive" | "encrypted" | "plain";
|
||||
}): ResultAsync<void, VercelApiError> {
|
||||
return this.getVercelClient(params.orgIntegration).andThen((client) =>
|
||||
ResultAsync.fromPromise(
|
||||
(async () => {
|
||||
const { vercelProjectId, teamId, key, value, customEnvironmentId, type } = params;
|
||||
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "upsertEnvVarForCustomEnvironment" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
|
||||
const existingEnv = envs.find((env) => {
|
||||
if (env.key !== key) return false;
|
||||
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
|
||||
});
|
||||
|
||||
if (existingEnv && existingEnv.id) {
|
||||
await client.projects.editProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
id: existingEnv.id,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: {
|
||||
value,
|
||||
type,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await client.projects.createProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: {
|
||||
key,
|
||||
value,
|
||||
type,
|
||||
customEnvironmentIds: [customEnvironmentId],
|
||||
} as any,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
(error) => toVercelApiError(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static removeEnvVarForCustomEnvironment(params: {
|
||||
orgIntegration: OrganizationIntegration & { tokenReference: SecretReference };
|
||||
vercelProjectId: string;
|
||||
teamId: string | null;
|
||||
key: string;
|
||||
customEnvironmentId: string;
|
||||
}): ResultAsync<void, VercelApiError> {
|
||||
return this.getVercelClient(params.orgIntegration).andThen((client) =>
|
||||
ResultAsync.fromPromise(
|
||||
(async () => {
|
||||
const { vercelProjectId, teamId, key, customEnvironmentId } = params;
|
||||
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "removeEnvVarForCustomEnvironment" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
|
||||
const existingEnv = envs.find((env) => {
|
||||
if (env.key !== key) return false;
|
||||
return (env as any).customEnvironmentIds?.includes(customEnvironmentId);
|
||||
});
|
||||
|
||||
if (existingEnv && existingEnv.id) {
|
||||
await client.projects.batchRemoveProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: { ids: [existingEnv.id] },
|
||||
});
|
||||
}
|
||||
})(),
|
||||
(error) => toVercelApiError(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
static pullEnvVarsFromVercel(params: {
|
||||
projectId: string;
|
||||
vercelProjectId: string;
|
||||
@@ -1188,7 +1294,7 @@ export class VercelIntegrationRepository {
|
||||
);
|
||||
|
||||
if (envVarsResult.isErr()) {
|
||||
logger.error("pullEnvVarsFromVercel: Failed to get env vars", {
|
||||
logger.warn("pullEnvVarsFromVercel: Failed to get env vars", {
|
||||
triggerEnvType: mapping.triggerEnvType,
|
||||
vercelTarget: mapping.vercelTarget,
|
||||
error: envVarsResult.error.message,
|
||||
@@ -1409,10 +1515,17 @@ export class VercelIntegrationRepository {
|
||||
return { created: 0, updated: 0, errors: [] };
|
||||
}
|
||||
|
||||
const existingEnvs = await client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
});
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "batchUpsertVercelEnvVars" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const existingEnvsList = extractVercelEnvs(existingEnvs);
|
||||
|
||||
@@ -1526,6 +1639,42 @@ export class VercelIntegrationRepository {
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
private static async removeAllVercelEnvVarsByKey(params: {
|
||||
client: Vercel;
|
||||
vercelProjectId: string;
|
||||
teamId: string | null;
|
||||
key: string;
|
||||
}): Promise<void> {
|
||||
const { client, vercelProjectId, teamId, key } = params;
|
||||
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "removeAllVercelEnvVarsByKey" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
const idsToRemove = envs
|
||||
.filter((env) => env.key === key && env.id)
|
||||
.map((env) => env.id!);
|
||||
|
||||
if (idsToRemove.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.projects.batchRemoveProjectEnv({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: { ids: idsToRemove },
|
||||
});
|
||||
}
|
||||
|
||||
private static async upsertVercelEnvVar(params: {
|
||||
client: Vercel;
|
||||
vercelProjectId: string;
|
||||
@@ -1537,10 +1686,17 @@ export class VercelIntegrationRepository {
|
||||
}): Promise<void> {
|
||||
const { client, vercelProjectId, teamId, key, value, target, type } = params;
|
||||
|
||||
const existingEnvs = await client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
});
|
||||
const existingEnvs = await callVercelWithRecovery(
|
||||
client.projects.filterProjectEnvs({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
}),
|
||||
VercelSchemas.filterProjectEnvs,
|
||||
{ context: "upsertVercelEnvVar" }
|
||||
).match(
|
||||
(val) => val,
|
||||
(err) => { throw err; }
|
||||
);
|
||||
|
||||
const envs = extractVercelEnvs(existingEnvs);
|
||||
|
||||
@@ -1584,14 +1740,16 @@ export class VercelIntegrationRepository {
|
||||
teamId?: string | null
|
||||
): ResultAsync<boolean | null, VercelApiError> {
|
||||
// Vercel SDK lacks a getProject method — updateProject with empty body reads without modifying.
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.updateProject({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
requestBody: {},
|
||||
}),
|
||||
VercelSchemas.updateProject,
|
||||
"Failed to get Vercel project autoAssignCustomDomains",
|
||||
{ vercelProjectId, teamId }
|
||||
{ vercelProjectId, teamId },
|
||||
toVercelApiError
|
||||
).map((project) => project.autoAssignCustomDomains ?? null);
|
||||
}
|
||||
|
||||
@@ -1601,7 +1759,7 @@ export class VercelIntegrationRepository {
|
||||
vercelProjectId: string,
|
||||
teamId?: string | null
|
||||
): ResultAsync<void, VercelApiError> {
|
||||
return wrapVercelCall(
|
||||
return wrapVercelCallWithRecovery(
|
||||
client.projects.updateProject({
|
||||
idOrName: vercelProjectId,
|
||||
...(teamId && { teamId }),
|
||||
@@ -1609,8 +1767,10 @@ export class VercelIntegrationRepository {
|
||||
autoAssignCustomDomains: false,
|
||||
},
|
||||
}),
|
||||
VercelSchemas.updateProject,
|
||||
"Failed to disable autoAssignCustomDomains",
|
||||
{ vercelProjectId, teamId }
|
||||
{ vercelProjectId, teamId },
|
||||
toVercelApiError
|
||||
).map(() => undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { z } from "zod";
|
||||
import { ResultAsync, okAsync, errAsync } from "neverthrow";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import type { VercelApiError } from "./vercelIntegration.server";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recovery utilities for Vercel SDK validation errors
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The Vercel SDK (Speakeasy-generated) validates API responses with strict Zod
|
||||
// schemas. When the API returns valid data but a field doesn't match the SDK's
|
||||
// type (e.g., `deletedAt: null` vs `number`), a `ResponseValidationError` is
|
||||
// thrown — even though the response contains all the data we need.
|
||||
//
|
||||
// Error hierarchy:
|
||||
// VercelError.body → raw HTTP body text (HTTP errors — never recover)
|
||||
// ResponseValidationError.rawValue → parsed JSON that failed validation
|
||||
// SDKValidationError.rawValue → same pattern, different base class
|
||||
//
|
||||
// Recovery: gate on validation error type → extract rawValue → validate → return.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Only attempt recovery for SDK validation errors — not HTTP errors (401/403).
|
||||
*
|
||||
* ResponseValidationError and SDKValidationError both carry `rawValue` with the
|
||||
* parsed JSON that failed schema validation. VercelError (HTTP errors) carries
|
||||
* `body` instead — we must NOT recover from those since the response is an error
|
||||
* payload, not the data we asked for.
|
||||
*/
|
||||
function isValidationError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
return (
|
||||
error.constructor.name === "ResponseValidationError" ||
|
||||
error.constructor.name === "SDKValidationError" ||
|
||||
"rawValue" in error
|
||||
);
|
||||
}
|
||||
|
||||
function extractRawValue(error: unknown): unknown | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
if ("rawValue" in error) {
|
||||
return (error as { rawValue: unknown }).rawValue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to recover usable data from a Vercel SDK error.
|
||||
*
|
||||
* Returns the validated data on success, or `undefined` if recovery fails.
|
||||
*/
|
||||
export function recoverFromVercelSdkError<T>(
|
||||
error: unknown,
|
||||
schema: z.ZodType<any>,
|
||||
options?: { context?: string }
|
||||
): T | undefined {
|
||||
if (!isValidationError(error)) return undefined;
|
||||
|
||||
const raw = extractRawValue(error);
|
||||
if (raw === undefined) return undefined;
|
||||
|
||||
const result = schema.safeParse(raw);
|
||||
if (!result.success) return undefined;
|
||||
|
||||
logger.warn("Recovered data from Vercel SDK validation error", {
|
||||
context: options?.context,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorType: error?.constructor?.name,
|
||||
});
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a Vercel SDK promise with automatic recovery on validation errors.
|
||||
*
|
||||
* On success: returns the SDK result as-is.
|
||||
* On error: attempts recovery via rawValue + schema validation (validation errors only).
|
||||
*/
|
||||
export function callVercelWithRecovery<T>(
|
||||
sdkCall: Promise<T>,
|
||||
schema: z.ZodType<any>,
|
||||
options?: { context?: string }
|
||||
): ResultAsync<T, unknown> {
|
||||
return ResultAsync.fromPromise(sdkCall, (error) => error).orElse((error) => {
|
||||
const recovered = recoverFromVercelSdkError<T>(error, schema, options);
|
||||
if (recovered !== undefined) {
|
||||
return okAsync(recovered);
|
||||
}
|
||||
return errAsync(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for `wrapVercelCall` with SDK error recovery.
|
||||
*
|
||||
* Wraps a Vercel SDK promise in ResultAsync with structured error logging,
|
||||
* attempting to recover from validation errors before treating as failure.
|
||||
*/
|
||||
export function wrapVercelCallWithRecovery<T>(
|
||||
promise: Promise<T>,
|
||||
schema: z.ZodType<any>,
|
||||
message: string,
|
||||
context: Record<string, unknown>,
|
||||
toError: (error: unknown) => VercelApiError
|
||||
): ResultAsync<T, VercelApiError> {
|
||||
return callVercelWithRecovery(promise, schema, { context: message }).mapErr((error) => {
|
||||
const apiError = toError(error);
|
||||
logger.error(message, { ...context, error, authInvalid: apiError.authInvalid });
|
||||
return apiError;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal Zod schemas — validate only the fields we actually use.
|
||||
// All use .passthrough() to preserve extra fields from the API response.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VercelSchemas = {
|
||||
getTeam: z.object({ slug: z.string() }).passthrough(),
|
||||
|
||||
getAuthUser: z
|
||||
.object({ user: z.object({ username: z.string() }).passthrough() })
|
||||
.passthrough(),
|
||||
|
||||
getCustomEnvironments: z
|
||||
.object({
|
||||
environments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
slug: z.string(),
|
||||
description: z.string().optional(),
|
||||
branchMatcher: z.unknown().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
|
||||
filterProjectEnvs: z
|
||||
.union([
|
||||
z
|
||||
.object({
|
||||
envs: z.array(z.record(z.unknown())),
|
||||
pagination: z.unknown().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
z.array(z.record(z.unknown())),
|
||||
])
|
||||
.transform((val) => (Array.isArray(val) ? { envs: val } : val)),
|
||||
|
||||
getProjectEnv: z.object({ key: z.string(), value: z.string().optional() }).passthrough(),
|
||||
|
||||
getProjects: z.union([
|
||||
z.array(z.object({ id: z.string(), name: z.string() }).passthrough()),
|
||||
z
|
||||
.object({
|
||||
projects: z.array(
|
||||
z.object({ id: z.string(), name: z.string() }).passthrough()
|
||||
),
|
||||
pagination: z.unknown().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
]),
|
||||
|
||||
listSharedEnvVariable: z
|
||||
.object({
|
||||
data: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
target: z.unknown().optional(),
|
||||
value: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
|
||||
getSharedEnvVar: z.object({ value: z.string().optional() }).passthrough(),
|
||||
|
||||
updateProject: z
|
||||
.object({ id: z.string(), name: z.string(), autoAssignCustomDomains: z.boolean().optional() })
|
||||
.passthrough(),
|
||||
} as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user