Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 84a00b6eb6 | |||
| 506b161751 | |||
| 1ec7722690 | |||
| 2825a200e6 | |||
| eb0f963393 | |||
| 59b6eb9a3e | |||
| b793f33e59 | |||
| 6e0b54b081 | |||
| 4c986ad1eb | |||
| 7af789bc3e | |||
| 72ce6a8300 | |||
| 921285ca32 | |||
| 667a93ca3c | |||
| 4dfa65809a | |||
| d4cd34094e | |||
| 796ad29386 | |||
| b4e08bddec | |||
| 5d6085dcff | |||
| 1d744fa3c6 | |||
| a3d3b17df4 | |||
| 35e11e09a3 | |||
| 9030e94362 | |||
| 2462c80c8a | |||
| a8f9a90280 | |||
| 4b7f67604a | |||
| bfe417f679 | |||
| ba320a4bdb | |||
| bc0d1ff59a | |||
| 062bcaece8 | |||
| c2085e6cc6 | |||
| d7bc37fdc0 | |||
| 6e3ac8bd91 | |||
| 170fde3498 | |||
| ddeb9c415e | |||
| 2feecece88 | |||
| 48a96efbdc | |||
| ebffa1039c | |||
| bc63edd6bf | |||
| eaed7d0ba4 | |||
| 9b21f8d322 | |||
| e536d35b17 | |||
| b96a0b70d4 | |||
| 3bb9aac014 | |||
| 283f88b203 | |||
| c55af7bead | |||
| db4fb9eeef | |||
| c0595700f8 | |||
| 6a45f5623b | |||
| 104f720f6f | |||
| e017913021 | |||
| 7781e2aad1 | |||
| 8e0034484c | |||
| b72cacc671 | |||
| 1ccb8c186f | |||
| 279102c17c | |||
| b221719c09 | |||
| e6861f4fe4 | |||
| bc7ce78103 | |||
| 9937823a7f | |||
| 3925f8cc49 | |||
| 01208fde27 | |||
| 5e049cde3a | |||
| 72c357125b | |||
| 0674d74bbb | |||
| 9e08712749 | |||
| f53db6fd16 | |||
| c0b86efbd3 | |||
| e29e1c86d9 | |||
| 34203d6a6f | |||
| d4e4fbd7fc | |||
| 49de105862 | |||
| 5fb9cc36bc | |||
| 70c8d6d14b | |||
| eeab6bdeac | |||
| 825219a2f4 | |||
| b143027d95 | |||
| 409388365e | |||
| fe5178f3e8 | |||
| a3f1eb2361 | |||
| ab4b50b95a | |||
| 1859fd0283 |
@@ -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).
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Vouch Request
|
||||
description: Request to be vouched as a contributor
|
||||
labels: ["vouch-request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Vouch Request
|
||||
|
||||
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. PRs from unvouched users are automatically closed.
|
||||
|
||||
To get vouched, fill out this form. A maintainer will review your request and vouch for you by commenting on this issue.
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Why do you want to contribute?
|
||||
description: Tell us a bit about yourself and what you'd like to work on.
|
||||
placeholder: "I'd like to fix a bug I found in..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: prior-work
|
||||
attributes:
|
||||
label: Prior contributions or relevant experience
|
||||
description: Links to previous open source work, relevant projects, or anything that helps us understand your background.
|
||||
placeholder: "https://github.com/..."
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,16 @@
|
||||
# Vouched contributors for Trigger.dev
|
||||
# See: https://github.com/mitchellh/vouch
|
||||
#
|
||||
# Org members
|
||||
0ski
|
||||
D-K-P
|
||||
ericallam
|
||||
matt-aitken
|
||||
mpcgrid
|
||||
myftija
|
||||
nicktrn
|
||||
samejr
|
||||
isshaddad
|
||||
# Outside contributors
|
||||
gautamsi
|
||||
capaj
|
||||
@@ -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.
|
||||
@@ -29,3 +29,7 @@ jobs:
|
||||
with:
|
||||
package: cli-v3
|
||||
secrets: inherit
|
||||
|
||||
sdk-compat:
|
||||
uses: ./.github/workflows/sdk-compat.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -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,7 +122,19 @@ jobs:
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# this triggers the publish workflow for the docker images
|
||||
- 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: |
|
||||
@@ -130,6 +142,73 @@ jobs:
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# Trigger Docker builds directly via workflow_call since tags pushed with
|
||||
# GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation).
|
||||
publish-docker:
|
||||
name: 🐳 Publish Docker images
|
||||
needs: release
|
||||
if: needs.release.outputs.published == 'true'
|
||||
uses: ./.github/workflows/publish.yml
|
||||
secrets: inherit
|
||||
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}" --json body --jq '.body' > /tmp/release-body.md
|
||||
sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md
|
||||
|
||||
gh release edit "${TAG}" --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
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
name: "🔌 SDK Compatibility Tests"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
node-compat:
|
||||
name: "Node.js ${{ matrix.node }} (${{ matrix.os }})"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: ["20.20", "22.12"]
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk^...'
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk'
|
||||
|
||||
- name: 🧪 Run SDK Compatibility Tests
|
||||
shell: bash
|
||||
run: pnpm --filter @internal/sdk-compat-tests test
|
||||
|
||||
bun-compat:
|
||||
name: "Bun Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🥟 Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🧪 Run Bun Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun
|
||||
run: bun run test.ts
|
||||
|
||||
deno-compat:
|
||||
name: "Deno Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🦕 Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🔗 Link node_modules for Deno fixture
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: ln -s ../../../../../node_modules node_modules
|
||||
|
||||
- name: 🧪 Run Deno Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: deno run --allow-read --allow-env --allow-sys test.ts
|
||||
|
||||
cloudflare-compat:
|
||||
name: "Cloudflare Workers"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 📥 Install Cloudflare fixture deps
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: pnpm install
|
||||
|
||||
- name: 🧪 Run Cloudflare Workers Compatibility Test (dry-run)
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: npx wrangler deploy --dry-run --outdir dist
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Vouch - Check PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: read
|
||||
|
||||
jobs:
|
||||
check-pr:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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 }}
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Vouch - Manage by Issue
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
manage:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
contains(github.event.comment.body, 'vouch') ||
|
||||
contains(github.event.comment.body, 'denounce') ||
|
||||
contains(github.event.comment.body, 'unvouch')
|
||||
steps:
|
||||
- uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2
|
||||
with:
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
issue-id: ${{ github.event.issue.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
+5
-1
@@ -15,6 +15,9 @@ out/
|
||||
dist
|
||||
packages/**/dist
|
||||
|
||||
# vendored bundles (generated during build)
|
||||
packages/**/src/**/vendor
|
||||
|
||||
# Tailwind
|
||||
apps/**/styles/tailwind.css
|
||||
packages/**/styles/tailwind.css
|
||||
@@ -64,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
|
||||
```
|
||||
Vendored
+9
@@ -31,6 +31,15 @@
|
||||
"cwd": "${workspaceFolder}/apps/webapp",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "Debug opened test file",
|
||||
"command": "pnpm run test -- ./${relativeFile}",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"sourceMaps": true
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
|
||||
Vendored
+1
-1
@@ -7,5 +7,5 @@
|
||||
"packages/cli-v3/e2e": true
|
||||
},
|
||||
"vitest.disableWorkspaceWarning": true,
|
||||
"typescript.experimental.useTsgo": false
|
||||
"chat.agent.maxRequests": 10000
|
||||
}
|
||||
|
||||
+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,47 @@
|
||||
# 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
|
||||
## 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 }) => {
|
||||
/* ... */
|
||||
});
|
||||
redisTest("should use redis", async ({ redisOptions }) => { /* ... */ });
|
||||
postgresTest("should use postgres", async ({ prisma }) => { /* ... */ });
|
||||
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 +51,112 @@ 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
|
||||
|
||||
- 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
|
||||
|
||||
### Key Project Refs
|
||||
|
||||
- hello-world: `proj_rrkpdguyagvsoktglnod`
|
||||
Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs
|
||||
|
||||
+49
-1
@@ -2,10 +2,25 @@
|
||||
|
||||
Thank you for taking the time to contribute to Trigger.dev. Your involvement is not just welcomed, but we encourage it! 🚀
|
||||
|
||||
Please take some time to read this guide to understand contributing best practices for Trigger.dev.
|
||||
Please take some time to read this guide to understand contributing best practices for Trigger.dev. Note that we use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust, so you'll need to be vouched before opening a PR.
|
||||
|
||||
Thank you for helping us make Trigger.dev even better! 🤩
|
||||
|
||||
> **Important:** We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one.
|
||||
|
||||
## Getting vouched (required before opening a PR)
|
||||
|
||||
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. **PRs from unvouched users are automatically closed.**
|
||||
|
||||
Before you open your first pull request, you need to be vouched by a maintainer. Here's how:
|
||||
|
||||
1. Open a [Vouch Request](https://github.com/triggerdotdev/trigger.dev/issues/new?template=vouch-request.yml) issue.
|
||||
2. Tell us what you'd like to work on and share any relevant background.
|
||||
3. A maintainer will review your request and vouch for you by commenting on the issue.
|
||||
4. Once vouched, your PRs will be accepted normally.
|
||||
|
||||
If you're unsure whether you're already vouched, go ahead and open a PR — the check will tell you.
|
||||
|
||||
## Developing
|
||||
|
||||
The development branch is `main`. This is the branch that all pull
|
||||
@@ -252,6 +267,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
|
||||
|
||||
@@ -35,7 +35,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter coordinator build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter coordinator build:bundle
|
||||
|
||||
FROM alpine AS cri-tools
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter docker-provider build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter docker-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter kubernetes-provider build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter kubernetes-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -112,6 +112,11 @@ const Env = z.object({
|
||||
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
|
||||
KUBERNETES_LARGE_MACHINE_POOL_LABEL: z.string().optional(), // if set, large-* presets affinity for machinepool=<value>
|
||||
|
||||
// Project affinity settings - pods from the same project prefer the same node
|
||||
KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false),
|
||||
KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50),
|
||||
KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"),
|
||||
|
||||
// Placement tags settings
|
||||
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
|
||||
PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"),
|
||||
|
||||
@@ -120,7 +120,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
},
|
||||
spec: {
|
||||
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
|
||||
affinity: this.#getNodeAffinity(opts.machine),
|
||||
affinity: this.#getAffinity(opts.machine, opts.projectId),
|
||||
terminationGracePeriodSeconds: 60 * 60,
|
||||
containers: [
|
||||
{
|
||||
@@ -390,7 +390,21 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
return preset.name.startsWith("large-");
|
||||
}
|
||||
|
||||
#getNodeAffinity(preset: MachinePreset): k8s.V1Affinity | undefined {
|
||||
#getAffinity(preset: MachinePreset, projectId: string): k8s.V1Affinity | undefined {
|
||||
const nodeAffinity = this.#getNodeAffinityRules(preset);
|
||||
const podAffinity = this.#getProjectPodAffinity(projectId);
|
||||
|
||||
if (!nodeAffinity && !podAffinity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(nodeAffinity && { nodeAffinity }),
|
||||
...(podAffinity && { podAffinity }),
|
||||
};
|
||||
}
|
||||
|
||||
#getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined {
|
||||
if (!env.KUBERNETES_LARGE_MACHINE_POOL_LABEL) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -398,42 +412,64 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
if (this.#isLargeMachine(preset)) {
|
||||
// soft preference for the large-machine pool, falls back to standard if unavailable
|
||||
return {
|
||||
nodeAffinity: {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: 100,
|
||||
preference: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "In",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: 100,
|
||||
preference: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "In",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// not schedulable in the large-machine pool
|
||||
return {
|
||||
nodeAffinity: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined {
|
||||
if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT,
|
||||
podAffinityTerm: {
|
||||
labelSelector: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "project",
|
||||
operator: "In",
|
||||
values: [projectId],
|
||||
},
|
||||
],
|
||||
},
|
||||
topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Webapp
|
||||
|
||||
Remix 2.1.0 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`).
|
||||
|
||||
## 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.
|
||||
@@ -30,3 +30,32 @@ export function AlphaTitle({ children }: { children: React.ReactNode }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaBadge({
|
||||
inline = false,
|
||||
className,
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
|
||||
Beta
|
||||
</Badge>
|
||||
}
|
||||
content="This feature is in Beta."
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<BetaBadge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HandThumbUpIcon,
|
||||
StopIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import DOMPurify from "dompurify";
|
||||
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
|
||||
return routeMatch?.kapa.websiteId;
|
||||
}
|
||||
|
||||
export function AskAI() {
|
||||
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const websiteId = useKapaWebsiteId();
|
||||
|
||||
@@ -54,21 +55,23 @@ export function AskAI() {
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
disabled
|
||||
className={isCollapsed ? "w-full justify-center" : ""}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{() => <AskAIProvider websiteId={websiteId} />}
|
||||
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIProviderProps = {
|
||||
websiteId: string;
|
||||
isCollapsed?: boolean;
|
||||
};
|
||||
|
||||
function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -112,28 +115,39 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
}}
|
||||
botProtectionMechanism="hcaptcha"
|
||||
>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
|
||||
Ask AI
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
fullWidth={isCollapsed}
|
||||
className={cn("h-full", isCollapsed && "justify-center")}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
Ask AI
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
<AskAIDialog
|
||||
initialQuery={initialQuery}
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -599,9 +599,9 @@ function DeploymentOnboardingSteps() {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
<div className="mb-2 flex min-w-0 items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8 shrink-0" />
|
||||
<Header1 className="truncate">Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
|
||||
@@ -32,8 +32,6 @@ export function OctoKitty({ className }: { className?: string }) {
|
||||
baseProfile="tiny"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 2350 2314.8"
|
||||
xmlSpace="preserve"
|
||||
fill="currentColor"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { LogLevel } from "./logs/LogLevel";
|
||||
|
||||
export function LogLevelTooltipInfo() {
|
||||
return (
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
|
||||
<div>
|
||||
<Header3>Log Levels</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Structured logging helps you debug and monitor your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="TRACE" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Traces and spans representing the execution flow of your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="INFO" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
General informational messages about task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="WARN" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Warning messages indicating potential issues that don't prevent execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="ERROR" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Error messages for failures and exceptions during task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="DEBUG" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Detailed diagnostic information for development and debugging.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetTrigger
|
||||
} from "./primitives/SheetV3";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
|
||||
export function Shortcuts() {
|
||||
return (
|
||||
@@ -26,8 +25,8 @@ export function Shortcuts() {
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
|
||||
className="gap-x-0 pl-0.5"
|
||||
iconSpacing="gap-x-0.5"
|
||||
className="gap-x-0 pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
@@ -77,11 +76,16 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Ask AI">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter">
|
||||
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Toggle side menu">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select filter">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
@@ -135,8 +139,8 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to next/previous run">
|
||||
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "j" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "k" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
@@ -158,6 +162,43 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Logs page</Header3>
|
||||
<Shortcut name="Filter by task">
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by run ID">
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by level">
|
||||
<ShortcutKey shortcut={{ key: "l" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select log level">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "4" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Close detail panel">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Details tab">
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Run tab">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="View full run">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Metrics page</Header3>
|
||||
<Shortcut name="Toggle fullscreen chart">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
<Shortcut name="New schedule">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import type { loader } from "~/root";
|
||||
|
||||
export function TimezoneSetter() {
|
||||
const { timezone: storedTimezone } = useTypedLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
const hasSetTimezone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSetTimezone.current) return;
|
||||
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
if (browserTimezone && browserTimezone !== storedTimezone) {
|
||||
hasSetTimezone.current = true;
|
||||
fetcher.submit(
|
||||
{ timezone: browserTimezone },
|
||||
{
|
||||
method: "POST",
|
||||
action: "/resources/timezone",
|
||||
encType: "application/json",
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [storedTimezone, fetcher]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
// Lazy load streamdown components to avoid SSR issues
|
||||
const StreamdownRenderer = lazy(() =>
|
||||
@@ -13,13 +19,6 @@ const StreamdownRenderer = lazy(() =>
|
||||
),
|
||||
}))
|
||||
);
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
@@ -179,21 +178,7 @@ export function AIQueryInput({
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
if (event.tool === "setTimeFilter") {
|
||||
setThinking((prev) => {
|
||||
if (prev.trimEnd().endsWith("Setting time filter...")) {
|
||||
return prev;
|
||||
}
|
||||
return prev + `\nSetting time filter...\n`;
|
||||
});
|
||||
} else {
|
||||
setThinking((prev) => {
|
||||
if (prev.trimEnd().endsWith("Validating query...")) {
|
||||
return prev;
|
||||
}
|
||||
return prev + `\nValidating query...\n`;
|
||||
});
|
||||
}
|
||||
// Tool calls are handled silently — no UI text needed
|
||||
break;
|
||||
case "time_filter":
|
||||
// Apply time filter immediately when the AI sets it
|
||||
@@ -262,13 +247,13 @@ export function AIQueryInput({
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col">
|
||||
{/* Gradient border wrapper like the schedules AI input */}
|
||||
<div
|
||||
className="rounded-md p-px"
|
||||
className="overflow-hidden rounded-md p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-[5px] bg-background-bright">
|
||||
<div className="overflow-hidden rounded-md bg-background-bright">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -297,10 +282,10 @@ export function AIQueryInput({
|
||||
variant="tertiary/small"
|
||||
disabled={true}
|
||||
LeadingIcon={Spinner}
|
||||
className="pl-1.5"
|
||||
className="pl-2"
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
{mode === "edit" ? "Editing..." : "Generating..."}
|
||||
{mode === "edit" ? "Editing…" : "Generating…"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
@@ -366,64 +351,60 @@ export function AIQueryInput({
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading ? (
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 0.3)",
|
||||
foreground: "rgba(99, 102, 241, 1)",
|
||||
}}
|
||||
className="size-3"
|
||||
/>
|
||||
) : lastResult === "success" ? (
|
||||
<div className="size-3 rounded-full bg-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<div className="size-3 rounded-full bg-error" />
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-dimmed">
|
||||
{isLoading
|
||||
? "AI is thinking..."
|
||||
: lastResult === "success"
|
||||
<div className="px-1">
|
||||
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-charcoal-850 p-3 pb-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : lastResult === "success" ? (
|
||||
<CheckIcon className="size-4 text-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<XMarkIcon className="size-4 text-error" />
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-dimmed">
|
||||
{isLoading
|
||||
? "AI is thinking…"
|
||||
: lastResult === "success"
|
||||
? "Query generated"
|
||||
: lastResult === "error"
|
||||
? "Generation failed"
|
||||
: "AI response"}
|
||||
</span>
|
||||
? "Generation failed"
|
||||
: "AI response"}
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
||||
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
||||
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { BarChart, LineChart, Plus, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Header3 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { Select, SelectItem } from "../primitives/Select";
|
||||
import { Switch } from "../primitives/Switch";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
|
||||
export type ChartType = "bar" | "line";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
|
||||
|
||||
export interface ChartConfiguration {
|
||||
chartType: ChartType;
|
||||
xAxisColumn: string | null;
|
||||
yAxisColumns: string[];
|
||||
groupByColumn: string | null;
|
||||
stacked: boolean;
|
||||
sortByColumn: string | null;
|
||||
sortDirection: SortDirection;
|
||||
aggregation: AggregationType;
|
||||
}
|
||||
import {
|
||||
type AggregationType,
|
||||
type ChartConfiguration,
|
||||
type SortDirection,
|
||||
} from "../metrics/QueryWidget";
|
||||
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
|
||||
|
||||
export const defaultChartConfig: ChartConfiguration = {
|
||||
chartType: "bar",
|
||||
@@ -32,6 +25,7 @@ export const defaultChartConfig: ChartConfiguration = {
|
||||
sortByColumn: null,
|
||||
sortDirection: "asc",
|
||||
aggregation: "sum",
|
||||
seriesColors: {},
|
||||
};
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
@@ -155,8 +149,11 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
if (needsUpdate) {
|
||||
onChangeRef.current({ ...currentConfig, ...updates });
|
||||
}
|
||||
// Only re-run when the actual column structure changes, not on every config change
|
||||
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
|
||||
// Only re-run when the actual column structure changes, not on every config change.
|
||||
// columnsKey (a string) is stable when columns match, so this won't re-fire
|
||||
// unnecessarily when the same query is re-run with identical columns.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnsKey]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfiguration>) => {
|
||||
@@ -239,54 +236,38 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2 p-2", className)}>
|
||||
<div className={cn("flex flex-col gap-3 p-2", className)}>
|
||||
{/* Chart Type */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<ConfigField label="Type">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-r-none border-b pl-1 pr-2",
|
||||
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "bar" })}
|
||||
LeadingIcon={BarChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
|
||||
Bar
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
className={cn(
|
||||
"rounded-l-none border-b pl-1 pr-2",
|
||||
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
|
||||
)}
|
||||
iconSpacing="gap-x-1"
|
||||
onClick={() => updateConfig({ chartType: "line" })}
|
||||
LeadingIcon={LineChart}
|
||||
leadingIconClassName={
|
||||
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
|
||||
>
|
||||
Line
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
name="chartType"
|
||||
value={config.chartType}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart className="size-3" /> Bar
|
||||
</span>
|
||||
),
|
||||
value: "bar",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<LineChart className="size-3" /> Line
|
||||
</span>
|
||||
),
|
||||
value: "line",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* X-Axis */}
|
||||
<ConfigField label="X-Axis">
|
||||
<Select
|
||||
@@ -329,60 +310,86 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
|
||||
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map(
|
||||
(col, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<Select
|
||||
value={col}
|
||||
setValue={(value) => {
|
||||
const newColumns = [...config.yAxisColumns];
|
||||
if (value) {
|
||||
// If this is a new slot (empty string), add it
|
||||
if (index >= config.yAxisColumns.length) {
|
||||
newColumns.push(value);
|
||||
} else {
|
||||
newColumns[index] = value;
|
||||
}
|
||||
} else if (index < config.yAxisColumns.length) {
|
||||
newColumns.splice(index, 1);
|
||||
}
|
||||
updateConfig({ yAxisColumns: newColumns });
|
||||
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
{col && !config.groupByColumn && (
|
||||
<SeriesColorPicker
|
||||
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
|
||||
onColorChange={(color) => {
|
||||
updateConfig({
|
||||
seriesColors: { ...config.seriesColors, [col]: color },
|
||||
});
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={yAxisOptions.filter(
|
||||
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
|
||||
)}
|
||||
dropdownIcon
|
||||
className="min-w-[140px] flex-1"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={col}
|
||||
setValue={(value) => {
|
||||
const newColumns = [...config.yAxisColumns];
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
if (value) {
|
||||
// If this is a new slot (empty string), add it
|
||||
if (index >= config.yAxisColumns.length) {
|
||||
newColumns.push(value);
|
||||
} else {
|
||||
// If the column name changed, migrate the color
|
||||
const oldCol = newColumns[index];
|
||||
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
newSeriesColors[value] = newSeriesColors[oldCol];
|
||||
delete newSeriesColors[oldCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
newColumns[index] = value;
|
||||
}
|
||||
} else if (index < config.yAxisColumns.length) {
|
||||
newColumns.splice(index, 1);
|
||||
}
|
||||
</Select>
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
|
||||
updateConfig({ yAxisColumns: newColumns });
|
||||
}}
|
||||
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="Remove series"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
updateConfig({ ...updates, yAxisColumns: newColumns });
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={yAxisOptions.filter(
|
||||
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
dropdownIcon
|
||||
className="min-w-[140px] flex-1"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const removedCol = config.yAxisColumns[index];
|
||||
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
|
||||
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
|
||||
// Clean up the color entry for the removed series
|
||||
if (removedCol && config.seriesColors?.[removedCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
delete newSeriesColors[removedCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="Remove series"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add another series button - only show when we have at least one series and not grouped */}
|
||||
{config.yAxisColumns.length > 0 &&
|
||||
@@ -439,9 +446,7 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
{/* Group By - disabled when multiple series are selected */}
|
||||
<ConfigField label="Group by">
|
||||
{config.yAxisColumns.length > 1 ? (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
Not available with multiple series
|
||||
</span>
|
||||
<span className="text-xs text-text-dimmed">Not available with multiple series</span>
|
||||
) : (
|
||||
<Select
|
||||
value={config.groupByColumn ?? "__none__"}
|
||||
@@ -510,9 +515,29 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
{/* Sort Direction (only when sorting) */}
|
||||
{config.sortByColumn && (
|
||||
<ConfigField label="Sort direction">
|
||||
<SortDirectionToggle
|
||||
direction={config.sortDirection}
|
||||
onChange={(direction) => updateConfig({ sortDirection: direction })}
|
||||
<SegmentedControl
|
||||
name="sortDirection"
|
||||
value={config.sortDirection}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortAscending className="size-3" /> Asc
|
||||
</span>
|
||||
),
|
||||
value: "asc",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortDescending className="size-3" /> Desc
|
||||
</span>
|
||||
),
|
||||
value: "desc",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
@@ -524,48 +549,55 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
|
||||
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-text-dimmed">{label}</span>}
|
||||
{label && <span className="text-xs text-text-bright">{label}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortDirectionToggle({
|
||||
direction,
|
||||
onChange,
|
||||
function SeriesColorPicker({
|
||||
color,
|
||||
onColorChange,
|
||||
}: {
|
||||
direction: SortDirection;
|
||||
onChange: (direction: SortDirection) => void;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("asc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "asc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Ascending"
|
||||
>
|
||||
Asc
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("desc")}
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs transition-colors",
|
||||
direction === "desc"
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
|
||||
)}
|
||||
title="Descending"
|
||||
>
|
||||
Desc
|
||||
</button>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-0.5 hover:bg-charcoal-700"
|
||||
title="Change series color"
|
||||
>
|
||||
<span
|
||||
className="block h-4 w-4 rounded-full border border-white/30"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-2">
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{CHART_COLORS_BY_HUE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onColorChange(c);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
>
|
||||
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ type CodeBlockProps = {
|
||||
|
||||
/** Search term to highlight in the code */
|
||||
searchTerm?: string;
|
||||
|
||||
/** Whether to wrap the code */
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const dimAmount = 0.5;
|
||||
@@ -207,6 +210,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
fileName,
|
||||
rowTitle,
|
||||
searchTerm,
|
||||
wrap = false,
|
||||
...props
|
||||
}: CodeBlockProps,
|
||||
ref
|
||||
@@ -215,7 +219,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [modalCopied, setModalCopied] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isWrapped, setIsWrapped] = useState(false);
|
||||
const [isWrapped, setIsWrapped] = useState(wrap);
|
||||
|
||||
const onCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
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 { Paragraph } from "../primitives/Paragraph";
|
||||
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
|
||||
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
|
||||
import { aggregateValues } from "../primitives/charts/aggregation";
|
||||
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { getSeriesColor } from "./chartColors";
|
||||
|
||||
// Color palette for chart series
|
||||
const CHART_COLORS = [
|
||||
"#7655fd", // Primary purple
|
||||
"#22c55e", // Green
|
||||
"#f59e0b", // Amber
|
||||
"#ef4444", // Red
|
||||
"#06b6d4", // Cyan
|
||||
"#ec4899", // Pink
|
||||
"#8b5cf6", // Violet
|
||||
"#14b8a6", // Teal
|
||||
"#f97316", // Orange
|
||||
"#6366f1", // Indigo
|
||||
];
|
||||
|
||||
function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
const MAX_SERIES = 50;
|
||||
const MAX_SVG_ELEMENT_BUDGET = 6_000;
|
||||
const MIN_DATA_POINTS = 100;
|
||||
const MAX_DATA_POINTS = 500;
|
||||
|
||||
interface QueryResultsChartProps {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
/** The effective time range from the query filter (used to show the full x-axis period) */
|
||||
timeRange?: { from: string; to: string };
|
||||
fullLegend?: boolean;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface TransformedData {
|
||||
data: Record<string, unknown>[];
|
||||
series: string[];
|
||||
/** Total number of series before any truncation (equals series.length when no truncation) */
|
||||
totalSeriesCount: number;
|
||||
/** Raw date values for determining formatting granularity */
|
||||
dateValues: Date[];
|
||||
/** Whether the x-axis is date-based (continuous time scale) */
|
||||
@@ -128,12 +130,41 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a millisecond value up to the nearest "nice" interval
|
||||
*/
|
||||
function snapToNiceInterval(ms: number): number {
|
||||
const SECOND = 1000;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
if (ms <= SECOND) return SECOND;
|
||||
if (ms <= 5 * SECOND) return 5 * SECOND;
|
||||
if (ms <= 10 * SECOND) return 10 * SECOND;
|
||||
if (ms <= 15 * SECOND) return 15 * SECOND;
|
||||
if (ms <= 30 * SECOND) return 30 * SECOND;
|
||||
if (ms <= MINUTE) return MINUTE;
|
||||
if (ms <= 5 * MINUTE) return 5 * MINUTE;
|
||||
if (ms <= 10 * MINUTE) return 10 * MINUTE;
|
||||
if (ms <= 15 * MINUTE) return 15 * MINUTE;
|
||||
if (ms <= 30 * MINUTE) return 30 * MINUTE;
|
||||
if (ms <= HOUR) return HOUR;
|
||||
if (ms <= 2 * HOUR) return 2 * HOUR;
|
||||
if (ms <= 4 * HOUR) return 4 * HOUR;
|
||||
if (ms <= 6 * HOUR) return 6 * HOUR;
|
||||
if (ms <= 12 * HOUR) return 12 * HOUR;
|
||||
if (ms <= DAY) return DAY;
|
||||
|
||||
return ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the most common interval between consecutive data points
|
||||
* This helps us understand the natural granularity of the data
|
||||
*/
|
||||
function detectDataInterval(timestamps: number[]): number {
|
||||
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
|
||||
if (timestamps.length < 2) return 24 * 60 * 60 * 1000; // Default to 1 day
|
||||
|
||||
const sorted = [...timestamps].sort((a, b) => a - b);
|
||||
const gaps: number[] = [];
|
||||
@@ -151,25 +182,7 @@ function detectDataInterval(timestamps: number[]): number {
|
||||
// We use the minimum gap as a heuristic for the data interval
|
||||
const minGap = Math.min(...gaps);
|
||||
|
||||
// Round to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
// Snap to common intervals
|
||||
if (minGap <= MINUTE) return MINUTE;
|
||||
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
|
||||
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
|
||||
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
|
||||
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
|
||||
if (minGap <= HOUR) return HOUR;
|
||||
if (minGap <= 2 * HOUR) return 2 * HOUR;
|
||||
if (minGap <= 4 * HOUR) return 4 * HOUR;
|
||||
if (minGap <= 6 * HOUR) return 6 * HOUR;
|
||||
if (minGap <= 12 * HOUR) return 12 * HOUR;
|
||||
if (minGap <= DAY) return DAY;
|
||||
|
||||
return minGap;
|
||||
return snapToNiceInterval(minGap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,20 +206,7 @@ function fillTimeGaps(
|
||||
// If filling would create too many points, increase the interval to stay within limits
|
||||
let effectiveInterval = interval;
|
||||
if (estimatedPoints > maxPoints) {
|
||||
effectiveInterval = Math.ceil(range / maxPoints);
|
||||
// Round up to a nice interval
|
||||
const MINUTE = 60 * 1000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
|
||||
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
|
||||
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
|
||||
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
|
||||
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
|
||||
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
|
||||
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
|
||||
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
|
||||
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
|
||||
else effectiveInterval = 24 * HOUR;
|
||||
effectiveInterval = snapToNiceInterval(Math.ceil(range / maxPoints));
|
||||
}
|
||||
|
||||
// Create a map to collect values for each bucket (for aggregation)
|
||||
@@ -256,17 +256,18 @@ function fillTimeGaps(
|
||||
}
|
||||
filledData.push(point);
|
||||
} else {
|
||||
// Create a zero-filled data point
|
||||
const zeroPoint: Record<string, unknown> = {
|
||||
// Create a null-filled data point so gaps appear in line/bar charts
|
||||
// and legend aggregations (avg/min/max) skip these slots
|
||||
const gapPoint: Record<string, unknown> = {
|
||||
[xDataKey]: t,
|
||||
__rawDate: new Date(t),
|
||||
__granularity: granularity,
|
||||
__originalX: new Date(t).toISOString(),
|
||||
};
|
||||
for (const s of series) {
|
||||
zeroPoint[s] = 0;
|
||||
gapPoint[s] = null;
|
||||
}
|
||||
filledData.push(zeroPoint);
|
||||
filledData.push(gapPoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,22 +366,32 @@ function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): numb
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for tooltips (always shows full precision)
|
||||
* Formats a date for tooltips and legend headers.
|
||||
* Always includes time when the data point has a non-midnight time,
|
||||
* so hovering a specific bar at e.g. 14:00 shows the full timestamp
|
||||
* even when the axis labels only show the day.
|
||||
* Seconds are shown whenever the granularity is "seconds" or the
|
||||
* specific data point has non-zero seconds.
|
||||
*/
|
||||
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
|
||||
// For shorter time ranges, include time
|
||||
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
|
||||
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0;
|
||||
const hasSeconds = date.getSeconds() !== 0;
|
||||
|
||||
if (
|
||||
granularity === "seconds" ||
|
||||
(hasTime && granularity !== "months" && granularity !== "years")
|
||||
) {
|
||||
return date.toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: granularity === "seconds" ? "2-digit" : undefined,
|
||||
second: granularity === "seconds" || hasSeconds ? "2-digit" : undefined,
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
// For longer ranges, just show date
|
||||
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -438,7 +449,8 @@ function tryParseDate(value: unknown): Date | null {
|
||||
*/
|
||||
function transformDataForChart(
|
||||
rows: Record<string, unknown>[],
|
||||
config: ChartConfiguration
|
||||
config: ChartConfiguration,
|
||||
timeRange?: { from: string; to: string }
|
||||
): TransformedData {
|
||||
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
|
||||
|
||||
@@ -446,6 +458,7 @@ function transformDataForChart(
|
||||
return {
|
||||
data: [],
|
||||
series: [],
|
||||
totalSeriesCount: 0,
|
||||
dateValues: [],
|
||||
isDateBased: false,
|
||||
xDataKey: xAxisColumn || "",
|
||||
@@ -464,24 +477,37 @@ function transformDataForChart(
|
||||
}
|
||||
|
||||
// Determine if X-axis is date-based (most values should be parseable as dates)
|
||||
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
|
||||
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
|
||||
// When there are no results but a timeRange is provided, treat as date-based
|
||||
const isDateBased =
|
||||
rows.length === 0 && timeRange ? true : dateValues.length >= rows.length * 0.8; // At least 80% are dates
|
||||
|
||||
// Detect granularity from the full time range when available, otherwise from data
|
||||
const granularity = isDateBased
|
||||
? timeRange
|
||||
? detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)])
|
||||
: detectTimeGranularity(dateValues)
|
||||
: "days";
|
||||
|
||||
// For date-based axes, use a special key for the timestamp
|
||||
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
|
||||
|
||||
// Calculate time domain and ticks for date-based axes
|
||||
// When a timeRange is provided (from the query filter), use it so the chart
|
||||
// shows the full requested period rather than just the range of returned data.
|
||||
let timeDomain: [number, number] | null = null;
|
||||
let timeTicks: number[] | null = null;
|
||||
if (isDateBased && dateValues.length > 0) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const minTime = Math.min(...timestamps);
|
||||
const maxTime = Math.max(...timestamps);
|
||||
// Raw min/max used for gap filling (without padding)
|
||||
let rawMinTime = 0;
|
||||
let rawMaxTime = 0;
|
||||
if (isDateBased && (dateValues.length > 0 || timeRange)) {
|
||||
const dataTimestamps = dateValues.map((d) => d.getTime());
|
||||
rawMinTime = timeRange ? new Date(timeRange.from).getTime() : Math.min(...dataTimestamps);
|
||||
rawMaxTime = timeRange ? new Date(timeRange.to).getTime() : Math.max(...dataTimestamps);
|
||||
// Add a small padding (2% on each side) so points aren't at the very edge
|
||||
const padding = (maxTime - minTime) * 0.02;
|
||||
timeDomain = [minTime - padding, maxTime + padding];
|
||||
const padding = (rawMaxTime - rawMinTime) * 0.02;
|
||||
timeDomain = [rawMinTime - padding, rawMaxTime + padding];
|
||||
// Generate evenly-spaced ticks across the entire range using nice intervals
|
||||
timeTicks = generateTimeTicks(minTime, maxTime);
|
||||
timeTicks = generateTimeTicks(rawMinTime, rawMaxTime);
|
||||
}
|
||||
|
||||
// Helper to format X value for categorical axes (non-date)
|
||||
@@ -536,30 +562,57 @@ function transformDataForChart(
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES);
|
||||
const effectiveMaxPoints = Math.max(
|
||||
MIN_DATA_POINTS,
|
||||
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget))
|
||||
);
|
||||
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
|
||||
const maxRangeInterval =
|
||||
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
|
||||
const effectiveInterval = Math.min(
|
||||
Math.max(dataInterval, minRangeInterval),
|
||||
maxRangeInterval
|
||||
);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
yAxisColumns,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
rawMinTime,
|
||||
rawMaxTime,
|
||||
effectiveInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
aggregation,
|
||||
effectiveMaxPoints
|
||||
);
|
||||
} else if (data.length > effectiveMaxPoints) {
|
||||
data = data.slice(0, effectiveMaxPoints);
|
||||
}
|
||||
|
||||
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
return {
|
||||
data,
|
||||
series: yAxisColumns,
|
||||
totalSeriesCount: yAxisColumns.length,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
};
|
||||
}
|
||||
|
||||
// With grouping: pivot data so each group value becomes a series
|
||||
const yCol = yAxisColumns[0]; // Use first Y column when grouping
|
||||
const groupValues = new Set<string>();
|
||||
|
||||
// For date-based, key by timestamp; otherwise by formatted string
|
||||
// Collect all values for aggregation
|
||||
// First pass: collect all values grouped by (xKey, groupValue) and accumulate
|
||||
// per-group totals so we can pick the top-N groups before building heavy data
|
||||
// objects with thousands of keys.
|
||||
const groupTotals = new Map<string, number>();
|
||||
const groupedByX = new Map<
|
||||
string | number,
|
||||
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
|
||||
@@ -568,29 +621,39 @@ function transformDataForChart(
|
||||
for (const row of rows) {
|
||||
const rawDate = tryParseDate(row[xAxisColumn]);
|
||||
|
||||
// Skip rows with invalid dates for date-based axes
|
||||
if (isDateBased && !rawDate) continue;
|
||||
|
||||
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
|
||||
const groupValue = String(row[groupByColumn] ?? "Unknown");
|
||||
const yValue = toNumber(row[yCol]);
|
||||
|
||||
groupValues.add(groupValue);
|
||||
groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue));
|
||||
|
||||
if (!groupedByX.has(xKey)) {
|
||||
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
|
||||
}
|
||||
|
||||
const existing = groupedByX.get(xKey)!;
|
||||
// Collect values for aggregation
|
||||
if (!existing.values[groupValue]) {
|
||||
existing.values[groupValue] = [];
|
||||
}
|
||||
existing.values[groupValue].push(yValue);
|
||||
}
|
||||
|
||||
// Convert to array format with aggregation applied
|
||||
const series = Array.from(groupValues).sort();
|
||||
// Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing
|
||||
// downstream (data objects, gap filling, legend totals, SVG rendering).
|
||||
const totalSeriesCount = groupTotals.size;
|
||||
let series: string[];
|
||||
if (groupTotals.size <= MAX_SERIES) {
|
||||
series = Array.from(groupTotals.keys()).sort();
|
||||
} else {
|
||||
series = Array.from(groupTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, MAX_SERIES)
|
||||
.map(([key]) => key)
|
||||
.sort();
|
||||
}
|
||||
// Convert to array format with aggregation applied (only for kept series)
|
||||
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
|
||||
const point: Record<string, unknown> = {
|
||||
[xDataKey]: xKey,
|
||||
@@ -604,23 +667,44 @@ function transformDataForChart(
|
||||
return point;
|
||||
});
|
||||
|
||||
// Fill in gaps with zeros for date-based data
|
||||
// Dynamic data-point budget based on the (already capped) series count
|
||||
const effectiveMaxPoints = Math.max(
|
||||
MIN_DATA_POINTS,
|
||||
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length))
|
||||
);
|
||||
|
||||
if (isDateBased && timeDomain) {
|
||||
const timestamps = dateValues.map((d) => d.getTime());
|
||||
const dataInterval = detectDataInterval(timestamps);
|
||||
const rangeMs = rawMaxTime - rawMinTime;
|
||||
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
|
||||
const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
|
||||
const effectiveInterval = Math.min(Math.max(dataInterval, minRangeInterval), maxRangeInterval);
|
||||
data = fillTimeGaps(
|
||||
data,
|
||||
xDataKey,
|
||||
series,
|
||||
timeDomain[0],
|
||||
timeDomain[1],
|
||||
dataInterval,
|
||||
rawMinTime,
|
||||
rawMaxTime,
|
||||
effectiveInterval,
|
||||
granularity,
|
||||
aggregation
|
||||
aggregation,
|
||||
effectiveMaxPoints
|
||||
);
|
||||
} else if (data.length > effectiveMaxPoints) {
|
||||
data = data.slice(0, effectiveMaxPoints);
|
||||
}
|
||||
|
||||
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
|
||||
return {
|
||||
data,
|
||||
series,
|
||||
totalSeriesCount,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
@@ -632,25 +716,6 @@ function toNumber(value: unknown): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate an array of numbers using the specified aggregation function
|
||||
*/
|
||||
function aggregateValues(values: number[], aggregation: AggregationType): number {
|
||||
if (values.length === 0) return 0;
|
||||
switch (aggregation) {
|
||||
case "sum":
|
||||
return values.reduce((a, b) => a + b, 0);
|
||||
case "avg":
|
||||
return values.reduce((a, b) => a + b, 0) / values.length;
|
||||
case "count":
|
||||
return values.length;
|
||||
case "min":
|
||||
return Math.min(...values);
|
||||
case "max":
|
||||
return Math.max(...values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort data array by a specified column
|
||||
*/
|
||||
@@ -700,8 +765,11 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
config,
|
||||
timeRange,
|
||||
fullLegend = false,
|
||||
onViewAllLegendItems,
|
||||
isLoading = false,
|
||||
legendScrollable = false,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
xAxisColumn,
|
||||
@@ -717,12 +785,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
const {
|
||||
data: unsortedData,
|
||||
series,
|
||||
totalSeriesCount,
|
||||
dateValues,
|
||||
isDateBased,
|
||||
xDataKey,
|
||||
timeDomain,
|
||||
timeTicks,
|
||||
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
|
||||
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);
|
||||
|
||||
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
|
||||
const data = useMemo(() => {
|
||||
@@ -733,13 +802,54 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
|
||||
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
|
||||
|
||||
// Detect time granularity for the data
|
||||
const timeGranularity = useMemo(
|
||||
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
|
||||
[dateValues]
|
||||
// Sort series by descending total sum so largest appears at bottom of
|
||||
// stacked charts and first in the legend
|
||||
const sortedSeries = useMemo(() => {
|
||||
if (series.length <= 1) return series;
|
||||
const totals = new Map<string, number>();
|
||||
for (const s of series) {
|
||||
let total = 0;
|
||||
for (const point of data) {
|
||||
const val = point[s];
|
||||
if (typeof val === "number" && isFinite(val)) {
|
||||
total += Math.abs(val);
|
||||
}
|
||||
}
|
||||
totals.set(s, total);
|
||||
}
|
||||
return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
|
||||
}, [series, data]);
|
||||
|
||||
// Limit SVG-rendered series to MAX_SERIES (top N by total value)
|
||||
const visibleSeries = useMemo(
|
||||
() => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries),
|
||||
[sortedSeries]
|
||||
);
|
||||
|
||||
// X-axis tick formatter for date-based axes
|
||||
const seriesLimitCallout =
|
||||
totalSeriesCount > series.length ? (
|
||||
<div className="mt-1 px-2">
|
||||
<Callout variant="warning">
|
||||
{`Limited to the top ${
|
||||
series.length
|
||||
} of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
|
||||
</Callout>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Detect time granularity — use the full time range when available so tick
|
||||
// labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
|
||||
// instead of just "16:00:00" when data is sparse)
|
||||
const timeGranularity = useMemo(() => {
|
||||
if (timeRange) {
|
||||
return detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]);
|
||||
}
|
||||
return dateValues.length > 0 ? detectTimeGranularity(dateValues) : null;
|
||||
}, [dateValues, timeRange]);
|
||||
|
||||
// X-axis tick formatter for date-based axes (pure – no deduplication).
|
||||
// Label deduplication is handled inside dateAxisTick below so that the
|
||||
// mutable "lastLabel" state is correctly reset on each Recharts render pass.
|
||||
const xAxisTickFormatter = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity) return undefined;
|
||||
return (value: number) => {
|
||||
@@ -748,20 +858,46 @@ 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(() => {
|
||||
if (!groupByColumn) return false;
|
||||
const col = columns.find((c) => c.name === groupByColumn);
|
||||
return col?.customRenderType === "runStatus";
|
||||
}, [groupByColumn, columns]);
|
||||
|
||||
// Build chart config for colors/labels
|
||||
const chartConfig = useMemo(() => {
|
||||
const cfg: ChartConfig = {};
|
||||
series.forEach((s, i) => {
|
||||
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: getSeriesColor(i),
|
||||
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(colorIndex),
|
||||
};
|
||||
});
|
||||
return cfg;
|
||||
}, [series]);
|
||||
}, [sortedSeries, groupByIsRunStatus, config.seriesColors, config.yAxisColumns]);
|
||||
|
||||
// Custom tooltip label formatter for better date display
|
||||
const tooltipLabelFormatter = useMemo(() => {
|
||||
@@ -805,30 +941,125 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
return [min, "auto"] as [number, string];
|
||||
}, [data, series]);
|
||||
|
||||
// Validation
|
||||
// Angle all date-based labels for consistent appearance and to avoid overlap
|
||||
const xAxisAngle = isDateBased ? -45 : 0;
|
||||
const xAxisHeight = xAxisAngle !== 0 ? 65 : undefined;
|
||||
|
||||
// Check if the data would produce duplicate labels at the current granularity.
|
||||
// Only use the custom tick renderer (with interval:0) when duplicates exist,
|
||||
// otherwise let Recharts handle label spacing to avoid collisions.
|
||||
const hasDuplicateLabels = useMemo(() => {
|
||||
if (!isDateBased || !timeGranularity || data.length === 0) return false;
|
||||
const labels = new Set<string>();
|
||||
for (const point of data) {
|
||||
const ts = point.__timestamp ?? point[xDataKey];
|
||||
if (typeof ts === "number") {
|
||||
labels.add(formatDateByGranularity(new Date(ts), timeGranularity));
|
||||
}
|
||||
}
|
||||
return labels.size < data.length;
|
||||
}, [isDateBased, timeGranularity, data, xDataKey]);
|
||||
|
||||
// Custom tick renderer for date-based axes: renders a tick mark alongside
|
||||
// each label, and for unlabelled points (de-duplicated) just a subtle tick mark.
|
||||
// De-duplication lives here (not in xAxisTickFormatter) so that the mutable
|
||||
// lastLabel is reset when Recharts starts a new render pass (index === 0).
|
||||
const dateAxisTick = useMemo(() => {
|
||||
if (!isDateBased || !xAxisTickFormatter) return undefined;
|
||||
let lastLabel = "";
|
||||
return (props: Record<string, unknown>) => {
|
||||
const { x, y, payload, index } = props as {
|
||||
x: number;
|
||||
y: number;
|
||||
payload: { value: number };
|
||||
index: number;
|
||||
};
|
||||
|
||||
// Reset dedup state at the start of each Recharts render pass
|
||||
if (index === 0) lastLabel = "";
|
||||
|
||||
const formatted = xAxisTickFormatter(payload.value);
|
||||
const label = formatted === lastLabel ? "" : formatted;
|
||||
lastLabel = formatted;
|
||||
// y is the tick text position, offset from the axis by tickMargin + internal padding
|
||||
const axisY = (y as number) - 12;
|
||||
if (label) {
|
||||
return (
|
||||
<g>
|
||||
<line
|
||||
x1={x as number}
|
||||
y1={axisY}
|
||||
x2={x as number}
|
||||
y2={axisY - 3}
|
||||
stroke="#878C99"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={x}
|
||||
y={axisY}
|
||||
dy={10}
|
||||
fill="#878C99"
|
||||
fontSize={11}
|
||||
textAnchor={xAxisAngle !== 0 ? "end" : "middle"}
|
||||
style={{ fontVariantNumeric: "tabular-nums" }}
|
||||
transform={
|
||||
xAxisAngle !== 0 ? `rotate(${xAxisAngle}, ${x}, ${axisY + 10})` : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
// Small tick mark sitting on the axis baseline, pointing upward
|
||||
return (
|
||||
<line
|
||||
x1={x as number}
|
||||
y1={axisY}
|
||||
x2={x as number}
|
||||
y2={axisY - 3}
|
||||
stroke="#272A2E"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
|
||||
|
||||
// Validation — all hooks must be above this point
|
||||
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
|
||||
|
||||
if (!xAxisColumn) {
|
||||
return <EmptyState message="Select an X-axis column to display the chart" />;
|
||||
return (
|
||||
<ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />
|
||||
);
|
||||
}
|
||||
|
||||
if (yAxisColumns.length === 0) {
|
||||
return <EmptyState message="Select a Y-axis column to display the chart" />;
|
||||
return (
|
||||
<ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <EmptyState message="No data to display" />;
|
||||
return <ChartBlankState icon={chartIcon} message="No data to display" />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message="Unable to transform data for chart" />;
|
||||
return <ChartBlankState icon={chartIcon} message="Unable to transform data for chart" />;
|
||||
}
|
||||
|
||||
// Determine appropriate angle for X-axis labels based on granularity
|
||||
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
|
||||
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
|
||||
|
||||
// Base x-axis props shared by all chart types
|
||||
const baseXAxisProps = {
|
||||
tickFormatter: xAxisTickFormatter,
|
||||
...(dateAxisTick
|
||||
? {
|
||||
tick: dateAxisTick,
|
||||
tickLine: false,
|
||||
tickFormatter: undefined,
|
||||
// Only force every tick to render when there are duplicates to de-duplicate;
|
||||
// otherwise let Recharts auto-space to avoid label collisions
|
||||
...(hasDuplicateLabels ? { interval: 0 } : {}),
|
||||
}
|
||||
: { tickFormatter: xAxisTickFormatter }),
|
||||
angle: xAxisAngle,
|
||||
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
|
||||
height: xAxisHeight,
|
||||
@@ -838,13 +1069,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
// This properly represents time gaps between data points
|
||||
const xAxisPropsForLine = isDateBased
|
||||
? {
|
||||
type: "number" as const,
|
||||
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
|
||||
scale: "time" as const,
|
||||
// Explicitly specify tick positions so labels appear across the entire range
|
||||
ticks: timeTicks ?? undefined,
|
||||
...baseXAxisProps,
|
||||
}
|
||||
type: "number" as const,
|
||||
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
|
||||
scale: "time" as const,
|
||||
// Explicitly specify tick positions so labels appear across the entire range
|
||||
ticks: timeTicks ?? undefined,
|
||||
...baseXAxisProps,
|
||||
}
|
||||
: baseXAxisProps;
|
||||
|
||||
// Bar charts always use categorical axis positioning
|
||||
@@ -857,7 +1088,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
domain: yAxisDomain,
|
||||
};
|
||||
|
||||
const showLegend = series.length > 0;
|
||||
const showLegend = sortedSeries.length > 0;
|
||||
|
||||
if (chartType === "bar") {
|
||||
return (
|
||||
@@ -865,19 +1096,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
series={sortedSeries}
|
||||
visibleSeries={visibleSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
legendValueFormatter={tooltipValueFormatter}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
beforeLegend={seriesLimitCallout}
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={xAxisPropsForBar}
|
||||
yAxisProps={yAxisProps}
|
||||
stackId={stacked ? "stack" : undefined}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
tooltipValueFormatter={tooltipValueFormatter}
|
||||
/>
|
||||
</Chart.Root>
|
||||
);
|
||||
@@ -889,19 +1127,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config={chartConfig}
|
||||
data={data}
|
||||
dataKey={xDataKey}
|
||||
series={series}
|
||||
series={sortedSeries}
|
||||
visibleSeries={visibleSeries}
|
||||
labelFormatter={legendLabelFormatter}
|
||||
showLegend={showLegend}
|
||||
maxLegendItems={fullLegend ? Infinity : 5}
|
||||
legendAggregation={config.aggregation}
|
||||
legendValueFormatter={tooltipValueFormatter}
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
state={isLoading ? "loading" : "loaded"}
|
||||
beforeLegend={seriesLimitCallout}
|
||||
>
|
||||
<Chart.Line
|
||||
xAxisProps={xAxisPropsForLine}
|
||||
yAxisProps={yAxisProps}
|
||||
stacked={stacked && series.length > 1}
|
||||
stacked={stacked && visibleSeries.length > 1}
|
||||
tooltipLabelFormatter={tooltipLabelFormatter}
|
||||
tooltipValueFormatter={tooltipValueFormatter}
|
||||
lineType="linear"
|
||||
/>
|
||||
</Chart.Root>
|
||||
@@ -909,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;
|
||||
@@ -928,6 +1177,46 @@ 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 === "costInDollars" || format === "cost") {
|
||||
return (value: number): string => {
|
||||
const dollars = format === "cost" ? value / 100 : value;
|
||||
return formatCurrencyAccurate(dollars);
|
||||
};
|
||||
}
|
||||
|
||||
// Default formatter
|
||||
return (value: number): string => {
|
||||
// Use abbreviations for large numbers
|
||||
if (Math.abs(value) >= 1_000_000) {
|
||||
@@ -960,13 +1249,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
|
||||
return Math.round(value).toString();
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[300px] items-center justify-center">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sql, StandardSQL } from "@codemirror/lang-sql";
|
||||
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { linter, lintGutter } from "@codemirror/lint";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
@@ -60,6 +60,54 @@ const defaultProps: TSQLEditorDefaultProps = {
|
||||
schema: [],
|
||||
};
|
||||
|
||||
// Toggle comment on current line or selected lines with -- comment symbol
|
||||
const toggleLineComment = (view: EditorView): boolean => {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const startLine = view.state.doc.lineAt(from);
|
||||
// When `to` is exactly at the start of a line and there's an actual selection,
|
||||
// the caret sits before that line — so exclude it by stepping back one position.
|
||||
const adjustedTo = to > from && view.state.doc.lineAt(to).from === to ? to - 1 : to;
|
||||
const endLine = view.state.doc.lineAt(adjustedTo);
|
||||
|
||||
// Collect all lines in the selection
|
||||
const lines: { from: number; to: number; text: string }[] = [];
|
||||
for (let i = startLine.number; i <= endLine.number; i++) {
|
||||
const line = view.state.doc.line(i);
|
||||
lines.push({ from: line.from, to: line.to, text: line.text });
|
||||
}
|
||||
|
||||
// Determine action: if all non-empty lines are commented, uncomment; otherwise comment
|
||||
const allCommented = lines.every((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
return trimmed.length === 0 || trimmed.startsWith("--");
|
||||
});
|
||||
|
||||
const changes = lines
|
||||
.map((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
if (trimmed.length === 0) return null; // skip empty lines
|
||||
const indent = line.text.length - trimmed.length;
|
||||
|
||||
if (allCommented) {
|
||||
// Remove comment: strip "-- " or just "--"
|
||||
const afterComment = trimmed.slice(2);
|
||||
const newText = line.text.slice(0, indent) + afterComment.replace(/^\s/, "");
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
} else {
|
||||
// Add comment: prepend "-- " to the line content
|
||||
const newText = line.text.slice(0, indent) + "-- " + trimmed;
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
}
|
||||
})
|
||||
.filter((c): c is { from: number; to: number; insert: string } => c !== null);
|
||||
|
||||
if (changes.length > 0) {
|
||||
view.dispatch({ changes });
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
@@ -133,6 +181,14 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Add keyboard shortcut for toggling comments
|
||||
exts.push(
|
||||
keymap.of([
|
||||
{ key: "Cmd-/", run: toggleLineComment },
|
||||
{ key: "Ctrl-/", run: toggleLineComment },
|
||||
])
|
||||
);
|
||||
|
||||
return exts;
|
||||
}, [schema, linterEnabled]);
|
||||
|
||||
@@ -218,6 +274,9 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
)}
|
||||
ref={editor}
|
||||
onClick={() => {
|
||||
view?.focus();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
if (!view) return;
|
||||
@@ -225,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
}}
|
||||
/>
|
||||
{showButtons && (
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-1.5">
|
||||
{additionalActions && additionalActions}
|
||||
{showFormatButton && (
|
||||
<Button
|
||||
@@ -279,11 +338,50 @@ export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// SQL keywords that legitimately appear before parentheses with a space
|
||||
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
|
||||
"IN",
|
||||
"NOT",
|
||||
"EXISTS",
|
||||
"OVER",
|
||||
"USING",
|
||||
"VALUES",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"AND",
|
||||
"OR",
|
||||
"ON",
|
||||
"SET",
|
||||
"INTO",
|
||||
"TABLE",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"AS",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"HAVING",
|
||||
"JOIN",
|
||||
"SELECT",
|
||||
]);
|
||||
|
||||
export function autoFormatSQL(sql: string) {
|
||||
return formatSQL(sql, {
|
||||
let formatted = formatSQL(sql, {
|
||||
language: "sql",
|
||||
keywordCase: "upper",
|
||||
indentStyle: "standard",
|
||||
linesBetweenQueries: 2,
|
||||
});
|
||||
|
||||
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
|
||||
// Remove that space for anything that isn't a SQL keyword
|
||||
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
|
||||
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
|
||||
return match;
|
||||
}
|
||||
return `${name}(`;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { IconFilter2, IconFilter2X, IconTable } from "@tabler/icons-react";
|
||||
import { rankItem } from "@tanstack/match-sorter-utils";
|
||||
import {
|
||||
useReactTable,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
useReactTable,
|
||||
type CellContext,
|
||||
type ColumnResizeMode,
|
||||
type ColumnFiltersState,
|
||||
type FilterFn,
|
||||
type Column,
|
||||
type SortingState,
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ColumnResizeMode,
|
||||
type FilterFn,
|
||||
type SortDirection,
|
||||
type SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { AlertCircle, ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
||||
import { forwardRef, memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { EnvironmentLabel, EnvironmentSlug } from "~/components/environments/EnvironmentLabel";
|
||||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||||
@@ -33,18 +35,15 @@ 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";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { QueueName } from "../runs/v3/QueueName";
|
||||
import {
|
||||
FunnelIcon,
|
||||
ChevronUpIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpDownIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
|
||||
const MAX_STRING_DISPLAY_LENGTH = 64;
|
||||
const ROW_HEIGHT = 33; // Estimated row height in pixels
|
||||
@@ -54,7 +53,7 @@ const MIN_COLUMN_WIDTH = 60;
|
||||
const MAX_COLUMN_WIDTH = 400;
|
||||
const CHAR_WIDTH_PX = 7.5; // Approximate width of a monospace character at text-xs (12px)
|
||||
const CELL_PADDING_PX = 40; // px-2 (8px) on each side + buffer for copy button
|
||||
const HEADER_ICONS_WIDTH_PX = 72; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (16px)
|
||||
const HEADER_ICONS_WIDTH_PX = 80; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (24px)
|
||||
const SAMPLE_SIZE = 100; // Number of rows to sample for width calculation
|
||||
|
||||
// Type for row data
|
||||
@@ -68,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" });
|
||||
@@ -97,6 +97,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +133,7 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
} catch {
|
||||
return String(value);
|
||||
@@ -160,10 +181,10 @@ const fuzzyFilter: FilterFn<RowData> = (row, columnId, value, addMeta) => {
|
||||
cellValue === null
|
||||
? "NULL"
|
||||
: cellValue === undefined
|
||||
? ""
|
||||
: typeof cellValue === "object"
|
||||
? JSON.stringify(cellValue)
|
||||
: String(cellValue);
|
||||
? ""
|
||||
: typeof cellValue === "object"
|
||||
? JSON.stringify(cellValue)
|
||||
: String(cellValue);
|
||||
|
||||
// Build searchable strings - formatted value (if we have column metadata)
|
||||
const formattedValue = meta?.outputColumn
|
||||
@@ -224,6 +245,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) {
|
||||
@@ -265,6 +301,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +434,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);
|
||||
}
|
||||
|
||||
@@ -416,7 +458,7 @@ function CellValueWrapper({
|
||||
|
||||
return (
|
||||
<span
|
||||
className="flex-1"
|
||||
className="flex flex-1 items-center"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
@@ -462,6 +504,7 @@ function CellValue({
|
||||
</pre>
|
||||
}
|
||||
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -477,12 +520,45 @@ 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") {
|
||||
return <TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content="Jump to run"
|
||||
disableHoverableContent
|
||||
hidden={!hovered}
|
||||
button={<TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -490,19 +566,17 @@ function CellValue({
|
||||
const status = isTaskRunStatus(value)
|
||||
? value
|
||||
: isRunFriendlyStatus(value)
|
||||
? runStatusFromFriendlyTitle(value)
|
||||
: undefined;
|
||||
? runStatusFromFriendlyTitle(value)
|
||||
: undefined;
|
||||
if (status) {
|
||||
if (hovered) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(status)}
|
||||
disableHoverableContent
|
||||
button={<TaskRunStatusCombo status={status} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <TaskRunStatusCombo status={status} />;
|
||||
return (
|
||||
<SimpleTooltip
|
||||
content={descriptionForTaskRunStatus(status)}
|
||||
disableHoverableContent
|
||||
hidden={!hovered}
|
||||
button={<TaskRunStatusCombo status={status} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -573,6 +647,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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,7 +668,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>;
|
||||
}
|
||||
@@ -607,6 +694,7 @@ function CellValue({
|
||||
{truncateString(arrayString)}
|
||||
</span>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -642,6 +730,7 @@ function CellValue({
|
||||
</pre>
|
||||
}
|
||||
button={<span>{truncateString(stringValue)}</span>}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -672,7 +761,9 @@ function EnvironmentCellValue({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
function JSONCellValue({ value }: { value: unknown }) {
|
||||
const jsonString = JSON.stringify(value);
|
||||
// If the value is already a string (e.g., from a textColumn optimization),
|
||||
// use it directly without double-stringifying
|
||||
const jsonString = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const isTruncated = jsonString.length > MAX_STRING_DISPLAY_LENGTH;
|
||||
|
||||
if (isTruncated) {
|
||||
@@ -686,6 +777,7 @@ function JSONCellValue({ value }: { value: unknown }) {
|
||||
button={
|
||||
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
|
||||
}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -711,15 +803,16 @@ function CopyableCell({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full items-center overflow-hidden px-2 py-1.5",
|
||||
"bg-background-dimmed group-hover/row:bg-charcoal-800",
|
||||
"relative flex h-full w-full items-center overflow-hidden px-2",
|
||||
"bg-background-bright group-hover/row:bg-charcoal-750",
|
||||
"font-mono text-xs text-text-dimmed group-hover/row:text-text-bright",
|
||||
"[&_a:focus-visible]:underline [&_a:focus-visible]:underline-offset-[3px] [&_a:focus-visible]:outline-none",
|
||||
alignment === "right" && "justify-end"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
<span className="flex items-center truncate">{children}</span>
|
||||
{isHovered && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
@@ -779,18 +872,21 @@ function HeaderCellContent({
|
||||
onSortClick?: (event: React.MouseEvent) => void;
|
||||
canSort?: boolean;
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isCellHovered, setIsCellHovered] = useState(false);
|
||||
const [isFilterHovered, setIsFilterHovered] = useState(false);
|
||||
|
||||
const sortHighlighted = isCellHovered && !isFilterHovered;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 overflow-hidden bg-background-dimmed py-1.5 pl-2 pr-1",
|
||||
"flex w-full items-center gap-1 overflow-hidden bg-background-bright py-2 pl-2 pr-3",
|
||||
"font-mono text-xs font-medium text-text-bright",
|
||||
alignment === "right" && "justify-end",
|
||||
canSort && "cursor-pointer select-none"
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onMouseEnter={() => setIsCellHovered(true)}
|
||||
onMouseLeave={() => setIsCellHovered(false)}
|
||||
onClick={onSortClick}
|
||||
>
|
||||
{tooltip ? (
|
||||
@@ -800,11 +896,14 @@ function HeaderCellContent({
|
||||
})}
|
||||
>
|
||||
<span className="truncate text-left">{children}</span>
|
||||
<InfoIconTooltip
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isHovered}
|
||||
/>
|
||||
<span className="flex flex-shrink-0">
|
||||
<InfoIconTooltip
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isCellHovered}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
|
||||
@@ -812,7 +911,10 @@ function HeaderCellContent({
|
||||
{/* Sort indicator */}
|
||||
{canSort && (
|
||||
<span
|
||||
className={cn("flex-shrink-0", sortDirection ? "text-text-bright" : "text-text-dimmed")}
|
||||
className={cn(
|
||||
"flex-shrink-0 transition-colors",
|
||||
sortHighlighted ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
@@ -829,10 +931,12 @@ function HeaderCellContent({
|
||||
e.stopPropagation();
|
||||
onFilterClick();
|
||||
}}
|
||||
className="flex-shrink-0 rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
|
||||
onMouseEnter={() => setIsFilterHovered(true)}
|
||||
onMouseLeave={() => setIsFilterHovered(false)}
|
||||
className="flex-shrink-0 rounded text-text-dimmed transition-colors focus-custom hover:text-text-bright"
|
||||
title="Toggle column filters"
|
||||
>
|
||||
<FunnelIcon className="size-3" />
|
||||
{showFilters ? <IconFilter2X className="size-4" /> : <IconFilter2 className="size-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -864,7 +968,7 @@ function FilterCell({
|
||||
}, [shouldFocus, onFocused]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center bg-background-dimmed px-1.5 pb-1" style={{ width }}>
|
||||
<div className="flex items-center bg-background-bright px-1.5 pb-2" style={{ width }}>
|
||||
<DebouncedInput
|
||||
ref={inputRef}
|
||||
value={columnFilterValue ?? ""}
|
||||
@@ -884,10 +988,15 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
rows,
|
||||
columns,
|
||||
prettyFormatting = true,
|
||||
sorting: defaultSorting = [],
|
||||
showHeaderOnEmpty = false,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
prettyFormatting?: boolean;
|
||||
sorting?: SortingState;
|
||||
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
|
||||
showHeaderOnEmpty?: boolean;
|
||||
}) {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -897,7 +1006,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
// Track which column's filter should be focused
|
||||
const [focusFilterColumn, setFocusFilterColumn] = useState<string | null>(null);
|
||||
// State for column sorting
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
|
||||
|
||||
// Create TanStack Table column definitions from OutputColumnMetadata
|
||||
// Calculate column widths based on content
|
||||
@@ -957,6 +1066,10 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
|
||||
// Empty state
|
||||
if (rows.length === 0) {
|
||||
if (!showHeaderOnEmpty) {
|
||||
return <ChartBlankState icon={IconTable} message="No data to display" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full min-h-0 w-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
@@ -964,7 +1077,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
>
|
||||
<table style={{ display: "grid" }}>
|
||||
<thead
|
||||
className="bg-background-dimmed"
|
||||
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
|
||||
style={{
|
||||
display: "grid",
|
||||
position: "sticky",
|
||||
@@ -985,63 +1098,24 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
width: header.getSize(),
|
||||
}}
|
||||
>
|
||||
<HeaderCellContent
|
||||
alignment={meta?.alignment ?? "left"}
|
||||
tooltip={meta?.outputColumn.description}
|
||||
onFilterClick={() => {
|
||||
if (!showFilters) {
|
||||
setFocusFilterColumn(header.id);
|
||||
} else {
|
||||
setColumnFilters([]);
|
||||
}
|
||||
setShowFilters(!showFilters);
|
||||
}}
|
||||
showFilters={showFilters}
|
||||
hasActiveFilter={!!header.column.getFilterValue()}
|
||||
sortDirection={header.column.getIsSorted()}
|
||||
onSortClick={header.column.getToggleSortingHandler()}
|
||||
canSort={header.column.getCanSort()}
|
||||
>
|
||||
<HeaderCellContent alignment={meta?.alignment ?? "left"}>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</HeaderCellContent>
|
||||
{/* Column resizer */}
|
||||
<div
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
|
||||
"opacity-0 group-hover/header:opacity-100",
|
||||
"bg-charcoal-600 hover:bg-indigo-500",
|
||||
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
|
||||
)}
|
||||
/>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{/* Filter row - shown when filters are toggled */}
|
||||
{showFilters && (
|
||||
<tr style={{ display: "flex", width: "100%" }}>
|
||||
{table.getHeaderGroups()[0]?.headers.map((header) => (
|
||||
<FilterCell
|
||||
key={`filter-${header.id}`}
|
||||
column={header.column}
|
||||
width={header.getSize()}
|
||||
shouldFocus={focusFilterColumn === header.id}
|
||||
onFocused={() => setFocusFilterColumn(null)}
|
||||
/>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody style={{ display: "grid" }}>
|
||||
<tr style={{ display: "flex" }}>
|
||||
<td>
|
||||
<Paragraph variant="extra-small" className="p-4 text-text-dimmed">
|
||||
No results
|
||||
</Paragraph>
|
||||
<tr style={{ display: "flex", width: "100%" }}>
|
||||
<td className="w-full px-3 py-6" colSpan={columns.length}>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<AlertCircle className="size-5 text-text-dimmed/50" />
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
This query returned no results
|
||||
</Paragraph>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -1058,7 +1132,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
>
|
||||
<table style={{ display: "grid" }}>
|
||||
<thead
|
||||
className="bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
|
||||
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
|
||||
style={{
|
||||
display: "grid",
|
||||
position: "sticky",
|
||||
@@ -1105,7 +1179,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none",
|
||||
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
|
||||
"opacity-0 group-hover/header:opacity-100",
|
||||
"bg-charcoal-600 hover:bg-indigo-500",
|
||||
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
|
||||
@@ -1137,6 +1211,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
className="divide-y divide-charcoal-700 bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:z-[1] after:h-px after:bg-grid-bright"
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = tableRows[virtualRow.index];
|
||||
@@ -1144,12 +1219,13 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
<tr
|
||||
key={row.id}
|
||||
data-index={virtualRow.index}
|
||||
className="group/row hover:bg-charcoal-800"
|
||||
className="group/row hover:bg-charcoal-750"
|
||||
style={{
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
|
||||
*
|
||||
* HSL is a human-friendly color model:
|
||||
* h: 0–360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
|
||||
* s: 0–100 (saturation — 0 is gray, 100 is full color)
|
||||
* l: 0–100 (lightness — 0 is black, 50 is pure color, 100 is white)
|
||||
*/
|
||||
|
||||
interface HSLColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
interface ChartColorDef {
|
||||
name: string;
|
||||
hsl: HSLColor;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Palette — 30 distinct colors for chart series, defined in HSL
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHART_COLOR_DEFS: ChartColorDef[] = [
|
||||
// Primary colors (high contrast, spread across hue wheel)
|
||||
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
|
||||
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
|
||||
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
|
||||
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
|
||||
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
|
||||
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
|
||||
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
|
||||
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
|
||||
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
|
||||
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
|
||||
// Extended palette
|
||||
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
|
||||
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
|
||||
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
|
||||
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
|
||||
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
|
||||
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
|
||||
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
|
||||
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
|
||||
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
|
||||
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
|
||||
// Additional distinct colors (lighter variants)
|
||||
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
|
||||
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
|
||||
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
|
||||
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
|
||||
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
|
||||
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
|
||||
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
|
||||
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
|
||||
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
|
||||
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HSL ↔ Hex conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert an HSL color (h: 0–360, s: 0–100, l: 0–100) to a hex string */
|
||||
function hslToHex({ h, s, l }: HSLColor): string {
|
||||
const sNorm = s / 100;
|
||||
const lNorm = l / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||
const hPrime = h / 60;
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
||||
const m = lNorm - c / 2;
|
||||
|
||||
let r1: number, g1: number, b1: number;
|
||||
|
||||
if (hPrime < 1) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 2) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 3) {
|
||||
r1 = 0;
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (hPrime < 4) {
|
||||
r1 = 0;
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (hPrime < 5) {
|
||||
r1 = x;
|
||||
g1 = 0;
|
||||
b1 = c;
|
||||
} else {
|
||||
r1 = c;
|
||||
g1 = 0;
|
||||
b1 = x;
|
||||
}
|
||||
|
||||
const toHex = (v: number) =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
|
||||
}
|
||||
|
||||
/** Convert a hex string to HSL (h: 0–360, s: 0–100, l: 0–100) */
|
||||
function hexToHsl(hex: string): HSLColor {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const delta = max - min;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (delta === 0) {
|
||||
return { h: 0, s: 0, l: Math.round(l * 100) };
|
||||
}
|
||||
|
||||
const s = delta / (1 - Math.abs(2 * l - 1));
|
||||
|
||||
let h: number;
|
||||
if (max === r) {
|
||||
h = 60 * (((g - b) / delta + 6) % 6);
|
||||
} else if (max === g) {
|
||||
h = 60 * ((b - r) / delta + 2);
|
||||
} else {
|
||||
h = 60 * ((r - g) / delta + 4);
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived hex palette (for consumers that need plain hex strings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
|
||||
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
|
||||
|
||||
/** Get the hex color for a series by its index (wraps around) */
|
||||
export function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hue-sorted palette (rainbow order for color pickers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SATURATION_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Chart colors sorted by perceived hue — the natural rainbow order
|
||||
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
|
||||
*
|
||||
* Very desaturated colors (like grays) are placed at the end since they don't
|
||||
* have a strong hue.
|
||||
*/
|
||||
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
|
||||
.sort((a, b) => {
|
||||
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
|
||||
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
|
||||
|
||||
// Push desaturated colors to the end
|
||||
if (aIsGray && !bIsGray) return 1;
|
||||
if (!aIsGray && bIsGray) return -1;
|
||||
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
|
||||
|
||||
// Sort by hue, then by saturation (more vivid first), then by lightness
|
||||
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
|
||||
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
|
||||
return a.hsl.l - b.hsl.l;
|
||||
})
|
||||
.map((def) => hslToHex(def.hsl));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { closeBrackets } from "@codemirror/autocomplete";
|
||||
import { indentWithTab } from "@codemirror/commands";
|
||||
import { indentWithTab, history, historyKeymap, undo, redo } from "@codemirror/commands";
|
||||
import { bracketMatching } from "@codemirror/language";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
@@ -18,6 +18,7 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A
|
||||
const options = [
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
Prec.highest(
|
||||
@@ -31,7 +32,15 @@ export function getEditorSetup(showLineNumbers = true, showHighlights = true): A
|
||||
},
|
||||
])
|
||||
),
|
||||
keymap.of([indentWithTab, ...lintKeymap]),
|
||||
// Explicit undo/redo keybindings with high precedence
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{ key: "Mod-z", run: undo },
|
||||
{ key: "Mod-Shift-z", run: redo },
|
||||
{ key: "Mod-y", run: redo },
|
||||
])
|
||||
),
|
||||
keymap.of([indentWithTab, ...historyKeymap, ...lintKeymap]),
|
||||
];
|
||||
|
||||
if (showLineNumbers) {
|
||||
|
||||
@@ -67,9 +67,10 @@ export function darkTheme(): Extension {
|
||||
},
|
||||
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: selection,
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
backgroundColor: selection,
|
||||
},
|
||||
|
||||
".cm-panels": { backgroundColor: darkBackground, color: ivory },
|
||||
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
|
||||
@@ -87,8 +88,8 @@ export function darkTheme(): Extension {
|
||||
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
|
||||
|
||||
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
||||
backgroundColor: "#bad0f847",
|
||||
outline: "1px solid #515a6b",
|
||||
backgroundColor: "rgba(18, 19, 23, 0.9)",
|
||||
outline: "1px solid rgba(81, 90, 107, 0.5)",
|
||||
},
|
||||
|
||||
".cm-gutters": {
|
||||
@@ -166,14 +167,20 @@ export function darkTheme(): Extension {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
{ dark: true },
|
||||
);
|
||||
|
||||
/// The highlighting style for code in the JSON Hero theme.
|
||||
const jsonHeroHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: violet },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
tag: [
|
||||
tags.name,
|
||||
tags.deleted,
|
||||
tags.character,
|
||||
tags.propertyName,
|
||||
tags.macroName,
|
||||
],
|
||||
color: lilac,
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: malibu },
|
||||
|
||||
@@ -123,6 +123,16 @@ function createFunctionCompletions(): Completion[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Add special TSQL functions not in the ClickHouse function registry
|
||||
functions.push({
|
||||
label: "timeBucket",
|
||||
type: "function",
|
||||
detail: "auto time bucket (0 args)",
|
||||
apply: "timeBucket()",
|
||||
boost: 1.5,
|
||||
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
|
||||
});
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,13 @@ export function EnvironmentLabel({
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
disableTooltip = false,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
disableTooltip?: boolean;
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
@@ -117,7 +119,7 @@ export function EnvironmentLabel({
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
if (isTruncated && !disableTooltip) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
|
||||
@@ -10,11 +10,14 @@ import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
hasVercelIntegration: boolean;
|
||||
isDevelopment: boolean;
|
||||
};
|
||||
|
||||
type ModalContentProps = ModalProps & {
|
||||
@@ -22,7 +25,12 @@ type ModalContentProps = ModalProps & {
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
export function RegenerateApiKeyModal({
|
||||
id,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
}: ModalProps) {
|
||||
const randomWord = generateTwoRandomWords();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
@@ -37,6 +45,8 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
hasVercelIntegration={hasVercelIntegration}
|
||||
isDevelopment={isDevelopment}
|
||||
randomWord={randomWord}
|
||||
closeModal={() => setOpen(false)}
|
||||
/>
|
||||
@@ -45,7 +55,14 @@ export function RegenerateApiKeyModal({ id, title }: ModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: ModalContentProps) => {
|
||||
const RegenerateApiKeyModalContent = ({
|
||||
id,
|
||||
randomWord,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
closeModal,
|
||||
}: ModalContentProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
@@ -83,6 +100,15 @@ const RegenerateApiKeyModalContent = ({ id, randomWord, title, closeModal }: Mod
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{hasVercelIntegration && !isDevelopment && (
|
||||
<CheckboxWithLabel
|
||||
name="syncToVercel"
|
||||
variant="simple/small"
|
||||
label="Also update TRIGGER_SECRET_KEY in Vercel"
|
||||
defaultChecked={true}
|
||||
value="on"
|
||||
/>
|
||||
)}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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 {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { envSlugToType, type EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
type BuildSettingsFieldsProps = {
|
||||
availableEnvSlugs: EnvSlug[];
|
||||
pullEnvVarsBeforeBuild: EnvSlug[];
|
||||
onPullEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
discoverEnvVars: EnvSlug[];
|
||||
onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
atomicBuilds: EnvSlug[];
|
||||
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
|
||||
envVarsConfigLink?: string;
|
||||
};
|
||||
|
||||
export function BuildSettingsFields({
|
||||
availableEnvSlugs,
|
||||
pullEnvVarsBeforeBuild,
|
||||
onPullEnvVarsChange,
|
||||
discoverEnvVars,
|
||||
onDiscoverEnvVarsChange,
|
||||
atomicBuilds,
|
||||
onAtomicBuildsChange,
|
||||
envVarsConfigLink,
|
||||
}: BuildSettingsFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Pull env vars before build */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Pull env vars before build</Label>
|
||||
{availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
availableEnvSlugs.length > 0 &&
|
||||
availableEnvSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...availableEnvSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={pullEnvVarsBeforeBuild.includes(slug)}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(
|
||||
checked
|
||||
? [...pullEnvVarsBeforeBuild, slug]
|
||||
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discover new env vars */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Discover new env vars</Label>
|
||||
{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))
|
||||
: []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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 isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
|
||||
return (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${isPullDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={discoverEnvVars.includes(slug)}
|
||||
disabled={isPullDisabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? [...discoverEnvVars, slug]
|
||||
: discoverEnvVars.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Atomic deployments */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Atomic deployments</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={atomicBuilds.includes("prod")}
|
||||
onCheckedChange={(checked) => {
|
||||
onAtomicBuildsChange(checked ? ["prod"] : []);
|
||||
}}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function VercelLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 76 65"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ export function MainBody({ children }: { children: React.ReactNode }) {
|
||||
|
||||
/** This container should be placed around the content on a page */
|
||||
export function PageContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
|
||||
return <div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
|
||||
}
|
||||
|
||||
export function PageBody({
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon } from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import {
|
||||
type MachinePresetName,
|
||||
formatDurationMilliseconds,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
descriptionForTaskRunStatus,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { getLevelColor, getKindColor, getKindLabel } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
|
||||
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
|
||||
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run";
|
||||
|
||||
type RunContextData = {
|
||||
run: RunContext | null;
|
||||
};
|
||||
|
||||
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getLevelColor } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { LogLevel } from "./LogLevel";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
type LogDetailViewProps = {
|
||||
logId: string;
|
||||
// If we have the log entry from the list, we can display it immediately
|
||||
@@ -46,27 +33,38 @@ type LogDetailViewProps = {
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
type TabType = "details" | "run";
|
||||
|
||||
type LogAttributes = Record<string, unknown> & {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getDisplayMessage(log: {
|
||||
message: string;
|
||||
level: string;
|
||||
attributes?: LogAttributes;
|
||||
}): string {
|
||||
let message = log.message ?? "";
|
||||
if (log.level === "ERROR") {
|
||||
const maybeErrorMessage = log.attributes?.error?.message;
|
||||
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
|
||||
message = maybeErrorMessage;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function formatStringJSON(str: string): string {
|
||||
return str
|
||||
.replace(/\\n/g, "\n") // Converts literal "\n" to newline
|
||||
.replace(/\\t/g, "\t"); // Converts literal "\t" to tab
|
||||
}
|
||||
|
||||
|
||||
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useTypedFetcher<typeof logDetailLoader>();
|
||||
const [activeTab, setActiveTab] = useState<TabType>("details");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch full log details when logId changes
|
||||
@@ -75,7 +73,9 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
|
||||
setError(null);
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}`
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
environment.slug
|
||||
}/logs/${encodeURIComponent(logId)}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organization.slug, project.slug, environment.slug, logId]);
|
||||
@@ -93,17 +93,15 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const log = fetcher.data ?? initialLog;
|
||||
const runStatus = fetcher.data?.runStatus;
|
||||
|
||||
// Handle Escape key to close panel
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log?.runId ?? "" },
|
||||
{ spanId: log?.spanId ?? "" }
|
||||
);
|
||||
|
||||
if (isLoading && !log) {
|
||||
return (
|
||||
@@ -116,11 +114,16 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
if (!log) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed p-4">
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed py-2 pl-3 pr-2">
|
||||
<Header2>Log Details</Header2>
|
||||
<Button variant="minimal/small" onClick={onClose}>
|
||||
<XMarkIcon className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
|
||||
@@ -129,122 +132,113 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
);
|
||||
}
|
||||
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log.runId },
|
||||
{ spanId: log.spanId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium",
|
||||
getKindColor(log.kind)
|
||||
)}
|
||||
>
|
||||
{getKindLabel(log.kind)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
|
||||
<XMarkIcon className="size-5" />
|
||||
</Button>
|
||||
<div className="flex items-center justify-between overflow-hidden border-b border-grid-dimmed py-2 pl-3 pr-2">
|
||||
<Header2 className="truncate">{getDisplayMessage(log)}</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-4">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={activeTab === "details"}
|
||||
layoutId="log-detail-tabs"
|
||||
onClick={() => setActiveTab("details")}
|
||||
shortcut={{ key: "d" }}
|
||||
>
|
||||
Details
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={activeTab === "run"}
|
||||
layoutId="log-detail-tabs"
|
||||
onClick={() => setActiveTab("run")}
|
||||
shortcut={{ key: "r" }}
|
||||
>
|
||||
Run
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
<Link to={runPath} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="secondary/small" LeadingIcon={ArrowTopRightOnSquareIcon}>
|
||||
View Full Run
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === "details" && (
|
||||
<DetailsTab log={log} runPath={runPath} searchTerm={searchTerm} />
|
||||
)}
|
||||
{activeTab === "run" && (
|
||||
<RunTab log={log} runPath={runPath} />
|
||||
)}
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<DetailsTab log={log} runPath={runPath} runStatus={runStatus} searchTerm={searchTerm} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
|
||||
const logWithExtras = log as LogEntry & {
|
||||
function DetailsTab({
|
||||
log,
|
||||
runPath,
|
||||
runStatus,
|
||||
searchTerm,
|
||||
}: {
|
||||
log: LogEntry & {
|
||||
attributes?: LogAttributes;
|
||||
};
|
||||
|
||||
|
||||
runPath: string;
|
||||
runStatus?: TaskRunStatus;
|
||||
searchTerm?: string;
|
||||
}) {
|
||||
let beautifiedAttributes: string | null = null;
|
||||
|
||||
if (logWithExtras.attributes) {
|
||||
beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
|
||||
if (log.attributes) {
|
||||
beautifiedAttributes = JSON.stringify(log.attributes, null, 2);
|
||||
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
|
||||
}
|
||||
|
||||
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
|
||||
|
||||
// Determine message to show
|
||||
let message = log.message ?? "";
|
||||
if (log.level === "ERROR") {
|
||||
const maybeErrorMessage = logWithExtras.attributes?.error?.message;
|
||||
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
|
||||
message = maybeErrorMessage;
|
||||
}
|
||||
}
|
||||
const message = getDisplayMessage(log);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Time */}
|
||||
<div className="mb-6">
|
||||
<Header3 className="mb-2">Timestamp</Header3>
|
||||
<div className="text-sm text-text-dimmed">
|
||||
<DateTime date={log.startTime} />
|
||||
</div>
|
||||
</div>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Run ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={log.runId} copyValue={log.runId} asChild />
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="secondary/small"
|
||||
shortcut={{ key: "v" }}
|
||||
className="mt-2"
|
||||
>
|
||||
View full run
|
||||
</LinkButton>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{runStatus && (
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={<TaskRunStatusCombo status={runStatus} />}
|
||||
content={descriptionForTaskRunStatus(runStatus)}
|
||||
disableHoverableContent
|
||||
className="mt-1"
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={log.taskIdentifier} copyValue={log.taskIdentifier} asChild />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Level</Property.Label>
|
||||
<Property.Value>
|
||||
<LogLevel level={log.level} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Timestamp</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTimeAccurate date={log.triggeredTimestamp} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-6 mt-3">
|
||||
<PacketDisplay
|
||||
data={message}
|
||||
dataType="application/json"
|
||||
title="Message"
|
||||
searchTerm={searchTerm}
|
||||
wrap={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -262,222 +256,3 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useTypedFetcher<RunContextData>();
|
||||
|
||||
// Fetch run details when tab is active
|
||||
useEffect(() => {
|
||||
if (!log.runId) return;
|
||||
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organization.slug, project.slug, environment.slug, log.id, log.runId]);
|
||||
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const runData = fetcher.data?.run;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!runData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Paragraph className="text-text-dimmed">Run not found in database.</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Run ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={runData.friendlyId} copyValue={runData.friendlyId} asChild />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={<TaskRunStatusCombo status={runData.status as TaskRunStatus} />}
|
||||
content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText
|
||||
value={runData.taskIdentifier}
|
||||
copyValue={runData.taskIdentifier}
|
||||
asChild
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{runData.rootRun && (
|
||||
<Property.Item>
|
||||
<Property.Label>Root and parent run</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText
|
||||
value={runData.rootRun.taskIdentifier}
|
||||
copyValue={runData.rootRun.taskIdentifier}
|
||||
asChild
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
{runData.batch && (
|
||||
<Property.Item>
|
||||
<Property.Label>Batch</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText
|
||||
value={runData.batch.friendlyId}
|
||||
copyValue={runData.batch.friendlyId}
|
||||
asChild
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Version</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.version ? (
|
||||
environment.type === "DEVELOPMENT" ? (
|
||||
<CopyableText value={runData.version} copyValue={runData.version} asChild />
|
||||
) : (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={v3DeploymentVersionPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
runData.version
|
||||
)}
|
||||
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
|
||||
>
|
||||
<CopyableText value={runData.version} copyValue={runData.version} asChild />
|
||||
</TextLink>
|
||||
}
|
||||
content={"Jump to deployment"}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span className="flex items-center gap-1">
|
||||
<span>Never started</span>
|
||||
<InfoIconTooltip
|
||||
content={"Runs get locked to the latest version when they start."}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Test run</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Environment</Property.Label>
|
||||
<Property.Value>
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Queue</Property.Label>
|
||||
<Property.Value>
|
||||
<div>Name: {runData.queue}</div>
|
||||
<div>Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : "–"}</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{runData.tags && runData.tags.length > 0 && (
|
||||
<Property.Item>
|
||||
<Property.Label>Tags</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
|
||||
{runData.tags.map((tag: string) => (
|
||||
<RunTag
|
||||
key={tag}
|
||||
tag={tag}
|
||||
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
|
||||
tooltip={`Filter runs by ${tag}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Machine</Property.Label>
|
||||
<Property.Value className="-ml-0.5">
|
||||
{runData.machinePreset ? (
|
||||
<MachineLabelCombo preset={runData.machinePreset as MachinePresetName} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Run invocation cost</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.baseCostInCents > 0
|
||||
? formatCurrencyAccurate(runData.baseCostInCents / 100)
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Compute cost</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Total cost</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.costInCents > 0 || runData.baseCostInCents > 0
|
||||
? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100)
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Usage duration</Property.Label>
|
||||
<Property.Value>
|
||||
{runData.usageDurationMs > 0
|
||||
? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" })
|
||||
: "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getLevelColor } from "~/utils/logUtils";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
|
||||
export function LogLevel({ level }: { level: LogEntry["level"] }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
|
||||
getLevelColor(level)
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { IconListTree } from "@tabler/icons-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
@@ -12,24 +11,21 @@ import {
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "TRACE", label: "Trace", color: "text-purple-400" },
|
||||
{ level: "INFO", label: "Info", color: "text-blue-400" },
|
||||
{ level: "CANCELLED", label: "Cancelled", color: "text-charcoal-400" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
|
||||
{ level: "TRACE", label: "Trace", color: "text-charcoal-500" },
|
||||
];
|
||||
|
||||
function getAvailableLevels(showDebug: boolean): typeof allLogLevels {
|
||||
if (showDebug) {
|
||||
return allLogLevels;
|
||||
}
|
||||
return allLogLevels.filter((level) => level.level !== "DEBUG");
|
||||
// In the future we might add other levels or change which are available
|
||||
function getAvailableLevels(): typeof allLogLevels {
|
||||
return allLogLevels;
|
||||
}
|
||||
|
||||
function getLevelBadgeColor(level: LogLevel): string {
|
||||
@@ -38,14 +34,12 @@ function getLevelBadgeColor(level: LogLevel): string {
|
||||
return "text-error bg-error/10 border-error/20";
|
||||
case "WARN":
|
||||
return "text-warning bg-warning/10 border-warning/20";
|
||||
case "TRACE":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
|
||||
case "DEBUG":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
case "TRACE":
|
||||
return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
|
||||
case "CANCELLED":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
@@ -53,81 +47,50 @@ function getLevelBadgeColor(level: LogLevel): string {
|
||||
|
||||
const shortcut = { key: "l" };
|
||||
|
||||
export function LogsLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
export function LogsLevelFilter() {
|
||||
const { values } = useSearchParams();
|
||||
const selectedLevels = values("levels");
|
||||
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
|
||||
|
||||
if (hasLevels) {
|
||||
return <AppliedLevelFilter showDebug={showDebug} />;
|
||||
return <AppliedLevelFilter/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<IconListTree className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
showDebug = false,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
showDebug?: boolean;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ levels: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const availableLevels = getAvailableLevels(showDebug);
|
||||
const filtered = useMemo(() => {
|
||||
return availableLevels.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue, availableLevels]);
|
||||
const availableLevels = getAvailableLevels();
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by level..." value={searchValue} />
|
||||
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
{availableLevels.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.level}
|
||||
value={item.level}
|
||||
@@ -149,7 +112,7 @@ function LevelDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
function AppliedLevelFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const levels = values("levels");
|
||||
|
||||
@@ -158,25 +121,18 @@ function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<IconListTree className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "r" };
|
||||
const shortcut = { key: "i" };
|
||||
|
||||
export function LogsRunIdFilter() {
|
||||
const { value } = useSearchParams();
|
||||
|
||||
@@ -1,58 +1,62 @@
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
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";
|
||||
|
||||
export function LogsSearchInput() {
|
||||
const location = useOptimisticLocation();
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { value, replace, del } = useSearchParams();
|
||||
|
||||
// Get initial search value from URL
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const initialSearch = searchParams.get("search") ?? "";
|
||||
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 params = new URLSearchParams(location.search);
|
||||
const urlSearch = params.get("search") ?? "";
|
||||
const urlSearch = value("search") ?? "";
|
||||
if (urlSearch !== text && !isFocused) {
|
||||
setText(urlSearch);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
}, [value, text, isFocused]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (text.trim()) {
|
||||
params.set("search", text.trim());
|
||||
replace({ search: text.trim() });
|
||||
} else {
|
||||
params.delete("search");
|
||||
del("search");
|
||||
}
|
||||
// Reset cursor when searching
|
||||
params.delete("cursor");
|
||||
params.delete("direction");
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}, [text, location.pathname, location.search, navigate]);
|
||||
}, [text, replace, del]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setText("");
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("search");
|
||||
params.delete("cursor");
|
||||
params.delete("direction");
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setText("");
|
||||
del(["search", "cursor", "direction"]);
|
||||
},
|
||||
[del]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative h-6 min-w-52">
|
||||
<motion.div
|
||||
initial={{ width: "auto" }}
|
||||
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
className="relative h-6 min-w-52"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
@@ -61,7 +65,7 @@ export function LogsSearchInput() {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
fullWidth
|
||||
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
|
||||
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -76,22 +80,21 @@ export function LogsSearchInput() {
|
||||
icon={<MagnifyingGlassIcon className="size-4" />}
|
||||
accessory={
|
||||
text.length > 0 ? (
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
|
||||
<div className="-mr-1 flex items-center gap-1">
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
|
||||
<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"
|
||||
title="Clear search"
|
||||
>
|
||||
<XMarkIcon className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{text.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="flex size-6 items-center justify-center rounded text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="Clear search"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { getLevelColor, highlightSearchText } from "~/utils/logUtils";
|
||||
import { highlightSearchText } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { DateTime } from "../primitives/DateTime";
|
||||
import { DateTimeAccurate } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { LogLevel } from "./LogLevel";
|
||||
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
|
||||
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
@@ -23,7 +26,7 @@ import {
|
||||
TableRow,
|
||||
type TableVariant,
|
||||
} from "../primitives/Table";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogEntry[];
|
||||
@@ -32,30 +35,29 @@ type LogsTableProps = {
|
||||
isLoadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
onCheckForMore?: () => void;
|
||||
variant?: TableVariant;
|
||||
selectedLogId?: string;
|
||||
onLogSelect?: (logId: string) => void;
|
||||
};
|
||||
|
||||
// Left border color for error highlighting
|
||||
function getLevelBorderColor(level: LogEntry["level"]): string {
|
||||
// Inner shadow for level highlighting (better scroll performance than border-l)
|
||||
function getLevelBoxShadow(level: LogEntry["level"]): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "border-l-error";
|
||||
return "inset 2px 0 0 0 rgb(239, 68, 68)";
|
||||
case "WARN":
|
||||
return "border-l-warning";
|
||||
return "inset 2px 0 0 0 rgb(234, 179, 8)";
|
||||
case "INFO":
|
||||
return "border-l-blue-500";
|
||||
case "CANCELLED":
|
||||
return "border-l-charcoal-600";
|
||||
case "DEBUG":
|
||||
return "inset 2px 0 0 0 rgb(59, 130, 246)";
|
||||
case "TRACE":
|
||||
return "inset 2px 0 0 0 rgb(168, 85, 247)";
|
||||
case "DEBUG":
|
||||
default:
|
||||
return "border-l-transparent hover:border-l-charcoal-800";
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function LogsTable({
|
||||
logs,
|
||||
searchTerm,
|
||||
@@ -63,6 +65,7 @@ export function LogsTable({
|
||||
isLoadingMore = false,
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
onCheckForMore,
|
||||
selectedLogId,
|
||||
onLogSelect,
|
||||
}: LogsTableProps) {
|
||||
@@ -112,14 +115,20 @@ export function LogsTable({
|
||||
}, [hasMore, isLoadingMore, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible">
|
||||
<div className="relative h-full overflow-auto border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible" showTopBorder={false}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHeaderCell className="min-w-48 whitespace-nowrap">Time</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Run</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-32 whitespace-nowrap">Task</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Level</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
className="min-w-24 whitespace-nowrap"
|
||||
tooltip={<LogLevelTooltipInfo />}
|
||||
disableTooltipHoverableContent
|
||||
>
|
||||
Level
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -143,8 +152,7 @@ export function LogsTable({
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-2 transition-colors",
|
||||
getLevelBorderColor(log.level),
|
||||
"cursor-pointer transition-colors",
|
||||
isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850"
|
||||
)}
|
||||
isSelected={isSelected}
|
||||
@@ -153,24 +161,20 @@ export function LogsTable({
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
onClick={handleRowClick}
|
||||
hasAction
|
||||
style={{
|
||||
boxShadow: getLevelBoxShadow(log.level),
|
||||
}}
|
||||
>
|
||||
<DateTime date={log.startTime} />
|
||||
<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>
|
||||
<span className="font-mono text-xs">{log.taskIdentifier}</span>
|
||||
</TableCell>
|
||||
<TableCell onClick={handleRowClick} hasAction>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
<LogLevel level={log.level} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
|
||||
<span className="block truncate font-mono text-xs" title={log.message}>
|
||||
@@ -180,12 +184,15 @@ export function LogsTable({
|
||||
<TableCellMenu
|
||||
className="pl-32"
|
||||
hiddenButtons={
|
||||
<PopoverMenuItem
|
||||
openInNewTab={true}
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
icon={ArrowTopRightOnSquareIcon}
|
||||
title="View Run"
|
||||
/>
|
||||
variant="minimal/small"
|
||||
TrailingIcon={RunsIcon}
|
||||
trailingIconClassName="text-text-bright"
|
||||
className="h-[1.375rem] pl-1.5 pr-2"
|
||||
>
|
||||
<span className="text-[0.6875rem] text-text-bright">View run</span>
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
@@ -196,12 +203,18 @@ export function LogsTable({
|
||||
</Table>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && logs.length > 0 && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{showLoadMoreSpinner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-12">
|
||||
<div className={cn("flex items-center gap-2", !showLoadMoreSpinner && "invisible")}>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show all logs message with check for more button */}
|
||||
{!hasMore && logs.length > 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-text-dimmed">Showing all {logs.length} logs</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -220,11 +233,7 @@ function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?:
|
||||
No logs match your filters. Try refreshing or modifying your filters.
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
variant="tertiary/medium"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<Button LeadingIcon={ArrowPathIcon} variant="tertiary/medium" onClick={handleRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
|
||||
const shortcut = { key: "t" };
|
||||
|
||||
type TaskOption = {
|
||||
slug: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
};
|
||||
|
||||
interface LogsTaskFilterProps {
|
||||
possibleTasks: TaskOption[];
|
||||
}
|
||||
|
||||
export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedTasks = values("tasks");
|
||||
|
||||
if (selectedTasks.length === 0 || selectedTasks.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by task"
|
||||
>
|
||||
<span className="ml-0.5">Tasks</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Task"
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
value={appliedSummary(
|
||||
selectedTasks.map((v) => {
|
||||
const task = possibleTasks.find((task) => task.slug === v);
|
||||
return task ? task.slug : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["tasks", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleTasks,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleTasks: TaskOption[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ tasks: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleTasks.filter((item) => {
|
||||
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleTasks]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("tasks")} 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 task..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={`${item.triggerSource}-${item.slug}`}
|
||||
value={item.slug}
|
||||
icon={
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { ClipboardIcon } from "@heroicons/react/24/outline";
|
||||
import { ChartBarIcon } from "@heroicons/react/24/solid";
|
||||
import { type OutputColumnMetadata } from "@internal/tsql";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react";
|
||||
import { assertNever } from "assert-never";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
import { Card } from "~/components/primitives/charts/Card";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { rowsToCSV, rowsToJSON } from "~/utils/dataExport";
|
||||
import { QueryResultsChart } from "../code/QueryResultsChart";
|
||||
import { TSQLResultsTable } from "../code/TSQLResultsTable";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { BigNumberCard } from "../primitives/charts/BigNumberCard";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Label } from "../primitives/Label";
|
||||
import { LoadingBarDivider } from "../primitives/LoadingBarDivider";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
} from "../primitives/Popover";
|
||||
|
||||
const ChartType = z.union([z.literal("bar"), z.literal("line")]);
|
||||
export type ChartType = z.infer<typeof ChartType>;
|
||||
|
||||
const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
|
||||
export type SortDirection = z.infer<typeof SortDirection>;
|
||||
|
||||
const AggregationType = z.union([
|
||||
z.literal("sum"),
|
||||
z.literal("avg"),
|
||||
z.literal("count"),
|
||||
z.literal("min"),
|
||||
z.literal("max"),
|
||||
]);
|
||||
export type AggregationType = z.infer<typeof AggregationType>;
|
||||
|
||||
const chartConfigOptions = {
|
||||
chartType: ChartType,
|
||||
xAxisColumn: z.string().nullable(),
|
||||
yAxisColumns: z.string().array(),
|
||||
groupByColumn: z.string().nullable(),
|
||||
stacked: z.boolean(),
|
||||
sortByColumn: z.string().nullable(),
|
||||
sortDirection: SortDirection,
|
||||
aggregation: AggregationType,
|
||||
seriesColors: z.record(z.string()).optional(),
|
||||
};
|
||||
|
||||
const ChartConfiguration = z.object({ ...chartConfigOptions });
|
||||
export type ChartConfiguration = z.infer<typeof ChartConfiguration>;
|
||||
|
||||
const BigNumberAggregationType = z.union([
|
||||
z.literal("sum"),
|
||||
z.literal("avg"),
|
||||
z.literal("count"),
|
||||
z.literal("min"),
|
||||
z.literal("max"),
|
||||
z.literal("first"),
|
||||
z.literal("last"),
|
||||
]);
|
||||
export type BigNumberAggregationType = z.infer<typeof BigNumberAggregationType>;
|
||||
|
||||
const BigNumberSortDirection = z.union([z.literal("asc"), z.literal("desc")]);
|
||||
|
||||
const bigNumberConfigOptions = {
|
||||
column: z.string(),
|
||||
aggregation: BigNumberAggregationType,
|
||||
sortDirection: BigNumberSortDirection.optional(),
|
||||
abbreviate: z.boolean().default(false),
|
||||
prefix: z.string().optional(),
|
||||
suffix: z.string().optional(),
|
||||
};
|
||||
|
||||
const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions });
|
||||
export type BigNumberConfiguration = z.infer<typeof BigNumberConfiguration>;
|
||||
|
||||
export const QueryWidgetConfig = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("table"),
|
||||
prettyFormatting: z.boolean().default(true),
|
||||
sorting: z
|
||||
.array(
|
||||
z.object({
|
||||
desc: z.boolean(),
|
||||
id: z.string(),
|
||||
})
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("chart"),
|
||||
...chartConfigOptions,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("bignumber"),
|
||||
...bigNumberConfigOptions,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("title"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type QueryWidgetConfig = z.infer<typeof QueryWidgetConfig>;
|
||||
|
||||
/** Result data containing rows and column metadata */
|
||||
export type QueryWidgetData = {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
};
|
||||
|
||||
/** Widget configuration with optional result data (used for edit callbacks) */
|
||||
export type WidgetData = {
|
||||
title: string;
|
||||
query: string;
|
||||
display: QueryWidgetConfig;
|
||||
/** The current result data from the widget */
|
||||
resultData?: QueryWidgetData;
|
||||
};
|
||||
|
||||
export type QueryWidgetProps = {
|
||||
title: ReactNode;
|
||||
/** String title for rename dialog (optional - if not provided, rename won't be available) */
|
||||
titleString?: string;
|
||||
/** The TSQL query string (used for "Copy query" in the menu) */
|
||||
query?: string;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
data: QueryWidgetData;
|
||||
config: QueryWidgetConfig;
|
||||
/** The effective time range for the query (used to show full x-axis on time-based charts) */
|
||||
timeRange?: { from: string; to: string };
|
||||
accessory?: ReactNode;
|
||||
isResizing?: boolean;
|
||||
isDraggable?: boolean;
|
||||
/** Additional className applied to the Card wrapper */
|
||||
className?: string;
|
||||
/** Callback when edit is clicked. Receives the current data. */
|
||||
onEdit?: (data: QueryWidgetData) => void;
|
||||
/** Callback when rename is clicked. Receives the new title. */
|
||||
onRename?: (newTitle: string) => void;
|
||||
/** Callback when delete is clicked. */
|
||||
onDelete?: () => void;
|
||||
/** Callback when duplicate is clicked. Receives the current data. */
|
||||
onDuplicate?: (data: QueryWidgetData) => void;
|
||||
/** When true, show table column headers even when there are no rows */
|
||||
showTableHeaderOnEmpty?: boolean;
|
||||
};
|
||||
|
||||
export function QueryWidget({
|
||||
title,
|
||||
titleString,
|
||||
query,
|
||||
accessory,
|
||||
isLoading,
|
||||
error,
|
||||
isResizing,
|
||||
isDraggable,
|
||||
className,
|
||||
onEdit,
|
||||
onRename,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
...props
|
||||
}: QueryWidgetProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(titleString ?? "");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const hasEditActions = onEdit || onRename || onDelete || onDuplicate;
|
||||
const hasData = props.data.rows.length > 0;
|
||||
|
||||
// "v" to toggle fullscreen on hovered widget
|
||||
useShortcutKeys({
|
||||
shortcut: { key: "v" },
|
||||
action: useCallback(() => {
|
||||
const isHovered = containerRef.current?.matches(":hover");
|
||||
if (!isFullscreen && !isHovered) return;
|
||||
setIsFullscreen((prev) => !prev);
|
||||
}, [isFullscreen]),
|
||||
});
|
||||
|
||||
const copyToClipboard = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
}, []);
|
||||
|
||||
const copyQuery = useCallback(() => {
|
||||
if (query) {
|
||||
copyToClipboard(query);
|
||||
}
|
||||
}, [query, copyToClipboard]);
|
||||
|
||||
const copyJSON = useCallback(() => {
|
||||
copyToClipboard(rowsToJSON(props.data.rows));
|
||||
}, [props.data.rows, copyToClipboard]);
|
||||
|
||||
const copyCSV = useCallback(() => {
|
||||
copyToClipboard(rowsToCSV(props.data.rows, props.data.columns));
|
||||
}, [props.data, copyToClipboard]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="group h-full">
|
||||
<Card className={cn("h-full overflow-hidden px-0 pb-0", className)}>
|
||||
<Card.Header draggable={isDraggable}>
|
||||
<div className="flex items-center gap-1.5">{title}</div>
|
||||
<Card.Accessory>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={Maximize2}
|
||||
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="!px-1"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Maximize
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
/>
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverVerticalEllipseTrigger
|
||||
isOpen={isMenuOpen}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
isMenuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
/>
|
||||
<PopoverContent align="end" className="p-0">
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{hasEditActions && (
|
||||
<>
|
||||
{onEdit && (
|
||||
<PopoverMenuItem
|
||||
icon={IconChartHistogram}
|
||||
title="Edit chart"
|
||||
onClick={() => {
|
||||
onEdit(props.data);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
leadingIconClassName="-ml-0.5 -mr-1"
|
||||
/>
|
||||
)}
|
||||
{onRename && (
|
||||
<PopoverMenuItem
|
||||
icon={PencilSquareIcon}
|
||||
title="Rename"
|
||||
onClick={() => {
|
||||
setRenameValue(titleString ?? "");
|
||||
setIsRenameDialogOpen(true);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<PopoverMenuItem
|
||||
icon={DocumentDuplicateIcon}
|
||||
title="Duplicate chart"
|
||||
onClick={() => {
|
||||
onDuplicate(props.data);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="pr-4"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{query && (
|
||||
<PopoverMenuItem
|
||||
icon={ClipboardIcon}
|
||||
title="Copy query"
|
||||
onClick={() => {
|
||||
copyQuery();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
icon={IconBraces}
|
||||
title="Copy JSON"
|
||||
disabled={!hasData}
|
||||
onClick={() => {
|
||||
copyJSON();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
leadingIconClassName="-ml-0.5 -mr-1"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={IconFileTypeCsv}
|
||||
title="Copy CSV"
|
||||
disabled={!hasData}
|
||||
onClick={() => {
|
||||
copyCSV();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
leadingIconClassName="-ml-0.5 -mr-1"
|
||||
/>
|
||||
{onDelete && (
|
||||
<PopoverMenuItem
|
||||
icon={TrashIcon}
|
||||
title="Delete chart"
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error hover:!bg-error/10"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{accessory}
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<LoadingBarDivider isLoading={isLoading ?? false} className="bg-transparent" />
|
||||
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{isResizing ? (
|
||||
<div className="flex h-full flex-1 items-center justify-center p-3">
|
||||
<div className="flex flex-col items-center gap-1 text-text-dimmed">
|
||||
<ChartBarIcon className="size-10 text-text-dimmed" />{" "}
|
||||
<span className="text-base font-medium">Resizing...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3">
|
||||
<Callout variant="error">{error}</Callout>
|
||||
</div>
|
||||
) : (
|
||||
<QueryWidgetBody
|
||||
{...props}
|
||||
title={title}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
isLoading={isLoading ?? false}
|
||||
/>
|
||||
)}
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
{onRename && (
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Rename chart</DialogHeader>
|
||||
<form
|
||||
className="space-y-4 pt-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (renameValue.trim()) {
|
||||
onRename(renameValue.trim());
|
||||
setIsRenameDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="Chart title"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type QueryWidgetBodyProps = {
|
||||
title: ReactNode;
|
||||
data: QueryWidgetData;
|
||||
config: QueryWidgetConfig;
|
||||
timeRange?: { from: string; to: string };
|
||||
isFullscreen: boolean;
|
||||
setIsFullscreen: (open: boolean) => void;
|
||||
isLoading: boolean;
|
||||
showTableHeaderOnEmpty?: boolean;
|
||||
};
|
||||
|
||||
function QueryWidgetBody({
|
||||
title,
|
||||
data,
|
||||
config,
|
||||
timeRange,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
isLoading,
|
||||
showTableHeaderOnEmpty,
|
||||
}: QueryWidgetBodyProps) {
|
||||
const type = config.type;
|
||||
|
||||
// Only show the loading state if we have no data yet (initial load).
|
||||
// During a reload with existing data, keep showing the current data
|
||||
// while the loading bar in the header indicates a refresh is in progress.
|
||||
const hasData = data.rows.length > 0;
|
||||
const showLoading = isLoading && !hasData;
|
||||
|
||||
switch (type) {
|
||||
case "table": {
|
||||
return (
|
||||
<>
|
||||
<TSQLResultsTable
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
prettyFormatting={config.prettyFormatting}
|
||||
sorting={config.sorting}
|
||||
showHeaderOnEmpty={showTableHeaderOnEmpty}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent
|
||||
fullscreen
|
||||
className="flex flex-col gap-0 bg-background-bright px-0 pb-0"
|
||||
>
|
||||
<DialogHeader className="px-4">{title}</DialogHeader>
|
||||
<div className="min-h-0 w-full flex-1 pt-2.5">
|
||||
<TSQLResultsTable
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
prettyFormatting={config.prettyFormatting}
|
||||
sorting={config.sorting}
|
||||
showHeaderOnEmpty={showTableHeaderOnEmpty}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "chart": {
|
||||
return (
|
||||
<>
|
||||
<QueryResultsChart
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
onViewAllLegendItems={() => setIsFullscreen(true)}
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent fullscreen className="flex flex-col bg-background-bright">
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
|
||||
<QueryResultsChart
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
fullLegend
|
||||
legendScrollable
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "bignumber": {
|
||||
return (
|
||||
<>
|
||||
<BigNumberCard
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent fullscreen className="flex flex-col bg-background-bright">
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<div className="flex min-h-0 w-full flex-1 items-center justify-center pt-4">
|
||||
<BigNumberCard
|
||||
rows={data.rows}
|
||||
columns={data.columns}
|
||||
config={config}
|
||||
isLoading={showLoading}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "title": {
|
||||
// Title widgets are rendered by TitleWidget, not QueryWidget
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { RectangleStackIcon } from "@heroicons/react/20/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useDebounceEffect } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "q" };
|
||||
|
||||
export function QueuesFilter() {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedQueues = values("queues");
|
||||
|
||||
if (selectedQueues.length === 0 || selectedQueues.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<QueuesDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<RectangleStackIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by queue"
|
||||
>
|
||||
<span className="ml-1">Queues</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<QueuesDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Queues"
|
||||
icon={<RectangleStackIcon className="size-4" />}
|
||||
value={appliedSummary(selectedQueues.map((v) => v.replace("task/", "")))}
|
||||
onRemove={() => del(["queues"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function QueuesDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({
|
||||
queues: values.length > 0 ? values : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const queueValues = values("queues").filter((v) => v !== "");
|
||||
const selected = queueValues.length > 0 ? queueValues : undefined;
|
||||
|
||||
const fetcher = useFetcher<typeof queuesLoader>();
|
||||
|
||||
useDebounceEffect(
|
||||
searchValue,
|
||||
(s) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("per_page", "25");
|
||||
if (searchValue) {
|
||||
searchParams.set("query", s);
|
||||
}
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
environment.slug
|
||||
}/queues?${searchParams.toString()}`
|
||||
);
|
||||
},
|
||||
250
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
// Use a Map to deduplicate by value
|
||||
const itemsMap = new Map<string, { name: string; type: "custom" | "task"; value: string }>();
|
||||
|
||||
// Add selected items first (for items not yet loaded from fetcher)
|
||||
for (const queueName of selected ?? []) {
|
||||
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
|
||||
if (!queueItem) {
|
||||
if (queueName.startsWith("task/")) {
|
||||
itemsMap.set(queueName, {
|
||||
name: queueName.replace("task/", ""),
|
||||
type: "task",
|
||||
value: queueName,
|
||||
});
|
||||
} else {
|
||||
itemsMap.set(queueName, {
|
||||
name: queueName,
|
||||
type: "custom",
|
||||
value: queueName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add items from fetcher data
|
||||
if (fetcher.data !== undefined) {
|
||||
for (const q of fetcher.data.queues) {
|
||||
const value = q.type === "task" ? `task/${q.name}` : q.name;
|
||||
itemsMap.set(value, {
|
||||
name: q.name,
|
||||
type: q.type,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const items = Array.from(itemsMap.values());
|
||||
return matchSorter(items, searchValue, {
|
||||
keys: ["name"],
|
||||
});
|
||||
}, [searchValue, fetcher.data, selected]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={selected ?? []} 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
|
||||
value={searchValue}
|
||||
render={(props) => (
|
||||
<div className="flex items-center justify-stretch">
|
||||
<input {...props} placeholder={"Filter by queues..."} />
|
||||
{fetcher.state === "loading" && <Spinner color="muted" />}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<SelectList>
|
||||
{filtered.length > 0
|
||||
? filtered.map((queue) => (
|
||||
<SelectItem
|
||||
key={queue.value}
|
||||
value={queue.value}
|
||||
icon={
|
||||
queue.type === "task" ? (
|
||||
<TaskIcon className="size-4 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{queue.name}
|
||||
</SelectItem>
|
||||
))
|
||||
: null}
|
||||
{filtered.length === 0 && fetcher.state !== "loading" && (
|
||||
<SelectItem disabled>No queues found</SelectItem>
|
||||
)}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import {
|
||||
useCustomDashboards,
|
||||
useOrganization,
|
||||
useWidgetLimitPerDashboard,
|
||||
} from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import type { QueryWidgetConfig } from "./QueryWidget";
|
||||
|
||||
export type SaveToDashboardDialogProps = {
|
||||
title: string;
|
||||
query: string;
|
||||
config: QueryWidgetConfig;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function SaveToDashboardDialog({
|
||||
title,
|
||||
query,
|
||||
config,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: SaveToDashboardDialogProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const customDashboards = useCustomDashboards();
|
||||
const widgetLimit = useWidgetLimitPerDashboard();
|
||||
const fetcher = useFetcher<{ success: boolean }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Find the first dashboard that isn't at the widget limit
|
||||
const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
|
||||
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
|
||||
firstAvailableDashboard?.friendlyId ?? customDashboards[0]?.friendlyId ?? null
|
||||
);
|
||||
|
||||
// Build the form action URL
|
||||
const formAction = selectedDashboardId
|
||||
? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/widgets`
|
||||
: "";
|
||||
|
||||
const isLoading = fetcher.state === "submitting";
|
||||
|
||||
// Check if selected dashboard is at widget limit
|
||||
const selectedDashboard = customDashboards.find((d) => d.friendlyId === selectedDashboardId);
|
||||
const isSelectedAtLimit = selectedDashboard
|
||||
? selectedDashboard.widgetCount >= widgetLimit
|
||||
: false;
|
||||
|
||||
// Navigate to the dashboard when the fetcher completes successfully
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data?.success && selectedDashboardId) {
|
||||
onOpenChange(false);
|
||||
navigate(
|
||||
v3CustomDashboardPath(
|
||||
{ slug: organization.slug },
|
||||
{ slug: project.slug },
|
||||
{ slug: environment.slug },
|
||||
{ friendlyId: selectedDashboardId }
|
||||
)
|
||||
);
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, selectedDashboardId, onOpenChange, navigate, organization.slug, project.slug, environment.slug]);
|
||||
|
||||
// Update selection if dashboards change
|
||||
useEffect(() => {
|
||||
if (customDashboards.length > 0 && !selectedDashboardId) {
|
||||
const available = customDashboards.find((d) => d.widgetCount < widgetLimit);
|
||||
setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId);
|
||||
}
|
||||
}, [customDashboards, selectedDashboardId, widgetLimit]);
|
||||
|
||||
if (customDashboards.length === 0) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Add to dashboard</DialogHeader>
|
||||
<div className="!mt-1 space-y-4">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
You don't have any custom dashboards yet. Create one first from the sidebar menu.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
className="justify-end"
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Close</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Add to dashboard</DialogHeader>
|
||||
<fetcher.Form method="post" action={formAction} className="space-y-4">
|
||||
<input type="hidden" name="action" value="add" />
|
||||
<input type="hidden" name="title" value={title} />
|
||||
<input type="hidden" name="query" value={query} />
|
||||
<input type="hidden" name="config" value={JSON.stringify(config)} />
|
||||
|
||||
<div className="!mt-1 space-y-2">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Select a dashboard to add this chart to:
|
||||
</Paragraph>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{customDashboards.map((dashboard) => {
|
||||
const isAtLimit = dashboard.widgetCount >= widgetLimit;
|
||||
return (
|
||||
<button
|
||||
key={dashboard.friendlyId}
|
||||
type="button"
|
||||
onClick={() => !isAtLimit && setSelectedDashboardId(dashboard.friendlyId)}
|
||||
disabled={isAtLimit}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
|
||||
isAtLimit
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: selectedDashboardId === dashboard.friendlyId
|
||||
? "bg-charcoal-700 text-text-bright"
|
||||
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{selectedDashboardId === dashboard.friendlyId ? (
|
||||
<IconCheck className="size-4 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate">{dashboard.title}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs",
|
||||
isAtLimit ? "text-error" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{dashboard.widgetCount}/{widgetLimit}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={isLoading || !selectedDashboardId || isSelectedAtLimit}
|
||||
>
|
||||
{isLoading ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { CubeTransparentIcon, GlobeAltIcon } from "@heroicons/react/20/solid";
|
||||
import { IconListLetters } from "@tabler/icons-react";
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: "environment", label: "Environment" },
|
||||
{ value: "project", label: "Project" },
|
||||
{ value: "organization", label: "Organization" },
|
||||
] as const;
|
||||
|
||||
export function ScopeFilter() {
|
||||
const { value, replace } = useSearchParams();
|
||||
const scope = (value("scope") as QueryScope) ?? "environment";
|
||||
|
||||
const handleChange = (newScope: string) => {
|
||||
replace({ scope: newScope === "environment" ? undefined : newScope });
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectProvider value={scope} setValue={handleChange}>
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Scope"
|
||||
icon={<CubeTransparentIcon className="size-4" />}
|
||||
value={<ScopeItem scope={scope} />}
|
||||
removable={false}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
||||
{scopeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<ScopeItem scope={option.value} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeItem({ scope }: { scope: QueryScope }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
switch (scope) {
|
||||
case "organization":
|
||||
return `Org: ${organization.title}`;
|
||||
case "project":
|
||||
return `Project: ${project.name}`;
|
||||
case "environment":
|
||||
return <EnvironmentLabel environment={environment} />;
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useState } from "react";
|
||||
import { PencilIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverVerticalEllipseTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Label } from "../primitives/Label";
|
||||
|
||||
export type TitleWidgetProps = {
|
||||
title: string;
|
||||
isDraggable?: boolean;
|
||||
isResizing?: boolean;
|
||||
/** Callback when rename is clicked. Receives the new title. */
|
||||
onRename?: (newTitle: string) => void;
|
||||
/** Callback when delete is clicked. */
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
export function TitleWidget({
|
||||
title,
|
||||
isDraggable,
|
||||
isResizing,
|
||||
onRename,
|
||||
onDelete,
|
||||
}: TitleWidgetProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(title);
|
||||
|
||||
const hasMenu = onRename || onDelete;
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-full items-center gap-2 rounded-lg border border-grid-bright bg-background-bright px-4",
|
||||
isDraggable && "drag-handle cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-lg font-medium text-text-bright">
|
||||
{title}
|
||||
</span>
|
||||
{hasMenu && (
|
||||
<div className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<PopoverVerticalEllipseTrigger isOpen={isMenuOpen} />
|
||||
<PopoverContent align="end" className="p-0">
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{onRename && (
|
||||
<PopoverMenuItem
|
||||
icon={PencilIcon}
|
||||
title="Rename"
|
||||
onClick={() => {
|
||||
setRenameValue(title);
|
||||
setIsRenameDialogOpen(true);
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onDelete && (
|
||||
<PopoverMenuItem
|
||||
icon={TrashIcon}
|
||||
title="Delete"
|
||||
leadingIconClassName="text-error"
|
||||
className="text-error hover:!bg-error/10"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rename Dialog */}
|
||||
{onRename && (
|
||||
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>Rename title</DialogHeader>
|
||||
<form
|
||||
className="space-y-4 pt-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (renameValue.trim()) {
|
||||
onRename(renameValue.trim());
|
||||
setIsRenameDialogOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="Section title"
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
personalAccessTokensPath,
|
||||
rootPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
return (
|
||||
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
data-action="security"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { PlusIcon } from "@heroicons/react/20/solid";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "../primitives/Dialog";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Label } from "../primitives/Label";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
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,
|
||||
environment,
|
||||
isCollapsed,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
const dashboard = useCreateDashboard({ organization, project, environment });
|
||||
|
||||
if (isCollapsed) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={dashboard.isOpen} onOpenChange={dashboard.setIsOpen}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full w-full items-center justify-center rounded text-text-dimmed transition focus-custom hover:bg-charcoal-600 hover:text-text-bright"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
Create dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{dashboard.isAtLimit ? (
|
||||
<CreateDashboardUpgradeDialog
|
||||
limits={dashboard.limits}
|
||||
canUpgrade={dashboard.canUpgrade}
|
||||
isFreePlan={dashboard.isFreePlan}
|
||||
organization={dashboard.organization}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const PROGRESS_RING_R = 27.5;
|
||||
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
|
||||
const PROGRESS_COLOR_SUCCESS = "#28BF5C"; // mint-500 / success
|
||||
const PROGRESS_COLOR_ERROR = "#E11D48"; // rose-600 / error
|
||||
|
||||
function CreateDashboardUpgradeDialog({
|
||||
limits,
|
||||
canUpgrade,
|
||||
isFreePlan,
|
||||
organization,
|
||||
}: {
|
||||
limits: { used: number; limit: number };
|
||||
canUpgrade: boolean;
|
||||
isFreePlan: boolean;
|
||||
organization: { slug: string };
|
||||
}) {
|
||||
|
||||
if (isFreePlan) {
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>Upgrade to unlock dashboards</DialogHeader>
|
||||
<div className="flex items-center gap-4 pt-3">
|
||||
<ArrowUpCircleIcon className="ml-1 size-14 shrink-0 text-indigo-500" />
|
||||
<DialogDescription className="pt-0">
|
||||
Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
|
||||
track your task metrics.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="flex justify-between">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
|
||||
Upgrade plan
|
||||
</LinkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
const percentage = Math.min(limits.used / limits.limit, 1);
|
||||
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>Dashboard limit reached</DialogHeader>
|
||||
<div className="flex items-center gap-4 pt-3">
|
||||
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="5"
|
||||
r={PROGRESS_RING_R}
|
||||
cx="30"
|
||||
cy="30"
|
||||
/>
|
||||
<motion.circle
|
||||
className="fill-none"
|
||||
strokeWidth="5"
|
||||
r={PROGRESS_RING_R}
|
||||
cx="30"
|
||||
cy="30"
|
||||
strokeLinecap="round"
|
||||
initial={{
|
||||
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
|
||||
stroke: PROGRESS_COLOR_SUCCESS,
|
||||
}}
|
||||
animate={{
|
||||
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
|
||||
stroke: PROGRESS_COLOR_ERROR,
|
||||
}}
|
||||
transition={{ duration: 1.2, ease: "easeInOut" }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
|
||||
{limits.limit}
|
||||
</span>
|
||||
</div>
|
||||
<DialogDescription className="pt-0">
|
||||
{canUpgrade ? (
|
||||
<>
|
||||
{limits.limit === 1
|
||||
? "Your plan includes 1 custom dashboard and it's already in use."
|
||||
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
|
||||
Upgrade your plan to create more.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{limits.limit === 1
|
||||
? "Your plan includes 1 custom dashboard and it's already in use."
|
||||
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
|
||||
To create more, request a limit increase or visit the{" "}
|
||||
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
|
||||
details.
|
||||
</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogFooter className="flex justify-between">
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
{canUpgrade ? (
|
||||
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
|
||||
Upgrade plan
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="primary/medium">Request more…</Button>}
|
||||
defaultValue="help"
|
||||
/>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateDashboardDialog({
|
||||
formAction,
|
||||
limits,
|
||||
}: {
|
||||
formAction: string;
|
||||
limits: { used: number; limit: number };
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Create dashboard</DialogHeader>
|
||||
<Form method="post" action={formAction} className="space-y-4 pt-3">
|
||||
<InputGroup>
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
name="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="My Dashboard"
|
||||
required
|
||||
/>
|
||||
</InputGroup>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{limits.used}/{limits.limit} dashboards used
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
|
||||
{isLoading ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { IconChartHistogram } from "@tabler/icons-react";
|
||||
import { GripVerticalIcon, LineChartIcon } from "lucide-react";
|
||||
import ReactGridLayout from "react-grid-layout";
|
||||
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
|
||||
import { type UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
|
||||
import { useReorderableList } from "./useReorderableList";
|
||||
|
||||
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
|
||||
export function DashboardList({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
isCollapsed,
|
||||
user,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
isCollapsed: boolean;
|
||||
user: SideMenuUser;
|
||||
}) {
|
||||
const customDashboards = useCustomDashboards();
|
||||
const initialOrder =
|
||||
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
|
||||
"customDashboards"
|
||||
];
|
||||
|
||||
const {
|
||||
orderedItems: orderedDashboards,
|
||||
layout,
|
||||
containerRef,
|
||||
gridWidth,
|
||||
canReorder,
|
||||
handleDrag,
|
||||
handleDragStop,
|
||||
getIsLast,
|
||||
} = useReorderableList({
|
||||
organizationId: organization.id,
|
||||
listId: "customDashboards",
|
||||
items: customDashboards,
|
||||
itemKey: (d) => d.friendlyId,
|
||||
initialOrder,
|
||||
isImpersonating: user.isImpersonating,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
{canReorder ? (
|
||||
<ReactGridLayout
|
||||
layout={layout}
|
||||
width={gridWidth}
|
||||
gridConfig={{
|
||||
cols: 1,
|
||||
rowHeight: 32,
|
||||
margin: [0, 0] as const,
|
||||
containerPadding: [0, 0] as const,
|
||||
}}
|
||||
resizeConfig={{ enabled: false }}
|
||||
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
|
||||
onDrag={handleDrag}
|
||||
onDragStop={handleDragStop}
|
||||
className="sidebar-reorder-grid"
|
||||
autoSize
|
||||
>
|
||||
{orderedDashboards.map((dashboard, index) => {
|
||||
const isLast = getIsLast(dashboard.friendlyId, index);
|
||||
return (
|
||||
<div key={dashboard.friendlyId}>
|
||||
<SideMenuItem
|
||||
name={dashboard.title}
|
||||
icon={
|
||||
isCollapsed
|
||||
? IconChartHistogram
|
||||
: isLast
|
||||
? TreeConnectorEnd
|
||||
: TreeConnectorBranch
|
||||
}
|
||||
activeIconColor={isCollapsed ? "text-customDashboards" : undefined}
|
||||
inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined}
|
||||
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
|
||||
isCollapsed={isCollapsed}
|
||||
action={
|
||||
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
|
||||
<GripVerticalIcon className="size-3.5" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ReactGridLayout>
|
||||
) : (
|
||||
orderedDashboards.map((dashboard, index) => {
|
||||
const isLast = index === orderedDashboards.length - 1;
|
||||
return (
|
||||
<SideMenuItem
|
||||
key={dashboard.friendlyId}
|
||||
name={dashboard.title}
|
||||
icon={
|
||||
isCollapsed
|
||||
? LineChartIcon
|
||||
: isLast
|
||||
? TreeConnectorEnd
|
||||
: TreeConnectorBranch
|
||||
}
|
||||
activeIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
|
||||
inactiveIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
|
||||
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
|
||||
import { ButtonContent } from "../primitives/Buttons";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { V4Badge } from "../V4Badge";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
|
||||
project,
|
||||
environment,
|
||||
className,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
className?: string;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isMenuOpen}
|
||||
overflowHidden
|
||||
fullWidth
|
||||
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
|
||||
>
|
||||
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
|
||||
</PopoverArrowTrigger>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
|
||||
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={environmentFullTitle(environment)}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
SignalIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Shortcuts } from "../Shortcuts";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
@@ -19,30 +22,85 @@ import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
|
||||
export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
|
||||
export function HelpAndFeedback({
|
||||
disableShortcut = false,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
disableShortcut?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHelpMenuOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
|
||||
<PopoverSideMenuTrigger
|
||||
isOpen={isHelpMenuOpen}
|
||||
shortcut={{ key: "h", enabledOnInputElements: false }}
|
||||
className="grow pr-2"
|
||||
disabled={disableShortcut}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<QuestionMarkCircleIcon className="size-4 text-success" />
|
||||
Help & Feedback
|
||||
</div>
|
||||
</PopoverSideMenuTrigger>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={isCollapsed ? undefined : "flex-1"}
|
||||
>
|
||||
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750 focus-custom",
|
||||
isCollapsed ? "w-full" : "w-full justify-between"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<QuestionMarkCircleIcon className="size-5 min-w-5 shrink-0 text-success" />
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
|
||||
)}
|
||||
>
|
||||
Help & Feedback
|
||||
</span>
|
||||
</span>
|
||||
<ShortcutKey
|
||||
className={cn(
|
||||
"size-4 flex-none transition-all duration-150",
|
||||
isCollapsed ? "hidden" : ""
|
||||
)}
|
||||
shortcut={{ key: "h" }}
|
||||
variant="medium/bright"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Help & Feedback
|
||||
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8 w-full"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "top"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
@@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
className="pl-2"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
leadingIconClassName="text-blue-500 pr-1"
|
||||
data-action="contact-us"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
@@ -189,6 +248,7 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Popover>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import {
|
||||
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,
|
||||
v3BillingAlertsPath,
|
||||
v3BillingPath,
|
||||
@@ -25,6 +29,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { AskAI } from "../AskAI";
|
||||
|
||||
export type BuildInfo = {
|
||||
appVersion: string | undefined;
|
||||
@@ -113,6 +118,25 @@ export function OrganizationSettingsSideMenu({
|
||||
data-action="settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-1">
|
||||
<SideMenuHeader title="Integrations" />
|
||||
</div>
|
||||
<SideMenuItem
|
||||
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" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
@@ -131,7 +155,14 @@ export function OrganizationSettingsSideMenu({
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="Git ref" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
{buildInfo.gitRefName}
|
||||
<a
|
||||
href={`https://github.com/triggerdotdev/trigger.dev/tree/${buildInfo.gitRefName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-text-bright"
|
||||
>
|
||||
{buildInfo.gitRefName}
|
||||
</a>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
@@ -139,13 +170,21 @@ export function OrganizationSettingsSideMenu({
|
||||
<div className="flex flex-col gap-1">
|
||||
<SideMenuHeader title="Git sha" />
|
||||
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
|
||||
{buildInfo.gitSha.slice(0, 9)}
|
||||
<a
|
||||
href={`https://github.com/triggerdotdev/trigger.dev/commit/${buildInfo.gitSha}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-text-bright"
|
||||
>
|
||||
{buildInfo.gitSha.slice(0, 9)}
|
||||
</a>
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,21 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||
export function SideMenuHeader({
|
||||
title,
|
||||
children,
|
||||
isCollapsed = false,
|
||||
collapsedTitle,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
/** When provided, this text stays visible when collapsed and the rest fades out */
|
||||
collapsedTitle?: string;
|
||||
}) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -11,9 +23,34 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
// If collapsedTitle is provided and title starts with it, split the title
|
||||
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
|
||||
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
|
||||
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<motion.div
|
||||
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="text-xs whitespace-nowrap">
|
||||
{visiblePart}
|
||||
{fadingPart && (
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{fadingPart}
|
||||
</motion.span>
|
||||
)}
|
||||
</h2>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
import { type RenderIcon, Icon } from "../primitives/Icon";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
@@ -14,6 +16,8 @@ export function SideMenuItem({
|
||||
to,
|
||||
badge,
|
||||
target,
|
||||
isCollapsed = false,
|
||||
action,
|
||||
}: {
|
||||
icon?: RenderIcon;
|
||||
activeIconColor?: string;
|
||||
@@ -24,30 +28,92 @@ export function SideMenuItem({
|
||||
to: string;
|
||||
badge?: ReactNode;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
isCollapsed?: boolean;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
|
||||
TrailingIcon={trailingIcon}
|
||||
trailingIconClassName={trailingIconClassName}
|
||||
const link = (
|
||||
<Link
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
|
||||
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750 group-hover/menuitem:bg-charcoal-750",
|
||||
isActive ? "bg-tertiary" : ""
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<span className="truncate select-none text-2sm">{name}</span>
|
||||
{badge && !isCollapsed && (
|
||||
<motion.div
|
||||
className="ml-1 flex shrink-0 items-center gap-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{badge}
|
||||
</motion.div>
|
||||
)}
|
||||
{trailingIcon && !isCollapsed && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (action) {
|
||||
return (
|
||||
<div className="group/menuitem relative h-8 w-full">
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="!h-8 block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="absolute top-1 right-1 bottom-1 flex aspect-square items-center justify-center rounded group-hover/menuitem:bg-charcoal-750">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={link}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="!h-8 block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ type Props = {
|
||||
initialCollapsed?: boolean;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
/** When true, hides the section header and shows only children */
|
||||
isSideMenuCollapsed?: boolean;
|
||||
itemSpacingClassName?: string;
|
||||
/** Optional action element (e.g., + button) to render on the right side of the header */
|
||||
headerAction?: React.ReactNode;
|
||||
};
|
||||
|
||||
/** A collapsible section for the side menu
|
||||
@@ -17,6 +22,9 @@ export function SideMenuSection({
|
||||
initialCollapsed = false,
|
||||
onCollapseToggle,
|
||||
children,
|
||||
isSideMenuCollapsed = false,
|
||||
itemSpacingClassName = "space-y-px",
|
||||
headerAction,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
||||
|
||||
@@ -27,22 +35,45 @@ export function SideMenuSection({
|
||||
}, [isCollapsed, onCollapseToggle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<div className="w-full overflow-hidden">
|
||||
{/* Header container - stays in DOM to preserve height */}
|
||||
<div className="relative w-full">
|
||||
{/* Header - fades out when sidebar is collapsed */}
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-charcoal-750"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
onClick={isSideMenuCollapsed ? undefined : handleToggle}
|
||||
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
|
||||
<h2 className="whitespace-nowrap text-xs">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
</div>
|
||||
{headerAction && <div className="flex items-center">{headerAction}</div>}
|
||||
</motion.div>
|
||||
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
|
||||
<motion.div
|
||||
className="absolute left-2 right-2 top-1 h-px bg-charcoal-600"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
className="w-full"
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
exit="collapsed"
|
||||
@@ -63,6 +94,7 @@ export function SideMenuSection({
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<motion.div
|
||||
className={`w-full ${itemSpacingClassName}`}
|
||||
variants={{
|
||||
expanded: {
|
||||
translateY: 0,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
|
||||
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
|
||||
export function TreeConnectorBranch({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeConnectorEnd({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={cn("overflow-visible", className, "text-charcoal-600")}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
>
|
||||
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Valid section IDs that can have their collapsed state toggled
|
||||
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics", "project-settings"]);
|
||||
|
||||
// Inferred type from the schema
|
||||
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Layout, useContainerWidth } from "react-grid-layout";
|
||||
|
||||
/**
|
||||
* Generic hook for managing a reorderable list in the side menu.
|
||||
*
|
||||
* Handles order state, sorting, grid layout, drag callbacks, and persistence
|
||||
* via the `/resources/preferences/sidemenu` resource route.
|
||||
*
|
||||
* @param organizationId - Organization ID for scoping the persisted order
|
||||
* @param listId - Identifier for this list (e.g. "customDashboards")
|
||||
* @param items - The items to reorder
|
||||
* @param itemKey - Extract a stable string key from each item
|
||||
* @param initialOrder - Initial order from stored preferences (if any)
|
||||
* @param isImpersonating - Skip persistence when impersonating
|
||||
*/
|
||||
export function useReorderableList<T>({
|
||||
organizationId,
|
||||
listId,
|
||||
items,
|
||||
itemKey,
|
||||
initialOrder,
|
||||
isImpersonating,
|
||||
}: {
|
||||
organizationId: string;
|
||||
listId: string;
|
||||
items: T[];
|
||||
itemKey: (item: T) => string;
|
||||
initialOrder: string[] | undefined;
|
||||
isImpersonating: boolean;
|
||||
}) {
|
||||
const orderFetcher = useFetcher();
|
||||
|
||||
const [order, setOrder] = useState<string[]>(
|
||||
() => initialOrder ?? items.map(itemKey)
|
||||
);
|
||||
|
||||
// Sync order when organizationId changes (component may not remount)
|
||||
useEffect(() => {
|
||||
setOrder(initialOrder ?? items.map(itemKey));
|
||||
}, [organizationId]);
|
||||
|
||||
// Sort items by stored order, new items go to end
|
||||
const orderedItems = useMemo(() => {
|
||||
const orderMap = new Map(order.map((id, i) => [id, i]));
|
||||
return [...items].sort((a, b) => {
|
||||
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
|
||||
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
|
||||
return aIdx - bIdx;
|
||||
});
|
||||
}, [items, order, itemKey]);
|
||||
|
||||
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
orderedItems.map((item, i) => ({
|
||||
i: itemKey(item),
|
||||
x: 0,
|
||||
y: i,
|
||||
w: 1,
|
||||
h: 1,
|
||||
})),
|
||||
[orderedItems, itemKey]
|
||||
);
|
||||
|
||||
// Width measurement for ReactGridLayout
|
||||
const {
|
||||
width: gridWidth,
|
||||
containerRef,
|
||||
mounted: gridMounted,
|
||||
} = useContainerWidth({ initialWidth: 216 });
|
||||
|
||||
const canReorder = orderedItems.length >= 2;
|
||||
|
||||
// Track layout during drag for real-time visual updates
|
||||
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
|
||||
|
||||
const handleDrag = useCallback((layout: Layout) => {
|
||||
setDragLayout(layout);
|
||||
}, []);
|
||||
|
||||
// Handle drag stop - extract new order from layout y-positions
|
||||
const handleDragStop = useCallback(
|
||||
(layout: Layout) => {
|
||||
setDragLayout(null);
|
||||
const sorted = [...layout].sort((a, b) => a.y - b.y);
|
||||
const newOrder = sorted.map((item) => item.i);
|
||||
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
|
||||
setOrder(newOrder);
|
||||
// Persist immediately
|
||||
if (!isImpersonating) {
|
||||
const formData = new FormData();
|
||||
formData.append("organizationId", organizationId);
|
||||
formData.append("listId", listId);
|
||||
formData.append("itemOrder", JSON.stringify(newOrder));
|
||||
orderFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/resources/preferences/sidemenu",
|
||||
});
|
||||
}
|
||||
},
|
||||
[order, organizationId, listId, isImpersonating, orderFetcher]
|
||||
);
|
||||
|
||||
// Compute which item is visually last (during drag or at rest)
|
||||
const getIsLast = useCallback(
|
||||
(key: string, index: number) => {
|
||||
if (dragLayout) {
|
||||
const maxY = Math.max(...dragLayout.map((l) => l.y));
|
||||
return dragLayout.find((l) => l.i === key)?.y === maxY;
|
||||
}
|
||||
return index === orderedItems.length - 1;
|
||||
},
|
||||
[dragLayout, orderedItems.length]
|
||||
);
|
||||
|
||||
return {
|
||||
orderedItems,
|
||||
layout,
|
||||
containerRef: containerRef as Ref<HTMLDivElement>,
|
||||
gridWidth,
|
||||
gridMounted,
|
||||
canReorder,
|
||||
handleDrag,
|
||||
handleDragStop,
|
||||
getIsLast,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
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 && !customValues.includes(trimmed) && !value.includes(trimmed)) {
|
||||
onCustomValuesChange([...customValues, trimmed]);
|
||||
setOtherInputValue("");
|
||||
}
|
||||
}, [otherInputValue, customValues, onCustomValuesChange, value]);
|
||||
|
||||
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,9 +1,64 @@
|
||||
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
export function AnimatedNumber({ value, duration = 0.5 }: { value: number; duration?: number }) {
|
||||
/**
|
||||
* Determines the number of decimal places to display based on the value.
|
||||
* - For integers or large numbers (>=100), no decimals
|
||||
* - For numbers >= 10, 1 decimal place
|
||||
* - For numbers >= 1, 2 decimal places
|
||||
* - For smaller numbers, up to 4 decimal places
|
||||
*/
|
||||
function getDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) return 0;
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100) return 0;
|
||||
if (absValue >= 10) return 1;
|
||||
if (absValue >= 1) return 2;
|
||||
if (absValue >= 0.1) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a decimal places value to ensure it's valid for toLocaleString.
|
||||
* - Coerces to a finite number (handles NaN, Infinity, -Infinity)
|
||||
* - Rounds to an integer
|
||||
* - Clamps to the valid 0-20 range for toLocaleString options
|
||||
*/
|
||||
function sanitizeDecimals(decimals: number): number {
|
||||
if (!Number.isFinite(decimals)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(20, Math.max(0, Math.round(decimals)));
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
duration = 0.5,
|
||||
decimalPlaces,
|
||||
}: {
|
||||
value: number;
|
||||
duration?: number;
|
||||
/** Number of decimal places to display. If not provided, auto-detects based on value. */
|
||||
decimalPlaces?: number;
|
||||
}) {
|
||||
const motionValue = useMotionValue(value);
|
||||
let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString());
|
||||
|
||||
// Determine decimal places - use provided value or auto-detect, then sanitize
|
||||
const safeDecimals = useMemo(() => {
|
||||
const rawDecimals = decimalPlaces !== undefined ? decimalPlaces : getDecimalPlaces(value);
|
||||
return sanitizeDecimals(rawDecimals);
|
||||
}, [decimalPlaces, value]);
|
||||
|
||||
const display = useTransform(motionValue, (current) => {
|
||||
if (safeDecimals === 0) {
|
||||
return Math.round(current).toLocaleString();
|
||||
}
|
||||
return current.toLocaleString(undefined, {
|
||||
minimumFractionDigits: safeDecimals,
|
||||
maximumFractionDigits: safeDecimals,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
animate(motionValue, value, {
|
||||
|
||||
@@ -27,6 +27,7 @@ type AppliedFilterProps = {
|
||||
onRemove?: () => void;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
valueClassName?: string;
|
||||
};
|
||||
|
||||
export function AppliedFilter({
|
||||
@@ -37,6 +38,7 @@ export function AppliedFilter({
|
||||
onRemove,
|
||||
variant = "secondary/small",
|
||||
className,
|
||||
valueClassName,
|
||||
}: AppliedFilterProps) {
|
||||
const variantClassName = variants[variant];
|
||||
return (
|
||||
@@ -48,14 +50,18 @@ export function AppliedFilter({
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}>
|
||||
<div
|
||||
className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}
|
||||
>
|
||||
<div className="-mt-[0.5px] flex items-center gap-1">
|
||||
{icon}
|
||||
{label && <div className="text-text-bright">
|
||||
<span>{label}</span>:
|
||||
</div>}
|
||||
{label && (
|
||||
<div className="text-text-bright">
|
||||
<span>{label}</span>:
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-text-dimmed">
|
||||
<div className={cn("text-text-dimmed", valueClassName)}>
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
</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,4 +1,5 @@
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -60,10 +61,10 @@ export const variantClasses = {
|
||||
linkClassName: "transition hover:bg-blue-400/20",
|
||||
},
|
||||
pricing: {
|
||||
className: "border-charcoal-700 bg-charcoal-800",
|
||||
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
|
||||
textColor: "text-text-bright",
|
||||
linkClassName: "transition hover:bg-charcoal-750",
|
||||
className: "border-indigo-400/20 bg-indigo-800/30",
|
||||
icon: <CreditCardIcon className="h-5 w-5 shrink-0 text-indigo-400" />,
|
||||
textColor: "text-indigo-300",
|
||||
linkClassName: "transition hover:bg-indigo-400/20",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ const ClientTabs = React.forwardRef<
|
||||
<ClientTabsContext.Provider value={contextValue}>
|
||||
<TabsPrimitive.Root
|
||||
ref={ref}
|
||||
activationMode="manual"
|
||||
onValueChange={handleValueChange}
|
||||
{...controlledProps}
|
||||
{...props}
|
||||
@@ -96,6 +97,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
"flex-1 basis-0",
|
||||
@@ -134,6 +136,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
@@ -143,7 +146,7 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm transition duration-200",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
|
||||
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -170,8 +173,9 @@ const ClientTabsTrigger = React.forwardRef<
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -188,9 +192,11 @@ const ClientTabsContent = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
className
|
||||
"mt-1 outline-none",
|
||||
className,
|
||||
"data-[state=inactive]:hidden"
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
|
||||
import { useRouteLoaderData } from "@remix-run/react";
|
||||
import { Laptop } from "lucide-react";
|
||||
import { memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
@@ -19,7 +20,7 @@ function getLocalTimeZone(): string {
|
||||
// For SSR compatibility: returns "UTC" on server, actual timezone on client
|
||||
function subscribeToTimeZone() {
|
||||
// No-op - timezone doesn't change
|
||||
return () => { };
|
||||
return () => {};
|
||||
}
|
||||
|
||||
function getTimeZoneSnapshot(): string {
|
||||
@@ -39,6 +40,18 @@ export function useLocalTimeZone(): string {
|
||||
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the user's preferred timezone.
|
||||
* Returns the timezone stored in the user's preferences cookie (from root loader),
|
||||
* falling back to the browser's local timezone if not set.
|
||||
*/
|
||||
export function useUserTimeZone(): string {
|
||||
const rootData = useRouteLoaderData("root") as { timezone?: string } | undefined;
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
// Use stored timezone from cookie, or fall back to browser's local timezone
|
||||
return rootData?.timezone && rootData.timezone !== "UTC" ? rootData.timezone : localTimeZone;
|
||||
}
|
||||
|
||||
type DateTimeProps = {
|
||||
date: Date | string;
|
||||
timeZone?: string;
|
||||
@@ -63,7 +76,7 @@ export const DateTime = ({
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
|
||||
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
|
||||
|
||||
@@ -71,7 +84,7 @@ export const DateTime = ({
|
||||
<span suppressHydrationWarning>
|
||||
{formatDateTime(
|
||||
realDate,
|
||||
timeZone ?? localTimeZone,
|
||||
timeZone ?? userTimeZone,
|
||||
locales,
|
||||
includeSeconds,
|
||||
includeTime,
|
||||
@@ -91,7 +104,7 @@ export const DateTime = ({
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
localTimeZone={userTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
}
|
||||
@@ -167,7 +180,7 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
|
||||
// New component that only shows date when it changes
|
||||
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -180,10 +193,14 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date
|
||||
|
||||
// Format with appropriate function
|
||||
const formattedDateTime = showDatePart
|
||||
? formatSmartDateTime(realDate, localTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, localTimeZone, locales, hour12);
|
||||
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
|
||||
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
|
||||
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to check if two dates are on the same day
|
||||
@@ -235,14 +252,16 @@ function formatTimeOnly(
|
||||
|
||||
const DateTimeAccurateInner = ({
|
||||
date,
|
||||
timeZone = "UTC",
|
||||
timeZone,
|
||||
previousDate = null,
|
||||
showTooltip = true,
|
||||
hideDate = false,
|
||||
hour12 = true,
|
||||
}: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
// Use provided timeZone prop if available, otherwise fall back to user's preferred timezone
|
||||
const displayTimeZone = timeZone ?? userTimeZone;
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const realPrevDate = previousDate
|
||||
? typeof previousDate === "string"
|
||||
@@ -253,29 +272,37 @@ const DateTimeAccurateInner = ({
|
||||
// Smart formatting based on whether date changed
|
||||
const formattedDateTime = useMemo(() => {
|
||||
return hideDate
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
|
||||
: realPrevDate
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
|
||||
}, [realDate, localTimeZone, locales, hour12, hideDate, previousDate]);
|
||||
? isSameDay(realDate, realPrevDate)
|
||||
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
|
||||
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
|
||||
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
|
||||
|
||||
if (!showTooltip)
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
);
|
||||
|
||||
const tooltipContent = (
|
||||
<TooltipContent
|
||||
realDate={realDate}
|
||||
timeZone={timeZone}
|
||||
localTimeZone={localTimeZone}
|
||||
localTimeZone={userTimeZone}
|
||||
locales={locales}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={<span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>}
|
||||
button={
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
}
|
||||
content={tooltipContent}
|
||||
side="right"
|
||||
asChild={true}
|
||||
@@ -311,9 +338,13 @@ function formatDateTimeAccurate(
|
||||
locales: string[],
|
||||
hour12: boolean = true
|
||||
): string {
|
||||
const formattedDateTime = new Intl.DateTimeFormat(locales, {
|
||||
const datePart = new Intl.DateTimeFormat(locales, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone,
|
||||
}).format(date);
|
||||
|
||||
const timePart = new Intl.DateTimeFormat(locales, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
@@ -323,16 +354,20 @@ function formatDateTimeAccurate(
|
||||
hour12,
|
||||
}).format(date);
|
||||
|
||||
return formattedDateTime;
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
|
||||
const locales = useLocales();
|
||||
const localTimeZone = useLocalTimeZone();
|
||||
const userTimeZone = useUserTimeZone();
|
||||
const realDate = typeof date === "string" ? new Date(date) : date;
|
||||
const formattedDateTime = formatDateTimeShort(realDate, localTimeZone, locales, hour12);
|
||||
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
|
||||
|
||||
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
|
||||
return (
|
||||
<span suppressHydrationWarning>
|
||||
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
function formatDateTimeShort(
|
||||
|
||||
@@ -7,7 +7,7 @@ export function FormButtons({
|
||||
className,
|
||||
}: {
|
||||
cancelButton?: React.ReactNode;
|
||||
confirmButton: React.ReactNode;
|
||||
confirmButton?: React.ReactNode;
|
||||
defaultAction?: { name: string; value: string; disabled?: boolean };
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -29,7 +29,7 @@ export function FormButtons({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton}
|
||||
{cancelButton ? cancelButton : <div />} {confirmButton ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { AnimatePresence, useAnimate, usePresence } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type LoadingBarDividerProps = {
|
||||
isLoading: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function LoadingBarDivider({ isLoading }: LoadingBarDividerProps) {
|
||||
export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerProps) {
|
||||
return (
|
||||
<div className="relative h-px w-full overflow-hidden bg-grid-bright">
|
||||
<div className={cn("relative h-px w-full overflow-hidden bg-grid-bright", className)}>
|
||||
<AnimationDivider isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useRef, useState, useLayoutEffect, useCallback } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
type MiddleTruncateProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that truncates text in the middle, showing the beginning and end.
|
||||
* Shows the full text in a tooltip on hover when truncated.
|
||||
*
|
||||
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
|
||||
*/
|
||||
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [displayText, setDisplayText] = useState(text);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
const calculateTruncation = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure) return;
|
||||
|
||||
const parent = container.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
// Get the available width from the parent container
|
||||
const parentStyle = getComputedStyle(parent);
|
||||
const availableWidth =
|
||||
parent.clientWidth -
|
||||
parseFloat(parentStyle.paddingLeft) -
|
||||
parseFloat(parentStyle.paddingRight);
|
||||
|
||||
// Measure full text width
|
||||
measure.textContent = text;
|
||||
const fullTextWidth = measure.offsetWidth;
|
||||
|
||||
// If text fits, no truncation needed
|
||||
if (fullTextWidth <= availableWidth) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text needs truncation - find optimal split
|
||||
const ellipsis = "…";
|
||||
measure.textContent = ellipsis;
|
||||
const ellipsisWidth = measure.offsetWidth;
|
||||
|
||||
const targetWidth = availableWidth - ellipsisWidth - 4; // small buffer
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
setDisplayText(ellipsis);
|
||||
setIsTruncated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incrementally find the optimal character counts
|
||||
let startChars = 0;
|
||||
let endChars = 0;
|
||||
|
||||
// Alternate adding characters from start and end
|
||||
while (startChars + endChars < text.length) {
|
||||
// Try adding to start
|
||||
const testStart = text.slice(0, startChars + 1);
|
||||
const testEnd = endChars > 0 ? text.slice(-endChars) : "";
|
||||
measure.textContent = testStart + ellipsis + testEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
startChars++;
|
||||
|
||||
if (startChars + endChars >= text.length) break;
|
||||
|
||||
// Try adding to end
|
||||
const newTestEnd = text.slice(-(endChars + 1));
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + newTestEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
endChars++;
|
||||
}
|
||||
|
||||
// Ensure minimum characters on each side for readability
|
||||
const minChars = 4;
|
||||
const prevStartChars = startChars;
|
||||
const prevEndChars = endChars;
|
||||
|
||||
if (startChars < minChars && text.length > minChars * 2 + 1) {
|
||||
startChars = minChars;
|
||||
}
|
||||
if (endChars < minChars && text.length > minChars * 2 + 1) {
|
||||
endChars = minChars;
|
||||
}
|
||||
|
||||
// Re-measure after enforcing minChars to prevent overflow
|
||||
if (startChars !== prevStartChars || endChars !== prevEndChars) {
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
if (measure.offsetWidth > targetWidth) {
|
||||
// Revert to previous values if minChars enforcement causes overflow
|
||||
startChars = prevStartChars;
|
||||
endChars = prevEndChars;
|
||||
}
|
||||
}
|
||||
|
||||
// If combined chars would exceed text length, show full text
|
||||
if (startChars + endChars >= text.length) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
setDisplayText(result);
|
||||
setIsTruncated(true);
|
||||
}, [text]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
calculateTruncation();
|
||||
|
||||
// Recalculate on resize (guard for jsdom/older browsers)
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calculateTruncation();
|
||||
});
|
||||
|
||||
const container = containerRef.current;
|
||||
if (container?.parentElement) {
|
||||
resizeObserver.observe(container.parentElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateTruncation]);
|
||||
|
||||
const content = (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={cn("block", isTruncated && "min-w-[360px]", className)}
|
||||
>
|
||||
{/* Hidden span for measuring text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
className="invisible absolute whitespace-nowrap"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={content}
|
||||
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
|
||||
side="top"
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -154,10 +154,12 @@ function PopoverSideMenuTrigger({
|
||||
children,
|
||||
className,
|
||||
shortcut,
|
||||
hideShortcutKey = false,
|
||||
...props
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
shortcut?: useShortcutKeys.ShortcutDefinition;
|
||||
hideShortcutKey?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys.useShortcutKeys({
|
||||
@@ -176,14 +178,14 @@ function PopoverSideMenuTrigger({
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut ? "justify-between" : "",
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
{shortcut && !hideShortcutKey && (
|
||||
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
@@ -241,20 +243,41 @@ function PopoverArrowTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
const popoverVerticalEllipseVariants = {
|
||||
minimal: {
|
||||
trigger:
|
||||
"size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
|
||||
icon: "size-5",
|
||||
},
|
||||
secondary: {
|
||||
trigger:
|
||||
"size-6 rounded border border-charcoal-600 bg-secondary text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550",
|
||||
icon: "size-4",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
|
||||
|
||||
function PopoverVerticalEllipseTrigger({
|
||||
isOpen,
|
||||
variant = "minimal",
|
||||
className,
|
||||
...props
|
||||
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
variant?: PopoverVerticalEllipseVariant;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const styles = popoverVerticalEllipseVariants[variant];
|
||||
return (
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
|
||||
"group flex items-center justify-center transition focus-custom",
|
||||
styles.trigger,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
|
||||
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,19 +26,40 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<PanelResizer
|
||||
className={cn(
|
||||
"group relative flex w-0.75 items-center justify-center focus-custom after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
// Base styles
|
||||
"group relative flex items-center justify-center focus-custom",
|
||||
// Horizontal orientation (default)
|
||||
"w-0.75 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2",
|
||||
// Vertical orientation
|
||||
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
|
||||
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
|
||||
"data-[handle-orientation=vertical]:after:left-0 data-[handle-orientation=vertical]:after:top-1/2",
|
||||
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
|
||||
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
|
||||
className
|
||||
)}
|
||||
size="3px"
|
||||
{...props}
|
||||
>
|
||||
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500" />
|
||||
{/* Horizontal orientation line indicator */}
|
||||
<div className="absolute left-[0.0625rem] top-0 z-20 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:hidden" />
|
||||
{/* Vertical orientation line indicator */}
|
||||
<div className="absolute left-0 top-[0.0625rem] z-20 hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:block" />
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-5 w-3 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* Horizontal orientation dots (vertical arrangement) */}
|
||||
<div className="z-10 flex h-5 w-0.75 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
{/* Vertical orientation dots (horizontal arrangement) */}
|
||||
<div className="z-10 hidden h-0.75 w-5 flex-row items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:flex">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-0.75 w-[0.1875rem] rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelResizer>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const sizes = {
|
||||
@@ -63,7 +64,7 @@ const variants = {
|
||||
type VariantType = keyof typeof variants;
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
value: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -8,12 +8,15 @@ import { cn } from "~/utils/cn";
|
||||
import { useOperatingSystem } from "./OperatingSystemProvider";
|
||||
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
|
||||
|
||||
const small =
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border transition uppercase";
|
||||
|
||||
const medium =
|
||||
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
|
||||
|
||||
export const variants = {
|
||||
small:
|
||||
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
|
||||
small: cn(small, "border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60"),
|
||||
"small/bright": cn(small, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
medium: cn(medium, "group-hover:border-charcoal-550"),
|
||||
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
|
||||
};
|
||||
@@ -54,10 +57,10 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
|
||||
);
|
||||
}
|
||||
|
||||
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
|
||||
function keyString(key: string, isMac: boolean, variant: ShortcutKeyVariant) {
|
||||
key = key.toLowerCase();
|
||||
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-2.5 h-4.5";
|
||||
|
||||
switch (key) {
|
||||
case "enter":
|
||||
@@ -86,9 +89,9 @@ function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "m
|
||||
function modifierString(
|
||||
modifier: Modifier,
|
||||
isMac: boolean,
|
||||
variant: "small" | "medium" | "medium/bright"
|
||||
variant: ShortcutKeyVariant
|
||||
): string | JSX.Element {
|
||||
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
|
||||
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-3.5 h-5";
|
||||
|
||||
switch (modifier) {
|
||||
case "alt":
|
||||
|
||||
@@ -64,18 +64,30 @@ type TableProps = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
};
|
||||
|
||||
// Add TableContext
|
||||
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
|
||||
({ className, containerClassName, children, fullWidth, variant = "dimmed" }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
children,
|
||||
fullWidth,
|
||||
variant = "dimmed",
|
||||
showTopBorder = true,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<TableContext.Provider value={{ variant }}>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
"overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
showTopBorder && "border-t",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
@@ -164,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) {
|
||||
@@ -210,6 +234,7 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
|
||||
content={tooltip}
|
||||
contentClassName="normal-case tracking-normal"
|
||||
enabled={isHovered}
|
||||
disableHoverableContent={disableTooltipHoverableContent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -230,6 +255,7 @@ type TableCellProps = TableCellBasicProps & {
|
||||
isSelected?: boolean;
|
||||
isTabbableCell?: boolean;
|
||||
children?: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
@@ -246,6 +272,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
isSticky = false,
|
||||
isSelected,
|
||||
isTabbableCell = false,
|
||||
style,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -291,6 +318,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
style={style}
|
||||
>
|
||||
{to ? (
|
||||
<Link
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -87,7 +87,7 @@ function SimpleTooltip({
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className={cn("h-fit", buttonClassName)}
|
||||
className={cn(!asChild && "h-fit", buttonClassName)}
|
||||
style={buttonStyle}
|
||||
asChild={asChild}
|
||||
>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
interface BigNumberProps {
|
||||
animate?: boolean;
|
||||
loading?: boolean;
|
||||
value?: number;
|
||||
valueClassName?: string;
|
||||
defaultValue?: number;
|
||||
suffix?: string;
|
||||
suffixClassName?: string;
|
||||
}
|
||||
|
||||
export function BigNumber({
|
||||
value,
|
||||
defaultValue,
|
||||
valueClassName,
|
||||
suffix,
|
||||
suffixClassName,
|
||||
animate = false,
|
||||
loading = false,
|
||||
}: BigNumberProps) {
|
||||
const v = value ?? defaultValue;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full text-[3.75rem] font-normal tabular-nums leading-none text-text-bright",
|
||||
valueClassName
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="grid h-full place-items-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : v !== undefined ? (
|
||||
<div className="flex items-baseline gap-1">
|
||||
{animate ? <AnimatedNumber value={v} /> : v}
|
||||
{suffix && <div className={cn("text-xs", suffixClassName)}>{suffix}</div>}
|
||||
</div>
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user