Compare commits

...

50 Commits

Author SHA1 Message Date
Mihai Popescu e0998e6049 Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-02-08 21:57:34 +02:00
nicktrn e536d35b17 fix(ci): fix docker image publishing and worker builds (#3013)
## Summary
- **Fix Docker publish automation**: The `v.docker.*` tags pushed by the
release workflow using `GITHUB_TOKEN` don't trigger the publish workflow
(GitHub Actions limitation to prevent infinite loops). Added a
`workflow_call` to `publish.yml` directly from the release job so Docker
images are built automatically after npm publish. Tags are still pushed
for reference.
- **Fix worker Containerfiles**: The coordinator, docker-provider, and
kubernetes-provider builds have been failing since the superjson
vendoring change in `@trigger.dev/core` (#2949). The Containerfiles now
run `bundle-vendor` before `build:bundle` to generate the vendor files
that esbuild needs.

### Context
- Docker images on GHCR have been stuck at v4.3.0 — v4.3.1, v4.3.2,
v4.3.3 tags existed on GitHub but never triggered publish runs
- The worker builds (publish-worker) have been failing on every push to
main since Jan 30

## Test plan
- [x] Verified kubernetes-provider Containerfile builds locally with the
fix
- [x] Manually dispatched publish workflow for v4.3.1 — all jobs
succeeded
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3013"
target="_blank">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-06 13:56:39 +00:00
DKP b96a0b70d4 Docs: Clarify AI tool compatibility and expand context snippet (#3000)
This pull request overhauls the "Building with AI" documentation
section. It includes a comprehensive restructuring of the main
building-with-ai page with new setup guides and troubleshooting
sections, reorganizes the navigation hierarchy to elevate
mcp-agent-rules as a top-level page, and updates multiple documentation
pages to clarify the relationships between three AI tools: Skills, Agent
Rules, and MCP Server. Changes also include formatting improvements,
such as replacing italicized text with inline code formatting, and
consistent additions of explanatory Note blocks and CardGroup components
across related pages.
2026-02-05 15:22:44 -08:00
James Ritchie 3bb9aac014 Fix(webapp): Prevent big numbers on Queue page from jumping around when animating (#3007) 2026-02-05 07:45:40 -08:00
Saadi Myftija 283f88b203 feat(webapp): add triggered via field to deployment details page (#2850)
Display the deployment trigger source (CLI, CI/CD, Dashboard, GitHub
Integration) with appropriate icons on the deployment details page. The
triggeredVia field was already in the database but not displayed.

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-05 15:24:23 +01:00
James Ritchie c55af7bead fix(webapp): ask ai button missing tooltip (#2964)
Fixes
- the tooltip not displaying on the AskAI button in the side menu
- incorrect AskAI button heights
- Small UI tweaks
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2964">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Mihai Popescu <mihai.popescu.dev@gmail.com>
2026-02-05 03:17:18 +02:00
Iss db4fb9eeef docs: Add Hookdeck example (#3005)
Add documentation for integrating Hookdeck with Trigger.dev to receive
webhooks and forward them to Trigger.dev tasks.
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3005">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-04 16:50:59 -08:00
mpcgird f99c780c28 fixed on leave edge case 2026-02-05 00:34:49 +02:00
mpcgird 171c50f72a Added event for recalculating vertical line hover effect on timeline span 2026-02-04 23:19:31 +02:00
mpcgird dcd73f129e Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-02-04 18:45:09 +02:00
Iss c0595700f8 docs: clarify .env.local loading and idempotency key reset scope (#2996)
Document that .env.local is automatically loaded during dev, and clarify
that backend-triggered idempotency keys should be reset with global
scope
2026-02-04 09:37:35 -05:00
Iss 6a45f5623b docs: multi-tenant applications and concurrency limits (#2961)
Adds an example of multi-tenant applications as alternative to
project/limit increase, and more info about queue times and concurrency
2026-02-04 09:33:05 -05:00
Iss 104f720f6f docs(troubleshooting): add COULD_NOT_FIND_EXECUTOR error and IPv4 support (#2950)
Document COULD_NOT_FIND_EXECUTOR error with dynamic imports and IPv4
database connection limitation in troubleshooting guide.
2026-02-04 09:30:51 -05:00
Iss e017913021 docs(self-hosting): added graphile worker troubleshooting to docs (#2883)
Add troubleshooting documentation for graphile worker schema migration
failures and PostgreSQL SSL certificate issues that prevent worker
initialization.
2026-02-04 09:29:01 -05:00
Matt Aitken 7781e2aad1 docs: usage function examples were missing the imports (#2830)
Closes #2828
2026-02-04 14:05:59 +00:00
Saadi Myftija 8e0034484c feat(supervisor): project-based scheduling affinity for image cache locality (#2995)
⚒️ Publish Worker (v4) / build (supervisor) (push) Has been cancelled
Adds optional pod affinity so pods from the same project prefer
scheduling on the same node. This can help improve image cache hit
rates; subsequent pods benefit from already-pulled image layers,
reducing startup time.

Complements the built-in ImageLocality scheduler plugin by helping
during burst scheduling scenarios. Pod affinity sees scheduled pods
immediately, while ImageLocality only sees images after they're fully
pulled.

Configuration:
- `KUBERNETES_PROJECT_AFFINITY_ENABLED` - Enable/disable (default:
false)
- `KUBERNETES_PROJECT_AFFINITY_WEIGHT` - Scheduler weight 1-100
(default: 50)
- `KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY` - Topology key (default:
kubernetes.io/hostname)

Uses soft (preferred) affinity so pods always schedule even if preferred
node is full.

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2995">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-02-04 14:39:47 +01:00
Eric Allam b72cacc671 feat(debounce): add maxDelay option to limit total debounce time (#2984) 2026-02-02 20:15:06 +00:00
Mihai Popescu 1ccb8c186f changed schema id (#2983)
Fixed duplicate schema id for clickhouse
2026-02-02 02:43:39 +02:00
nicktrn 279102c17c fix(cli): reject execute() immediately when child process is dead (#2978)
## Summary
- When a child process crashes and a retry (`RETRY_IMMEDIATELY`) is
attempted on the same `TaskRunProcess`, `execute()` hangs forever
because the IPC send is silently skipped and the attempt promise can
never resolve
- This caused runner pods to stay up indefinitely with no heartbeats or
polls
- Fix: reject the attempt promise immediately when the child is not
connected, so the controller can proceed to warm start or exit

## Test plan
- [x] Added `taskRunProcess.test.ts` — verifies `execute()` rejects
promptly instead of hanging when the child process is dead
- [x] Deploy and verify no more stuck runner pods accumulate over time
2026-01-30 16:44:46 +00:00
Eric Allam b221719c09 chore(repo): fixed missing dependency in pnpm lockfile (#2976)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2976">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 10:02:30 +00:00
Matt Aitken e6861f4fe4 Fix: run page logs keep refreshing when a run finishes (#2971)
Closes #2798

When a run finished the logs UI could get stuck and so be pending and
never update again. If you did a hard reload it would be correct.

This happened because when we insert a log/span we ping Redis which
causes a reload of the UI. However there was a race condition – the
insert into ClickHouse can take a while so we were refreshing the UI too
early. Then never refreshing it again.
  
Changes
- Send refresh pings every 5s to keep run page logs live
- Throttle updates so the run UI is never updated more than once per
second
- Stop auto-reloading when a run has been completed for >= 30s
- Add type inference improvements for the throttle function
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2971">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 09:47:56 +00:00
Matt Aitken bc7ce78103 fix(sdk): export AnyOnStartAttemptHookFunction type (#2966)
Export AnyOnStartAttemptHookFunction type to allow defining
onStartAttempt hooks for individual tasks.

https://claude.ai/code/session_018jgSVcFtKVyv65ktGNQFFq
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2966">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-30 09:17:37 +00:00
Eric Allam 9937823a7f Standardize @types/node to version 20.14.14 across monorepo (#2970)
##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Description

This PR standardizes the `@types/node` dependency across the entire
monorepo to version `20.14.14`. Previously, different packages were
using different versions (ranging from 12.20.55 to 22.13.9), which could
cause type conflicts and inconsistencies.

### Changes Made

1. **tsconfig.json** - Added `"node"` to the `types` array in
`apps/webapp/tsconfig.json` to ensure Node.js types are properly
recognized
2. **package.json overrides** - Added `@types/node` version override to
`20.14.14` in the root `package.json`
3. **pnpm-lock.yaml** - Updated lock file to reflect the standardized
version across all packages and their dependencies
4. **Fixture package.json** - Updated
`packages/cli-v3/e2e/fixtures/emit-decorator-metadata/package.json` to
use the standardized version

This ensures consistent type definitions across the monorepo and
prevents version mismatches that could lead to type errors or unexpected
behavior.

---

## Testing

- Verified that all package references to `@types/node` now point to
version `20.14.14`
- Confirmed that the lock file properly reflects the override across all
transitive dependencies
- Ensured TypeScript configuration includes Node.js types for proper
type checking

---

## Changelog

- Standardized `@types/node` to version `20.14.14` across all packages
in the monorepo
- Added `"node"` to TypeScript compiler types in webapp configuration
- Updated all package dependencies to use the consistent version through
pnpm overrides

💯

https://claude.ai/code/session_018eqp2LvvErkFSN9oK5xBh1
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2970">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-30 09:15:35 +00:00
Eric Allam 3925f8cc49 fix(core): vendor superjson to fix ESM/CJS compatibility (#2949)
Bundle superjson and its dependency (copy-anything) during build to
avoid
ERR_REQUIRE_ESM errors on Node.js versions that don't support
require(ESM)
by default (< 22.12.0) and AWS Lambda which intentionally disables it.

- Add scripts/bundle-superjson.mjs to bundle superjson with esbuild
- Update build script to bundle vendor files before tshy compilation
- Move superjson from dependencies to devDependencies
- Update imports to use vendored bundles

Fixes #2937
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2949">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Eric Allam <ericallam@users.noreply.github.com>
2026-01-30 09:15:02 +00:00
Iss 01208fde27 chore(docs): Add playwright workaround (#2973)
Adds a workaround to playwright to browser download failures based on
this GH issue: https://github.com/triggerdotdev/trigger.dev/issues/2440
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2973">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-30 08:43:23 +00:00
Eric Allam 5e049cde3a fix(run-engine): avoid NAPI string overflow in getExecutionSnapshotsSince by only fetching waitpoints for latest snapshot (#2972) 2026-01-29 19:03:54 +00:00
Matt Aitken 72c357125b Query enabled via feature flag (#2968)
- Renamed FeatureFlag functions to be singular where it makes sense.
- Added function to handle multiple feature flags
- canAccessQuery now checks the global feature flag and environment
variable as well
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2968">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 15:30:56 +00:00
DKP 0674d74bbb Added Trigger.dark theme to the docs (#2967)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2967">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:59:27 +00:00
DKP 9e08712749 Add building with ai/skills pages and updated intro (#2962)
Closes #<issue>

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

---

## Changelog

_[Short description of what has changed]_

---

## Screenshots

_[Screenshots]_

💯

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2962">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:55:43 +00:00
Matt Aitken f53db6fd16 Query: time limits, performance improvements, styling (#2953)
Summary
- Query: add time limits, performance improvements, and styling updates

Changes
- Add ClickHouse output_text and error_text columns with indexes
- Automatically use _text columns for JSON based on query pattern;
support JSON column data prefixes
- Add idempotency key and scope columns
- Add enforcedWhereClause for tenant and time restrictions, instead of
the old tenant stuff.
- Implement basic time filter limiting and set default time period based
on plan; show message when results are clipped
- UX: resizable code area (including vertical splits), collapsible
sidebar, fix table/chart vertical sizing, max height for chart legend in
fullscreen
- Styling and UI tweaks: improved chart legend styling, more chart
colours, thinner line chart stroke, pricing callout color, improved
layout for callouts
- Features: generate and save AI titles
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2953">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-29 13:02:47 +00:00
Oskar Otwinowski c0b86efbd3 feat(webapp): Add MiddleTruncate component for long task names (#2946)
Closes #

##  Checklist

- [ ] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [ ] The PR title follows the convention.
- [ ] I ran and tested the code works

---

## Testing

Tested the MiddleTruncate component in the TasksDropdown by:
1. Verifying that long task names (e.g.,
"namespace:category:subcategory:task-name") are truncated in the middle
2. Confirming the full text appears in a tooltip on hover
3. Testing responsive behavior - truncation adjusts when the container
is resized
4. Verifying that short task names that fit within the container are
displayed in full without truncation

---

## Changelog

Added a new `MiddleTruncate` primitive component that intelligently
truncates text in the middle while preserving the beginning and end
portions. This is particularly useful for long hierarchical identifiers
like task slugs.

**Key features:**
- Truncates text in the middle with an ellipsis (…) when it exceeds
available width
- Shows full text in a tooltip on hover when truncated
- Responsive - recalculates truncation on container resize using
ResizeObserver
- Maintains minimum character visibility (4 chars minimum on each side
for readability)
- Integrated into TasksDropdown to handle long task names

**Changes:**
- Created new `MiddleTruncate.tsx` component with binary search
algorithm for optimal character distribution
- Updated TasksDropdown to use MiddleTruncate for task slug display
- Increased TasksDropdown popover width from 240px to 360px to provide
better space for truncated text

---

## Screenshots
💯


https://github.com/user-attachments/assets/a7a2191a-2e36-437e-ab3f-517fe7620b93


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2946">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-29 12:51:58 +01:00
Mihai Popescu e29e1c86d9 Fix/tri 7032 logs page feedback (#2947)
##  Changes
### UI & UX
- Normalized log level display across table and detail view
- Fixed table header scroll behavior and sidebar positioning
- Improved loading state with taller segment and disabled resizing
- Added "no more logs" message with count
- Enhanced keyboard shortcuts
### Filtering & Search
- Streamlined filters: RunId and Task only (removed run filters)
- Side panel closes when filters change
- Fixed logs from previous search remaining in table
- Fixed table scroll position when changing filters
### Backend
- Added performance indexes on message and attributes
(`014_add_task_runs_v2_search_indexes.sql`)
- Added DEBUG level logging by default
- Removed internal logs from display
- Fixed ServiceValidationError forwarding to frontend
  - Removed v1 logs API support
2026-01-29 12:47:59 +02:00
mpcgird 2066843998 Merge remote-tracking branch 'origin/main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-01-28 21:08:37 +02:00
James Ritchie 34203d6a6f Fix(webapp) prevent incidents url being called every frame (#2956) 2026-01-27 18:33:36 +00:00
James Ritchie d4e4fbd7fc fix(webapp): Truncate long branch names to prevent breaking the onboarding layout (#2954)
### Before
<img width="1736" height="964" alt="CleanShot 2026-01-21 at 20 11 08@2x"
src="https://github.com/user-attachments/assets/76c6ed42-d2a0-4212-aaad-6fb848025c85"
/>

### After
<img width="1776" height="1032" alt="CleanShot 2026-01-27 at 17 32
47@2x"
src="https://github.com/user-attachments/assets/030ba26e-6aad-4232-a9ff-f2fde9931f8c"
/>

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2954">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-27 18:26:43 +00:00
James Ritchie 49de105862 Feat(webapp): collapsible side menu (#2939)
- Better separation between main feature pages and manage pages
- Toggle via keyboard shortcut Cmd/Ctrl + B or click collapse button
- Collapsed state persisted to database (survives page refresh)
- All menu items show tooltips when collapsed
- Smooth animations using Framer Motion and CSS transitions
- Impersonation-safe (preferences not modified when impersonating)


https://github.com/user-attachments/assets/35827922-b80a-418e-8341-eb22c9bb5ed4


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2939">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-27 17:13:21 +00:00
DKP 5fb9cc36bc fix(security): upgrade CLI deps and add overrides (#2952)
- Upgrade @modelcontextprotocol/sdk 1.24.0 → 1.25.2 (CVE-2026-0621
ReDoS)
- Upgrade tar 7.4.3 → 7.5.4+ (CVE-2026-23950 race condition)
- Add pnpm overrides for transitive deps:
  - qs <6.14.0 → 6.14.0 (CVE-2025-15284 DoS)
  - systeminformation <5.27.14 → 5.27.14 (CVE-2025-68154 cmd injection)
  - lodash <4.17.23 → 4.17.23 (CVE-2025-13465 prototype pollution)

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
2026-01-27 16:33:04 +00:00
nicktrn 70c8d6d14b fix(react-hooks): prevent onComplete from firing prematurely when stream disconnects (#2929)
## Summary

Fixes #2856 - The `onComplete` callback in `useRealtimeRun` was firing prematurely

## Root Cause

The callback was triggered when the long-poll stream ended, regardless
of whether the run had actually completed. Reverse proxies often close
idle connections, causing the stream to end prematurely. In this case it
was caused by fetch abort due to React strict mode.

## Fix

Changed the condition from checking if `run` exists to checking if
`run?.finishedAt` exists, ensuring `onComplete` only fires when the run
has reached a terminal state.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: nicktrn <nicktrn@users.noreply.github.com>
2026-01-27 16:21:56 +00:00
Eric Allam eeab6bdeac fix(run-engine): fix queue cache memory leak and replace MemoryStore with LRU cache (#2945)
- Fix memory leak in RunAttemptSystem queue cache - was keying by runId
instead of queue identifier
- Replace `@unkey/cache` MemoryStore with new LRUMemoryStore for O(1)
operations and better memory bounds

## Problem

### Cache Key Bug
The queue cache in `#resolveTaskRunExecutionQueue` was keyed by `runId`,
creating one cache entry per run instead of per queue. With 1-2 hour
TTLs and 5000 entry soft cap, these accumulated causing memory growth.

### MemoryStore Performance
The `@unkey/cache` MemoryStore uses O(n) synchronous iteration for
eviction, blocking the event loop at high throughput.

## Solution

### Cache Key Fix
Changed cache key from `params.runId` to queue identifier:

```typescript
const cacheKey = params.lockedQueueId ?? `${params.runtimeEnvironmentId}:${params.queueName}`;
```

LRU Cache

Created LRUMemoryStore adapter using lru-cache package:
- O(1) get/set/delete operations
- Strict memory bounds (hard max vs soft cap)
- No event loop blocking

Test Results:

| Metric | Before Fix | After Fix |
|---|---|---|
| Queue cache entries (per 1000 runs) | ~1000 | 1 |
| Old space growth | 32.27 MB | 5.45 MB |
| Heap growth | 7.21 MB (3.9%) | 4.81 MB (2.6%) |


<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2945">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-26 22:23:23 +00:00
Matt Aitken 825219a2f4 Fix: requeue a run if the DB is unavailable during dequeuing (#2938)
In the DequeueSystem if the database is unavailable we were dequeuing
from Redis and then failing to requeue in the error catcher – this was
because the requeuing required DB access.

Now if in the `catch` we encounter a DB error we requeue directly using
Redis, putting it back in the queue.
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2938">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-25 19:23:04 +00:00
bharath kumar b143027d95 Fix/sdk stream root fallback (#2874) 2026-01-25 06:53:26 +00:00
Eric Allam 409388365e fix(fair-queue): ensure concurrency is released when a message reaches visibility timeout to prevent concurrency leaks (#2907)
## Summary

Fixes a concurrency leak in the batch queue where visibility timeout
reclaims do not release concurrency slots.

**The bug:** When a message visibility timeout expires (60s),
`reclaimTimedOut` puts the message back in the queue but does NOT
release the concurrency slot. The messageId stays in the concurrency set
(`engine:batch:concurrency:tenant:{envId}`), counting against the tenant
limit even though the message is no longer in-flight.

This causes:
1. Tenant appears at capacity when checking `SCARD >= limit`
2. New messages get released back to queue instead of being processed
3. Messages stuck in infinite loop, master queue grows indefinitely

**The fix:**
- Modified `reclaimTimedOut` to capture message data (including
tenantId) BEFORE releasing from in-flight
- Returns `ReclaimedMessageInfo[]` with messageId, queueId, tenantId,
and metadata
- `#reclaimTimedOutMessages` now iterates over reclaimed messages and
calls `concurrencyManager.release()` for each

## Test plan

- [x] Added test: `should return reclaimed message info with tenantId
for concurrency release`
- [x] Added test: `should return empty array when no messages have timed
out`
- [x] Added test: `should reclaim multiple timed-out messages and return
all their info`
- [x] Updated `raceConditions.test.ts` for new return type
- [x] All tests passing
- [ ] Monitor production after deploy for concurrency leak recurrence

refs TRI-7049
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2907">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-24 13:06:35 +00:00
Gautam Singh fe5178f3e8 feat(webapp): add priority to task test and replay options (#2936)
Closes #2934

##  Checklist

- [x] I have followed every step in the [contributing
guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md)
- [x] The PR title follows the convention.
- [x] I ran and tested the code works

---

## Testing

_[Describe the steps you took to test this change]_

1. make sure there are no worker connected or the queue is paused.
2. run hello world tasks and assign priority
3. notice the priority value in task context.
4. try running a replay for task with priority
5. notice the context priority
---

## Changelog

_[Short description of what has changed]_
Added option to set priority on task test and replay dialog
---

## Screenshots

_[Screenshots]_
<img width="953" height="804" alt="image"
src="https://github.com/user-attachments/assets/d452b872-6c05-4a9e-a1e3-f6a283bb363d"
/>
<img width="958" height="886" alt="image"
src="https://github.com/user-attachments/assets/8f9f3096-da99-4d81-a9c5-2f3004b89ceb"
/>
<img width="1022" height="736" alt="image"
src="https://github.com/user-attachments/assets/233e01ac-a42b-4b4f-ac3e-e9b18638b971"
/>


💯

<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2936">
  <picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1">
<img
src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1"
alt="Open with Devin">
  </picture>
</a>
<!-- devin-review-badge-end -->
2026-01-24 09:10:14 +00:00
Iss a3f1eb2361 chore(docs): Admin requirements for creating worker groups (#2925)
Adds documentation for creating additional worker groups via the admin
API endpoint, including how to make users admin (new vs existing users),
and clarifies that ADMIN_EMAILS only applies on signup.
2026-01-24 09:00:50 +00:00
Iss ab4b50b95a chore(docs): Add clarifications for delay semantics and payload handling (#2928)
Adds documentation notes clarifying that:
- delayed runs execute on the current deployment version
- guidance for Date objects in payloads
- static IP availability
- version locking behavior for delayed runs
2026-01-24 08:59:55 +00:00
Eric Allam 1859fd0283 chore(docs): Improvements to the idempotency key docs for the user expored itempotency key changes (#2910) 2026-01-23 18:07:12 +00:00
mpcgird 12f508a949 Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2026-01-12 14:35:32 +02:00
mpcgird fd46381c8b optimized the animation frame loop 2025-12-15 23:28:27 +02:00
mpcgird a2277d5a37 Merge branch 'main' into fix/tri-6732-hover-vertical-timeline-does-not-update-on-task-runs 2025-12-12 09:33:13 +00:00
mpcgird b4d6d7859e Fix for mouse hover on a timline not being updates after resize
Refactor MousePositionProvider to improve reactivity during resize, scroll, and layout shifts.
2025-12-11 22:53:49 +00:00
210 changed files with 12029 additions and 2913 deletions
+16
View File
@@ -0,0 +1,16 @@
---
"@trigger.dev/core": patch
"@trigger.dev/sdk": patch
---
Add `maxDelay` option to debounce feature. This allows setting a maximum time limit for how long a debounced run can be delayed, ensuring execution happens within a specified window even with continuous triggers.
```typescript
await myTask.trigger(payload, {
debounce: {
key: "my-key",
delay: "5s",
maxDelay: "30m", // Execute within 30 minutes regardless of continuous triggers
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/react-hooks": patch
---
Fix `onComplete` callback firing prematurely when the realtime stream disconnects before the run finishes.
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Aligned the SDK's `getRunIdForOptions` logic with the Core package to handle semantic targets (`root`, `parent`) in root tasks.
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Export `AnyOnStartAttemptHookFunction` type to allow defining `onStartAttempt` hooks for individual tasks.
@@ -0,0 +1,5 @@
---
"trigger.dev": patch
---
Fix runner getting stuck indefinitely when `execute()` is called on a dead child process.
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/core": patch
---
fix: vendor superjson to fix ESM/CJS compatibility
Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on Node.js versions that don't support `require(ESM)` by default (< 22.12.0) and AWS Lambda which intentionally disables it.
+4
View File
@@ -29,3 +29,7 @@ jobs:
with:
package: cli-v3
secrets: inherit
sdk-compat:
uses: ./.github/workflows/sdk-compat.yml
secrets: inherit
+11 -1
View File
@@ -122,7 +122,6 @@ 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 and push Docker tag
if: steps.changesets.outputs.published == 'true'
run: |
@@ -130,6 +129,17 @@ 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 }}
# 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
+178
View File
@@ -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
+3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+5
View File
@@ -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,
},
},
],
};
}
}
+39 -25
View File
@@ -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
@@ -0,0 +1,63 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { LinkButton } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
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-0.5">
<Header3 className="text-blue-400">Info</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
General informational messages about task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-warning">Warn</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
Warning messages indicating potential issues that don't prevent execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-error">Error</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
Error messages for failures and exceptions during task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-charcoal-400">Debug</Header3>
</div>
<Paragraph variant="small" className="text-text-dimmed">
Detailed diagnostic information for development and debugging.
</Paragraph>
</div>
<div className="border-t border-charcoal-700 pt-4">
<Header3>Tracing & Spans</Header3>
<Paragraph variant="small" className="text-text-dimmed">
Automatically track the flow of your code through task triggers, attempts, and HTTP
requests. Create custom traces to monitor specific operations.
</Paragraph>
</div>
<LinkButton
to="https://trigger.dev/docs/logging#tracing-and-spans"
variant="docs/small"
LeadingIcon={BookOpenIcon}
>
Read docs
</LinkButton>
</div>
);
}
+43 -8
View File
@@ -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">
@@ -158,6 +162,37 @@ 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>Schedules page</Header3>
<Shortcut name="New schedule">
@@ -5,9 +5,10 @@ import { Chart } from "~/components/primitives/charts/ChartCompound";
import { Paragraph } from "../primitives/Paragraph";
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
// Color palette for chart series
// Color palette for chart series - 30 distinct colors for large datasets
const CHART_COLORS = [
"#7655fd", // Primary purple
// Primary colors
"#7655fd", // Purple
"#22c55e", // Green
"#f59e0b", // Amber
"#ef4444", // Red
@@ -17,6 +18,28 @@ const CHART_COLORS = [
"#14b8a6", // Teal
"#f97316", // Orange
"#6366f1", // Indigo
// Extended palette
"#84cc16", // Lime
"#0ea5e9", // Sky
"#f43f5e", // Rose
"#a855f7", // Fuchsia
"#eab308", // Yellow
"#10b981", // Emerald
"#3b82f6", // Blue
"#d946ef", // Magenta
"#78716c", // Stone
"#facc15", // Gold
// Additional distinct colors
"#2dd4bf", // Turquoise
"#fb923c", // Light orange
"#a3e635", // Yellow-green
"#38bdf8", // Light blue
"#c084fc", // Light purple
"#4ade80", // Light green
"#fbbf24", // Light amber
"#f472b6", // Light pink
"#67e8f9", // Light cyan
"#818cf8", // Light indigo
];
function getSeriesColor(index: number): string {
@@ -30,6 +53,8 @@ interface QueryResultsChartProps {
fullLegend?: boolean;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
}
interface TransformedData {
@@ -702,6 +727,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
config,
fullLegend = false,
onViewAllLegendItems,
legendScrollable = false,
}: QueryResultsChartProps) {
const {
xAxisColumn,
@@ -872,6 +898,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
@@ -896,6 +923,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
>
<Chart.Line
xAxisProps={xAxisPropsForLine}
@@ -672,7 +672,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) {
@@ -1137,6 +1139,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
className="bg-background-dimmed divide-y divide-charcoal-700"
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
@@ -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
@@ -21,7 +21,7 @@ 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 { getLevelColor } 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";
@@ -94,16 +94,34 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
const isLoading = fetcher.state === "loading";
const log = fetcher.data ?? initialLog;
// Handle Escape key to close panel
const runPath = v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: log?.runId ?? "" },
{ spanId: log?.spanId ?? "" }
);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target && (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.contentEditable === "true"
)) {
return;
}
if (e.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
}, [onClose, log, runPath, isLoading]);
if (isLoading && !log) {
return (
@@ -129,36 +147,18 @@ 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">
{/* 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>
<div className="flex items-center justify-between border-b border-grid-dimmed px-2 py-2">
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
<XMarkIcon className="size-5" />
</Button>
@@ -185,8 +185,8 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
</TabButton>
</TabContainer>
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="secondary/small" LeadingIcon={ArrowTopRightOnSquareIcon}>
View Full Run
<Button variant="minimal/small" LeadingIcon={ArrowTopRightOnSquareIcon} shortcut={{ key: "v" }}>
View full run
</Button>
</Link>
</div>
@@ -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,20 @@ 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: "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 {
@@ -42,10 +37,6 @@ function getLevelBadgeColor(level: LogLevel): string {
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 +44,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 +109,7 @@ function LevelDropdown({
);
}
function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
function AppliedLevelFilter() {
const { values, del } = useSearchParams();
const levels = values("levels");
@@ -158,25 +118,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,5 +1,6 @@
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";
@@ -52,7 +53,16 @@ export function LogsSearchInput() {
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}
@@ -80,7 +90,7 @@ export function LogsSearchInput() {
) : undefined
}
/>
</div>
</motion.div>
{text.length > 0 && (
<button
+43 -26
View File
@@ -12,6 +12,7 @@ import { DateTime } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
import {
Table,
TableBlankRow,
@@ -24,6 +25,7 @@ import {
type TableVariant,
} from "../primitives/Table";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import { Link } from "@remix-run/react";
type LogsTableProps = {
logs: LogEntry[];
@@ -37,25 +39,23 @@ type LogsTableProps = {
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";
return "inset 2px 0 0 0 rgb(59, 130, 246)";
case "DEBUG":
case "TRACE":
default:
return "border-l-transparent hover:border-l-charcoal-800";
return "none";
}
}
export function LogsTable({
logs,
searchTerm,
@@ -112,14 +112,19 @@ 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 />}
>
Level
</TableHeaderCell>
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
</TableRow>
</TableHeader>
@@ -143,8 +148,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,6 +157,9 @@ export function LogsTable({
className="whitespace-nowrap tabular-nums"
onClick={handleRowClick}
hasAction
style={{
boxShadow: getLevelBoxShadow(log.level),
}}
>
<DateTime date={log.startTime} />
</TableCell>
@@ -180,12 +187,11 @@ export function LogsTable({
<TableCellMenu
className="pl-32"
hiddenButtons={
<PopoverMenuItem
openInNewTab={true}
to={runPath}
icon={ArrowTopRightOnSquareIcon}
title="View Run"
/>
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" TrailingIcon={ArrowTopRightOnSquareIcon}>
View run
</Button>
</Link>
}
/>
</TableRow>
@@ -196,12 +202,23 @@ 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 */}
{!hasMore && logs.length > 0 && (
<div className="flex items-center justify-center py-12">
<div className="flex items-center gap-2">
<span className="text-text-dimmed">Showing all {logs.length} logs</span>
</div>
</div>
)}
</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"
>
Tasks
</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(240px,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>
);
}
@@ -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>
);
@@ -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",
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>
);
}
@@ -25,6 +25,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;
@@ -144,8 +145,9 @@ export function OrganizationSettingsSideMenu({
</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>
);
+470 -112
View File
@@ -6,7 +6,6 @@ import {
BellAlertIcon,
ChartBarIcon,
ChevronRightIcon,
CircleStackIcon,
ClockIcon,
Cog8ToothIcon,
CogIcon,
@@ -15,19 +14,20 @@ import {
GlobeAmericasIcon,
IdentificationIcon,
KeyIcon,
MagnifyingGlassCircleIcon,
PencilSquareIcon,
PlusIcon,
RectangleStackIcon,
ServerStackIcon,
Squares2X2Icon,
TableCellsIcon,
UsersIcon,
UsersIcon
} from "@heroicons/react/20/solid";
import { Link, useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import simplur from "simplur";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { LogsIcon } from "~/assets/icons/LogsIcon";
@@ -41,7 +41,9 @@ import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { type User } from "~/models/user.server";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { type FeedbackType } from "~/routes/resources.feedback";
import { IncidentStatusPanel } from "~/routes/resources.incidents";
@@ -78,6 +80,7 @@ import {
v3UsagePath,
v3WaitpointTokensPath,
} from "~/utils/pathBuilder";
import { AlphaBadge } from "../AlphaBadge";
import { AskAI } from "../AskAI";
import { FreePlanUsage } from "../billing/FreePlanUsage";
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
@@ -87,24 +90,23 @@ import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverArrowTrigger,
PopoverContent,
PopoverMenuItem,
PopoverTrigger,
PopoverTrigger
} from "../primitives/Popover";
import { TextLink } from "../primitives/TextLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { ShortcutsAutoOpen } from "../Shortcuts";
import { UserProfilePhoto } from "../UserProfilePhoto";
import { V4Badge } from "../V4Badge";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { AlphaBadge } from "../AlphaBadge";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
isImpersonating: boolean;
};
export type SideMenuProject = Pick<
MatchedProject,
"id" | "name" | "slug" | "version" | "environments" | "engine"
@@ -130,6 +132,15 @@ export function SideMenu({
}: SideMenuProps) {
const borderRef = useRef<HTMLDivElement>(null);
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
);
const preferencesFetcher = useFetcher();
const pendingPreferencesRef = useRef<{
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentPlan = useCurrentPlan();
const { isConnected } = useDevPresence();
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
@@ -137,6 +148,84 @@ export function SideMenu({
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const persistSideMenuPreferences = useCallback(
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
if (user.isImpersonating) return;
// Merge with any pending changes
pendingPreferencesRef.current = {
...pendingPreferencesRef.current,
...data,
};
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce the actual submission by 500ms
debounceTimeoutRef.current = setTimeout(() => {
const pending = pendingPreferencesRef.current;
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}, 500);
},
[user.isImpersonating, preferencesFetcher]
);
// Flush pending preferences on unmount to avoid losing the last toggle
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
pendingPreferencesRef.current = {};
}
};
}, [preferencesFetcher, user.isImpersonating]);
const handleToggleCollapsed = () => {
const newIsCollapsed = !isCollapsed;
setIsCollapsed(newIsCollapsed);
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
const handleManageSectionToggle = useCallback(
(collapsed: boolean) => {
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
useShortcutKeys({
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
action: handleToggleCollapsed,
});
useEffect(() => {
const handleScroll = () => {
if (borderRef.current) {
@@ -154,246 +243,306 @@ export function SideMenu({
return (
<div
className={cn(
"grid h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden border-r border-grid-bright bg-background-bright transition"
"relative h-full border-r border-grid-bright bg-background-bright transition-all duration-200",
isCollapsed ? "w-[2.75rem]" : "w-56"
)}
>
<div
className={cn(
"flex items-center overflow-hidden border-b px-1 py-1 transition duration-300",
showHeaderDivider ? "border-grid-bright" : "border-transparent"
)}
>
<div className="min-w-0 flex-1">
<CollapseToggle isCollapsed={isCollapsed} onToggle={handleToggleCollapsed} />
<div className="absolute inset-0 grid grid-cols-[100%] grid-rows-[2.5rem_1fr_auto] overflow-hidden">
<div
className={cn(
"flex min-w-0 items-center overflow-hidden border-b px-1 py-1 transition duration-300",
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
)}
>
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
isCollapsed={isCollapsed}
/>
</div>
{isAdmin && !user.isImpersonating ? (
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
<CollapsibleElement isCollapsed={isCollapsed}>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger>
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
</TooltipTrigger>
<TooltipContent side="bottom" className={"text-xs"}>
Admin dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CollapsibleElement>
) : isAdmin && user.isImpersonating ? (
<ImpersonationBanner />
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
<div
className="overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
className={cn(
"min-h-0 overflow-y-auto pt-2",
isCollapsed
? "scrollbar-none"
: "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={borderRef}
>
<div className="mb-6 flex flex-col gap-4 px-1">
<div className="space-y-1">
<SideMenuHeader title={"Environment"} />
<div className="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
<div className="w-full space-y-1">
<SideMenuHeader title={"Environment"} isCollapsed={isCollapsed} collapsedTitle="Env" />
<div className="flex items-center">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-full"
isCollapsed={isCollapsed}
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
<CollapsibleElement isCollapsed={isCollapsed}>
<Dialog>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="inline-flex">
<DialogTrigger asChild>
<Button
variant="minimal/small"
className="aspect-square h-7 p-1"
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{isConnected === undefined
? "Checking connection..."
: isConnected
? "Your dev server is connected"
: "Your dev server is not connected"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DevPresencePanel isConnected={isConnected} />
</Dialog>
</CollapsibleElement>
)}
</div>
</div>
<div>
<div className="w-full">
<SideMenuItem
name="Tasks"
icon={TaskIconSmall}
activeIconColor="text-tasks"
inactiveIconColor="text-tasks"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Runs"
icon={RunsIconExtraSmall}
activeIconColor="text-runs"
inactiveIconColor="text-runs"
to={v3RunsPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Batches"
icon={Squares2X2Icon}
activeIconColor="text-batches"
inactiveIconColor="text-batches"
to={v3BatchesPath(organization, project, environment)}
data-action="batches"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Schedules"
icon={ClockIcon}
activeIconColor="text-schedules"
inactiveIconColor="text-schedules"
to={v3SchedulesPath(organization, project, environment)}
data-action="schedules"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Queues"
icon={RectangleStackIcon}
activeIconColor="text-queues"
inactiveIconColor="text-queues"
to={v3QueuesPath(organization, project, environment)}
data-action="queues"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Waitpoint tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
inactiveIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Deployments"
icon={ServerStackIcon}
activeIconColor="text-deployments"
inactiveIconColor="text-deployments"
to={v3DeploymentsPath(organization, project, environment)}
data-action="deployments"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-tests"
inactiveIconColor="text-tests"
to={v3TestPath(organization, project, environment)}
data-action="test"
isCollapsed={isCollapsed}
/>
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
<SideMenuItem
name="Query"
icon={TableCellsIcon}
activeIconColor="text-purple-500"
inactiveIconColor="text-purple-500"
to={queryPath(organization, project, environment)}
data-action="query"
badge={<AlphaBadge />}
isCollapsed={isCollapsed}
/>
)}
</div>
<SideMenuSection title="Waitpoints">
<SideMenuItem
name="Tokens"
icon={WaitpointTokenIcon}
activeIconColor="text-sky-500"
to={v3WaitpointTokensPath(organization, project, environment)}
badge={<V4Badge />}
/>
</SideMenuSection>
<SideMenuSection title="Manage">
<SideMenuSection
title="Manage"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
onCollapseToggle={handleManageSectionToggle}
>
<SideMenuItem
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-bulkActions"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
activeIconColor="text-apiKeys"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ApiKeysPath(organization, project, environment)}
data-action="api keys"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
activeIconColor="text-environmentVariables"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
activeIconColor="text-alerts"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectAlertsPath(organization, project, environment)}
data-action="alerts"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Preview branches"
icon={BranchEnvironmentIconSmall}
activeIconColor="text-preview"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
badge={<V4Badge />}
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
activeIconColor="text-concurrency"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Regions"
icon={GlobeAmericasIcon}
activeIconColor="text-regions"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
badge={<V4Badge />}
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Limits"
icon={AdjustmentsHorizontalIcon}
activeIconColor="text-limits"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={limitsPath(organization, project, environment)}
data-action="limits"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Project settings"
icon={Cog8ToothIcon}
activeIconColor="text-projectSettings"
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
</div>
</div>
<div>
<IncidentStatusPanel />
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
<div className="flex w-full items-center justify-between">
<HelpAndAI />
</div>
<IncidentStatusPanel isCollapsed={isCollapsed} />
<motion.div
layout
transition={{ duration: 0.2, ease: "easeInOut" }}
className={cn("flex flex-col gap-1 border-t border-grid-bright p-1", isCollapsed && "items-center")}
>
<HelpAndAI isCollapsed={isCollapsed} />
{isFreeUser && (
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
</CollapsibleHeight>
)}
</div>
</motion.div>
</div>
</div>
</div>
);
@@ -404,11 +553,13 @@ function ProjectSelector({
organization,
organizations,
user,
isCollapsed = false,
}: {
project: SideMenuProject;
organization: MatchedOrganization;
organizations: MatchedOrganization[];
user: SideMenuUser;
isCollapsed?: boolean;
}) {
const currentPlan = useCurrentPlan();
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
@@ -428,21 +579,50 @@ function ProjectSelector({
return (
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
<PopoverArrowTrigger
isOpen={isOrgMenuOpen}
overflowHidden
className="h-8 w-full justify-between py-1 pl-1.5"
>
<span className="flex items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
</span>
</span>
</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" : "w-full justify-between pr-1"
)}
>
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
<span
className={cn(
"flex min-w-0 items-center gap-1.5 overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
)}
>
<SelectorDivider />
<span className="truncate text-2sm font-normal text-text-bright">
{project.name ?? "Select a project"}
</span>
</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={`${organization.title} / ${project.name ?? "Select a project"}`}
side="right"
sideOffset={8}
hidden={!isCollapsed}
buttonClassName="!h-8"
asChild
disableHoverableContent
/>
<PopoverContent
className="min-w-[16rem] 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)` }}
>
@@ -661,12 +841,190 @@ function SelectorDivider() {
);
}
function HelpAndAI() {
/** Helper component that fades out but preserves width (collapses to 0 width) */
function CollapsibleElement({
isCollapsed,
children,
className,
}: {
isCollapsed: boolean;
children: ReactNode;
className?: string;
}) {
return (
<>
<ShortcutsAutoOpen />
<HelpAndFeedback />
<AskAI />
</>
<div
className={cn(
"overflow-hidden transition-all duration-200",
isCollapsed ? "max-w-0 opacity-0" : "max-w-[100px] opacity-100",
className
)}
>
{children}
</div>
);
}
/** Helper component that fades out and collapses height completely */
function CollapsibleHeight({
isCollapsed,
children,
className,
}: {
isCollapsed: boolean;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid transition-all duration-200 ease-in-out",
isCollapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
className
)}
>
<div className="overflow-hidden">{children}</div>
</div>
);
}
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
return (
<LayoutGroup>
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
<ShortcutsAutoOpen />
<HelpAndFeedback isCollapsed={isCollapsed} />
<AskAI isCollapsed={isCollapsed} />
</div>
</LayoutGroup>
);
}
function AnimatedChevron({
isHovering,
isCollapsed,
}: {
isHovering: boolean;
isCollapsed: boolean;
}) {
// When hovering and expanded: left chevron (pointing left to collapse)
// When hovering and collapsed: right chevron (pointing right to expand)
// When not hovering: straight vertical line
const getRotation = () => {
if (!isHovering) return { top: 0, bottom: 0 };
if (isCollapsed) {
// Right chevron
return { top: -17, bottom: 17 };
} else {
// Left chevron
return { top: 17, bottom: -17 };
}
};
const { top, bottom } = getRotation();
// Calculate horizontal offset to keep chevron centered when rotated
// Left chevron: translate left (-1.5px)
// Right chevron: translate right (+1.5px)
const getTranslateX = () => {
if (!isHovering) return 0;
return isCollapsed ? 1.5 : -1.5;
};
return (
<motion.svg
width="4"
height="30"
viewBox="0 0 4 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 group-hover:text-text-bright transition-colors"
initial={false}
animate={{
x: getTranslateX(),
}}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{/* Top segment */}
<motion.line
x1="2"
y1="1.5"
x2="2"
y2="15"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: top,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
{/* Bottom segment */}
<motion.line
x1="2"
y1="15"
x2="2"
y2="28.5"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={false}
animate={{
rotate: bottom,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ transformOrigin: "2px 15px" }}
/>
</motion.svg>
);
}
function CollapseToggle({
isCollapsed,
onToggle,
}: {
isCollapsed: boolean;
onToggle: () => void;
}) {
const [isHovering, setIsHovering] = useState(false);
return (
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
{/* Vertical line to mask the side menu border */}
<div className={cn(
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
isHovering ? "bg-charcoal-750" : "bg-background-bright"
)} />
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={isCollapsed ? "Expand side menu" : "Collapse side menu"}
onClick={onToggle}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom",
isHovering
? "border border-grid-bright bg-background-bright shadow-md hover:bg-charcoal-750 hover:text-text-bright"
: "border border-transparent bg-transparent"
)}
>
<AnimatedChevron isHovering={isHovering} isCollapsed={isCollapsed} />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
{isCollapsed ? "Expand" : "Collapse"}
<span className="flex items-center">
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -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,7 @@ export function SideMenuItem({
to,
badge,
target,
isCollapsed = false,
}: {
icon?: RenderIcon;
activeIconColor?: string;
@@ -24,30 +27,67 @@ export function SideMenuItem({
to: string;
badge?: ReactNode;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
}) {
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}
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"
)}
>
<div className="flex w-full items-center justify-between">
{name}
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
</div>
</LinkButton>
<SimpleTooltip
button={
<Link
to={to}
target={target}
className={cn(
"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",
isActive ? "bg-tertiary" : ""
)}
>
<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 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>
}
content={name}
side="right"
sideOffset={8}
buttonClassName="!h-8 block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
);
}
@@ -7,6 +7,9 @@ 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;
};
/** A collapsible section for the side menu
@@ -17,6 +20,8 @@ export function SideMenuSection({
initialCollapsed = false,
onCollapseToggle,
children,
isSideMenuCollapsed = false,
itemSpacingClassName = "space-y-px",
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
@@ -27,22 +32,42 @@ 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="flex cursor-pointer items-center gap-1 overflow-hidden rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<ToggleArrowIcon className="size-2" />
<h2 className="text-xs whitespace-nowrap">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.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 +88,7 @@ export function SideMenuSection({
style={{ overflow: "hidden" }}
>
<motion.div
className={`w-full ${itemSpacingClassName}`}
variants={{
expanded: {
translateY: 0,
@@ -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, {
@@ -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,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>
);
@@ -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:top-1/2 data-[handle-orientation=vertical]:after:left-0",
"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 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:hidden" />
{/* Vertical orientation line indicator */}
<div className="absolute left-0 top-[0.0625rem] hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-lavender-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>
);
@@ -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"
)}
@@ -230,6 +242,7 @@ type TableCellProps = TableCellBasicProps & {
isSelected?: boolean;
isTabbableCell?: boolean;
children?: ReactNode;
style?: React.CSSProperties;
};
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
@@ -246,6 +259,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
isSticky = false,
isSelected,
isTabbableCell = false,
style,
},
ref
) => {
@@ -291,6 +305,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
className
)}
colSpan={colSpan}
style={style}
>
{to ? (
<Link
@@ -6,6 +6,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
@@ -16,12 +17,21 @@ interface MousePosition {
y: number;
}
const MousePositionContext = createContext<MousePosition | undefined>(undefined);
export function MousePositionProvider({ children }: { children: ReactNode }) {
export function MousePositionProvider({
children,
recalculateTrigger,
}: {
children: ReactNode;
recalculateTrigger?: unknown;
}) {
const ref = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<MousePosition | undefined>(undefined);
const lastMouseCoordsRef = useRef<{ clientX: number; clientY: number } | null>(null);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
lastMouseCoordsRef.current = { clientX: e.clientX, clientY: e.clientY };
if (!ref.current) {
setPosition(undefined);
return;
@@ -41,11 +51,41 @@ export function MousePositionProvider({ children }: { children: ReactNode }) {
[ref.current]
);
// Recalculate position when trigger changes (e.g., panel opens/closes)
// Use requestAnimationFrame to wait for the DOM layout to complete
useEffect(() => {
if (!lastMouseCoordsRef.current) {
return;
}
const rafId = requestAnimationFrame(() => {
if (!ref.current || !lastMouseCoordsRef.current) {
return;
}
const { top, left, width, height } = ref.current.getBoundingClientRect();
const x = (lastMouseCoordsRef.current.clientX - left) / width;
const y = (lastMouseCoordsRef.current.clientY - top) / height;
if (x < 0 || x > 1 || y < 0 || y > 1) {
setPosition(undefined);
return;
}
setPosition({ x, y });
});
return () => cancelAnimationFrame(rafId);
}, [recalculateTrigger]);
return (
<div
ref={ref}
onMouseEnter={handleMouseMove}
onMouseLeave={() => setPosition(undefined)}
onMouseLeave={() => {
lastMouseCoordsRef.current = null;
setPosition(undefined);
}}
onMouseMove={handleMouseMove}
style={{ width: "100%", height: "100%" }}
>
@@ -83,6 +123,8 @@ export type RootProps = {
maxWidth: number;
children?: ReactNode;
className?: string;
/** When this value changes, recalculate the mouse position (useful when panels resize) */
recalculateTrigger?: unknown;
};
/** The main element that determines the dimensions for all sub-elements */
@@ -94,6 +136,7 @@ export function Root({
maxWidth,
children,
className,
recalculateTrigger,
}: RootProps) {
const pixelWidth = calculatePixelWidth(minWidth, maxWidth, scale);
@@ -106,7 +149,9 @@ export function Root({
width: `${pixelWidth}px`,
}}
>
<MousePositionProvider>{children}</MousePositionProvider>
<MousePositionProvider recalculateTrigger={recalculateTrigger}>
{children}
</MousePositionProvider>
</div>
</TimelineContext.Provider>
);
@@ -6,7 +6,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
return (
<div
className={cn(
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-2 pt-4",
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-1.5 pt-3",
className
)}
>
@@ -17,7 +17,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
const CardHeader = ({ children }: { children: ReactNode }) => {
return (
<Header3 className="mb-4 flex items-center justify-between gap-2 px-4">{children}</Header3>
<Header3 className="mb-3 flex items-center justify-between gap-2 px-3">{children}</Header3>
);
};
@@ -17,6 +17,8 @@ export type ChartLegendCompoundProps = {
totalLabel?: string;
/** Callback when "View all" button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
scrollable?: boolean;
};
/**
@@ -37,6 +39,7 @@ export function ChartLegendCompound({
className,
totalLabel = "Total",
onViewAllLegendItems,
scrollable = false,
}: ChartLegendCompoundProps) {
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
const totals = useSeriesTotal();
@@ -128,11 +131,17 @@ export function ChartLegendCompound({
const isHovering = (highlight.activePayload?.length ?? 0) > 0;
return (
<div className={cn("flex flex-col pt-4 text-sm", className)}>
<div
className={cn(
"flex flex-col pt-4 text-sm",
scrollable && "max-h-[50%] min-h-0",
className
)}
>
{/* Total row */}
<div
className={cn(
"flex w-full items-center justify-between gap-2 rounded px-2 py-1 transition",
"flex w-full shrink-0 items-center justify-between gap-2 rounded px-2 py-1 transition",
isHovering ? "text-text-bright" : "text-text-dimmed"
)}
>
@@ -143,62 +152,68 @@ export function ChartLegendCompound({
</div>
{/* Separator */}
<div className="mx-2 my-1 border-t border-charcoal-750" />
<div className="mx-2 my-1 shrink-0 border-t border-charcoal-750" />
{legendItems.visible.map((item) => {
const total = currentData[item.dataKey] ?? 0;
const isActive = highlight.activeBarKey === item.dataKey;
{/* Legend items - scrollable when scrollable prop is true */}
<div className={cn("flex flex-col", scrollable && "min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600")}>
{legendItems.visible.map((item) => {
const total = currentData[item.dataKey] ?? 0;
const isActive = highlight.activeBarKey === item.dataKey;
return (
<div
key={item.dataKey}
className={cn(
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
total === 0 && "opacity-50"
)}
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
onMouseLeave={() => highlight.reset()}
>
{/* Active highlight background */}
{isActive && item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{item.color && (
<div
className="h-3 w-1 shrink-0 rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
{item.label}
return (
<div
key={item.dataKey}
className={cn(
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
total === 0 && "opacity-50"
)}
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
onMouseLeave={() => highlight.reset()}
>
{/* Active highlight background */}
{isActive && item.color && (
<div
className="absolute inset-0 rounded opacity-10"
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
{item.label}
</span>
</div>
<span
className={cn(
"self-start tabular-nums",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
<AnimatedNumber value={total} duration={0.25} />
</span>
</div>
<span
className={cn("tabular-nums", isActive ? "text-text-bright" : "text-text-dimmed")}
>
<AnimatedNumber value={total} duration={0.25} />
</span>
</div>
</div>
);
})}
);
})}
{/* View more row - replaced by hovered hidden item when applicable */}
{legendItems.remaining > 0 &&
(legendItems.hoveredHiddenItem ? (
<HoveredHiddenItemRow
item={legendItems.hoveredHiddenItem}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
remainingCount={legendItems.remaining - 1}
/>
) : (
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
))}
{/* View more row - replaced by hovered hidden item when applicable */}
{legendItems.remaining > 0 &&
(legendItems.hoveredHiddenItem ? (
<HoveredHiddenItemRow
item={legendItems.hoveredHiddenItem}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
remainingCount={legendItems.remaining - 1}
/>
) : (
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
))}
</div>
</div>
);
}
@@ -172,7 +172,7 @@ export function ChartLineRenderer({
stroke={config[key]?.color}
fill={config[key]?.color}
fillOpacity={0.6}
strokeWidth={2}
strokeWidth={1}
stackId="stack"
isAnimationActive={false}
/>
@@ -220,7 +220,7 @@ export function ChartLineRenderer({
dataKey={key}
type={lineType}
stroke={config[key]?.color}
strokeWidth={2}
strokeWidth={1}
dot={false}
activeDot={{ r: 4 }}
isAnimationActive={false}
@@ -31,6 +31,8 @@ export type ChartRootProps = {
legendTotalLabel?: string;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
/** When true, chart fills its parent container height and distributes space between chart and legend */
fillContainer?: boolean;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
@@ -72,6 +74,7 @@ export function ChartRoot({
maxLegendItems = 5,
legendTotalLabel,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
children,
}: ChartRootProps) {
@@ -94,6 +97,7 @@ export function ChartRoot({
maxLegendItems={maxLegendItems}
legendTotalLabel={legendTotalLabel}
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
fillContainer={fillContainer}
>
{children}
@@ -109,6 +113,7 @@ type ChartRootInnerProps = {
maxLegendItems?: number;
legendTotalLabel?: string;
onViewAllLegendItems?: () => void;
legendScrollable?: boolean;
fillContainer?: boolean;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
};
@@ -120,6 +125,7 @@ function ChartRootInner({
maxLegendItems = 5,
legendTotalLabel,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
children,
}: ChartRootInnerProps) {
@@ -160,6 +166,7 @@ function ChartRootInner({
maxItems={maxLegendItems}
totalLabel={legendTotalLabel}
onViewAllLegendItems={onViewAllLegendItems}
scrollable={legendScrollable}
/>
)}
</div>
@@ -201,6 +201,7 @@ function ReplayForm({
tags,
version,
machine,
prioritySeconds,
},
] = useForm({
id: "replay-task",
@@ -499,6 +500,12 @@ function ReplayForm({
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Priority</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
@@ -31,6 +31,7 @@ import { DateTime } from "~/components/primitives/DateTime";
import { FormError } from "~/components/primitives/FormError";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
ComboBox,
@@ -634,7 +635,7 @@ function TasksDropdown({
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
@@ -654,7 +655,7 @@ function TasksDropdown({
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
}
>
{item.slug}
<MiddleTruncate text={item.slug}/>
</SelectItem>
))}
</SelectList>
@@ -4,36 +4,31 @@ import {
endOfDay,
endOfMonth,
endOfWeek,
isSaturday,
isSunday,
previousSaturday,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subMonths,
subWeeks,
subWeeks
} from "date-fns";
import parse from "parse-duration";
import { startTransition, useCallback, useEffect, useState, type ReactNode } from "react";
import simplur from "simplur";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { Callout } from "~/components/primitives/Callout";
import { DateTime } from "~/components/primitives/DateTime";
import { DateTimePicker } from "~/components/primitives/DateTimePicker";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type ShortcutDefinition } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { Button } from "../../primitives/Buttons";
import { organizationBillingPath } from "~/utils/pathBuilder";
import { Button, LinkButton } from "../../primitives/Buttons";
import { filterIcon } from "./RunFilters";
export type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
userName?: string;
};
export function FilterMenuProvider({
children,
onClose,
@@ -95,6 +90,10 @@ const timePeriods = [
label: "3 days",
value: "3d",
},
{
label: "5 days",
value: "5d",
},
{
label: "7 days",
value: "7d",
@@ -106,11 +105,7 @@ const timePeriods = [
{
label: "30 days",
value: "30d",
},
{
label: "90 days",
value: "90d",
},
}
];
const timeUnits = [
@@ -128,6 +123,22 @@ function parsePeriodString(period: string): { value: number; unit: string } | nu
return null;
}
const MS_PER_DAY = 1000 * 60 * 60 * 24;
// Convert a period string to days using parse-duration
function periodToDays(period: string): number {
const ms = parse(period);
if (!ms) return 0;
return ms / MS_PER_DAY;
}
// Calculate the number of days a date range spans from now
function dateRangeToDays(from?: Date): number {
if (!from) return 0;
const now = new Date();
return Math.ceil((now.getTime() - from.getTime()) / MS_PER_DAY);
}
const DEFAULT_PERIOD = "7d";
const defaultPeriodMs = parse(DEFAULT_PERIOD);
if (!defaultPeriodMs) {
@@ -292,6 +303,8 @@ export interface TimeFilterProps {
applyShortcut?: ShortcutDefinition | undefined;
/** Callback when the user applies a time filter selection, receives the applied values */
onValueChange?: (values: TimeFilterApplyValues) => void;
/** When set an upgrade message will be shown if you select a period further back than this number of days */
maxPeriodDays?: number;
}
export function TimeFilter({
@@ -303,6 +316,7 @@ export function TimeFilter({
hideLabel = false,
applyShortcut,
onValueChange,
maxPeriodDays,
}: TimeFilterProps = {}) {
const { value } = useSearchParams();
const periodValue = period ?? value("period");
@@ -339,6 +353,7 @@ export function TimeFilter({
labelName={labelName}
applyShortcut={applyShortcut}
onValueChange={onValueChange}
maxPeriodDays={maxPeriodDays}
/>
)}
</FilterMenuProvider>
@@ -356,6 +371,8 @@ function getInitialCustomDuration(period?: string): { value: string; unit: strin
return { value: "", unit: "m" };
}
type SectionType = "duration" | "dateRange";
export function TimeDropdown({
trigger,
period,
@@ -366,6 +383,7 @@ export function TimeDropdown({
applyShortcut,
onApply,
onValueChange,
maxPeriodDays,
}: {
trigger: ReactNode;
period?: string;
@@ -377,14 +395,16 @@ export function TimeDropdown({
onApply?: (values: TimeFilterApplyValues) => void;
/** When provided, the component operates in controlled mode and skips URL navigation */
onValueChange?: (values: TimeFilterApplyValues) => void;
/** When set an upgrade message will be shown if you select a period further back than this number of days */
maxPeriodDays?: number;
}) {
const organization = useOptionalOrganization();
const [open, setOpen] = useState<boolean | undefined>();
const { replace } = useSearchParams();
const [fromValue, setFromValue] = useState(from);
const [toValue, setToValue] = useState(to);
// Section selection state: "duration" or "dateRange"
type SectionType = "duration" | "dateRange";
const initialSection: SectionType = from || to ? "dateRange" : "duration";
const [activeSection, setActiveSection] = useState<SectionType>(initialSection);
const [validationError, setValidationError] = useState<string | null>(null);
@@ -418,9 +438,28 @@ export function TimeDropdown({
return !isNaN(value) && value > 0;
})();
// Calculate if the current selection exceeds maxPeriodDays
const exceedsMaxPeriod = (() => {
if (!maxPeriodDays) return false;
if (activeSection === "duration") {
const periodToCheck = selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
if (!periodToCheck) return false;
return periodToDays(periodToCheck) > maxPeriodDays;
} else {
// For date range, check if fromValue is further back than maxPeriodDays
return dateRangeToDays(fromValue) > maxPeriodDays;
}
})();
const applySelection = useCallback(() => {
setValidationError(null);
if (exceedsMaxPeriod) {
setValidationError(`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`);
return;
}
if (activeSection === "duration") {
// Validate custom duration
if (selectedPeriod === "custom" && !isCustomDurationValid) {
@@ -498,6 +537,8 @@ export function TimeDropdown({
replace,
onApply,
onValueChange,
exceedsMaxPeriod,
maxPeriodDays
]);
return (
@@ -683,7 +724,7 @@ export function TimeDropdown({
/>
</div>
{/* Quick select date ranges */}
<div className="mt-2 grid grid-cols-3 gap-2" onClick={(e) => e.stopPropagation()}>
<div className="mt-2 grid grid-cols-2 gap-2" onClick={(e) => e.stopPropagation()}>
<QuickDateButton
label="Yesterday"
isActive={selectedQuickDate === "yesterday"}
@@ -702,45 +743,26 @@ export function TimeDropdown({
onClick={() => {
const today = new Date();
setFromValue(startOfDay(today));
setToValue(today);
setToValue(endOfDay(today));
setActiveSection("dateRange");
setValidationError(null);
setSelectedQuickDate("today");
}}
/>
</div>
<div className="mt-2 grid grid-cols-3 gap-2" onClick={(e) => e.stopPropagation()}>
<QuickDateButton
label="This week"
isActive={selectedQuickDate === "thisWeek"}
onClick={() => {
const now = new Date();
setFromValue(startOfWeek(now, { weekStartsOn: 1 }));
setToValue(now);
setToValue(endOfWeek(now, { weekStartsOn: 1 }));
setActiveSection("dateRange");
setValidationError(null);
setSelectedQuickDate("thisWeek");
}}
/>
<QuickDateButton
label="Last weekend"
isActive={selectedQuickDate === "lastWeekend"}
onClick={() => {
const now = new Date();
let saturday: Date;
if (isSaturday(now)) {
saturday = subDays(now, 7);
} else if (isSunday(now)) {
saturday = subDays(now, 8);
} else {
saturday = previousSaturday(now);
}
const sunday = endOfDay(subDays(saturday, -1));
setFromValue(startOfDay(saturday));
setToValue(sunday);
setActiveSection("dateRange");
setValidationError(null);
setSelectedQuickDate("lastWeekend");
}}
/>
<QuickDateButton
label="Last week"
isActive={selectedQuickDate === "lastWeek"}
@@ -753,56 +775,18 @@ export function TimeDropdown({
setSelectedQuickDate("lastWeek");
}}
/>
<QuickDateButton
label="Last weekdays"
isActive={selectedQuickDate === "lastWeekdays"}
onClick={() => {
const lastWeek = subWeeks(new Date(), 1);
const monday = startOfWeek(lastWeek, { weekStartsOn: 1 });
const friday = endOfDay(subDays(monday, -4)); // Monday + 4 days = Friday
setFromValue(startOfDay(monday));
setToValue(friday);
setActiveSection("dateRange");
setValidationError(null);
setSelectedQuickDate("lastWeekdays");
}}
/>
<QuickDateButton
label="Last month"
isActive={selectedQuickDate === "lastMonth"}
onClick={() => {
const lastMonth = subMonths(new Date(), 1);
setFromValue(startOfMonth(lastMonth));
setToValue(endOfMonth(lastMonth));
setActiveSection("dateRange");
setValidationError(null);
setSelectedQuickDate("lastMonth");
}}
/>
<QuickDateButton
label="This month"
isActive={selectedQuickDate === "thisMonth"}
onClick={() => {
const now = new Date();
setFromValue(startOfMonth(now));
setToValue(now);
setToValue(endOfMonth(now));
setActiveSection("dateRange");
setValidationError(null);
setSelectedQuickDate("thisMonth");
}}
/>
<QuickDateButton
label="Year to date"
isActive={selectedQuickDate === "yearToDate"}
onClick={() => {
const now = new Date();
setFromValue(startOfYear(now));
setToValue(now);
setActiveSection("dateRange");
setValidationError(null);
setSelectedQuickDate("yearToDate");
}}
/>
</div>
{validationError && activeSection === "dateRange" && (
<Paragraph variant="extra-small" className="mt-2 text-error">
@@ -812,6 +796,17 @@ export function TimeDropdown({
</div>
</div>
{/* Upgrade callout when exceeding maxPeriodDays */}
{exceedsMaxPeriod && organization && (
<Callout
variant="pricing"
cta={<LinkButton variant="primary/small" to={organizationBillingPath({ slug: organization.slug })}>Upgrade</LinkButton>}
className="items-center"
>
{simplur`Your plan allows a maximum of ${maxPeriodDays} day[|s].`}
</Callout>
)}
{/* Action buttons */}
<div className="flex justify-between gap-1 border-t border-grid-bright px-0 pt-3">
<Button
@@ -839,6 +834,7 @@ export function TimeDropdown({
applySelection();
}}
type="button"
disabled={exceedsMaxPeriod}
>
Apply
</Button>
+4 -1
View File
@@ -521,7 +521,6 @@ const EnvironmentSchema = z
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
CENTS_PER_RUN: z.coerce.number().default(0),
CENTS_PER_QUERY_BYTE_SECOND: z.coerce.number().default(0),
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
@@ -1191,12 +1190,16 @@ const EnvironmentSchema = z
CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2),
CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60),
// Query feature flag
QUERY_FEATURE_ENABLED: z.string().default("1"),
// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: z.coerce.number().int().default(0),
QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: z.coerce.number().int().default(10_000),
// Query page concurrency limits
QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(3),
@@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { useOrganization } from "~/hooks/useOrganizations";
import { type loader as canViewLogsPageLoader } from "~/routes/resources.orgs.$organizationSlug.can-view-logs-page/route";
export function useCanViewLogsPage(): boolean | undefined {
const organization = useOrganization();
const fetcher = useTypedFetcher<typeof canViewLogsPageLoader>();
useEffect(() => {
const url = `/resources/orgs/${organization.slug}/can-view-logs-page`;
fetcher.load(url);
}, [organization.slug]);
return fetcher.data?.canViewLogsPage;
}
+5 -3
View File
@@ -1,10 +1,12 @@
import { UIMatch } from "@remix-run/react";
import type { User } from "~/models/user.server";
import { loader } from "~/root";
import { type UIMatch } from "@remix-run/react";
import type { UserWithDashboardPreferences } from "~/models/user.server";
import { type loader } from "~/root";
import { useChanged } from "./useChanged";
import { useTypedMatchesData } from "./useTypedMatchData";
import { useIsImpersonating } from "./useOrganizations";
export type User = UserWithDashboardPreferences;
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
const routeMatch = useTypedMatchesData<typeof loader>({
id: "root",
@@ -1,4 +1,4 @@
import { RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database";
import type { RuntimeEnvironment, PrismaClient } from "@trigger.dev/database";
import { redirect } from "remix-typedjson";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
@@ -10,7 +10,7 @@ import {
} from "./SelectBestEnvironmentPresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -153,18 +153,24 @@ export class OrganizationsPresenter {
},
});
// Get global feature flags (no overrides or defaults)
const globalFlags = await flags();
return orgs.map((org) => {
const flagsResult = org.featureFlags
const orgFlagsResult = org.featureFlags
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
: ({ success: false } as const);
const flags = flagsResult.success ? flagsResult.data : {};
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
// Combine global flags with org flags (org flags win)
const combinedFlags = { ...globalFlags, ...orgFlags };
return {
id: org.id,
slug: org.slug,
title: org.title,
avatar: parseAvatar(org.avatar, defaultAvatar),
featureFlags: flags,
featureFlags: combinedFlags,
projects: org.projects.map((project) => ({
id: project.id,
slug: project.slug,
@@ -156,6 +156,7 @@ export class DeploymentPresenter {
},
},
buildServerMetadata: true,
triggeredVia: true,
},
});
@@ -225,6 +226,7 @@ export class DeploymentPresenter {
isBuilt: !!deployment.builtAt,
type: deployment.type,
git: gitMetadata,
triggeredVia: deployment.triggeredVia,
},
};
}
@@ -36,10 +36,7 @@ export class LogDetailPresenter {
);
}
const isClickhouseV2 = store === "clickhouse_v2";
const queryBuilder = isClickhouseV2
? this.clickhouse.taskEventsV2.logDetailQueryBuilder()
: this.clickhouse.taskEvents.logDetailQueryBuilder();
const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder();
// Required filters - spanId, traceId, and startTime uniquely identify the log
// Multiple events can share the same spanId (span, span events, logs), so startTime is needed
@@ -1,21 +1,15 @@
import { z } from "zod";
import { type ClickHouse, type LogsListResult } from "@internal/clickhouse";
import { MachinePresetName } from "@trigger.dev/core/v3";
import { type ClickHouse } from "@internal/clickhouse";
import {
type PrismaClientOrTransaction,
type TaskRunStatus,
TaskRunStatus as TaskRunStatusEnum,
} from "@trigger.dev/database";
import { getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
// Create a schema that validates TaskRunStatus enum values
const TaskRunStatusSchema = z.array(z.nativeEnum(TaskRunStatusEnum));
import parseDuration from "parse-duration";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils";
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
@@ -33,27 +27,27 @@ type ErrorAttributes = {
[key: string]: unknown;
};
function escapeClickHouseString(val: string): string {
return val
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
}
export type LogsListOptions = {
userId?: string;
projectId: string;
// filters
tasks?: string[];
versions?: string[];
statuses?: TaskRunStatus[];
tags?: string[];
scheduleId?: string;
runId?: string;
period?: string;
bulkId?: string;
from?: number;
to?: number;
isTest?: boolean;
rootOnly?: boolean;
batchId?: string;
runId?: string[];
queues?: string[];
machines?: MachinePresetName[];
levels?: LogLevel[];
defaultPeriod?: string;
retentionLimitDays?: number;
// search
search?: string;
includeDebugLogs?: boolean;
@@ -67,22 +61,13 @@ export const LogsListOptionsSchema = z.object({
userId: z.string().optional(),
projectId: z.string(),
tasks: z.array(z.string()).optional(),
versions: z.array(z.string()).optional(),
statuses: TaskRunStatusSchema.optional(),
tags: z.array(z.string()).optional(),
scheduleId: z.string().optional(),
runId: z.string().optional(),
period: z.string().optional(),
bulkId: z.string().optional(),
from: z.number().int().nonnegative().optional(),
to: z.number().int().nonnegative().optional(),
isTest: z.boolean().optional(),
rootOnly: z.boolean().optional(),
batchId: z.string().optional(),
runId: z.array(z.string()).optional(),
queues: z.array(z.string()).optional(),
machines: z.array(MachinePresetName).optional(),
levels: z.array(LogLevelSchema).optional(),
defaultPeriod: z.string().optional(),
retentionLimitDays: z.number().int().positive().optional(),
search: z.string().max(1000).optional(),
includeDebugLogs: z.boolean().optional(),
direction: z.enum(["forward", "backward"]).optional(),
@@ -91,7 +76,6 @@ export const LogsListOptionsSchema = z.object({
});
const DEFAULT_PAGE_SIZE = 50;
const MAX_RUN_IDS = 5000;
export type LogsList = Awaited<ReturnType<LogsListPresenter["call"]>>;
export type LogEntry = LogsList["logs"][0];
@@ -99,17 +83,15 @@ export type LogsListAppliedFilters = LogsList["filters"];
// Cursor is a base64 encoded JSON of the pagination keys
type LogCursor = {
startTime: string;
environmentId: string;
unixTimestamp: number;
traceId: string;
spanId: string;
runId: string;
};
const LogCursorSchema = z.object({
startTime: z.string(),
environmentId: z.string(),
unixTimestamp: z.number(),
traceId: z.string(),
spanId: z.string(),
runId: z.string(),
});
function encodeCursor(cursor: LogCursor): string {
@@ -141,10 +123,6 @@ function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?
return { kinds: ["LOG_WARN"] };
case "ERROR":
return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] };
case "CANCELLED":
return { statuses: ["CANCELLED"] };
case "TRACE":
return { kinds: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"] };
}
}
@@ -180,18 +158,8 @@ export class LogsListPresenter extends BasePresenter {
userId,
projectId,
tasks,
versions,
statuses,
tags,
scheduleId,
period,
bulkId,
isTest,
rootOnly,
batchId,
runId,
queues,
machines,
period,
levels,
search,
from,
@@ -200,6 +168,7 @@ export class LogsListPresenter extends BasePresenter {
pageSize = DEFAULT_PAGE_SIZE,
includeDebugLogs = true,
defaultPeriod,
retentionLimitDays,
}: LogsListOptions
) {
const time = timeFilters({
@@ -220,23 +189,20 @@ export class LogsListPresenter extends BasePresenter {
}
}
const hasStatusFilters = statuses && statuses.length > 0;
const hasRunLevelFilters =
(versions !== undefined && versions.length > 0) ||
hasStatusFilters ||
(bulkId !== undefined && bulkId !== "") ||
(scheduleId !== undefined && scheduleId !== "") ||
(tags !== undefined && tags.length > 0) ||
batchId !== undefined ||
(runId !== undefined && runId.length > 0) ||
(queues !== undefined && queues.length > 0) ||
(machines !== undefined && machines.length > 0) ||
typeof isTest === "boolean" ||
rootOnly === true;
// Apply retention limit if provided
let wasClampedByRetention = false;
if (retentionLimitDays !== undefined && effectiveFrom) {
const retentionCutoffDate = new Date(Date.now() - retentionLimitDays * 24 * 60 * 60 * 1000);
if (effectiveFrom < retentionCutoffDate) {
effectiveFrom = retentionCutoffDate;
wasClampedByRetention = true;
}
}
const hasFilters =
(tasks !== undefined && tasks.length > 0) ||
hasRunLevelFilters ||
(runId !== undefined && runId !== "") ||
(levels !== undefined && levels.length > 0) ||
(search !== undefined && search !== "") ||
!time.isDefault;
@@ -266,120 +232,29 @@ export class LogsListPresenter extends BasePresenter {
findDisplayableEnvironment(environmentId, userId),
]);
if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) {
const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({
select: {
friendlyId: true,
type: true,
createdAt: true,
name: true,
},
where: {
friendlyId: bulkId,
projectId,
environmentId,
},
});
if (selectedBulkAction) {
bulkActions.push(selectedBulkAction);
}
}
if (!displayableEnvironment) {
throw new ServiceValidationError("No environment found");
}
// If we have run-level filters, we need to first get matching run IDs from Postgres
let runIds: string[] | undefined;
if (hasRunLevelFilters) {
const runsRepository = new RunsRepository({
clickhouse: this.clickhouse,
prisma: this.replica,
});
function clampToNow(date: Date): Date {
const now = new Date();
return date > now ? now : date;
}
runIds = await runsRepository.listFriendlyRunIds({
organizationId,
environmentId,
projectId,
tasks,
versions,
statuses,
tags,
scheduleId,
period,
from: effectiveFrom ? effectiveFrom.getTime() : undefined,
to: effectiveTo ? clampToNow(effectiveTo).getTime() : undefined,
isTest,
rootOnly,
batchId,
runId,
bulkId,
queues,
machines,
page: {
size: MAX_RUN_IDS,
direction: "forward",
},
});
if (runIds.length === 0) {
return {
logs: [],
pagination: {
next: undefined,
previous: undefined,
},
possibleTasks: possibleTasks
.map((task) => ({
slug: task.slug,
triggerSource: task.triggerSource,
}))
.sort((a, b) => a.slug.localeCompare(b.slug)),
bulkActions: bulkActions.map((bulkAction) => ({
id: bulkAction.friendlyId,
type: bulkAction.type,
createdAt: bulkAction.createdAt,
name: bulkAction.name || bulkAction.friendlyId,
})),
filters: {
tasks: tasks || [],
versions: versions || [],
statuses: statuses || [],
levels: levels || [],
from: effectiveFrom,
to: effectiveTo,
},
hasFilters,
hasAnyLogs: false,
searchTerm: search,
};
}
}
// Determine which store to use based on organization configuration
const { store } = await getConfiguredEventRepository(organizationId);
// Throw error if postgres is detected
if (store === "postgres") {
if (store === EVENT_STORE_TYPES.POSTGRES) {
throw new ServiceValidationError(
"Logs are not available for PostgreSQL event store. Please contact support."
);
}
// Get the appropriate query builder based on store type
const isClickhouseV2 = store === "clickhouse_v2";
if (store === EVENT_STORE_TYPES.CLICKHOUSE) {
throw new ServiceValidationError(
"Logs are not available for ClickHouse event store. Please contact support."
);
}
const queryBuilder = isClickhouseV2
? this.clickhouse.taskEventsV2.logsListQueryBuilder()
: this.clickhouse.taskEvents.logsListQueryBuilder();
const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
queryBuilder.prewhere("environment_id = {environmentId: String}", {
queryBuilder.where("environment_id = {environmentId: String}", {
environmentId,
});
@@ -388,16 +263,13 @@ export class LogsListPresenter extends BasePresenter {
});
queryBuilder.where("project_id = {projectId: String}", { projectId });
// Time filters - inserted_at in PREWHERE only for v2, start_time in WHERE for both
if (effectiveFrom) {
const fromNs = convertDateToNanoseconds(effectiveFrom);
// Only use inserted_at for partition pruning if v2
if (isClickhouseV2) {
queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", {
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
}
queryBuilder.where("start_time >= {fromTime: String}", {
fromTime: formatNanosecondsForClickhouse(fromNs),
@@ -408,12 +280,9 @@ export class LogsListPresenter extends BasePresenter {
const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
const toNs = convertDateToNanoseconds(clampedTo);
// Only use inserted_at for partition pruning if v2
if (isClickhouseV2) {
queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
}
queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
queryBuilder.where("start_time <= {toTime: String}", {
toTime: formatNanosecondsForClickhouse(toNs),
@@ -427,19 +296,18 @@ export class LogsListPresenter extends BasePresenter {
});
}
// Run IDs filter (from Postgres lookup)
if (runIds && runIds.length > 0) {
queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds });
// Run ID filter
if (runId && runId !== "") {
queryBuilder.where("run_id = {runId: String}", { runId });
}
// Case-insensitive search in message, attributes, and status fields
if (search && search.trim() !== "") {
const searchTerm = search.trim();
const searchTerm = escapeClickHouseString(search.trim()).toLowerCase();
queryBuilder.where(
"(message ilike {searchPattern: String} OR attributes_text ilike {searchPattern: String} OR status = {statusTerm: String})",
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
{
searchPattern: `%${searchTerm}%`,
statusTerm: searchTerm.toUpperCase(),
searchPattern: `%${searchTerm}%`
}
);
}
@@ -447,7 +315,6 @@ export class LogsListPresenter extends BasePresenter {
if (levels && levels.length > 0) {
const conditions: string[] = [];
const params: Record<string, string[]> = {};
const hasErrorOrCancelledLevel = levels.includes("ERROR") || levels.includes("CANCELLED");
for (const level of levels) {
const filter = levelToKindsAndStatuses(level);
@@ -457,11 +324,10 @@ export class LogsListPresenter extends BasePresenter {
const kindsKey = `kinds_${level}`;
let kindCondition = `kind IN {${kindsKey}: Array(String)}`;
// For TRACE: exclude error/cancelled traces if ERROR/CANCELLED not explicitly selected
if (level === "TRACE" && !hasErrorOrCancelledLevel) {
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
}
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
levelConditions.push(kindCondition);
params[kindsKey] = filter.kinds;
@@ -486,28 +352,34 @@ export class LogsListPresenter extends BasePresenter {
// Debug logs are available only to admins
if (includeDebugLogs === false) {
queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", {
debugKinds: ["DEBUG_EVENT", "LOG_DEBUG"],
debugKinds: ["DEBUG_EVENT"],
});
}
queryBuilder.where("kind NOT IN {debugSpans: Array(String)}", {
debugSpans: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"],
});
// kindCondition += ` `;
// params["excluded_statuses"] = ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"];
queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')");
// Cursor pagination
const decodedCursor = cursor ? decodeCursor(cursor) : null;
if (decodedCursor) {
queryBuilder.where(
"(start_time, trace_id, span_id, run_id) < ({cursorStartTime: String}, {cursorTraceId: String}, {cursorSpanId: String}, {cursorRunId: String})",
"(environment_id, toUnixTimestamp(start_time), trace_id) < ({cursorEnvId: String}, {cursorUnixTimestamp: Int64}, {cursorTraceId: String})",
{
cursorStartTime: decodedCursor.startTime,
cursorEnvId: decodedCursor.environmentId,
cursorUnixTimestamp: decodedCursor.unixTimestamp,
cursorTraceId: decodedCursor.traceId,
cursorSpanId: decodedCursor.spanId,
cursorRunId: decodedCursor.runId,
}
);
}
queryBuilder.orderBy("start_time DESC, trace_id DESC, span_id DESC, run_id DESC");
queryBuilder.orderBy("environment_id DESC, toUnixTimestamp(start_time) DESC, trace_id DESC");
// Limit + 1 to check if there are more results
queryBuilder.limit(pageSize + 1);
@@ -525,11 +397,11 @@ export class LogsListPresenter extends BasePresenter {
let nextCursor: string | undefined;
if (hasMore && logs.length > 0) {
const lastLog = logs[logs.length - 1];
const unixTimestamp = Math.floor(new Date(lastLog.start_time).getTime() / 1000);
nextCursor = encodeCursor({
startTime: lastLog.start_time,
environmentId,
unixTimestamp,
traceId: lastLog.trace_id,
spanId: lastLog.span_id,
runId: lastLog.run_id,
});
}
@@ -587,8 +459,6 @@ export class LogsListPresenter extends BasePresenter {
})),
filters: {
tasks: tasks || [],
versions: versions || [],
statuses: statuses || [],
levels: levels || [],
from: effectiveFrom,
to: effectiveTo,
@@ -596,6 +466,10 @@ export class LogsListPresenter extends BasePresenter {
hasFilters,
hasAnyLogs: transformedLogs.length > 0,
searchTerm: search,
retention: retentionLimitDays !== undefined ? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
} : undefined,
};
}
}
@@ -8,6 +8,8 @@ export type QueryHistoryItem = {
scope: QueryScope;
createdAt: Date;
userName: string | null;
/** AI-generated title summarizing the query */
title: string | null;
/** Time filter settings */
filterPeriod: string | null;
filterFrom: Date | null;
@@ -24,6 +26,7 @@ export class QueryPresenter extends BasePresenter {
id: true,
query: true,
scope: true,
title: true,
createdAt: true,
filterPeriod: true,
filterFrom: true,
@@ -43,6 +46,7 @@ export class QueryPresenter extends BasePresenter {
scope: q.scope.toLowerCase() as QueryScope,
createdAt: q.createdAt,
userName: q.user?.displayName ?? q.user?.name ?? null,
title: q.title,
filterPeriod: q.filterPeriod,
filterFrom: q.filterFrom,
filterTo: q.filterTo,
@@ -1,6 +1,6 @@
import { type Project } from "~/models/project.server";
import { type User } from "~/models/user.server";
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { BasePresenter } from "./basePresenter.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
@@ -48,7 +48,7 @@ export class RegionsPresenter extends BasePresenter {
throw new Error("Project not found");
}
const getFlag = makeFlags(this._replica);
const getFlag = makeFlag(this._replica);
const defaultWorkerInstanceGroupId = await getFlag({
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
});
@@ -1,12 +1,12 @@
import { PrismaClient, prisma } from "~/db.server";
import { type PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { createSSELoader } from "~/utils/sse";
import { createSSELoader, SendFunction } from "~/utils/sse";
import { throttle } from "~/utils/throttle";
import { tracePubSub } from "~/v3/services/tracePubSub.server";
const PING_INTERVAL = 1000;
const STREAM_TIMEOUT = 30 * 1000; // 30 seconds
const PING_INTERVAL = 5_000;
const STREAM_TIMEOUT = 30_000;
export class RunStreamPresenter {
#prismaClient: PrismaClient;
@@ -49,36 +49,40 @@ export class RunStreamPresenter {
// Subscribe to trace updates
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
// Store throttled send function and message listener for cleanup
let throttledSend: ReturnType<typeof throttle> | undefined;
// Only send max every 1 second
const throttledSend = throttle(
(args: { send: SendFunction; event?: string; data: string }) => {
try {
args.send({ event: args.event, data: args.data });
} catch (error) {
if (error instanceof Error) {
if (error.name !== "TypeError") {
logger.debug("Error sending SSE in RunStreamPresenter", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
}
}
// Abort the stream on send error
context.controller.abort("Send error");
}
},
1000
);
let messageListener: ((event: string) => void) | undefined;
return {
initStream: ({ send }) => {
// Create throttled send function
throttledSend = throttle((args: { event?: string; data: string }) => {
try {
send(args);
} catch (error) {
if (error instanceof Error) {
if (error.name !== "TypeError") {
logger.debug("Error sending SSE in RunStreamPresenter", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
}
}
// Abort the stream on send error
context.controller.abort("Send error");
}
}, 1000);
throttledSend({ send, event: "message", data: new Date().toISOString() });
// Set up message listener for pub/sub events
messageListener = (event: string) => {
throttledSend?.({ data: event });
throttledSend({ send, event: "message", data: event });
};
eventEmitter.addListener("message", messageListener);
@@ -88,7 +92,8 @@ export class RunStreamPresenter {
iterator: ({ send }) => {
// Send ping to keep connection alive
try {
send({ event: "ping", data: new Date().toISOString() });
// Send an actual message so the client refreshes
throttledSend({ send, event: "message", data: new Date().toISOString() });
} catch (error) {
// If we can't send a ping, the connection is likely dead
return false;
@@ -3,7 +3,16 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { useEffect, useState, useRef, useCallback } from "react";
import { S2, S2Error } from "@s2-dev/streamstore";
import { Clipboard, ClipboardCheck, ChevronDown, ChevronUp } from "lucide-react";
import {
Clipboard,
ClipboardCheck,
ChevronDown,
ChevronUp,
TerminalSquareIcon,
LayoutDashboardIcon,
GitBranchIcon,
ServerIcon,
} from "lucide-react";
import { ExitIcon } from "~/assets/icons/ExitIcon";
import { GitMetadata } from "~/components/GitMetadata";
import { RuntimeIcon } from "~/components/RuntimeIcon";
@@ -73,6 +82,90 @@ type LogEntry = {
level: "info" | "error" | "warn" | "debug";
};
function getTriggeredViaDisplay(triggeredVia: string | null | undefined): {
icon: React.ReactNode;
label: string;
} | null {
if (!triggeredVia) return null;
const iconClass = "size-4 text-text-dimmed";
switch (triggeredVia) {
case "cli:manual":
return {
icon: <TerminalSquareIcon className={iconClass} />,
label: "CLI (Manual)",
};
case "cli:github_actions":
return {
icon: <GitBranchIcon className={iconClass} />,
label: "CLI (GitHub Actions)",
};
case "cli:gitlab_ci":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (GitLab CI)",
};
case "cli:circleci":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (CircleCI)",
};
case "cli:jenkins":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Jenkins)",
};
case "cli:azure_pipelines":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Azure Pipelines)",
};
case "cli:bitbucket_pipelines":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Bitbucket Pipelines)",
};
case "cli:travis_ci":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Travis CI)",
};
case "cli:buildkite":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (Buildkite)",
};
case "cli:ci_other":
return {
icon: <ServerIcon className={iconClass} />,
label: "CLI (CI)",
};
case "git_integration:github":
return {
icon: <GitBranchIcon className={iconClass} />,
label: "GitHub Integration",
};
case "dashboard":
return {
icon: <LayoutDashboardIcon className={iconClass} />,
label: "Dashboard",
};
default:
// Handle any unknown values gracefully
if (triggeredVia.startsWith("cli:")) {
return {
icon: <TerminalSquareIcon className={iconClass} />,
label: `CLI (${triggeredVia.replace("cli:", "")})`,
};
}
return {
icon: <ServerIcon className={iconClass} />,
label: triggeredVia,
};
}
}
export default function Page() {
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
@@ -408,6 +501,21 @@ export default function Page() {
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Triggered via</Property.Label>
<Property.Value>
{(() => {
const display = getTriggeredViaDisplay(deployment.triggeredVia);
if (!display) return "";
return (
<span className="flex items-center gap-1.5">
{display.icon}
{display.label}
</span>
);
})()}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
@@ -1,5 +1,6 @@
import { type LoaderFunctionArgs , redirect} from "@remix-run/server-runtime";
import { type MetaFunction, useFetcher, useNavigation, useLocation } from "@remix-run/react";
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react";
import { XMarkIcon } from "@heroicons/react/20/solid";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import {
TypedAwait,
@@ -8,41 +9,41 @@ import {
useTypedLoaderData,
} from "remix-typedjson";
import { requireUser } from "~/services/session.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server";
import type { LogLevel } from "~/utils/logUtils";
import { $replica, prisma } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import {
setRootOnlyFilterPreference,
uiPreferencesStorage,
} from "~/services/preferences/uiPreferences.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Suspense, useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { Spinner } from "~/components/primitives/Spinner";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Callout } from "~/components/primitives/Callout";
import { RunsFilters } from "~/components/runs/v3/RunFilters";
import { LogsTable } from "~/components/logs/LogsTable";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { LogDetailView } from "~/components/logs/LogDetailView";
import { LogsSearchInput } from "~/components/logs/LogsSearchInput";
import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/primitives/Resizable";
import { Switch } from "~/components/primitives/Switch";
import { Button } from "~/components/primitives/Buttons";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
// Valid log levels for filtering
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -58,6 +59,7 @@ export const meta: MetaFunction = () => {
];
};
// TODO: Move this to a more appropriate shared location
async function hasLogsPageAccess(
userId: string,
isAdmin: boolean,
@@ -97,7 +99,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = user.id;
const isAdmin = user.admin || user.isImpersonating;
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const canAccess = await hasLogsPageAccess(
@@ -121,53 +122,58 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Environment not found", { status: 404 });
}
const filters = await getRunFiltersFromRequest(request);
// Get search term, levels, and showDebug from query params
// Get filters from query params
const url = new URL(request.url);
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
const runId = url.searchParams.get("runId") ?? undefined;
const search = url.searchParams.get("search") ?? undefined;
const levels = parseLevelsFromUrl(url);
const showDebug = url.searchParams.get("showDebug") === "true";
const period = url.searchParams.get("period") ?? undefined;
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
const from = fromStr ? parseInt(fromStr, 10) : undefined;
const to = toStr ? parseInt(toStr, 10) : undefined;
// Get the user's plan to determine log retention limit
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const presenter = new LogsListPresenter($replica, clickhouseClient);
const listPromise = presenter.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
...filters,
search,
levels,
includeDebugLogs: isAdmin && showDebug,
defaultPeriod: "1h",
}).catch((error) => {
if (error instanceof ServiceValidationError) {
return { error: "Failed to load logs. Please refresh and try again." };
}
throw error;
});
const session = await setRootOnlyFilterPreference(filters.rootOnly, request);
const cookieValue = await uiPreferencesStorage.commitSession(session);
return typeddefer(
{
data: listPromise,
rootOnlyDefault: filters.rootOnly,
filters,
isAdmin,
showDebug,
const listPromise = presenter
.call(project.organizationId, environment.id, {
userId,
projectId: project.id,
tasks: tasks.length > 0 ? tasks : undefined,
runId,
search,
levels,
period,
from,
to,
includeDebugLogs: isAdmin && showDebug,
defaultPeriod: "1h",
},
{
headers: {
"Set-Cookie": cookieValue,
},
}
);
retentionLimitDays,
})
.catch((error) => {
if (error instanceof ServiceValidationError) {
return { error: error.message };
}
throw error;
});
return typeddefer({
data: listPromise,
isAdmin,
showDebug,
defaultPeriod: "1h",
});
};
export default function Page() {
const { data, rootOnlyDefault, isAdmin, showDebug, defaultPeriod } = useTypedLoaderData<typeof loader>();
const { data, isAdmin, showDebug, defaultPeriod } =
useTypedLoaderData<typeof loader>();
return (
<PageContainer>
@@ -183,7 +189,7 @@ export default function Page() {
<div className="my-2 flex items-center justify-center">
<div className="mx-auto flex items-center gap-2">
<Spinner />
<Paragraph variant="small">Loading logs</Paragraph>
<Paragraph variant="small">Loading logs</Paragraph>
</div>
</div>
</div>
@@ -192,10 +198,17 @@ export default function Page() {
<TypedAwait
resolve={data}
errorElement={
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
Unable to load your logs. Please refresh the page or try again in a moment.
</Callout>
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
/>
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
Unable to load your logs. Please refresh the page or try again in a moment.
</Callout>
</div>
</div>
}
>
@@ -203,21 +216,35 @@ export default function Page() {
// Check if result contains an error
if ("error" in result) {
return (
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
{result.error}
</Callout>
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
/>
<div className="flex items-center justify-center px-3 py-12">
<Callout variant="error" className="max-w-fit">
{result.error}
</Callout>
</div>
</div>
);
}
return (
<LogsList
list={result}
rootOnlyDefault={rootOnlyDefault}
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
/>
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<FiltersBar
list={result}
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
/>
<LogsList
list={result}
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
/>
</div>
);
}}
</TypedAwait>
@@ -227,15 +254,117 @@ export default function Page() {
);
}
function LogsList({
function RetentionNotice({
logCount,
retentionDays,
}: {
logCount: number;
retentionDays: number;
}) {
return (
<Paragraph variant="extra-small" className="flex items-center gap-1 whitespace-nowrap">
<span className="text-text-dimmed">
Showing last {retentionDays} {retentionDays === 1 ? 'day' : 'days'}
</span>
<a
href="https://trigger.dev/pricing"
target="_blank"
rel="noopener noreferrer"
className="text-text-link hover:underline"
>
Upgrade
</a>
</Paragraph>
);
}
function FiltersBar({
list,
rootOnlyDefault,
isAdmin,
showDebug,
defaultPeriod,
}: {
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>;
isAdmin: boolean;
showDebug: boolean;
defaultPeriod?: string;
}) {
const location = useOptimisticLocation();
const searchParams = new URLSearchParams(location.search);
const hasFilters =
searchParams.has("tasks") ||
searchParams.has("runId") ||
searchParams.has("search") ||
searchParams.has("levels") ||
searchParams.has("period") ||
searchParams.has("from") ||
searchParams.has("to");
const handleDebugToggle = useCallback((checked: boolean) => {
const url = new URL(window.location.href);
if (checked) {
url.searchParams.set("showDebug", "true");
} else {
url.searchParams.delete("showDebug");
}
window.location.href = url.toString();
}, []);
return (
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
{list ? (
<>
<LogsTaskFilter possibleTasks={list.possibleTasks} />
<LogsRunIdFilter />
<TimeFilter defaultPeriod={defaultPeriod} />
<LogsLevelFilter/>
<LogsSearchInput />
{hasFilters && (
<Form className="h-6">
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
</Form>
)}
</>
) : (
<>
<LogsTaskFilter possibleTasks={[]} />
<LogsRunIdFilter />
<TimeFilter defaultPeriod={defaultPeriod} />
<LogsLevelFilter/>
<LogsSearchInput />
{hasFilters && (
<Form className="h-6">
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
</Form>
)}
</>
)}
</div>
<div className="flex items-center gap-2">
{list?.retention?.wasClamped && (
<RetentionNotice
logCount={list.logs.length}
retentionDays={list.retention.limitDays}
/>
)}
{isAdmin && (
<Switch
variant="small"
label="Debug"
checked={showDebug}
onCheckedChange={handleDebugToggle}
/>
)}
</div>
</div>
);
}
function LogsList({
list,
}: {
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>; //exclude error, it is handled
rootOnlyDefault: boolean;
isAdmin: boolean;
showDebug: boolean;
defaultPeriod?: string;
@@ -253,37 +382,50 @@ function LogsList({
// Selected log state - managed locally to avoid triggering navigation
const [selectedLogId, setSelectedLogId] = useState<string | undefined>();
const handleDebugToggle = useCallback(
(checked: boolean) => {
const url = new URL(window.location.href);
if (checked) {
url.searchParams.set("showDebug", "true");
} else {
url.searchParams.delete("showDebug");
}
window.location.href = url.toString();
},
[]
);
// Track which filter state (search params) the current fetcher request corresponds to
const fetcherFilterStateRef = useRef<string>(location.search);
// Clear accumulated logs immediately when filters change (for instant visual feedback)
useEffect(() => {
setAccumulatedLogs([]);
setNextCursor(undefined);
// Close side panel when filters change to avoid showing a log that's no longer visible
setSelectedLogId(undefined);
}, [location.search]);
// Reset accumulated logs when the initial list changes (e.g., filters change)
// Populate accumulated logs when new data arrives
useEffect(() => {
setAccumulatedLogs(list.logs);
setNextCursor(list.pagination.next);
}, [list.logs, list.pagination.next]);
// Clear log parameter from URL when selectedLogId is cleared
useEffect(() => {
if (!selectedLogId) {
const url = new URL(window.location.href);
if (url.searchParams.has("log")) {
url.searchParams.delete("log");
window.history.replaceState(null, "", url.toString());
}
}
}, [selectedLogId]);
// Append new logs when fetcher completes (with deduplication)
useEffect(() => {
if (fetcher.data && fetcher.state === "idle") {
// Ignore fetcher data if it was loaded for a different filter state
if (fetcherFilterStateRef.current !== location.search) {
return;
}
const existingIds = new Set(accumulatedLogs.map((log) => log.id));
const newLogs = fetcher.data.logs.filter((log) => !existingIds.has(log.id));
if (newLogs.length > 0) {
setAccumulatedLogs((prev) => [...prev, ...newLogs]);
setNextCursor(fetcher.data.pagination.next);
}
setNextCursor(fetcher.data.pagination.next);
}
}, [fetcher.data, fetcher.state, accumulatedLogs]);
}, [fetcher.data, fetcher.state, accumulatedLogs, location.search]);
// Build resource URL for loading more
const loadMoreUrl = useMemo(() => {
@@ -297,27 +439,26 @@ function LogsList({
const handleLoadMore = useCallback(() => {
if (loadMoreUrl && fetcher.state === "idle") {
// Store the current filter state before loading
fetcherFilterStateRef.current = location.search;
fetcher.load(loadMoreUrl);
}
}, [loadMoreUrl, fetcher]);
}, [loadMoreUrl, fetcher, location.search]);
const selectedLog = useMemo(() => {
if (!selectedLogId) return undefined;
return accumulatedLogs.find((log) => log.id === selectedLogId);
}, [selectedLogId, accumulatedLogs]);
const updateUrlWithLog = useCallback(
(logId: string | undefined) => {
const url = new URL(window.location.href);
if (logId) {
url.searchParams.set("log", logId);
} else {
url.searchParams.delete("log");
}
window.history.replaceState(null, "", url.toString());
},
[]
);
const updateUrlWithLog = useCallback((logId: string | undefined) => {
const url = new URL(window.location.href);
if (logId) {
url.searchParams.set("log", logId);
} else {
url.searchParams.delete("log");
}
window.history.replaceState(null, "", url.toString());
}, []);
const handleLogSelect = useCallback(
(logId: string) => {
@@ -339,51 +480,30 @@ function LogsList({
return (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="logs-main" min="200px">
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Filters */}
<div className="flex items-start justify-between gap-x-2 p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
<RunsFilters
possibleTasks={list.possibleTasks}
bulkActions={list.bulkActions}
hasFilters={list.hasFilters}
rootOnlyDefault={rootOnlyDefault}
hideSearch
defaultPeriod={defaultPeriod}
/>
<LogsLevelFilter showDebug={showDebug} />
<LogsSearchInput />
</div>
{isAdmin && (
<Switch
variant="small"
label="Debug"
checked={showDebug}
onCheckedChange={handleDebugToggle}
/>
)}
</div>
{/* Table */}
<LogsTable
logs={accumulatedLogs}
searchTerm={list.searchTerm}
isLoading={isLoading}
isLoadingMore={fetcher.state === "loading"}
hasMore={!!nextCursor}
onLoadMore={handleLoadMore}
selectedLogId={selectedLogId}
onLogSelect={handleLogSelect}
/>
</div>
<LogsTable
key={location.search}
logs={accumulatedLogs}
searchTerm={list.searchTerm}
isLoading={isLoading}
isLoadingMore={fetcher.state === "loading"}
hasMore={!!nextCursor}
onLoadMore={handleLoadMore}
selectedLogId={selectedLogId}
onLogSelect={handleLogSelect}
/>
</ResizablePanel>
{/* Side panel for log details */}
{selectedLogId && (
<>
<ResizableHandle id="logs-handle" />
<ResizablePanel id="log-detail" min="300px" default="430px" max="600px" isStaticAtRest>
<Suspense fallback={<div className="flex h-full items-center justify-center"><Spinner /></div>}>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
}
>
<LogDetailView
logId={selectedLogId}
initialLog={selectedLog}
@@ -48,7 +48,7 @@ LIMIT 20`,
total_cost,
usage_duration,
machine,
created_at
triggered_at
FROM runs
WHERE triggered_at > now() - INTERVAL 7 DAY
ORDER BY total_cost DESC
@@ -79,4 +79,3 @@ export function ExamplesContent({
</div>
);
}
@@ -36,53 +36,82 @@ export function QueryHelpSidebar({
onValueChange={onTabChange}
className="flex min-h-0 flex-col overflow-hidden pt-1"
>
<ClientTabsList variant="underline" className="mx-3 shrink-0">
<ClientTabsTrigger value="ai" variant="underline" layoutId="query-help-tabs">
<div className="flex items-center gap-0.5">
<AISparkleIcon className="size-4" /> AI
</div>
</ClientTabsTrigger>
<ClientTabsTrigger value="guide" variant="underline" layoutId="query-help-tabs">
Writing TRQL
</ClientTabsTrigger>
<ClientTabsTrigger value="schema" variant="underline" layoutId="query-help-tabs">
Table schema
</ClientTabsTrigger>
<ClientTabsTrigger value="examples" variant="underline" layoutId="query-help-tabs">
Examples
</ClientTabsTrigger>
</ClientTabsList>
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<ClientTabsList variant="underline" className="mx-3 shrink-0">
<ClientTabsTrigger
value="ai"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
<div className="flex items-center gap-0.5">
<AISparkleIcon className="size-4" /> AI
</div>
</ClientTabsTrigger>
<ClientTabsTrigger
value="guide"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
Writing TRQL
</ClientTabsTrigger>
<ClientTabsTrigger
value="schema"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
Table schema
</ClientTabsTrigger>
<ClientTabsTrigger
value="examples"
variant="underline"
layoutId="query-help-tabs"
className="shrink-0"
>
Examples
</ClientTabsTrigger>
</ClientTabsList>
</div>
<ClientTabsContent
value="ai"
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<AITabContent
onQueryGenerated={onQueryGenerated}
onTimeFilterChange={onTimeFilterChange}
getCurrentQuery={getCurrentQuery}
aiFixRequest={aiFixRequest}
/>
<div className="min-w-64 p-3">
<AITabContent
onQueryGenerated={onQueryGenerated}
onTimeFilterChange={onTimeFilterChange}
getCurrentQuery={getCurrentQuery}
aiFixRequest={aiFixRequest}
/>
</div>
</ClientTabsContent>
<ClientTabsContent
value="guide"
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<TRQLGuideContent onTryExample={onTryExample} />
<div className="min-w-64 p-3">
<TRQLGuideContent onTryExample={onTryExample} />
</div>
</ClientTabsContent>
<ClientTabsContent
value="schema"
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<TableSchemaContent />
<div className="min-w-64 p-3">
<TableSchemaContent />
</div>
</ClientTabsContent>
<ClientTabsContent
value="examples"
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
>
<ExamplesContent onTryExample={onTryExample} />
<div className="min-w-64 p-3">
<ExamplesContent onTryExample={onTryExample} />
</div>
</ClientTabsContent>
</ClientTabs>
</div>
);
}
@@ -36,9 +36,14 @@ const SQL_KEYWORDS = [
];
function highlightSQL(query: string): React.ReactNode[] {
// Normalize whitespace for display (let CSS line-clamp handle truncation)
const normalized = query.replace(/\s+/g, " ").slice(0, 200);
const suffix = "";
// Normalize: collapse multiple spaces/tabs to single space, but preserve newlines
// Then trim each line and limit total length
const normalized = query
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.filter((line) => line.length > 0)
.join("\n")
.slice(0, 500);
// Create a regex pattern that matches keywords as whole words (case insensitive)
const keywordPattern = new RegExp(
@@ -69,10 +74,6 @@ function highlightSQL(query: string): React.ReactNode[] {
parts.push(normalized.slice(lastIndex));
}
if (suffix) {
parts.push(suffix);
}
return parts;
}
@@ -118,10 +119,21 @@ export function QueryHistoryPopover({
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
>
<div className="flex flex-1 flex-col items-start overflow-hidden">
<p className="line-clamp-2 w-full break-words text-left font-mono text-xs text-[#9b99ff]">
{highlightSQL(item.query)}
</p>
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
{item.title ? (
<>
<p className="w-full truncate text-left text-sm font-medium text-text-bright">
{item.title}
</p>
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
{highlightSQL(item.query)}
</p>
</>
) : (
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-[#9b99ff]">
{highlightSQL(item.query)}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
<span className="capitalize">{item.scope}</span>
{valueLabel && <span>· {valueLabel}</span>}
@@ -1,13 +1,23 @@
import { ArrowDownTrayIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, ArrowTrendingUpIcon, ClipboardIcon } from "@heroicons/react/20/solid";
import type { OutputColumnMetadata, WhereClauseFallback } from "@internal/clickhouse";
import {
ArrowDownTrayIcon,
ArrowsPointingOutIcon,
ArrowTrendingUpIcon,
ClipboardIcon,
TableCellsIcon,
} from "@heroicons/react/20/solid";
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { type WhereClauseCondition } from "@internal/tsql";
import { useFetcher } from "@remix-run/react";
import {
redirect,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import parse from "parse-duration";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
import simplur from "simplur";
import { z } from "zod";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { AlphaTitle } from "~/components/AlphaBadge";
@@ -21,9 +31,7 @@ import { autoFormatSQL, TSQLEditor } from "~/components/code/TSQLEditor";
import { TSQLResultsTable } from "~/components/code/TSQLResultsTable";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { TimeFilter, timeFilters } from "~/components/runs/v3/SharedFilters";
import { useSearchParams } from "~/hooks/useSearchParam";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Card } from "~/components/primitives/charts/Card";
import {
@@ -32,6 +40,7 @@ import {
ClientTabsList,
ClientTabsTrigger,
} from "~/components/primitives/ClientTabs";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Header3 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
@@ -49,27 +58,30 @@ import {
import { Select, SelectItem } from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { Switch } from "~/components/primitives/Switch";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { TimeFilter, timeFilters } from "~/components/runs/v3/SharedFilters";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { QueryPresenter, type QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title";
import { getLimit } from "~/services/platform.v3.server";
import { executeQuery, type QueryScope } from "~/services/queryService.server";
import { requireUser } from "~/services/session.server";
import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
import { EnvironmentParamSchema, organizationBillingPath } from "~/utils/pathBuilder";
import { canAccessQuery } from "~/v3/canAccessQuery.server";
import { querySchemas } from "~/v3/querySchemas";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { QueryHelpSidebar } from "./QueryHelpSidebar";
import { QueryHistoryPopover } from "./QueryHistoryPopover";
import type { AITimeFilter } from "./types";
import { formatQueryStats } from "./utils";
import { requireUser } from "~/services/session.server";
import parse from "parse-duration";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { Dialog, DialogContent, DialogHeader, DialogPortal, DialogTrigger } from "~/components/primitives/Dialog";
import { DialogOverlay } from "@radix-ui/react-dialog";
import { formatDurationNanoseconds } from "@trigger.dev/core/v3";
/** Convert a Date or ISO string to ISO string format */
function toISOString(value: Date | string): string {
@@ -79,40 +91,6 @@ function toISOString(value: Date | string): string {
return value.toISOString();
}
async function hasQueryAccess(
userId: string,
isAdmin: boolean,
isImpersonating: boolean,
organizationSlug: string
): Promise<boolean> {
if (isAdmin || isImpersonating) {
return true;
}
// Check organization feature flags
const organization = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});
if (!organization?.featureFlags) {
return false;
}
const flags = organization.featureFlags as Record<string, unknown>;
const hasQueryAccessResult = validateFeatureFlagValue(
FEATURE_FLAG.hasQueryAccess,
flags.hasQueryAccess
);
return hasQueryAccessResult.success && hasQueryAccessResult.data === true;
}
const scopeOptions = [
{ value: "environment", label: "Environment" },
{ value: "project", label: "Project" },
@@ -123,12 +101,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const canAccess = await hasQueryAccess(
user.id,
user.admin,
user.isImpersonating,
organizationSlug
);
const canAccess = await canAccessQuery({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
});
if (!canAccess) {
throw redirect("/");
}
@@ -159,12 +137,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typedjson({
defaultQuery,
defaultPeriod: await getDefaultPeriod(project.organizationId),
history,
isAdmin,
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
});
};
const DEFAULT_PERIOD = "7d";
async function getDefaultPeriod(organizationId: string): Promise<string> {
const idealDefaultPeriodDays = 7;
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
if (maxQueryPeriod < idealDefaultPeriodDays) {
return `${maxQueryPeriod}d`;
}
return `${idealDefaultPeriodDays}d`;
}
const ActionSchema = z.object({
query: z.string().min(1, "Query is required"),
@@ -179,12 +166,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const canAccess = await hasQueryAccess(
user.id,
user.admin,
user.isImpersonating,
organizationSlug
);
const canAccess = await canAccessQuery({
userId: user.id,
isAdmin: user.admin,
isImpersonating: user.isImpersonating,
organizationSlug,
});
if (!canAccess) {
return typedjson(
{
@@ -193,8 +180,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 403 }
);
@@ -209,8 +198,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 404 }
);
@@ -225,8 +216,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 404 }
);
@@ -250,8 +243,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
periodClipped: null,
},
{ status: 400 }
);
@@ -263,31 +258,54 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
const explain = explainParam === "true" && isAdmin;
// Build time filter fallback for triggered_at column
const defaultPeriod = await getDefaultPeriod(project.organizationId);
const timeFilter = timeFilters({
period: period ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
defaultPeriod: DEFAULT_PERIOD,
defaultPeriod,
});
let triggeredAtFallback: WhereClauseFallback;
if (timeFilter.from && timeFilter.to) {
// Both from and to specified - use BETWEEN
triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
} else if (timeFilter.from) {
// Only from specified
triggeredAtFallback = { op: "gte", value: timeFilter.from };
} else if (timeFilter.to) {
// Only to specified
triggeredAtFallback = { op: "lte", value: timeFilter.to };
} else {
// Calculate the effective "from" date the user is requesting (for period clipping check)
// This is null only when the user specifies just a "to" date (rare case)
let requestedFromDate: Date | null = null;
if (timeFilter.from) {
requestedFromDate = new Date(timeFilter.from);
} else if (!timeFilter.to) {
// Period specified (or default) - calculate from now
const periodMs = parse(timeFilter.period ?? DEFAULT_PERIOD) ?? 7 * 24 * 60 * 60 * 1000;
triggeredAtFallback = { op: "gte", value: new Date(Date.now() - periodMs) };
const periodMs = parse(timeFilter.period ?? defaultPeriod) ?? 7 * 24 * 60 * 60 * 1000;
requestedFromDate = new Date(Date.now() - periodMs);
}
// Build the fallback WHERE condition based on what the user specified
let triggeredAtFallback: WhereClauseCondition;
if (timeFilter.from && timeFilter.to) {
triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
} else if (timeFilter.from) {
triggeredAtFallback = { op: "gte", value: timeFilter.from };
} else if (timeFilter.to) {
triggeredAtFallback = { op: "lte", value: timeFilter.to };
} else {
triggeredAtFallback = { op: "gte", value: requestedFromDate! };
}
const maxQueryPeriod = await getLimit(project.organizationId, "queryPeriodDays", 30);
const maxQueryPeriodDate = new Date(Date.now() - maxQueryPeriod * 24 * 60 * 60 * 1000);
// Check if the requested time period exceeds the plan limit
const periodClipped = requestedFromDate !== null && requestedFromDate < maxQueryPeriodDate;
// Force tenant isolation and time period limits
const enforcedWhereClause = {
organization_id: { op: "eq", value: project.organizationId },
project_id:
scope === "project" || scope === "environment" ? { op: "eq", value: project.id } : undefined,
environment_id: scope === "environment" ? { op: "eq", value: environment.id } : undefined,
triggered_at: { op: "gte", value: maxQueryPeriodDate },
} satisfies Record<string, WhereClauseCondition | undefined>;
try {
const [error, result] = await executeQuery({
const [error, result, queryId] = await executeQuery({
name: "query-page",
query,
schema: z.record(z.any()),
@@ -298,6 +316,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
projectId: project.id,
environmentId: environment.id,
explain,
enforcedWhereClause,
whereClauseFallback: {
triggered_at: triggeredAtFallback,
},
@@ -323,8 +342,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
queryId: null,
periodClipped: null,
},
{ status: 400 }
);
@@ -336,8 +358,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
columns: result.columns,
stats: result.stats,
hiddenColumns: result.hiddenColumns ?? null,
reachedMaxRows: result.reachedMaxRows,
explainOutput: result.explainOutput ?? null,
generatedSql: result.generatedSql ?? null,
queryId,
periodClipped: periodClipped ? maxQueryPeriod : null,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Unknown error executing query";
@@ -348,8 +373,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
columns: null,
stats: null,
hiddenColumns: null,
reachedMaxRows: null,
explainOutput: null,
generatedSql: null,
queryId: null,
periodClipped: null,
},
{ status: 500 }
);
@@ -368,18 +396,45 @@ interface QueryEditorFormHandle {
const QueryEditorForm = forwardRef<
QueryEditorFormHandle,
{
defaultPeriod: string;
defaultQuery: string;
defaultScope: QueryScope;
defaultTimeFilter?: { period?: string; from?: string; to?: string };
history: QueryHistoryItem[];
fetcher: ReturnType<typeof useTypedFetcher<typeof action>>;
isAdmin: boolean;
onQuerySubmit?: () => void;
onHistorySelected?: (item: QueryHistoryItem) => void;
}
>(function QueryEditorForm({ defaultQuery, defaultScope, defaultTimeFilter, history, fetcher, isAdmin }, ref) {
>(function QueryEditorForm(
{
defaultPeriod,
defaultQuery,
defaultScope,
defaultTimeFilter,
history,
fetcher,
isAdmin,
onQuerySubmit,
onHistorySelected,
},
ref
) {
const isLoading = fetcher.state === "submitting" || fetcher.state === "loading";
const [query, setQuery] = useState(defaultQuery);
const [scope, setScope] = useState<QueryScope>(defaultScope);
const formRef = useRef<HTMLFormElement>(null);
const prevFetcherState = useRef(fetcher.state);
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
// Notify parent when query is submitted (for title generation)
useEffect(() => {
if (prevFetcherState.current !== "submitting" && fetcher.state === "submitting") {
onQuerySubmit?.();
}
prevFetcherState.current = fetcher.state;
}, [fetcher.state, onQuerySubmit]);
// Get time filter values - initialize from props (which may come from history)
const [period, setPeriod] = useState<string | undefined>(defaultTimeFilter?.period);
@@ -406,18 +461,23 @@ const QueryEditorForm = forwardRef<
[query]
);
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
setQuery(item.query);
setScope(item.scope);
// Apply time filter from history item
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
setPeriod(item.filterPeriod ?? undefined);
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
}, []);
const handleHistorySelected = useCallback(
(item: QueryHistoryItem) => {
setQuery(item.query);
setScope(item.scope);
// Apply time filter from history item
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
setPeriod(item.filterPeriod ?? undefined);
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
// Notify parent about history selection (for title)
onHistorySelected?.(item);
},
[onHistorySelected]
);
return (
<div className="flex flex-col gap-2 bg-charcoal-900 pb-2">
<div className="flex h-full flex-col gap-2 bg-charcoal-900 pb-2">
<TSQLEditor
defaultValue={query}
onChange={setQuery}
@@ -425,10 +485,13 @@ const QueryEditorForm = forwardRef<
linterEnabled={true}
showCopyButton={true}
showClearButton={true}
minHeight="200px"
className="min-h-[200px]"
className="min-h-0 flex-1"
/>
<fetcher.Form ref={formRef} method="post" className="flex items-center justify-between gap-2 px-2">
<fetcher.Form
ref={formRef}
method="post"
className="flex items-center justify-between gap-2 px-2"
>
<input type="hidden" name="query" value={query} />
<input type="hidden" name="scope" value={scope} />
{/* Pass time filter values to action */}
@@ -468,14 +531,16 @@ const QueryEditorForm = forwardRef<
</Select>
{queryHasTriggeredAt ? (
<SimpleTooltip
button={<Button variant="tertiary/small" disabled={true} type="button">
Set in query
</Button>}
button={
<Button variant="tertiary/small" disabled={true} type="button">
Set in query
</Button>
}
content="Your query includes a WHERE clause with triggered_at so this filter is disabled."
/>
) : (
<TimeFilter
defaultPeriod={DEFAULT_PERIOD}
defaultPeriod={defaultPeriod}
labelName="Triggered"
hideLabel
period={period}
@@ -492,6 +557,7 @@ const QueryEditorForm = forwardRef<
fetcher.submit(formRef.current);
}
}}
maxPeriodDays={maxPeriodDays}
/>
)}
<Button
@@ -510,22 +576,28 @@ const QueryEditorForm = forwardRef<
});
export default function Page() {
const { defaultQuery, history, isAdmin } = useTypedLoaderData<typeof loader>();
const { defaultPeriod, defaultQuery, history, isAdmin, maxRows } =
useTypedLoaderData<typeof loader>();
const fetcher = useTypedFetcher<typeof action>();
const results = fetcher.data;
const { replace: replaceSearchParams } = useSearchParams();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
// Use most recent history item if available, otherwise fall back to defaults
const initialQuery = history.length > 0 ? history[0].query : defaultQuery;
const initialScope: QueryScope = history.length > 0 ? history[0].scope : "environment";
const initialTimeFilter = history.length > 0
? {
period: history[0].filterPeriod ?? undefined,
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
}
: undefined;
const initialTimeFilter =
history.length > 0
? {
period: history[0].filterPeriod ?? undefined,
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
}
: undefined;
const editorRef = useRef<QueryEditorFormHandle>(null);
const [prettyFormatting, setPrettyFormatting] = useState(true);
@@ -534,6 +606,53 @@ export default function Page() {
const [sidebarTab, setSidebarTab] = useState<string>("ai");
const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null);
// Title generation state
const titleFetcher = useFetcher<typeof titleAction>();
const isTitleLoading = titleFetcher.state !== "idle";
const generatedTitle = titleFetcher.data?.title;
const [historyTitle, setHistoryTitle] = useState<string | null>(
history.length > 0 ? history[0].title ?? null : null
);
// Effective title: history title takes precedence, then generated
const queryTitle = historyTitle ?? generatedTitle ?? null;
// Track whether we should generate a title for the current results
const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false);
// Trigger title generation when query succeeds (only for new queries, not history)
useEffect(() => {
if (
results?.rows &&
!results.error &&
results.queryId &&
shouldGenerateTitle &&
!historyTitle &&
titleFetcher.state === "idle"
) {
const currentQuery = editorRef.current?.getQuery();
if (currentQuery) {
titleFetcher.submit(
{ query: currentQuery, queryId: results.queryId },
{
method: "POST",
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-title`,
encType: "application/json",
}
);
setShouldGenerateTitle(false);
}
}
}, [
results,
shouldGenerateTitle,
historyTitle,
titleFetcher,
organization.slug,
project.slug,
environment.slug,
]);
const handleTryFixError = useCallback((errorMessage: string) => {
setSidebarTab("ai");
setAiFixRequest((prev) => ({
@@ -576,6 +695,18 @@ export default function Page() {
setChartConfig(config);
}, []);
// Handle query submission - prepare for title generation
const handleQuerySubmit = useCallback(() => {
setHistoryTitle(null); // Clear history title when running a new query
setShouldGenerateTitle(true); // Enable title generation for new results
}, []);
// Handle history selection - use existing title if available
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
setHistoryTitle(item.title ?? null);
setShouldGenerateTitle(false); // Don't generate title for history items
}, []);
return (
<PageContainer>
<NavBar>
@@ -584,23 +715,38 @@ export default function Page() {
<PageBody scrollable={false}>
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full bg-charcoal-800">
<ResizablePanel id="query-main" className="h-full">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
<ResizablePanelGroup orientation="vertical" className="h-full overflow-hidden">
{/* Query editor - isolated component to prevent re-renders */}
<QueryEditorForm
ref={editorRef}
defaultQuery={initialQuery}
defaultScope={initialScope}
defaultTimeFilter={initialTimeFilter}
history={history}
fetcher={fetcher}
isAdmin={isAdmin}
/>
<ResizablePanel
id="query-editor"
min="100px"
default="300px"
className="overflow-hidden"
>
<QueryEditorForm
ref={editorRef}
defaultPeriod={defaultPeriod}
defaultQuery={initialQuery}
defaultScope={initialScope}
defaultTimeFilter={initialTimeFilter}
history={history}
fetcher={fetcher}
isAdmin={isAdmin}
onQuerySubmit={handleQuerySubmit}
onHistorySelected={handleHistorySelected}
/>
</ResizablePanel>
<ResizableHandle id="query-editor-handle" />
{/* Results */}
<div className="grid max-h-full grid-rows-[1fr] overflow-hidden border-t border-grid-dimmed bg-charcoal-800">
<ResizablePanel
id="query-results"
min="200px"
className="overflow-hidden bg-charcoal-800"
>
<ClientTabs
value={resultsView}
onValueChange={(v) => setResultsView(v as "table" | "graph")}
className="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden"
className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden"
>
<ClientTabsList
variant="underline"
@@ -620,12 +766,22 @@ export default function Page() {
{results?.rows ? (
<div className="flex flex-1 items-center justify-end gap-2 overflow-hidden border-b border-grid-dimmed pl-3">
<div className="flex items-center gap-2 overflow-hidden truncate">
<span className="text-xs text-text-dimmed">
{results?.rows?.length ? `${results.rows.length} Results` : "Results"}
</span>
{results.reachedMaxRows ? (
<SimpleTooltip
buttonClassName="text-warning text-xs"
button={`${results.rows.length.toLocaleString()} Results`}
content={`Results are limited to ${maxRows.toLocaleString()} rows maximum.`}
/>
) : (
<span className="text-xs text-text-dimmed">
{results.rows.length > 0
? `${results.rows.length.toLocaleString()} Results`
: "Results"}
</span>
)}
{results?.stats && (
<span className="text-xs text-text-dimmed">
{formatQueryStats(results.stats)}
{formatDurationNanoseconds(parseInt(results.stats.elapsed_ns, 10))}
</span>
)}
</div>
@@ -687,19 +843,32 @@ export default function Page() {
</div>
</div>
) : results?.rows && results?.columns ? (
<div className="flex h-full flex-col overflow-hidden">
{results.hiddenColumns && results.hiddenColumns.length > 0 && (
<Callout variant="warning" className="m-2 shrink-0 text-sm">
<code>SELECT *</code> doesn't return all columns because it's slow. The
following columns are not shown:{" "}
<span className="font-mono text-xs">
{results.hiddenColumns.join(", ")}
</span>
. Specify them explicitly to include them.
</Callout>
)}
<div className="h-full bg-charcoal-900 p-2">
<Card className="h-full overflow-hidden p-0">
<div
className={`grid h-full max-h-full overflow-hidden bg-charcoal-900 ${
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
? "grid-rows-[auto_1fr]"
: "grid-rows-[1fr]"
}`}
>
<QueryResultsCallouts
hiddenColumns={results.hiddenColumns}
periodClipped={results.periodClipped}
organizationSlug={organization.slug}
/>
<div className="overflow-hidden p-2">
<Card className="h-full overflow-hidden px-0 pb-0">
<Card.Header>
<div className="flex items-center gap-1.5">
<TableCellsIcon className="size-5 text-indigo-500" />
{isTitleLoading ? (
<span className="flex items-center gap-2 text-text-dimmed">
<Spinner className="size-3" /> Generating title...
</span>
) : (
queryTitle ?? "Results"
)}
</div>
</Card.Header>
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
<TSQLResultsTable
rows={results.rows}
@@ -718,15 +887,30 @@ export default function Page() {
</ClientTabsContent>
<ClientTabsContent
value="graph"
className="m-0 grid min-h-0 grid-rows-[1fr] overflow-hidden"
className={`m-0 grid h-full max-h-full min-h-0 overflow-hidden bg-charcoal-900 ${
results?.rows &&
results.rows.length > 0 &&
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
? "grid-rows-[auto_1fr]"
: "grid-rows-[1fr]"
}`}
>
{results?.rows && results?.columns && results.rows.length > 0 ? (
<ResultsChart
rows={results.rows}
columns={results.columns}
chartConfig={chartConfig}
onChartConfigChange={handleChartConfigChange}
/>
<>
<QueryResultsCallouts
hiddenColumns={results.hiddenColumns}
periodClipped={results.periodClipped}
organizationSlug={organization.slug}
/>
<ResultsChart
rows={results.rows}
columns={results.columns}
chartConfig={chartConfig}
onChartConfigChange={handleChartConfigChange}
queryTitle={queryTitle}
isTitleLoading={isTitleLoading}
/>
</>
) : (
<Paragraph variant="small" className="p-4 text-text-dimmed">
Run a query to visualize results.
@@ -734,13 +918,15 @@ export default function Page() {
)}
</ClientTabsContent>
</ClientTabs>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle id="query-handle" />
<ResizablePanel
id="query-help"
min="200px"
collapsible
collapsedSize="20px"
default="400px"
max="500px"
className="w-full"
@@ -844,52 +1030,128 @@ function ScopeItem({ scope }: { scope: QueryScope }) {
}
}
function QueryResultsCallouts({
hiddenColumns,
periodClipped,
organizationSlug,
}: {
hiddenColumns: string[] | null | undefined;
periodClipped: number | null | undefined;
organizationSlug: string;
}) {
const hasCallouts = (hiddenColumns && hiddenColumns.length > 0) || periodClipped;
if (!hasCallouts) {
return null;
}
return (
<div className="flex flex-col gap-2 px-2 pt-2">
{hiddenColumns && hiddenColumns.length > 0 && (
<Callout variant="warning" className="shrink-0 text-sm">
<code>SELECT *</code> doesn't return all columns because it's slow. The following columns
are not shown: <span className="font-mono text-xs">{hiddenColumns.join(", ")}</span>.
Specify them explicitly to include them.
</Callout>
)}
{periodClipped && (
<Callout
variant="pricing"
cta={
<LinkButton
variant="primary/small"
to={organizationBillingPath({ slug: organizationSlug })}
>
Upgrade
</LinkButton>
}
className="items-center"
>
{simplur`Results are limited to the last ${periodClipped} day[|s] based on your plan.`}
</Callout>
)}
</div>
);
}
function hasQueryResultsCallouts(
hiddenColumns: string[] | null | undefined,
periodClipped: number | null | undefined
): boolean {
return (hiddenColumns && hiddenColumns.length > 0) || !!periodClipped;
}
function ResultsChart({
rows,
columns,
chartConfig,
onChartConfigChange,
queryTitle,
isTitleLoading,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
chartConfig: ChartConfiguration;
onChartConfigChange: (config: ChartConfiguration) => void;
queryTitle: string | null;
isTitleLoading: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
return (
<><ResizablePanelGroup className="h-full overflow-hidden">
<ResizablePanel id="chart-results">
<div className="h-full bg-charcoal-900 p-2 overflow-hidden">
<Card className="h-full">
<Card.Header>
<div className="flex items-center gap-1.5">
<ArrowTrendingUpIcon className="size-5 text-indigo-500" />
Chart
</div>
<Card.Accessory>
<Button variant="minimal/small" LeadingIcon={ArrowsPointingOutIcon} onClick={() => setIsOpen(true)} />
</Card.Accessory>
</Card.Header>
<Card.Content className="h-full flex-1 min-h-0">
<QueryResultsChart rows={rows} columns={columns} config={chartConfig} onViewAllLegendItems={() => setIsOpen(true)} />
</Card.Content>
</Card>
const titleContent = isTitleLoading ? (
<span className="flex items-center gap-2 text-text-dimmed">
<Spinner className="size-3" /> Generating title...
</span>
) : (
queryTitle ?? "Chart"
);
</div>
</ResizablePanel>
<ResizableHandle id="chart-split" />
<ResizablePanel id="chart-config" min="50px" default="200px">
<ChartConfigPanel columns={columns} config={chartConfig} onChange={onChartConfigChange} />
</ResizablePanel>
</ResizablePanelGroup>
return (
<>
<ResizablePanelGroup className="overflow-hidden">
<ResizablePanel id="chart-results">
<div className="h-full overflow-hidden bg-charcoal-900 p-2">
<Card className="h-full">
<Card.Header>
<div className="flex items-center gap-1.5">
<ArrowTrendingUpIcon className="size-5 text-indigo-500" />
{titleContent}
</div>
<Card.Accessory>
<Button
variant="minimal/small"
LeadingIcon={ArrowsPointingOutIcon}
onClick={() => setIsOpen(true)}
/>
</Card.Accessory>
</Card.Header>
<Card.Content className="h-full min-h-0 flex-1">
<QueryResultsChart
rows={rows}
columns={columns}
config={chartConfig}
onViewAllLegendItems={() => setIsOpen(true)}
/>
</Card.Content>
</Card>
</div>
</ResizablePanel>
<ResizableHandle id="chart-split" />
<ResizablePanel id="chart-config" min="50px" default="200px">
<ChartConfigPanel columns={columns} config={chartConfig} onChange={onChartConfigChange} />
</ResizablePanel>
</ResizablePanelGroup>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent fullscreen>
<DialogHeader>
Chart
</DialogHeader>
<div className="h-full min-h-0 flex-1 overflow-hidden w-full pt-4">
<QueryResultsChart rows={rows} columns={columns} config={chartConfig} fullLegend={true} />
<DialogHeader>{queryTitle ?? "Chart"}</DialogHeader>
<div className="h-full min-h-0 w-full flex-1 overflow-hidden pt-4">
<QueryResultsChart
rows={rows}
columns={columns}
config={chartConfig}
fullLegend={true}
legendScrollable={true}
/>
</div>
</DialogContent>
</Dialog>
@@ -1,32 +0,0 @@
export function formatQueryStats(stats: {
read_rows: string;
read_bytes: string;
elapsed_ns: string;
byte_seconds: string;
}): string {
const readRows = parseInt(stats.read_rows, 10);
const readBytes = parseInt(stats.read_bytes, 10);
const elapsedNs = parseInt(stats.elapsed_ns, 10);
const byteSeconds = parseFloat(stats.byte_seconds);
const elapsedMs = elapsedNs / 1_000_000;
const formattedTime =
elapsedMs < 1000 ? `${elapsedMs.toFixed(1)}ms` : `${(elapsedMs / 1000).toFixed(2)}s`;
const formattedBytes = formatBytes(readBytes);
return `${readRows.toLocaleString()} rows read · ${formattedBytes} · ${formattedTime} · ${formatBytes(
byteSeconds
)}s`;
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
if (bytes < 0) return "-" + formatBytes(-bytes);
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.max(
0,
Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
);
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
@@ -364,14 +364,14 @@ export default function Page() {
/>
</div>
}
valueClassName={env.paused ? "text-warning" : undefined}
valueClassName={cn(env.paused ? "text-warning" : undefined, "tabular-nums")}
compactThreshold={1000000}
/>
<BigNumber
title="Running"
value={environment.running}
animate
valueClassName={limitClassName}
valueClassName={cn(limitClassName, "tabular-nums")}
suffix={
limitStatus === "burst" ? (
<span className={cn(limitClassName, "flex items-center gap-1")}>
@@ -436,6 +436,24 @@ export default function Page() {
);
}
function shouldLiveReload({
events,
maximumLiveReloadingSetting,
run,
}: {
events: TraceEvent[];
maximumLiveReloadingSetting: number;
run: { completedAt: string | null };
}): boolean {
// We don't live reload if there are a ton of spans/logs
if (events.length > maximumLiveReloadingSetting) return false;
// If the run was completed a while ago, we don't need to live reload anymore
if (run.completedAt && new Date(run.completedAt).getTime() < Date.now() - 30_000) return false;
return true;
}
function TraceView({
run,
trace,
@@ -453,18 +471,19 @@ function TraceView({
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
trace;
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
const changeToSpan = useDebounce((selectedSpan: string) => {
replaceSearchParam("span", selectedSpan, { replace: true });
}, 250);
const isLiveReloading = shouldLiveReload({ events, maximumLiveReloadingSetting, run });
const revalidator = useRevalidator();
const streamedEvents = useEventSource(
v3RunStreamingPath(organization, project, environment, run),
{
event: "message",
disabled: !shouldLiveReload,
disabled: !isLiveReloading,
}
);
useEffect(() => {
@@ -511,7 +530,7 @@ function TraceView({
rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined}
queuedDuration={queuedDuration}
environmentType={run.environment.type}
shouldLiveReload={shouldLiveReload}
shouldLiveReload={isLiveReloading}
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
rootRun={run.rootTaskRun}
parentRun={run.parentTaskRun}
@@ -901,6 +920,7 @@ function TasksTreeView({
treeScrollRef={treeScrollRef}
virtualizer={virtualizer}
toggleNodeSelection={toggleNodeSelection}
selectedId={selectedId}
/>
</ResizablePanel>
</ResizablePanelGroup>
@@ -954,7 +974,7 @@ function TasksTreeView({
type TimelineViewProps = Pick<
TasksTreeViewProps,
"totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" | "queuedDuration"
"totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" | "queuedDuration" | "selectedId"
> & {
scale: number;
parentRef: React.RefObject<HTMLDivElement>;
@@ -985,6 +1005,7 @@ function TimelineView({
showDurations,
treeScrollRef,
queuedDuration,
selectedId,
}: TimelineViewProps) {
const timelineContainerRef = useRef<HTMLDivElement>(null);
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
@@ -1023,6 +1044,7 @@ function TimelineView({
className="h-full overflow-hidden"
minWidth={minTimelineWidth}
maxWidth={maxTimelineWidth}
recalculateTrigger={selectedId}
>
{/* Follows the cursor */}
<CurrentTimeIndicator
@@ -409,6 +409,7 @@ function StandardTaskForm({
tags,
version,
machine,
prioritySeconds,
},
] = useForm({
id: "test-task",
@@ -730,6 +731,12 @@ function StandardTaskForm({
<Hint>Delays run by a specific duration.</Hint>
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">Priority</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label variant="small">TTL</Label>
<DurationPicker
@@ -872,6 +879,7 @@ function ScheduledTaskForm({
tags,
version,
machine,
prioritySeconds,
},
] = useForm({
id: "test-task-scheduled",
@@ -1237,6 +1245,14 @@ function ScheduledTaskForm({
<Hint>Limits concurrency by creating a separate queue for each value of the key.</Hint>
<FormError id={concurrencyKey.errorId}>{concurrencyKey.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={prioritySeconds.id} variant="small">
Priority
</Label>
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
</InputGroup>
<InputGroup>
<Label htmlFor={ttlSeconds.id} variant="small">
TTL
@@ -19,7 +19,7 @@ export default function Project() {
return (
<>
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
<SideMenu
user={{ ...user, isImpersonating }}
+81 -17
View File
@@ -1,10 +1,12 @@
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { json } from "@remix-run/node";
import { useFetcher } from "@remix-run/react";
import { useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { useCallback, useEffect } from "react";
import { LinkButton } from "~/components/primitives/Buttons";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useFeatures } from "~/hooks/useFeatures";
import { BetterStackClient } from "~/services/betterstack/betterstack.server";
@@ -21,51 +23,61 @@ export async function loader() {
});
}
export function IncidentStatusPanel() {
export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) {
const { isManagedCloud } = useFeatures();
if (!isManagedCloud) {
return null;
}
const fetcher = useFetcher<typeof loader>();
const fetchIncidents = useCallback(() => {
if (fetcher.state === "idle") {
fetcher.load("/resources/incidents");
}
}, [fetcher]);
}, []);
useEffect(() => {
if (!isManagedCloud) return;
fetchIncidents();
const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute
return () => clearInterval(interval);
}, []);
}, [isManagedCloud, fetchIncidents]);
const operational = fetcher.data?.operational ?? true;
if (!isManagedCloud || operational) {
return null;
}
return (
<>
{!operational && (
<Popover>
<div className="p-1">
{/* Expanded panel - animated height and opacity */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="p-1"
initial={false}
animate={{
height: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
{/* Header */}
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
Active incident
</Paragraph>
</div>
{/* Description */}
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more
information.
</Paragraph>
{/* Button */}
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
@@ -77,7 +89,59 @@ export function IncidentStatusPanel() {
</LinkButton>
</div>
</motion.div>
)}
</>
{/* Collapsed button - animated height and opacity */}
<motion.div
initial={false}
animate={{
height: isCollapsed ? "auto" : 0,
opacity: isCollapsed ? 1 : 0,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<SimpleTooltip
button={
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-warning/20 bg-warning/10 transition-colors hover:border-warning/30 hover:bg-warning/20">
<ExclamationTriangleIcon className="size-5 text-warning" />
</PopoverTrigger>
}
content="Active incident"
side="right"
sideOffset={8}
disableHoverableContent
asChild
/>
</motion.div>
</div>
<PopoverContent side="right" sideOffset={8} align="start" className="!min-w-0 w-52 p-0">
<IncidentPopoverContent />
</PopoverContent>
</Popover>
);
}
function IncidentPopoverContent() {
return (
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
<ExclamationTriangleIcon className="size-4" />
<Paragraph variant="small/bright" className="text-warning">
Active incident
</Paragraph>
</div>
<Paragraph variant="extra-small/bright" className="text-warning/80">
Our team is working on resolving the issue. Check our status page for more information.
</Paragraph>
<LinkButton
variant="secondary/small"
to="https://status.trigger.dev"
target="_blank"
fullWidth
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
>
<span className="text-warning">View status page</span>
</LinkButton>
</div>
);
}
@@ -0,0 +1,53 @@
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { requireUser } from "~/services/session.server";
import { prisma } from "~/db.server";
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
async function hasLogsPageAccess(
userId: string,
isAdmin: boolean,
isImpersonating: boolean,
organizationSlug: string
): Promise<boolean> {
if (isAdmin || isImpersonating) {
return true;
}
const organization = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});
if (!organization?.featureFlags) {
return false;
}
const flags = organization.featureFlags as Record<string, unknown>;
const hasLogsPageAccessResult = validateFeatureFlagValue(
FEATURE_FLAG.hasLogsPageAccess,
flags.hasLogsPageAccess
);
return hasLogsPageAccessResult.success && hasLogsPageAccessResult.data === true;
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { organizationSlug } = OrganizationParamsSchema.parse(params);
const canViewLogsPage = user.admin || user.isImpersonating || await hasLogsPageAccess(
user.id,
user.admin,
user.isImpersonating,
organizationSlug
);
return typedjson({ canViewLogsPage });
};
@@ -4,13 +4,13 @@ import { requireUser, requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/presenters/v3/LogsListPresenter.server";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { getCurrentPlan } from "~/services/platform.v3.server";
// Valid log levels for filtering
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -19,7 +19,10 @@ function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const userId = user.id;
const isAdmin = user?.admin || user?.isImpersonating;
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
@@ -32,28 +35,41 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
throw new Response("Environment not found", { status: 404 });
}
const user = await requireUser(request);
const isAdmin = user?.admin || user?.isImpersonating;
// Get the user's plan to determine log retention limit
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const filters = await getRunFiltersFromRequest(request);
// Get search term, cursor, levels, and showDebug from query params
// Get filters from query params
const url = new URL(request.url);
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
const runId = url.searchParams.get("runId") ?? undefined;
const search = url.searchParams.get("search") ?? undefined;
const cursor = url.searchParams.get("cursor") ?? undefined;
const levels = parseLevelsFromUrl(url);
const showDebug = url.searchParams.get("showDebug") === "true";
const period = url.searchParams.get("period") ?? undefined;
const fromStr = url.searchParams.get("from");
const toStr = url.searchParams.get("to");
let from = fromStr ? parseInt(fromStr, 10) : undefined;
let to = toStr ? parseInt(toStr, 10) : undefined;
if (Number.isNaN(from)) from = undefined;
if (Number.isNaN(to)) to = undefined;
const options = LogsListOptionsSchema.parse({
userId,
projectId: project.id,
...filters,
tasks: tasks.length > 0 ? tasks : undefined,
runId,
search,
cursor,
period,
from,
to,
levels,
includeDebugLogs: isAdmin && showDebug,
defaultPeriod: "1h",
retentionLimitDays,
}) as any; // Validated by LogsListOptionsSchema at runtime
const presenter = new LogsListPresenter($replica, clickhouseClient);
@@ -0,0 +1,81 @@
import { openai } from "@ai-sdk/openai";
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { tryCatch } from "@trigger.dev/core/utils";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server";
const RequestSchema = z.object({
query: z.string().min(1, "Query is required"),
queryId: z.string().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
// Parse the request body
const [error, data] = await tryCatch(request.json());
if (error) {
return json({ success: false as const, error: error.message, title: null }, { status: 400 });
}
const submission = RequestSchema.safeParse(data);
if (!submission.success) {
return json(
{ success: false as const, error: "Invalid request data", title: null },
{ status: 400 }
);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return json(
{ success: false as const, error: "Project not found", title: null },
{ status: 404 }
);
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return json(
{ success: false as const, error: "Environment not found", title: null },
{ status: 404 }
);
}
if (!env.OPENAI_API_KEY) {
return json(
{ success: false as const, error: "OpenAI API key is not configured", title: null },
{ status: 400 }
);
}
const { query, queryId } = submission.data;
const service = new AIQueryTitleService(openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini"));
const result = await service.generateTitle(query);
if (!result.success) {
return json({ success: false as const, error: result.error, title: null }, { status: 500 });
}
// Strip leading/trailing quotes that AI sometimes adds
const title = result.title.replace(/^["']|["']$/g, "");
// If a queryId was provided, update the CustomerQuery record with the title
if (queryId) {
await prisma.customerQuery.update({
where: { id: queryId, organizationId: project.organizationId },
data: { title },
});
}
return json({ success: true as const, title, error: null });
}
@@ -73,6 +73,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { useHasAdminAccess } from "~/hooks/useUser";
import { useCanViewLogsPage } from "~/hooks/useCanViewLogsPage";
import { redirectWithErrorMessage } from "~/models/message.server";
import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server";
import { logger } from "~/services/logger.server";
@@ -319,6 +320,7 @@ function RunBody({
const { value, replace } = useSearchParams();
const tab = value("tab");
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
const canViewLogsPage = useCanViewLogsPage();
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_minmax(3.25rem,auto)] overflow-hidden bg-background-bright">
@@ -1012,44 +1014,55 @@ function RunBody({
</div>
<div className="flex items-center">
{run.logsDeletedAt === null ? (
<div className="flex">
canViewLogsPage ? (
<div className="flex">
<LinkButton
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
new Date(run.createdAt).getTime() - 60000
}`}
variant="secondary/medium"
className="rounded-r-none border-r-0"
>
View logs
</LinkButton>
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary/medium"
className="rounded-l-none border-l-charcoal-700 px-1.5"
>
<ChevronUpIcon className="size-4 transition group-hover/button:text-text-bright" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[140px] p-1" align="end">
<PopoverMenuItem
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
new Date(run.createdAt).getTime() - 60000
}`}
title="View logs"
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
/>
<PopoverMenuItem
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
title="Download logs"
icon={CloudArrowDownIcon}
leadingIconClassName="text-indigo-500"
openInNewTab
/>
</PopoverContent>
</Popover>
</div>
) : (
<LinkButton
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
new Date(run.createdAt).getTime() - 60000
}`}
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
LeadingIcon={CloudArrowDownIcon}
leadingIconClassName="text-indigo-400"
variant="secondary/medium"
className="rounded-r-none border-r-0"
>
View logs
Download logs
</LinkButton>
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary/medium"
className="rounded-l-none border-l-charcoal-700 px-1.5"
>
<ChevronUpIcon className="size-4 transition group-hover/button:text-text-bright" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-[140px] p-1" align="end">
<PopoverMenuItem
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
new Date(run.createdAt).getTime() - 60000
}`}
title="View logs"
icon={ArrowRightIcon}
leadingIconClassName="text-blue-500"
/>
<PopoverMenuItem
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
title="Download logs"
icon={CloudArrowDownIcon}
leadingIconClassName="text-indigo-500"
openInNewTab
/>
</PopoverContent>
</Popover>
</div>
)
) : null}
</div>
</div>
@@ -0,0 +1,35 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
import { requireUser } from "~/services/session.server";
// Transforms form data string "true"/"false" to boolean, or undefined if not present
const booleanFromFormData = z
.enum(["true", "false"])
.transform((val) => val === "true")
.optional();
const RequestSchema = z.object({
isCollapsed: booleanFromFormData,
manageSectionCollapsed: booleanFromFormData,
});
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
const result = RequestSchema.safeParse(rawData);
if (!result.success) {
return json({ success: false, error: "Invalid request data" }, { status: 400 });
}
await updateSideMenuPreferences({
user,
isCollapsed: result.data.isCollapsed,
manageSectionCollapsed: result.data.manageSectionCollapsed,
});
return json({ success: true });
}
@@ -199,6 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
ttlSeconds: submission.value.ttlSeconds,
version: submission.value.version,
prioritySeconds: submission.value.prioritySeconds,
});
if (!newRun) {
@@ -1,5 +1,5 @@
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { createLRUMemoryStore } from "@internal/cache";
import { Ratelimit } from "@upstash/ratelimit";
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { createHash } from "node:crypto";
@@ -157,10 +157,7 @@ export function authorizationRateLimitMiddleware({
limiterConfigOverride,
}: Options) {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: { frequency: 0.001, maxItems: limiterCache?.maxItems ?? 1000 },
});
const memory = createLRUMemoryStore(limiterCache?.maxItems ?? 1000);
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`,
@@ -1,6 +1,6 @@
import { type ApiResult, wrapZodFetch } from "@trigger.dev/core/v3/zodfetch";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { createLRUMemoryStore } from "@internal/cache";
import { z } from "zod";
import { env } from "~/env.server";
@@ -17,7 +17,7 @@ const IncidentSchema = z.object({
export type Incident = z.infer<typeof IncidentSchema>;
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const memory = createLRUMemoryStore(100);
const cache = createCache({
query: new Namespace<ApiResult<Incident>>(ctx, {
@@ -3,6 +3,13 @@ import { prisma } from "~/db.server";
import { logger } from "./logger.server";
import { type UserFromSession } from "./session.server";
const SideMenuPreferences = z.object({
isCollapsed: z.boolean().default(false),
manageSectionCollapsed: z.boolean().default(false),
});
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
const DashboardPreferences = z.object({
version: z.literal("1"),
currentProjectId: z.string().optional(),
@@ -12,6 +19,7 @@ const DashboardPreferences = z.object({
currentEnvironment: z.object({ id: z.string() }),
})
),
sideMenu: SideMenuPreferences.optional(),
});
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
@@ -99,3 +107,47 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
},
});
}
export async function updateSideMenuPreferences({
user,
isCollapsed,
manageSectionCollapsed,
}: {
user: UserFromSession;
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
}) {
if (user.isImpersonating) {
return;
}
// Parse with schema to apply defaults, then overlay any new values
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
...(isCollapsed !== undefined && { isCollapsed }),
...(manageSectionCollapsed !== undefined && { manageSectionCollapsed }),
});
// Only update if something changed
if (
updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
updatedSideMenu.manageSectionCollapsed === currentSideMenu.manageSectionCollapsed
) {
return;
}
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
sideMenu: updatedSideMenu,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}
@@ -15,7 +15,7 @@ import {
type CurrentPlan,
} from "@trigger.dev/platform";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { createLRUMemoryStore } from "@internal/cache";
import { existsSync, readFileSync } from "node:fs";
import { redirect } from "remix-typedjson";
import { z } from "zod";
@@ -45,13 +45,7 @@ const client = singleton("billingClient", initializeClient);
function initializePlatformCache() {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: {
frequency: 0.01,
maxItems: 1000,
},
});
const memory = createLRUMemoryStore(1000);
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:platform:v3",
+50 -51
View File
@@ -7,7 +7,7 @@ import {
type TSQLQueryResult,
} from "@internal/clickhouse";
import type { CustomerQuerySource } from "@trigger.dev/database";
import type { TableSchema } from "@internal/tsql";
import type { TableSchema, WhereClauseCondition } from "@internal/tsql";
import { type z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
@@ -56,17 +56,14 @@ function getDefaultClickhouseSettings(): ClickHouseSettings {
export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
ExecuteTSQLOptions<TOut>,
"tableSchema" | "organizationId" | "projectId" | "environmentId" | "fieldMappings"
"tableSchema" | "fieldMappings"
> & {
organizationId: string;
projectId?: string;
environmentId?: string;
tableSchema: TableSchema[];
/** The scope of the query - determines tenant isolation */
scope: QueryScope;
/** Organization ID (required) */
organizationId: string;
/** Project ID (required for project/environment scope) */
projectId: string;
/** Environment ID (required for environment scope) */
environmentId: string;
/** History options for saving query to billing/audit */
history?: {
/** Where the query originated from */
@@ -89,18 +86,27 @@ export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
customOrgConcurrencyLimit?: number;
};
/**
* Extended result type that includes the optional queryId when saved to history
*/
export type ExecuteQueryResult<T> =
| [error: Error, result: null, queryId: null]
| [error: null, result: T, queryId: string | null];
/**
* Execute a TSQL query against ClickHouse with tenant isolation
* Handles building tenant options, field mappings, and optionally saves to history
* Returns [error, result, queryId] where queryId is the CustomerQuery ID if saved to history
*/
export async function executeQuery<TOut extends z.ZodSchema>(
options: ExecuteQueryOptions<TOut>
): Promise<TSQLQueryResult<z.output<TOut>>> {
): Promise<ExecuteQueryResult<Exclude<TSQLQueryResult<z.output<TOut>>[1], null>>> {
const {
scope,
organizationId,
projectId,
environmentId,
enforcedWhereClause,
history,
customOrgConcurrencyLimit,
whereClauseFallback,
@@ -112,39 +118,22 @@ export async function executeQuery<TOut extends z.ZodSchema>(
const orgLimit = customOrgConcurrencyLimit ?? DEFAULT_ORG_CONCURRENCY_LIMIT;
// Acquire concurrency slot
const acquireResult = await queryConcurrencyLimiter.acquire({
key: organizationId,
requestId,
keyLimit: orgLimit,
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
});
const acquireResult = await queryConcurrencyLimiter.acquire({
key: organizationId,
requestId,
keyLimit: orgLimit,
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
});
if (!acquireResult.success) {
const errorMessage =
acquireResult.reason === "key_limit"
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
: "We're experiencing a lot of queries at the moment. Please try again later.";
return [new QueryError(errorMessage, { query: options.query }), null];
}
if (!acquireResult.success) {
const errorMessage =
acquireResult.reason === "key_limit"
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
: "We're experiencing a lot of queries at the moment. Please try again later.";
return [new QueryError(errorMessage, { query: options.query }), null, null];
}
try {
// Build tenant IDs based on scope
const tenantOptions: {
organizationId: string;
projectId?: string;
environmentId?: string;
} = {
organizationId,
};
if (scope === "project" || scope === "environment") {
tenantOptions.projectId = projectId;
}
if (scope === "environment") {
tenantOptions.environmentId = environmentId;
}
// Build field mappings for project_ref → project_id and environment_id → slug translation
const projects = await prisma.project.findMany({
where: { organizationId },
@@ -163,18 +152,29 @@ export async function executeQuery<TOut extends z.ZodSchema>(
const result = await executeTSQL(clickhouseClient.reader, {
...baseOptions,
...tenantOptions,
enforcedWhereClause,
fieldMappings,
whereClauseFallback,
clickhouseSettings: {
...getDefaultClickhouseSettings(),
...baseOptions.clickhouseSettings, // Allow caller overrides if needed
},
querySettings: {
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
...baseOptions.querySettings, // Allow caller overrides if needed
},
});
// If query failed, return early with no queryId
if (result[0] !== null) {
return [result[0], null, null];
}
let queryId: string | null = null;
// If query succeeded and history options provided, save to history
// Skip history for EXPLAIN queries (admin debugging) and when explicitly skipped (e.g., impersonating)
if (result[0] === null && history && !history.skip && !baseOptions.explain) {
if (history && !history.skip && !baseOptions.explain) {
// Check if this query is the same as the last one saved (avoid duplicate history entries)
const lastQuery = await prisma.customerQuery.findFirst({
where: {
@@ -183,7 +183,7 @@ export async function executeQuery<TOut extends z.ZodSchema>(
userId: history.userId ?? null,
},
orderBy: { createdAt: "desc" },
select: { query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true },
select: { id: true, query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true },
});
const timeFilter = history.timeFilter;
@@ -195,17 +195,15 @@ export async function executeQuery<TOut extends z.ZodSchema>(
lastQuery.filterFrom?.getTime() === (timeFilter?.from?.getTime() ?? undefined) &&
lastQuery.filterTo?.getTime() === (timeFilter?.to?.getTime() ?? undefined);
if (!isDuplicate) {
const stats = result[1].stats;
const byteSeconds = parseFloat(stats.byte_seconds) || 0;
const costInCents = byteSeconds * env.CENTS_PER_QUERY_BYTE_SECOND;
await prisma.customerQuery.create({
if (isDuplicate && lastQuery) {
// Return the existing query's ID for duplicate queries
queryId = lastQuery.id;
} else {
const created = await prisma.customerQuery.create({
data: {
query: options.query,
scope: scopeToEnum[scope],
stats: { ...stats },
costInCents,
stats: { ...result[1].stats },
source: history.source,
organizationId,
projectId: scope === "project" || scope === "environment" ? projectId : null,
@@ -216,10 +214,11 @@ export async function executeQuery<TOut extends z.ZodSchema>(
filterTo: history.timeFilter?.to ?? null,
},
});
queryId = created.id;
}
}
return result;
return [null, result[1], queryId];
} finally {
// Always release the concurrency slot
await queryConcurrencyLimiter.release({
@@ -1,6 +1,6 @@
import {
createCache,
createMemoryStore,
createLRUMemoryStore,
DefaultStatefulContext,
Namespace,
RedisCacheStore,
@@ -97,7 +97,7 @@ function initializeS2RealtimeStreamsCache() {
useModernCacheKeyBuilder: true,
});
const memoryStore = createMemoryStore(5000, 0.001);
const memoryStore = createLRUMemoryStore(5000);
return createCache({
accessToken: new Namespace<string>(ctx, {
@@ -8,7 +8,7 @@ import { longPollingFetch } from "~/utils/longPollingFetch";
import { logger } from "./logger.server";
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
import { Cache, createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { createLRUMemoryStore } from "@internal/cache";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { env } from "~/env.server";
import { API_VERSIONS, CURRENT_API_VERSION } from "~/api/versions";
@@ -84,10 +84,7 @@ export class RealtimeClient {
this.#registerCommands();
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: { frequency: 0.01, maxItems: 1000 },
});
const memory = createLRUMemoryStore(1000);
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:realtime",
@@ -1,6 +1,6 @@
import { Logger, LogLevel } from "@trigger.dev/core/logger";
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { createLRUMemoryStore } from "@internal/cache";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
import { RedisWithClusterOptions } from "~/redis.server";
import { validate as uuidValidate, version as uuidVersion } from "uuid";
@@ -33,13 +33,7 @@ export class RequestIdempotencyService<TTypes extends string> {
: "request-idempotency:";
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: {
frequency: 0.001,
maxItems: 1000,
},
});
const memory = createLRUMemoryStore(1000);
const redisCacheStore = new RedisCacheStore({
name: "request-idempotency",
connection: {
@@ -8,7 +8,7 @@ import parseDuration from "parse-duration";
import { z } from "zod";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server";
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
import { startActiveSpan } from "~/v3/tracer.server";
import { logger } from "../logger.server";
import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server";
@@ -163,7 +163,7 @@ export class RunsRepository implements IRunsRepository {
async #getRepository(): Promise<IRunsRepository> {
return startActiveSpan("runsRepository.getRepository", async (span) => {
const getFlag = makeFlags(this.options.prisma);
const getFlag = makeFlag(this.options.prisma);
const runsListRepository = await getFlag({
key: FEATURE_FLAG.runsListRepository,
defaultValue: this.defaultRepository,
+3 -47
View File
@@ -1,10 +1,10 @@
import { createElement, Fragment, type ReactNode } from "react";
import { z } from "zod";
export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]);
export const LogLevelSchema = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]);
export type LogLevel = z.infer<typeof LogLevelSchema>;
export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
export const validLogLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR",];
// Default styles for search highlighting
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
@@ -71,10 +71,6 @@ export function highlightSearchText(
// Convert ClickHouse kind to display level
export function kindToLevel(kind: string, status: string): LogLevel {
if (status === "CANCELLED") {
return "CANCELLED";
}
// ERROR can come from either kind or status
if (kind === "LOG_ERROR" || status === "ERROR") {
return "ERROR";
@@ -94,7 +90,7 @@ export function kindToLevel(kind: string, status: string): LogLevel {
case "ANCESTOR_OVERRIDE":
case "SPAN_EVENT":
default:
return "TRACE";
return "INFO";
}
}
@@ -109,47 +105,7 @@ export function getLevelColor(level: LogLevel): string {
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";
}
}
// Event kind badge color styles
export function getKindColor(kind: string): string {
if (kind === "SPAN") {
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
}
if (kind === "SPAN_EVENT") {
return "text-amber-400 bg-amber-500/10 border-amber-500/20";
}
if (kind.startsWith("LOG_")) {
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
}
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
}
// Get human readable kind label
export function getKindLabel(kind: string): string {
switch (kind) {
case "SPAN":
return "Span";
case "SPAN_EVENT":
return "Event";
case "LOG_DEBUG":
case "LOG_INFO":
case "LOG_WARN":
case "LOG_ERROR":
case "LOG_LOG":
return "Log";
case "DEBUG_EVENT":
return "Debug";
case "ANCESTOR_OVERRIDE":
return "Override";
default:
return kind;
}
}
+4 -4
View File
@@ -1,13 +1,13 @@
//From: https://kettanaito.com/blog/debounce-vs-throttle
/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */
export function throttle(
func: (...args: any[]) => void,
export function throttle<TArgs extends unknown[]>(
func: (...args: TArgs) => void,
durationMs: number
): (...args: any[]) => void {
): (...args: TArgs) => void {
let isPrimedToFire = false;
return (...args: any[]) => {
return (...args: TArgs) => {
if (!isPrimedToFire) {
isPrimedToFire = true;
@@ -0,0 +1,47 @@
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
export async function canAccessQuery(options: {
userId: string;
isAdmin: boolean;
isImpersonating: boolean;
organizationSlug: string;
}): Promise<boolean> {
const { userId, isAdmin, isImpersonating, organizationSlug } = options;
// 1. If it's on then we have access
const globallyEnabled = env.QUERY_FEATURE_ENABLED === "1";
if (globallyEnabled) {
return true;
}
// 2. Admins always have access
if (isAdmin || isImpersonating) {
return true;
}
// 3. Check if org/global feature flag is on
const org = await prisma.organization.findFirst({
where: {
slug: organizationSlug,
members: { some: { userId } },
},
select: {
featureFlags: true,
},
});
const flag = makeFlag();
const flagResult = await flag({
key: FEATURE_FLAG.hasQueryAccess,
defaultValue: false,
overrides: (org?.featureFlags as Record<string, unknown>) ?? {},
});
if (flagResult) {
return true;
}
// 4. Not enabled anywhere
return false;
}
@@ -5,9 +5,9 @@ import {
clickhouseEventRepositoryV2,
} from "./clickhouseEventRepositoryInstance.server";
import { IEventRepository, TraceEventOptions } from "./eventRepository.types";
import { prisma } from "~/db.server";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG, flags } from "../featureFlags.server";
import { FEATURE_FLAG, flag } from "../featureFlags.server";
import { getTaskEventStore } from "../taskEventStore.server";
export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository {
@@ -24,13 +24,13 @@ export function resolveEventRepositoryForStore(store: string | undefined): IEven
return eventRepository;
}
export const EVENT_STORE_TYPES = {
POSTGRES: "postgres",
CLICKHOUSE: "clickhouse",
CLICKHOUSE_V2: "clickhouse_v2",
} as const;
export const EVENT_STORE_TYPES = {
POSTGRES: "postgres",
CLICKHOUSE: "clickhouse",
CLICKHOUSE_V2: "clickhouse_v2",
} as const;
export type EventStoreType = typeof EVENT_STORE_TYPES[keyof typeof EVENT_STORE_TYPES];
export type EventStoreType = (typeof EVENT_STORE_TYPES)[keyof typeof EVENT_STORE_TYPES];
export async function getConfiguredEventRepository(
organizationId: string
@@ -122,21 +122,21 @@ export async function getV3EventRepository(
async function resolveTaskEventRepositoryFlag(
featureFlags: Record<string, unknown> | undefined
): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> {
const flag = await flags({
const flagResult = await flag({
key: FEATURE_FLAG.taskEventRepository,
defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE,
overrides: featureFlags,
});
if (flag === "clickhouse_v2") {
if (flagResult === "clickhouse_v2") {
return "clickhouse_v2";
}
if (flag === "clickhouse") {
if (flagResult === "clickhouse") {
return "clickhouse";
}
return flag;
return flagResult;
}
export async function recordRunDebugLog(
+60 -9
View File
@@ -25,14 +25,14 @@ export type FlagsOptions<T extends FeatureFlagKey> = {
overrides?: Record<string, unknown>;
};
export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
function flags<T extends FeatureFlagKey>(
export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {
function flag<T extends FeatureFlagKey>(
opts: FlagsOptions<T> & { defaultValue: z.infer<(typeof FeatureFlagCatalog)[T]> }
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]>>;
function flags<T extends FeatureFlagKey>(
function flag<T extends FeatureFlagKey>(
opts: FlagsOptions<T>
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined>;
async function flags<T extends FeatureFlagKey>(
async function flag<T extends FeatureFlagKey>(
opts: FlagsOptions<T>
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined> {
const value = await _prisma.featureFlag.findUnique({
@@ -60,11 +60,11 @@ export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
return parsed.data;
}
return flags;
return flag;
}
export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
return async function setFlags<T extends FeatureFlagKey>(
export function makeSetFlag(_prisma: PrismaClientOrTransaction = prisma) {
return async function setFlag<T extends FeatureFlagKey>(
opts: FlagsOptions<T> & { value: z.infer<(typeof FeatureFlagCatalog)[T]> }
): Promise<void> {
await _prisma.featureFlag.upsert({
@@ -82,8 +82,59 @@ export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
};
}
export type AllFlagsOptions = {
defaultValues?: Partial<FeatureFlagCatalog>;
overrides?: Record<string, unknown>;
};
export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
return async function flags(options?: AllFlagsOptions): Promise<Partial<FeatureFlagCatalog>> {
const rows = await _prisma.featureFlag.findMany();
// Build a map of key -> value from database
const dbValues = new Map<string, unknown>();
for (const row of rows) {
dbValues.set(row.key, row.value);
}
const result: Partial<FeatureFlagCatalog> = {};
// Process each flag in the catalog
for (const key of Object.keys(FeatureFlagCatalog) as FeatureFlagKey[]) {
const schema = FeatureFlagCatalog[key];
// Priority: overrides > database > defaultValues
if (options?.overrides?.[key] !== undefined) {
const parsed = schema.safeParse(options.overrides[key]);
if (parsed.success) {
(result as any)[key] = parsed.data;
continue;
}
}
if (dbValues.has(key)) {
const parsed = schema.safeParse(dbValues.get(key));
if (parsed.success) {
(result as any)[key] = parsed.data;
continue;
}
}
if (options?.defaultValues?.[key] !== undefined) {
const parsed = schema.safeParse(options.defaultValues[key]);
if (parsed.success) {
(result as any)[key] = parsed.data;
}
}
}
return result;
};
}
export const flag = makeFlag();
export const flags = makeFlags();
export const setFlags = makeSetFlags();
export const setFlag = makeSetFlag();
// Create a Zod schema from the existing catalog
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
@@ -112,7 +163,7 @@ export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma
return async function setMultipleFlags(
flags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>
): Promise<{ key: string; value: any }[]> {
const setFlag = makeSetFlags(_prisma);
const setFlag = makeSetFlag(_prisma);
const updatedFlags: { key: string; value: any }[] = [];
for (const [key, value] of Object.entries(flags)) {
@@ -1,5 +1,5 @@
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { createLRUMemoryStore } from "@internal/cache";
import { randomUUID } from "crypto";
import { Redis } from "ioredis";
import { EnvQueues, MarQSFairDequeueStrategy, MarQSKeyProducer } from "./types";
@@ -99,13 +99,7 @@ export class FairDequeuingStrategy implements MarQSFairDequeueStrategy {
constructor(private options: FairDequeuingStrategyOptions) {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({
persistentMap: new Map(),
unstableEvictOnSet: {
frequency: 0.01,
maxItems: 500,
},
});
const memory = createLRUMemoryStore(500);
this._cache = createCache({
concurrencyLimit: new Namespace<number>(ctx, {
+12 -2
View File
@@ -167,10 +167,14 @@ export const runsSchema: TableSchema = {
expression: "if(depth > 0, true, false)",
},
// Useless until we show the user-provided key
idempotency_key: {
name: "idempotency_key",
...column("String", { description: "Idempotency key", example: "user-123-action-456" }),
clickhouseName: "idempotency_key_user",
...column("String", { description: "Idempotency key (available from 4.3.3)", example: "user-123-action-456" }),
},
idempotency_key_scope: {
name: "idempotency_key_scope",
...column("String", { description: "The idempotency key scope determines whether a task should be considered unique within a parent run, a specific attempt, or globally. An empty value means there's no idempotency key set (available from 4.3.3).", example: "run", allowedValues: ["global", "run", "attempt"], }),
},
region: {
name: "region",
@@ -325,6 +329,8 @@ export const runsSchema: TableSchema = {
// Output & error (JSON columns)
// For JSON columns, NULL checks are transformed to check for empty object '{}'
// So `error IS NULL` becomes `error = '{}'` and `error IS NOT NULL` becomes `error != '{}'`
// textColumn uses the pre-materialized text columns for better performance
// dataPrefix handles the internal {"data": ...} wrapper transparently
output: {
name: "output",
...column("JSON", {
@@ -332,6 +338,8 @@ export const runsSchema: TableSchema = {
example: '{"result": "success"}',
}),
nullValue: "'{}'", // Transform NULL checks to compare against empty object
textColumn: "output_text", // Use output_text for full JSON value queries
dataPrefix: "data", // Internal data is wrapped in {"data": ...}
},
error: {
name: "error",
@@ -341,6 +349,8 @@ export const runsSchema: TableSchema = {
example: '{"message": "Task failed"}',
}),
nullValue: "'{}'", // Transform NULL checks to compare against empty object
textColumn: "error_text", // Use error_text for full JSON value queries
dataPrefix: "data", // Internal data is wrapped in {"data": ...}
},
// Tags & versions
@@ -0,0 +1,71 @@
import { openai } from "@ai-sdk/openai";
import { generateText, type LanguageModelV1 } from "ai";
import { env } from "~/env.server";
/**
* Result type for title generation
*/
export type AIQueryTitleResult =
| { success: true; title: string }
| { success: false; error: string };
/**
* Service for generating concise titles for SQL queries using AI
*/
export class AIQueryTitleService {
constructor(private readonly model: LanguageModelV1 = openai("gpt-4o-mini")) {}
/**
* Generate a concise title for a SQL query
*/
async generateTitle(query: string): Promise<AIQueryTitleResult> {
if (!env.OPENAI_API_KEY) {
return { success: false, error: "OpenAI API key is not configured" };
}
try {
const result = await generateText({
model: this.model,
system: `You are a helpful assistant that generates concise titles for SQL queries.
Your task is to create a short, descriptive title (5-10 words) that summarizes what the query does.
Guidelines:
- Focus on the main purpose/intent of the query
- Use plain language, not technical SQL terms
- Start with an action verb when appropriate (e.g., "Count", "List", "Show", "Find")
- Be specific about what data is being retrieved
- Do not include quotes around the title
- Do not include punctuation at the end
Examples:
- "Failed runs by hour over 7 days"
- "Top 50 most expensive task runs"
- "Run counts grouped by status"
- "Average execution time by task"
- "Recent runs with errors"`,
prompt: `Generate a concise title for this SQL query:\n\n${query}`,
maxTokens: 50,
experimental_telemetry: {
isEnabled: true,
metadata: {
feature: "ai-query-title",
},
},
});
const title = result.text.trim();
if (!title) {
return { success: false, error: "No title generated" };
}
return { success: true, title };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Failed to generate title",
};
}
}
}
@@ -110,6 +110,7 @@ export class ReplayTaskRunService extends BaseService {
overrideOptions.version === "latest" ? undefined : overrideOptions.version,
bulkActionId: overrideOptions?.bulkActionId,
region,
priority: overrideOptions.prioritySeconds,
},
},
{
@@ -29,6 +29,7 @@ export class TestTaskService extends BaseService {
tags: data.tags,
machine: data.machine,
lockToVersion: data.version === "latest" ? undefined : data.version,
priority: data.prioritySeconds,
},
});
@@ -66,6 +67,7 @@ export class TestTaskService extends BaseService {
tags: data.tags,
machine: data.machine,
lockToVersion: data.version === "latest" ? undefined : data.version,
priority: data.prioritySeconds,
},
},
{ customIcon: "scheduled" }
@@ -2,7 +2,7 @@ import { WorkerInstanceGroup, WorkerInstanceGroupType } from "@trigger.dev/datab
import { WithRunEngine } from "../baseService.server";
import { WorkerGroupTokenService } from "./workerGroupTokenService.server";
import { logger } from "~/services/logger.server";
import { FEATURE_FLAG, makeFlags, makeSetFlags } from "~/v3/featureFlags.server";
import { FEATURE_FLAG, makeFlag, makeSetFlag } from "~/v3/featureFlags.server";
export class WorkerGroupService extends WithRunEngine {
private readonly defaultNamePrefix = "worker_group";
@@ -47,14 +47,14 @@ export class WorkerGroupService extends WithRunEngine {
},
});
const getFlag = makeFlags(this._prisma);
const getFlag = makeFlag(this._prisma);
const defaultWorkerInstanceGroupId = await getFlag({
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
});
// If there's no global default yet we should set it to the new worker group
if (!defaultWorkerInstanceGroupId) {
const setFlag = makeSetFlags(this._prisma);
const setFlag = makeSetFlag(this._prisma);
await setFlag({
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
value: workerGroup.id,
@@ -166,7 +166,7 @@ export class WorkerGroupService extends WithRunEngine {
}
async getGlobalDefaultWorkerGroup() {
const flags = makeFlags(this._prisma);
const flags = makeFlag(this._prisma);
const defaultWorkerInstanceGroupId = await flags({
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
@@ -1,4 +1,4 @@
import { createCache, createMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
import { createCache, createLRUMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
import {
CheckpointInput,
CompleteRunAttemptResult,
@@ -39,7 +39,7 @@ function createAuthenticatedWorkerInstanceCache() {
authenticatedWorkerInstance: new Namespace<AuthenticatedWorkerInstance>(
new DefaultStatefulContext(),
{
stores: [createMemoryStore(1000, 0.001)],
stores: [createLRUMemoryStore(1000)],
fresh: 60_000 * 10, // 10 minutes
stale: 60_000 * 11, // 11 minutes
}
+5
View File
@@ -46,6 +46,11 @@ export const RunOptionsData = z.object({
message: "Each tag must be at most 128 characters long",
}),
version: z.string().optional(),
prioritySeconds: z
.number()
.min(0)
.optional()
.transform((val) => (val === 0 ? undefined : val)),
});
export type RunOptionsData = z.infer<typeof RunOptionsData>;

Some files were not shown because too many files have changed in this diff Show More