Compare commits

...

41 Commits

Author SHA1 Message Date
github-actions[bot] 98bf706437 chore: release v4.4.1 (#3100)
🚀 Publish Trigger.dev Docker / units (push) Failing after 0s
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 6s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## @trigger.dev/build@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## trigger.dev@4.4.1

### Patch Changes

- Add OTEL metrics pipeline for task workers. Workers collect process
CPU/memory, Node.js runtime metrics (event loop utilization, event loop
delay, heap usage), and user-defined custom metrics via
`otel.metrics.getMeter()`. Metrics are exported to ClickHouse with
10-second aggregation buckets and 1m/5m rollups, and are queryable
through the dashboard query engine with typed attribute columns,
`prettyFormat()` for human-readable values, and AI query support.
([#3061](https://github.com/triggerdotdev/trigger.dev/pull/3061))
-   Updated dependencies:
    -   `@trigger.dev/build@4.4.1`
    -   `@trigger.dev/core@4.4.1`
    -   `@trigger.dev/schema-to-json@4.4.1`

## @trigger.dev/python@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/sdk@4.4.1`
    -   `@trigger.dev/build@4.4.1`
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/react-hooks@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/redis-worker@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/rsc@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/schema-to-json@4.4.1

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/sdk@4.4.1

### Patch Changes

- Add OTEL metrics pipeline for task workers. Workers collect process
CPU/memory, Node.js runtime metrics (event loop utilization, event loop
delay, heap usage), and user-defined custom metrics via
`otel.metrics.getMeter()`. Metrics are exported to ClickHouse with
10-second aggregation buckets and 1m/5m rollups, and are queryable
through the dashboard query engine with typed attribute columns,
`prettyFormat()` for human-readable values, and AI query support.
([#3061](https://github.com/triggerdotdev/trigger.dev/pull/3061))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.1`

## @trigger.dev/core@4.4.1

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-20 16:40:06 +00:00
Iss 354363e408 docs: Vercel integration – marketplace link, Build options, Root Directory warning (#3097)
Adds a direct Vercel Marketplace link, documents configuring build
options via the project config page, and adds a warning and workaround
for projects using a Vercel Root Directory
2026-02-20 10:56:44 -05:00
Iss 525145def0 docs: v3 deprecation notice and Migrate using AI updates on migrating-from-v3 (#3098)
Adds a deprecation warning at the top of the migrating-from-v3 page and
updates the “Migrate using AI” prompt and intro
2026-02-20 10:55:48 -05:00
Eric Allam 23c327ea16 fix(metrics): disable collecting system and filesystem metrics in deployed workers (#3106) 2026-02-20 15:45:35 +00:00
Eric Allam d794101c67 fix(webapp): fix broken MFA by only committing one auth session set-cookie call (#3104)
Co-authored-by: Oskar Otwinowski <oskar.otwinowski@gmail.com>
2026-02-20 15:09:53 +00:00
Eric Allam f325638892 fix(tests): fix flaky getSnapshotsSince test (#3103) 2026-02-20 14:52:59 +00:00
Eric Allam 68e3f8c9db chore(clickhouse): more clickhouse migration conflict fixes (#3102) 2026-02-20 13:28:58 +00:00
Eric Allam 2071090042 chore(clickhouse): fix clickhouse migration version conflict (#3101) 2026-02-20 14:24:56 +01:00
Eric Allam 469b039090 feat: OTEL metrics pipeline for task workers (#3061)
- Adds an end-to-end OTEL metrics pipeline: task workers collect and
export metrics via OpenTelemetry, the webapp ingests them into
ClickHouse, and they're queryable through the existing dashboard query
engine
- Workers emit process CPU/memory metrics (via
`@opentelemetry/host-metrics`) and Node.js runtime metrics (event loop
utilization, event loop delay, heap usage)
- Users can create custom metrics in their tasks via
`otel.metrics.getMeter()` from `@trigger.dev/sdk`
- Metrics are automatically tagged with run context (run ID, task slug,
machine, worker version) so they can be sliced per-run, per-task, or
per-machine
- The TSQL query engine gains metrics table support with typed attribute
columns, `prettyFormat()` for human-readable values, and per-schema time
bucket thresholds
- Includes reference tasks
(`references/hello-world/src/trigger/metrics.ts`) demonstrating
CPU-intensive, memory-ramp, bursty workload, and custom metrics patterns

## What changed

### Metrics collection (packages/core, packages/cli-v3)
- **Metrics export pipeline** — `TracingSDK` now sets up a
`MeterProvider` with a `PeriodicExportingMetricReader` that chains
through `TaskContextMetricExporter` (adds run context attributes) and
`BufferingMetricExporter` (batches exports to reduce overhead)
- **Host metrics** — Enabled `@opentelemetry/host-metrics` for process
CPU, memory, and system-level metrics
- **Node.js runtime metrics** — New `nodejsRuntimeMetrics.ts` module
using `performance.eventLoopUtilization()`, `monitorEventLoopDelay()`,
and `process.memoryUsage()` to emit 6 observable gauges
- File system and diskio metrics
- **Custom metrics** — Exposed `otel.metrics` from `@trigger.dev/sdk` so
users can create counters, histograms, and gauges in their tasks
- **Machine ID** — Stable per-worker machine identifier for grouping
metrics
- **Dev worker** — Drops `system.*` metrics to reduce noise, keeps
sending metrics between runs in warm workers

### Metrics ingestion (apps/webapp)
- **OTEL endpoint** — `otel.v1.metrics.ts` accepts OTEL metric export
requests (JSON and protobuf), converts to ClickHouse rows
- **ClickHouse schema** — `017_create_metrics_v1.sql` with 10-second
aggregation buckets, JSON attributes column, 60-day TTLs

### Query engine (internal-packages/tsql, apps/webapp)
- **Metrics query schema** — Typed columns for metric attributes
(`task_identifier`, `run_id`, `machine_name`, `worker_version`, etc.)
extracted from the JSON attributes column
- **`prettyFormat()`** — TSQL function that annotates columns with
format hints (`bytes`, `percent`, `durationSeconds`) for frontend
rendering without changing the underlying data
- **Per-schema time buckets** — Different tables can define their own
time bucket thresholds (metrics uses tighter intervals than runs)
- **AI query integration** — The AI query service knows about the
metrics table and can generate metric queries
- **Chart improvements** — Better formatting for byte values,
percentages, and durations in charts and tables

### Reference project
- **`references/hello-world/src/trigger/metrics.ts`** — 6 example tasks:
`cpu-intensive`, `memory-ramp`, `bursty-workload`, `sustained-workload`,
`concurrent-load`, `custom-metrics`

## Test plan

- [ ] Build all packages and webapp
- [ ] Start dev worker with hello-world reference project
- [ ] Run `cpu-intensive`, `memory-ramp`, and `custom-metrics` tasks
- [ ] Verify metrics in ClickHouse: `SELECT DISTINCT metric_name FROM
metrics_v1`
- [ ] Query via dashboard AI: "show me CPU utilization over time"
- [ ] Verify `prettyFormat` renders correctly in chart tooltips and
table cells
- [ ] Confirm dev worker drops `system.*` metrics but keeps `process.*`
and `nodejs.*`
2026-02-20 13:16:34 +00:00
Eric Allam 38981f5e70 chore(docs): cover maxDelay debounce option (#2985)
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/2985">
  <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-19 13:50:33 +00:00
Matt Aitken 22505e9803 Query and Metrics docs (#3074)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-19 13:50:13 +00:00
github-actions[bot] 84a00b6eb6 chore: release v4.4.0 (#2941)
🚀 Publish Trigger.dev Docker / typecheck (push) Failing after 0s
🚀 Publish Trigger.dev Docker / publish-webapp (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker (push) Has been skipped
🚀 Publish Trigger.dev Docker / publish-worker-v4 (push) Has been skipped
🚀 Publish Trigger.dev Docker / units (push) Failing after 3s
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and publish to npm
yourself or [setup this action to publish
automatically](https://github.com/changesets/action#with-publishing). If
you're not ready to do a release yet, that's fine, whenever you add more
changesets to main, this PR will be updated.


# Releases
## @trigger.dev/sdk@4.4.0

### Minor Changes

- Added `query.execute()` which lets you query your Trigger.dev data
using TRQL (Trigger Query Language) and returns results as typed JSON
rows or CSV. It supports configurable scope (environment, project, or
organization), time filtering via `period` or `from`/`to` ranges, and a
`format` option for JSON or CSV output.
([#3060](https://github.com/triggerdotdev/trigger.dev/pull/3060))

    ```typescript
    import { query } from "@trigger.dev/sdk";
    import type { QueryTable } from "@trigger.dev/sdk";

    // Basic untyped query
const result = await query.execute("SELECT run_id, status FROM runs
LIMIT 10");

    // Type-safe query using QueryTable to pick specific columns
const typedResult = await query.execute<QueryTable<"runs", "run_id" |
"status" | "triggered_at">>(
      "SELECT run_id, status, triggered_at FROM runs LIMIT 10"
    );
    typedResult.results.forEach((row) => {
      console.log(row.run_id, row.status); // Fully typed
    });

    // Aggregation query with inline types
const stats = await query.execute<{ status: string; count: number }>(
      "SELECT status, COUNT(*) as count FROM runs GROUP BY status",
      { scope: "project", period: "30d" }
    );

    // CSV export
    const csv = await query.execute("SELECT run_id, status FROM runs", {
      format: "csv",
      period: "7d",
    });
    console.log(csv.results); // Raw CSV string
    ```

### Patch Changes

- 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.
([#2984](https://github.com/triggerdotdev/trigger.dev/pull/2984))

    ```typescript
    await myTask.trigger(payload, {
      debounce: {
        key: "my-key",
        delay: "5s",
maxDelay: "30m", // Execute within 30 minutes regardless of continuous
triggers
      },
    });
    ```

- Aligned the SDK's `getRunIdForOptions` logic with the Core package to
handle semantic targets (`root`, `parent`) in root tasks.
([#2874](https://github.com/triggerdotdev/trigger.dev/pull/2874))

- Export `AnyOnStartAttemptHookFunction` type to allow defining
`onStartAttempt` hooks for individual tasks.
([#2966](https://github.com/triggerdotdev/trigger.dev/pull/2966))

- Fixed a minor issue in the deployment command on distinguishing
between local builds for the cloud vs local builds for self-hosting
setups.
([#3070](https://github.com/triggerdotdev/trigger.dev/pull/3070))

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/build@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## trigger.dev@4.4.0

### Patch Changes

- Fix runner getting stuck indefinitely when `execute()` is called on a
dead child process.
([#2978](https://github.com/triggerdotdev/trigger.dev/pull/2978))
- Add optional `timeoutInSeconds` parameter to the
`wait_for_run_to_complete` MCP tool. Defaults to 60 seconds. If the run
doesn't complete within the timeout, the current state of the run is
returned instead of waiting indefinitely.
([#3035](https://github.com/triggerdotdev/trigger.dev/pull/3035))
- Fixed a minor issue in the deployment command on distinguishing
between local builds for the cloud vs local builds for self-hosting
setups.
([#3070](https://github.com/triggerdotdev/trigger.dev/pull/3070))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`
    -   `@trigger.dev/build@4.4.0`
    -   `@trigger.dev/schema-to-json@4.4.0`

## @trigger.dev/core@4.4.0

### Patch Changes

- 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.
([#2984](https://github.com/triggerdotdev/trigger.dev/pull/2984))

    ```typescript
    await myTask.trigger(payload, {
      debounce: {
        key: "my-key",
        delay: "5s",
maxDelay: "30m", // Execute within 30 minutes regardless of continuous
triggers
      },
    });
    ```

- Fixed a minor issue in the deployment command on distinguishing
between local builds for the cloud vs local builds for self-hosting
setups.
([#3070](https://github.com/triggerdotdev/trigger.dev/pull/3070))

- fix: vendor superjson to fix ESM/CJS compatibility
([#2949](https://github.com/triggerdotdev/trigger.dev/pull/2949))

Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on
Node.js versions that don't support `require(ESM)` by default (&lt;
22.12.0) and AWS Lambda which intentionally disables it.

- Add Vercel integration support to API schemas: `commitSHA` and
`integrationDeployments` on deployment responses, and `source` field for
environment variable imports.
([#2994](https://github.com/triggerdotdev/trigger.dev/pull/2994))

## @trigger.dev/python@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`
    -   `@trigger.dev/sdk@4.4.0`
    -   `@trigger.dev/build@4.4.0`

## @trigger.dev/react-hooks@4.4.0

### Patch Changes

- Fix `onComplete` callback firing prematurely when the realtime stream
disconnects before the run finishes.
([#2929](https://github.com/triggerdotdev/trigger.dev/pull/2929))
-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/redis-worker@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/rsc@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

## @trigger.dev/schema-to-json@4.4.0

### Patch Changes

-   Updated dependencies:
    -   `@trigger.dev/core@4.4.0`

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-19 13:38:10 +00:00
James Ritchie 506b161751 feature(webapp): Show a v3 deprecation notice in the side bar (#3090)
- If your project is v3, show a v3 deprecation panel in the side menu
- If there is an active incident, show the incident panel instead
- Links to the Migration guide in the docs
- Displays when the side menu is collapsed



https://github.com/user-attachments/assets/f8492713-c58b-4f83-bcce-0e85f4a967ef

<img width="972" height="694" alt="CleanShot 2026-02-19 at 08 13 59@2x"
src="https://github.com/user-attachments/assets/0599dd20-d598-48c6-b83c-208648cee071"
/>
2026-02-19 11:02:10 +00:00
Matt Aitken 1ec7722690 Add Queue Management API endpoints and SDK documentation (#3087)
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

N/A - Documentation and OpenAPI schema updates only.

---

## Changelog

Added comprehensive Queue Management API support:

**OpenAPI Endpoints:**
- `GET /api/v1/queues` - List all queues with pagination support
- `GET /api/v1/queues/{queueParam}` - Retrieve a specific queue by ID,
task ID, or custom queue name
- `POST /api/v1/queues/{queueParam}/pause` - Pause or resume a queue
- `POST /api/v1/queues/{queueParam}/concurrency/override` - Override
queue concurrency limits
- `POST /api/v1/queues/{queueParam}/concurrency/reset` - Reset
concurrency limits to base values

**Schema Definitions:**
- `QueueObject` - Complete queue representation with concurrency details
- `ListQueuesResult` - Paginated queue listing response

**Documentation:**
- Updated `queue-concurrency.mdx` with SDK usage examples for queue
management
- Added 5 new management API documentation pages for each endpoint
- Updated `docs.json` navigation structure with new "Queues API" section

All endpoints support flexible queue identification (by ID, task ID, or
custom queue name) and include TypeScript code samples.

---

## Screenshots

N/A

💯

https://claude.ai/code/session_01LyrXwxHCbejvi34fykifPP

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-18 22:35:39 +00:00
Iss 2825a200e6 docs: added v3 to v4 migreation note about IP change (#3086) 2026-02-18 18:08:00 +00:00
Matt Aitken eb0f963393 feat(metrics): Dashboard charts performance improvements (#3083)
Summary
- Only render the top 50 series
- Improved rendering performance on bar charts
2026-02-18 16:12:00 +00:00
nicktrn 59b6eb9a3e feat(webapp): add region selector to test and replay task (#3082)
Adds a region selector to the Test task page and Replay run dialog, so
users can override the region from the dashboard. Disabled with a
placeholder for dev environments.

Closes #3016
2026-02-18 10:39:38 +00:00
Mihai Popescu b793f33e59 Logs: new materialized view and some UI improvements (#3069)
This will prevent internal logs to be added to the
task_events_search_table

Closes #<issue>

##  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

Ran the migration, deleted the old invalid rows and ran new tasks.
The undesired logs are not added to the table.

---

## Changelog

Updated the MATERIALIZED VIEW to also filter for `trace_id != ''`

---------

Co-authored-by: Matt Aitken <matt@mattaitken.com>
2026-02-18 09:48:52 +00:00
Oskar Otwinowski 6e0b54b081 docs(vercel): add comprehensive Vercel integration docs (#3081)
Expand documentation for the Vercel integration with detailed usage,
installation, environment variable sync, atomic deployments, and
environment mapping. Replace the previous "coming soon" placeholder with
complete instructions and UI flow for connecting via the Trigger.dev
dashboard or the Vercel Marketplace. Explain required GitHub
integration,
how env vars sync in both directions, which vars are excluded, and how
to control sync behavior. Describe atomic deployments (default for
production), how they gate Vercel deployments to ensure task/app
consistency, and note related configuration changes. Add tips and notes
to guide setup and troubleshooting.

This provides users with actionable guidance to connect Vercel, map
environments, and keep app and tasks in sync without custom CI scripts.
2026-02-17 18:19:00 +01:00
Eric Allam 4c986ad1eb fix(docs): Fix loop to iterate over results.runs (#3077)
To get access to the runs from `batch.triggerAndWait` use `results.runs`
2026-02-17 13:52:58 +01:00
Saadi Myftija 7af789bc3e fix(deployments): external build token generation issue (#3070)
Fixes an issue introduced in #3024.

The behavior for local builds in older CLI versions relies on
`externalBuildData` to be defined to distinguish from the self-hosting
local build path, even though it doesn't actually use the token.
2026-02-16 19:05:40 +01:00
Oskar Otwinowski 72ce6a8300 fix(vercel): Keep search params for OAuth redirects (#3071) 2026-02-16 18:51:33 +01:00
Pramod Dhungana 921285ca32 add friendly messages for common http error codes (#3001)
Closes #<issue>

##  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

Verified the error message mapping logic locally and ensured existing
behavior remains unchanged.


---

## Changelog

Added friendly messages for HTTP 401, 403, and 429 errors.





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

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3001">
  <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-16 11:42:26 +00:00
Iss 667a93ca3c docs: add ClickHouse migration troubleshooting tip (#3011)
Adds troubleshooting tip for when ClickHouse migrations report "no
migrations to run" but the schema is missing. This happens when the
goose migration tracker is out of sync with the actual schema state.

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

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3011"
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-16 11:41:31 +00:00
DKP 4dfa65809a Dropped bracket highlight and border by 50% (#3068) 2026-02-16 11:31:02 +00:00
Matt Aitken d4cd34094e Query API and SDK (#3060)
Summary
- Add API endpoint to run TRQL queries
- Implement SDK function for executing queries

## SDK
Added `query.execute()` which lets you query your Trigger.dev data using
TRQL (Trigger Query Language) and returns results as typed JSON rows or
CSV. It supports configurable scope (environment, project, or
organization), time filtering via `period` or `from`/`to` ranges, and a
`format` option for JSON or CSV output.

```typescript
import { query } from "@trigger.dev/sdk";
import type { QueryTable } from "@trigger.dev/sdk";

// Basic untyped query
const result = await query.execute("SELECT run_id, status FROM runs LIMIT 10");

// Type-safe query using QueryTable to pick specific columns
const typedResult = await query.execute<QueryTable<"runs", "run_id" | "status" | "triggered_at">>(
  "SELECT run_id, status, triggered_at FROM runs LIMIT 10"
);
typedResult.results.forEach(row => {
  console.log(row.run_id, row.status); // Fully typed
});

// Aggregation query with inline types
const stats = await query.execute<{ status: string; count: number }>(
  "SELECT status, COUNT(*) as count FROM runs GROUP BY status",
  { scope: "project", period: "30d" }
);

// CSV export
const csv = await query.execute(
  "SELECT run_id, status FROM runs",
  { format: "csv", period: "7d" }
);
console.log(csv.results); // Raw CSV string
```
2026-02-16 10:53:41 +00:00
James Ritchie 796ad29386 Fix(webapp): consistent query button width (#3067)
Prevents layout shift by keeping the button width the same when
isLoading

Normal state
<img width="423" height="186" alt="CleanShot 2026-02-16 at 10 31 23"
src="https://github.com/user-attachments/assets/232bc14f-dc2c-4092-9b46-f6d652568633"
/>

isLoading
<img width="403" height="137" alt="CleanShot 2026-02-16 at 10 31 43"
src="https://github.com/user-attachments/assets/e9215b48-c2cc-4ede-91ff-08bbdb2382a0"
/>
2026-02-16 10:49:37 +00:00
James Ritchie b4e08bddec Fix(webapp): metrics UI improvements (#3063)
- Lots of small UI improvements
- Toggle full screen charts with "V" shortcut

<img width="3504" height="2286" alt="CleanShot 2026-02-14 at 20 06
30@2x"
src="https://github.com/user-attachments/assets/32403661-06b3-4f6c-a074-243b3f43197d"
/>

<img width="362" height="222" alt="CleanShot 2026-02-14 at 20 06 58@2x"
src="https://github.com/user-attachments/assets/ee4f5859-4b71-482e-9636-5abd78d9f623"
/>

<img width="434" height="360" alt="CleanShot 2026-02-14 at 20 06 53@2x"
src="https://github.com/user-attachments/assets/44339348-f3f3-425b-bbf3-bb41ed6a8e59"
/>

<img width="460" height="310" alt="CleanShot 2026-02-14 at 20 06 48@2x"
src="https://github.com/user-attachments/assets/eb3b6af6-b88a-4677-8a48-acfa6b768770"
/>

<img width="502" height="332" alt="CleanShot 2026-02-14 at 20 06 45@2x"
src="https://github.com/user-attachments/assets/b788d78b-8f4a-4b18-94c8-487673f3ec7b"
/>

<img width="370" height="257" alt="CleanShot 2026-02-14 at 20 10 29@2x"
src="https://github.com/user-attachments/assets/a81e8b14-b2eb-402c-bf3b-affd0d4fde26"
/>
2026-02-14 21:26:25 +00:00
Iss 5d6085dcff docs: Realtime skipColumns and Bun indexing troubleshooting (#3054)
Documents the skipColumns option on useRealtimeRun and
useRealtimeRunsWithTag for status-only subscriptions (smaller payloads,
e.g. for progress/completion UI). Adds a troubleshooting section for the
“Failed to index deployment” source-map error when using the Bun
runtime, with a pnpm patch workaround and link to the GitHub issue
2026-02-14 14:02:00 -05:00
nicktrn 1d744fa3c6 fix(dashboard): apply time filter preset periods immediately on click (#3053)
Extract `applyPeriod` callback from `applySelection` so preset period
buttons ("Created in the last X") apply immediately when clicked,
instead of only updating the selection state and requiring a separate
apply step.

Also validates `maxPeriodDays` on instant-apply so the upgrade prompt
still works correctly for plan-limited periods.
2026-02-13 18:35:39 +00:00
Matt Aitken a3d3b17df4 TRQL: always add FINAL keyword (#3051)
For now we’re going to always add FINAL to TRQL queries for data
correctness.

In the future we will implement an automated optimization where we use
`SELECT argMax(column, _version)` and `WHERE _is_deleted = 0`. But this
is a more complex change and needs more investigation of downsides.
2026-02-13 17:34:43 +00:00
James Ritchie 35e11e09a3 Fix(webapp): Show an error to the user when their add-on upgrade payment fails (#3050)
A customer experienced a bug where their subscription downgraded to the
free plan unintentionally. This was due to a concurrency upgrade payment
attempt that failed a card check. We auto retry the payment across 2
weeks of attempts. When the final attempt failed, the whole subscription
downgraded.

Now we check if the payment is successful and if not, return an error
immediately so the subscription isn't modified until a successful
payment is made for an upgrade.
2026-02-13 16:52:04 +00:00
Matt Aitken 9030e94362 Metrics improvements (#3046)
Summary
- Remove LIMIT from built-in dashboard queries
- Make concurrency configurable per project via environment variables
- Fix widget fallback period to Metrics default (1d) instead of 7d
- Handle concurrency at the project level
- Sort series for graphs so largest is displayed at the bottom (legend
shows largest at top)
- Use average aggregation for some built-in charts
- Improve aggregation handling for the legend
- Only render chart points when there is data; render dots on line
charts
- Truncate legend items and show tooltip on hover
- Better preserve chart configuration when the underlying query changes
2026-02-13 15:57:11 +00:00
Eric Allam 2462c80c8a chore(vouch): vouch for capaj (#3048) 2026-02-13 15:56:05 +01:00
Mihai Popescu a8f9a90280 improv(webapp): Add new table for optimized logs search (#3036)
##  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

Tested the migration on test env and locally.
Tested query and merge performance.
Generated tasks and observed the ingested data and searches.

---

## Changelog

* New ClickHouse table & MV (task_events_search_v1): A search-optimized
materialized view that filters out debug events, partial spans, and
empty span events at ingestion time.
* ClickHouse client updates: New getLogsSearchListQueryBuilder and
taskEventsSearch accessor on the ClickHouse class.
* LogsListPresenter: Switches to the new search table, uses
triggered_timestamp for cursor pagination instead of unixTimestamp.
  *  Spans route: Also switches to the new search query builder.
* Seed spanSpammer task: Adds a 10s trace with events and metadata
operations for testing.
2026-02-13 16:28:14 +02:00
Oskar Otwinowski 4b7f67604a feat(webapp/deployments): Vercel improvements & fixes (#3037) 2026-02-13 14:06:22 +01:00
Matt Aitken bfe417f679 docs: add note about single-issue PRs to contributing guide (#3044)
Add a prominent notice at the top of CONTRIBUTING.md clarifying that we
only accept PRs addressing a single issue, not multiple fixes.

https://claude.ai/code/session_011ZNQd5zkf38piSMhWbgiWt

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-13 10:07:06 +00:00
Eric Allam ba320a4bdb chore(vouch): vouch for gautamsi (#3039) 2026-02-12 22:27:14 +00:00
Matt Aitken bc0d1ff59a Metrics dashboards (#3019)
Summary
- Implemented metrics dashboards with a built-in dashboard and custom
dashboards
- Added a "Big number” display type

What changed
- New data format for metric layouts and saving/editing layouts
(editing, saving, cancel revert)
  - QueryWidget usable on Query page and Metrics dashboards
  - Time filtering, auto-reloading and timeBucket() auto-bin support
- Filters added to metrics; widget popover/improved history and blank
states
- Side menu:
- Metrics/Insights section with icons, colors, padding, collapsible
behavior and reordering of custom dashboards
- Move action logic into service for reuse and API querying; refactor
reordering for reuse
<!-- devin-review-badge-begin -->

---

<a
href="https://app.devin.ai/review/triggerdotdev/trigger.dev/pull/3019"
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 -->

---------

Co-authored-by: James Ritchie <james@trigger.dev>
2026-02-12 17:48:02 +00:00
Eric Allam 062bcaece8 feat(mcp): add timeout parameter to wait_for_run_to_complete tool (#3035)
## Summary

- Adds an optional `timeoutInSeconds` parameter (default 60s) to the
`wait_for_run_to_complete` MCP tool
- If the run doesn't complete within the timeout, returns the current
run state instead of blocking indefinitely
- Uses `AbortSignal.timeout()` combined with the existing MCP signal

Fixes #3032
2026-02-12 16:14:25 +00:00
nicktrn c2085e6cc6 feat(dashboard): link git sha and ref to GitHub on settings page (#3034)
Make the git SHA and git ref in the org settings sidebar clickable links
to GitHub — SHA links to the commit, ref links to the branch/tag.
2026-02-12 15:31:52 +00:00
239 changed files with 16821 additions and 3636 deletions
-16
View File
@@ -1,16 +0,0 @@
---
"@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
@@ -1,5 +0,0 @@
---
"@trigger.dev/react-hooks": patch
---
Fix `onComplete` callback firing prematurely when the realtime stream disconnects before the run finishes.
-5
View File
@@ -1,5 +0,0 @@
---
"@trigger.dev/sdk": patch
---
Aligned the SDK's `getRunIdForOptions` logic with the Core package to handle semantic targets (`root`, `parent`) in root tasks.
@@ -1,5 +0,0 @@
---
"@trigger.dev/sdk": patch
---
Export `AnyOnStartAttemptHookFunction` type to allow defining `onStartAttempt` hooks for individual tasks.
@@ -1,5 +0,0 @@
---
"trigger.dev": patch
---
Fix runner getting stuck indefinitely when `execute()` is called on a dead child process.
-7
View File
@@ -1,7 +0,0 @@
---
"@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.
-5
View File
@@ -1,5 +0,0 @@
---
"@trigger.dev/core": patch
---
Add Vercel integration support to API schemas: `commitSHA` and `integrationDeployments` on deployment responses, and `source` field for environment variable imports.
+4 -1
View File
@@ -10,4 +10,7 @@ mpcgrid
myftija
nicktrn
samejr
isshaddad
isshaddad
# Outside contributors
gautamsi
capaj
+9
View File
@@ -31,6 +31,15 @@
"cwd": "${workspaceFolder}/apps/webapp",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug opened test file",
"command": "pnpm run test -- ./${relativeFile}",
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceFolder}",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
-1
View File
@@ -7,6 +7,5 @@
"packages/cli-v3/e2e": true
},
"vitest.disableWorkspaceWarning": true,
"typescript.experimental.useTsgo": true,
"chat.agent.maxRequests": 10000
}
+2
View File
@@ -6,6 +6,8 @@ Please take some time to read this guide to understand contributing best practic
Thank you for helping us make Trigger.dev even better! 🤩
> **Important:** We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one.
## Getting vouched (required before opening a PR)
We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. **PRs from unvouched users are automatically closed.**
+29
View File
@@ -30,3 +30,32 @@ export function AlphaTitle({ children }: { children: React.ReactNode }) {
</>
);
}
export function BetaBadge({
inline = false,
className,
}: {
inline?: boolean;
className?: string;
}) {
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
Beta
</Badge>
}
content="This feature is in Beta."
disableHoverableContent
/>
);
}
export function BetaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<BetaBadge />
</>
);
}
@@ -1,7 +1,6 @@
import { BookOpenIcon } from "@heroicons/react/20/solid";
import { LinkButton } from "./primitives/Buttons";
import { Header3 } from "./primitives/Headers";
import { Paragraph } from "./primitives/Paragraph";
import { LogLevel } from "./logs/LogLevel";
export function LogLevelTooltipInfo() {
return (
@@ -13,51 +12,45 @@ export function LogLevelTooltipInfo() {
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-blue-400">Info</Header3>
<div className="mb-1">
<LogLevel level="TRACE" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Traces and spans representing the execution flow of your tasks.
</Paragraph>
</div>
<div>
<div className="mb-1">
<LogLevel level="INFO" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
General informational messages about task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-warning">Warn</Header3>
<div className="mb-1">
<LogLevel level="WARN" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Warning messages indicating potential issues that don't prevent execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-error">Error</Header3>
<div className="mb-1">
<LogLevel level="ERROR" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Error messages for failures and exceptions during task execution.
</Paragraph>
</div>
<div>
<div className="mb-0.5">
<Header3 className="text-charcoal-400">Debug</Header3>
<div className="mb-1">
<LogLevel level="DEBUG" />
</div>
<Paragraph variant="small" className="text-text-dimmed">
Detailed diagnostic information for development and debugging.
</Paragraph>
</div>
<div 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>
);
}
+6
View File
@@ -193,6 +193,12 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Metrics page</Header3>
<Shortcut name="Toggle fullscreen chart">
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
</Shortcut>
</div>
<div className="space-y-3">
<Header3>Schedules page</Header3>
<Shortcut name="New schedule">
@@ -1,7 +1,13 @@
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import { cn } from "~/utils/cn";
// Lazy load streamdown components to avoid SSR issues
const StreamdownRenderer = lazy(() =>
@@ -13,13 +19,6 @@ const StreamdownRenderer = lazy(() =>
),
}))
);
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
import { cn } from "~/utils/cn";
type StreamEventType =
| { type: "thinking"; content: string }
@@ -179,21 +178,7 @@ export function AIQueryInput({
setThinking((prev) => prev + event.content);
break;
case "tool_call":
if (event.tool === "setTimeFilter") {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Setting time filter...")) {
return prev;
}
return prev + `\nSetting time filter...\n`;
});
} else {
setThinking((prev) => {
if (prev.trimEnd().endsWith("Validating query...")) {
return prev;
}
return prev + `\nValidating query...\n`;
});
}
// Tool calls are handled silently — no UI text needed
break;
case "time_filter":
// Apply time filter immediately when the AI sets it
@@ -262,13 +247,13 @@ export function AIQueryInput({
}, [error]);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col">
{/* Gradient border wrapper like the schedules AI input */}
<div
className="rounded-md p-px"
className="overflow-hidden rounded-md p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="overflow-hidden rounded-[5px] bg-background-bright">
<div className="overflow-hidden rounded-md bg-background-bright">
<form onSubmit={handleSubmit}>
<textarea
ref={textareaRef}
@@ -297,10 +282,10 @@ export function AIQueryInput({
variant="tertiary/small"
disabled={true}
LeadingIcon={Spinner}
className="pl-1.5"
className="pl-2"
iconSpacing="gap-1.5"
>
{mode === "edit" ? "Editing..." : "Generating..."}
{mode === "edit" ? "Editing" : "Generating"}
</Button>
) : (
<>
@@ -366,64 +351,60 @@ export function AIQueryInput({
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{isLoading ? (
<Spinner
color={{
background: "rgba(99, 102, 241, 0.3)",
foreground: "rgba(99, 102, 241, 1)",
}}
className="size-3"
/>
) : lastResult === "success" ? (
<div className="size-3 rounded-full bg-success" />
) : lastResult === "error" ? (
<div className="size-3 rounded-full bg-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking..."
: lastResult === "success"
<div className="px-1">
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-charcoal-850 p-3 pb-1">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-1">
{isLoading ? (
<Spinner className="size-4" />
) : lastResult === "success" ? (
<CheckIcon className="size-4 text-success" />
) : lastResult === "error" ? (
<XMarkIcon className="size-4 text-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking…"
: lastResult === "success"
? "Query generated"
: lastResult === "error"
? "Generation failed"
: "AI response"}
</span>
? "Generation failed"
: "AI response"}
</span>
</div>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
</div>
</motion.div>
@@ -1,27 +1,20 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { BarChart, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Header3 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
import { Select, SelectItem } from "../primitives/Select";
import { Switch } from "../primitives/Switch";
import SegmentedControl from "../primitives/SegmentedControl";
import { Button } from "../primitives/Buttons";
export type ChartType = "bar" | "line";
export type SortDirection = "asc" | "desc";
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
export interface ChartConfiguration {
chartType: ChartType;
xAxisColumn: string | null;
yAxisColumns: string[];
groupByColumn: string | null;
stacked: boolean;
sortByColumn: string | null;
sortDirection: SortDirection;
aggregation: AggregationType;
}
import {
type AggregationType,
type ChartConfiguration,
type SortDirection,
} from "../metrics/QueryWidget";
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
export const defaultChartConfig: ChartConfiguration = {
chartType: "bar",
@@ -32,6 +25,7 @@ export const defaultChartConfig: ChartConfiguration = {
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
};
interface ChartConfigPanelProps {
@@ -155,8 +149,11 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
if (needsUpdate) {
onChangeRef.current({ ...currentConfig, ...updates });
}
// Only re-run when the actual column structure changes, not on every config change
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
// Only re-run when the actual column structure changes, not on every config change.
// columnsKey (a string) is stable when columns match, so this won't re-fire
// unnecessarily when the same query is re-run with identical columns.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnsKey]);
const updateConfig = useCallback(
(updates: Partial<ChartConfiguration>) => {
@@ -239,54 +236,38 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
}
return (
<div className={cn("flex flex-col gap-2 p-2", className)}>
<div className={cn("flex flex-col gap-3 p-2", className)}>
{/* Chart Type */}
<div className="flex flex-col gap-3">
<ConfigField label="Type">
<div className="flex items-center">
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-r-none border-b pl-1 pr-2",
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "bar" })}
LeadingIcon={BarChart}
leadingIconClassName={
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
Bar
</span>
</Button>
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-l-none border-b pl-1 pr-2",
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "line" })}
LeadingIcon={LineChart}
leadingIconClassName={
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
>
Line
</span>
</Button>
</div>
<SegmentedControl
name="chartType"
value={config.chartType}
variant="secondary/small"
options={[
{
label: (
<span className="flex items-center gap-1">
<BarChart className="size-3" /> Bar
</span>
),
value: "bar",
},
{
label: (
<span className="flex items-center gap-1">
<LineChart className="size-3" /> Line
</span>
),
value: "line",
},
]}
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
/>
</ConfigField>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-3">
{/* X-Axis */}
<ConfigField label="X-Axis">
<Select
@@ -329,60 +310,86 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
) : (
<div className="flex flex-col gap-1.5">
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map(
(col, index) => (
<div key={index} className="flex items-center gap-1">
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
updateConfig({ yAxisColumns: newColumns });
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
<div key={index} className="flex items-center gap-1">
{col && !config.groupByColumn && (
<SeriesColorPicker
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
onColorChange={(color) => {
updateConfig({
seriesColors: { ...config.seriesColors, [col]: color },
});
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
/>
)}
<Select
value={col}
setValue={(value) => {
const newColumns = [...config.yAxisColumns];
const updates: Partial<ChartConfiguration> = {};
if (value) {
// If this is a new slot (empty string), add it
if (index >= config.yAxisColumns.length) {
newColumns.push(value);
} else {
// If the column name changed, migrate the color
const oldCol = newColumns[index];
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
const newSeriesColors = { ...config.seriesColors };
newSeriesColors[value] = newSeriesColors[oldCol];
delete newSeriesColors[oldCol];
updates.seriesColors = newSeriesColors;
}
newColumns[index] = value;
}
} else if (index < config.yAxisColumns.length) {
newColumns.splice(index, 1);
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
updateConfig({ yAxisColumns: newColumns });
}}
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
updateConfig({ ...updates, yAxisColumns: newColumns });
}}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions.filter(
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
)}
</div>
)
)}
dropdownIcon
className="min-w-[140px] flex-1"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
{index > 0 && (
<button
type="button"
onClick={() => {
const removedCol = config.yAxisColumns[index];
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
// Clean up the color entry for the removed series
if (removedCol && config.seriesColors?.[removedCol]) {
const newSeriesColors = { ...config.seriesColors };
delete newSeriesColors[removedCol];
updates.seriesColors = newSeriesColors;
}
updateConfig(updates);
}}
className="rounded p-1 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Remove series"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
{/* Add another series button - only show when we have at least one series and not grouped */}
{config.yAxisColumns.length > 0 &&
@@ -439,9 +446,7 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
{/* Group By - disabled when multiple series are selected */}
<ConfigField label="Group by">
{config.yAxisColumns.length > 1 ? (
<span className="text-xs text-text-dimmed">
Not available with multiple series
</span>
<span className="text-xs text-text-dimmed">Not available with multiple series</span>
) : (
<Select
value={config.groupByColumn ?? "__none__"}
@@ -510,9 +515,29 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
{/* Sort Direction (only when sorting) */}
{config.sortByColumn && (
<ConfigField label="Sort direction">
<SortDirectionToggle
direction={config.sortDirection}
onChange={(direction) => updateConfig({ sortDirection: direction })}
<SegmentedControl
name="sortDirection"
value={config.sortDirection}
variant="secondary/small"
options={[
{
label: (
<span className="flex items-center gap-1">
<IconSortAscending className="size-3" /> Asc
</span>
),
value: "asc",
},
{
label: (
<span className="flex items-center gap-1">
<IconSortDescending className="size-3" /> Desc
</span>
),
value: "desc",
},
]}
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
/>
</ConfigField>
)}
@@ -524,48 +549,55 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
{label && <span className="text-xs text-text-dimmed">{label}</span>}
{label && <span className="text-xs text-text-bright">{label}</span>}
{children}
</div>
);
}
function SortDirectionToggle({
direction,
onChange,
function SeriesColorPicker({
color,
onColorChange,
}: {
direction: SortDirection;
onChange: (direction: SortDirection) => void;
color: string;
onColorChange: (color: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div className="flex gap-1">
<button
type="button"
onClick={() => onChange("asc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "asc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Ascending"
>
Asc
</button>
<button
type="button"
onClick={() => onChange("desc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "desc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Descending"
>
Desc
</button>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex-shrink-0 rounded p-0.5 hover:bg-charcoal-700"
title="Change series color"
>
<span
className="block h-4 w-4 rounded-full border border-white/30"
style={{ backgroundColor: color }}
/>
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-6 gap-1.5">
{CHART_COLORS_BY_HUE.map((c) => (
<button
key={c}
type="button"
onClick={() => {
onColorChange(c);
setOpen(false);
}}
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30"
style={{ backgroundColor: c }}
title={c}
>
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}
@@ -68,6 +68,9 @@ type CodeBlockProps = {
/** Search term to highlight in the code */
searchTerm?: string;
/** Whether to wrap the code */
wrap?: boolean;
};
const dimAmount = 0.5;
@@ -207,6 +210,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
fileName,
rowTitle,
searchTerm,
wrap = false,
...props
}: CodeBlockProps,
ref
@@ -215,7 +219,7 @@ export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
const [copied, setCopied] = useState(false);
const [modalCopied, setModalCopied] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isWrapped, setIsWrapped] = useState(false);
const [isWrapped, setIsWrapped] = useState(wrap);
const onCopied = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
@@ -1,65 +1,42 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/clickhouse";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { BarChart3, LineChart } from "lucide-react";
import { memo, useMemo } from "react";
import { createValueFormatter } from "~/utils/columnFormat";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { ChartConfig } from "~/components/primitives/charts/Chart";
import { Chart } from "~/components/primitives/charts/ChartCompound";
import { Paragraph } from "../primitives/Paragraph";
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
import { Callout } from "../primitives/Callout";
import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget";
import { aggregateValues } from "../primitives/charts/aggregation";
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
import { getSeriesColor } from "./chartColors";
// Color palette for chart series - 30 distinct colors for large datasets
const CHART_COLORS = [
// Primary colors
"#7655fd", // Purple
"#22c55e", // Green
"#f59e0b", // Amber
"#ef4444", // Red
"#06b6d4", // Cyan
"#ec4899", // Pink
"#8b5cf6", // Violet
"#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 {
return CHART_COLORS[index % CHART_COLORS.length];
}
const MAX_SERIES = 50;
const MAX_SVG_ELEMENT_BUDGET = 6_000;
const MIN_DATA_POINTS = 100;
const MAX_DATA_POINTS = 500;
interface QueryResultsChartProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: ChartConfiguration;
/** The effective time range from the query filter (used to show the full x-axis period) */
timeRange?: { from: string; to: string };
fullLegend?: boolean;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
isLoading?: boolean;
}
interface TransformedData {
data: Record<string, unknown>[];
series: string[];
/** Total number of series before any truncation (equals series.length when no truncation) */
totalSeriesCount: number;
/** Raw date values for determining formatting granularity */
dateValues: Date[];
/** Whether the x-axis is date-based (continuous time scale) */
@@ -153,12 +130,41 @@ function formatDateByGranularity(date: Date, granularity: TimeGranularity): stri
}
}
/**
* Snap a millisecond value up to the nearest "nice" interval
*/
function snapToNiceInterval(ms: number): number {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
if (ms <= SECOND) return SECOND;
if (ms <= 5 * SECOND) return 5 * SECOND;
if (ms <= 10 * SECOND) return 10 * SECOND;
if (ms <= 15 * SECOND) return 15 * SECOND;
if (ms <= 30 * SECOND) return 30 * SECOND;
if (ms <= MINUTE) return MINUTE;
if (ms <= 5 * MINUTE) return 5 * MINUTE;
if (ms <= 10 * MINUTE) return 10 * MINUTE;
if (ms <= 15 * MINUTE) return 15 * MINUTE;
if (ms <= 30 * MINUTE) return 30 * MINUTE;
if (ms <= HOUR) return HOUR;
if (ms <= 2 * HOUR) return 2 * HOUR;
if (ms <= 4 * HOUR) return 4 * HOUR;
if (ms <= 6 * HOUR) return 6 * HOUR;
if (ms <= 12 * HOUR) return 12 * HOUR;
if (ms <= DAY) return DAY;
return ms;
}
/**
* Detect the most common interval between consecutive data points
* This helps us understand the natural granularity of the data
*/
function detectDataInterval(timestamps: number[]): number {
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
if (timestamps.length < 2) return 24 * 60 * 60 * 1000; // Default to 1 day
const sorted = [...timestamps].sort((a, b) => a - b);
const gaps: number[] = [];
@@ -176,25 +182,7 @@ function detectDataInterval(timestamps: number[]): number {
// We use the minimum gap as a heuristic for the data interval
const minGap = Math.min(...gaps);
// Round to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// Snap to common intervals
if (minGap <= MINUTE) return MINUTE;
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
if (minGap <= HOUR) return HOUR;
if (minGap <= 2 * HOUR) return 2 * HOUR;
if (minGap <= 4 * HOUR) return 4 * HOUR;
if (minGap <= 6 * HOUR) return 6 * HOUR;
if (minGap <= 12 * HOUR) return 12 * HOUR;
if (minGap <= DAY) return DAY;
return minGap;
return snapToNiceInterval(minGap);
}
/**
@@ -218,20 +206,7 @@ function fillTimeGaps(
// If filling would create too many points, increase the interval to stay within limits
let effectiveInterval = interval;
if (estimatedPoints > maxPoints) {
effectiveInterval = Math.ceil(range / maxPoints);
// Round up to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
else effectiveInterval = 24 * HOUR;
effectiveInterval = snapToNiceInterval(Math.ceil(range / maxPoints));
}
// Create a map to collect values for each bucket (for aggregation)
@@ -281,17 +256,18 @@ function fillTimeGaps(
}
filledData.push(point);
} else {
// Create a zero-filled data point
const zeroPoint: Record<string, unknown> = {
// Create a null-filled data point so gaps appear in line/bar charts
// and legend aggregations (avg/min/max) skip these slots
const gapPoint: Record<string, unknown> = {
[xDataKey]: t,
__rawDate: new Date(t),
__granularity: granularity,
__originalX: new Date(t).toISOString(),
};
for (const s of series) {
zeroPoint[s] = 0;
gapPoint[s] = null;
}
filledData.push(zeroPoint);
filledData.push(gapPoint);
}
}
@@ -390,22 +366,32 @@ function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): numb
}
/**
* Formats a date for tooltips (always shows full precision)
* Formats a date for tooltips and legend headers.
* Always includes time when the data point has a non-midnight time,
* so hovering a specific bar at e.g. 14:00 shows the full timestamp
* even when the axis labels only show the day.
* Seconds are shown whenever the granularity is "seconds" or the
* specific data point has non-zero seconds.
*/
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
// For shorter time ranges, include time
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0;
const hasSeconds = date.getSeconds() !== 0;
if (
granularity === "seconds" ||
(hasTime && granularity !== "months" && granularity !== "years")
) {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: granularity === "seconds" ? "2-digit" : undefined,
second: granularity === "seconds" || hasSeconds ? "2-digit" : undefined,
hour12: false,
});
}
// For longer ranges, just show date
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
@@ -463,7 +449,8 @@ function tryParseDate(value: unknown): Date | null {
*/
function transformDataForChart(
rows: Record<string, unknown>[],
config: ChartConfiguration
config: ChartConfiguration,
timeRange?: { from: string; to: string }
): TransformedData {
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
@@ -471,6 +458,7 @@ function transformDataForChart(
return {
data: [],
series: [],
totalSeriesCount: 0,
dateValues: [],
isDateBased: false,
xDataKey: xAxisColumn || "",
@@ -489,24 +477,37 @@ function transformDataForChart(
}
// Determine if X-axis is date-based (most values should be parseable as dates)
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
// When there are no results but a timeRange is provided, treat as date-based
const isDateBased =
rows.length === 0 && timeRange ? true : dateValues.length >= rows.length * 0.8; // At least 80% are dates
// Detect granularity from the full time range when available, otherwise from data
const granularity = isDateBased
? timeRange
? detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)])
: detectTimeGranularity(dateValues)
: "days";
// For date-based axes, use a special key for the timestamp
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
// Calculate time domain and ticks for date-based axes
// When a timeRange is provided (from the query filter), use it so the chart
// shows the full requested period rather than just the range of returned data.
let timeDomain: [number, number] | null = null;
let timeTicks: number[] | null = null;
if (isDateBased && dateValues.length > 0) {
const timestamps = dateValues.map((d) => d.getTime());
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
// Raw min/max used for gap filling (without padding)
let rawMinTime = 0;
let rawMaxTime = 0;
if (isDateBased && (dateValues.length > 0 || timeRange)) {
const dataTimestamps = dateValues.map((d) => d.getTime());
rawMinTime = timeRange ? new Date(timeRange.from).getTime() : Math.min(...dataTimestamps);
rawMaxTime = timeRange ? new Date(timeRange.to).getTime() : Math.max(...dataTimestamps);
// Add a small padding (2% on each side) so points aren't at the very edge
const padding = (maxTime - minTime) * 0.02;
timeDomain = [minTime - padding, maxTime + padding];
const padding = (rawMaxTime - rawMinTime) * 0.02;
timeDomain = [rawMinTime - padding, rawMaxTime + padding];
// Generate evenly-spaced ticks across the entire range using nice intervals
timeTicks = generateTimeTicks(minTime, maxTime);
timeTicks = generateTimeTicks(rawMinTime, rawMaxTime);
}
// Helper to format X value for categorical axes (non-date)
@@ -561,30 +562,57 @@ function transformDataForChart(
});
// Fill in gaps with zeros for date-based data
const seriesForBudget = Math.min(yAxisColumns.length, MAX_SERIES);
const effectiveMaxPoints = Math.max(
MIN_DATA_POINTS,
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / seriesForBudget))
);
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
const rangeMs = rawMaxTime - rawMinTime;
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
const maxRangeInterval =
timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
const effectiveInterval = Math.min(
Math.max(dataInterval, minRangeInterval),
maxRangeInterval
);
data = fillTimeGaps(
data,
xDataKey,
yAxisColumns,
timeDomain[0],
timeDomain[1],
dataInterval,
rawMinTime,
rawMaxTime,
effectiveInterval,
granularity,
aggregation
aggregation,
effectiveMaxPoints
);
} else if (data.length > effectiveMaxPoints) {
data = data.slice(0, effectiveMaxPoints);
}
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
return {
data,
series: yAxisColumns,
totalSeriesCount: yAxisColumns.length,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
};
}
// With grouping: pivot data so each group value becomes a series
const yCol = yAxisColumns[0]; // Use first Y column when grouping
const groupValues = new Set<string>();
// For date-based, key by timestamp; otherwise by formatted string
// Collect all values for aggregation
// First pass: collect all values grouped by (xKey, groupValue) and accumulate
// per-group totals so we can pick the top-N groups before building heavy data
// objects with thousands of keys.
const groupTotals = new Map<string, number>();
const groupedByX = new Map<
string | number,
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
@@ -593,29 +621,39 @@ function transformDataForChart(
for (const row of rows) {
const rawDate = tryParseDate(row[xAxisColumn]);
// Skip rows with invalid dates for date-based axes
if (isDateBased && !rawDate) continue;
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
const groupValue = String(row[groupByColumn] ?? "Unknown");
const yValue = toNumber(row[yCol]);
groupValues.add(groupValue);
groupTotals.set(groupValue, (groupTotals.get(groupValue) ?? 0) + Math.abs(yValue));
if (!groupedByX.has(xKey)) {
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
}
const existing = groupedByX.get(xKey)!;
// Collect values for aggregation
if (!existing.values[groupValue]) {
existing.values[groupValue] = [];
}
existing.values[groupValue].push(yValue);
}
// Convert to array format with aggregation applied
const series = Array.from(groupValues).sort();
// Keep only the top MAX_SERIES groups by absolute total to avoid O(n) processing
// downstream (data objects, gap filling, legend totals, SVG rendering).
const totalSeriesCount = groupTotals.size;
let series: string[];
if (groupTotals.size <= MAX_SERIES) {
series = Array.from(groupTotals.keys()).sort();
} else {
series = Array.from(groupTotals.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, MAX_SERIES)
.map(([key]) => key)
.sort();
}
// Convert to array format with aggregation applied (only for kept series)
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
const point: Record<string, unknown> = {
[xDataKey]: xKey,
@@ -629,23 +667,44 @@ function transformDataForChart(
return point;
});
// Fill in gaps with zeros for date-based data
// Dynamic data-point budget based on the (already capped) series count
const effectiveMaxPoints = Math.max(
MIN_DATA_POINTS,
Math.min(MAX_DATA_POINTS, Math.floor(MAX_SVG_ELEMENT_BUDGET / series.length))
);
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
const rangeMs = rawMaxTime - rawMinTime;
const minRangeInterval = timeRange ? snapToNiceInterval(rangeMs / effectiveMaxPoints) : 0;
const maxRangeInterval = timeRange && rangeMs > 0 ? snapToNiceInterval(rangeMs / 8) : Infinity;
const effectiveInterval = Math.min(Math.max(dataInterval, minRangeInterval), maxRangeInterval);
data = fillTimeGaps(
data,
xDataKey,
series,
timeDomain[0],
timeDomain[1],
dataInterval,
rawMinTime,
rawMaxTime,
effectiveInterval,
granularity,
aggregation
aggregation,
effectiveMaxPoints
);
} else if (data.length > effectiveMaxPoints) {
data = data.slice(0, effectiveMaxPoints);
}
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
return {
data,
series,
totalSeriesCount,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
};
}
function toNumber(value: unknown): number {
@@ -657,25 +716,6 @@ function toNumber(value: unknown): number {
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: AggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
}
}
/**
* Sort data array by a specified column
*/
@@ -725,8 +765,10 @@ export const QueryResultsChart = memo(function QueryResultsChart({
rows,
columns,
config,
timeRange,
fullLegend = false,
onViewAllLegendItems,
isLoading = false,
legendScrollable = false,
}: QueryResultsChartProps) {
const {
@@ -743,12 +785,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
const {
data: unsortedData,
series,
totalSeriesCount,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
} = useMemo(() => transformDataForChart(rows, config, timeRange), [rows, config, timeRange]);
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
const data = useMemo(() => {
@@ -759,13 +802,54 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
// Detect time granularity for the data
const timeGranularity = useMemo(
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
[dateValues]
// Sort series by descending total sum so largest appears at bottom of
// stacked charts and first in the legend
const sortedSeries = useMemo(() => {
if (series.length <= 1) return series;
const totals = new Map<string, number>();
for (const s of series) {
let total = 0;
for (const point of data) {
const val = point[s];
if (typeof val === "number" && isFinite(val)) {
total += Math.abs(val);
}
}
totals.set(s, total);
}
return [...series].sort((a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0));
}, [series, data]);
// Limit SVG-rendered series to MAX_SERIES (top N by total value)
const visibleSeries = useMemo(
() => (sortedSeries.length > MAX_SERIES ? sortedSeries.slice(0, MAX_SERIES) : sortedSeries),
[sortedSeries]
);
// X-axis tick formatter for date-based axes
const seriesLimitCallout =
totalSeriesCount > series.length ? (
<div className="mt-1 px-2">
<Callout variant="warning">
{`Limited to the top ${
series.length
} of ${totalSeriesCount.toLocaleString()} series for performance reasons.`}
</Callout>
</div>
) : null;
// Detect time granularity — use the full time range when available so tick
// labels are appropriate for the period (e.g. "Jan 5" for a 7-day range
// instead of just "16:00:00" when data is sparse)
const timeGranularity = useMemo(() => {
if (timeRange) {
return detectTimeGranularity([new Date(timeRange.from), new Date(timeRange.to)]);
}
return dateValues.length > 0 ? detectTimeGranularity(dateValues) : null;
}, [dateValues, timeRange]);
// X-axis tick formatter for date-based axes (pure no deduplication).
// Label deduplication is handled inside dateAxisTick below so that the
// mutable "lastLabel" state is correctly reset on each Recharts render pass.
const xAxisTickFormatter = useMemo(() => {
if (!isDateBased || !timeGranularity) return undefined;
return (value: number) => {
@@ -774,20 +858,44 @@ export const QueryResultsChart = memo(function QueryResultsChart({
};
}, [isDateBased, timeGranularity]);
// Create dynamic Y-axis formatter based on data range
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
// Resolve the Y-axis column format for formatting
const yAxisFormat = useMemo(() => {
if (yAxisColumns.length === 0) return undefined;
const col = columns.find((c) => c.name === yAxisColumns[0]);
return (col?.format ?? col?.customRenderType) as ColumnFormatType | undefined;
}, [yAxisColumns, columns]);
// Create dynamic Y-axis formatter based on data range and format
const yAxisFormatter = useMemo(
() => createYAxisFormatter(data, series, yAxisFormat),
[data, series, yAxisFormat]
);
// Create value formatter for tooltips and legend based on column format
const tooltipValueFormatter = useMemo(
() => createValueFormatter(yAxisFormat),
[yAxisFormat]
);
// Check if the group-by column has a runStatus customRenderType
const groupByIsRunStatus = useMemo(() => {
if (!groupByColumn) return false;
const col = columns.find((c) => c.name === groupByColumn);
return col?.customRenderType === "runStatus";
}, [groupByColumn, columns]);
// Build chart config for colors/labels
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
series.forEach((s, i) => {
sortedSeries.forEach((s, i) => {
const statusColor = groupByIsRunStatus ? getRunStatusHexColor(s) : undefined;
cfg[s] = {
label: s,
color: getSeriesColor(i),
color: statusColor ?? config.seriesColors?.[s] ?? getSeriesColor(i),
};
});
return cfg;
}, [series]);
}, [sortedSeries, groupByIsRunStatus, config.seriesColors]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
@@ -831,30 +939,125 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return [min, "auto"] as [number, string];
}, [data, series]);
// Validation
// Angle all date-based labels for consistent appearance and to avoid overlap
const xAxisAngle = isDateBased ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 65 : undefined;
// Check if the data would produce duplicate labels at the current granularity.
// Only use the custom tick renderer (with interval:0) when duplicates exist,
// otherwise let Recharts handle label spacing to avoid collisions.
const hasDuplicateLabels = useMemo(() => {
if (!isDateBased || !timeGranularity || data.length === 0) return false;
const labels = new Set<string>();
for (const point of data) {
const ts = point.__timestamp ?? point[xDataKey];
if (typeof ts === "number") {
labels.add(formatDateByGranularity(new Date(ts), timeGranularity));
}
}
return labels.size < data.length;
}, [isDateBased, timeGranularity, data, xDataKey]);
// Custom tick renderer for date-based axes: renders a tick mark alongside
// each label, and for unlabelled points (de-duplicated) just a subtle tick mark.
// De-duplication lives here (not in xAxisTickFormatter) so that the mutable
// lastLabel is reset when Recharts starts a new render pass (index === 0).
const dateAxisTick = useMemo(() => {
if (!isDateBased || !xAxisTickFormatter) return undefined;
let lastLabel = "";
return (props: Record<string, unknown>) => {
const { x, y, payload, index } = props as {
x: number;
y: number;
payload: { value: number };
index: number;
};
// Reset dedup state at the start of each Recharts render pass
if (index === 0) lastLabel = "";
const formatted = xAxisTickFormatter(payload.value);
const label = formatted === lastLabel ? "" : formatted;
lastLabel = formatted;
// y is the tick text position, offset from the axis by tickMargin + internal padding
const axisY = (y as number) - 12;
if (label) {
return (
<g>
<line
x1={x as number}
y1={axisY}
x2={x as number}
y2={axisY - 3}
stroke="#878C99"
strokeWidth={1}
/>
<text
x={x}
y={axisY}
dy={10}
fill="#878C99"
fontSize={11}
textAnchor={xAxisAngle !== 0 ? "end" : "middle"}
style={{ fontVariantNumeric: "tabular-nums" }}
transform={
xAxisAngle !== 0 ? `rotate(${xAxisAngle}, ${x}, ${axisY + 10})` : undefined
}
>
{label}
</text>
</g>
);
}
// Small tick mark sitting on the axis baseline, pointing upward
return (
<line
x1={x as number}
y1={axisY}
x2={x as number}
y2={axisY - 3}
stroke="#272A2E"
strokeWidth={1}
/>
);
};
}, [isDateBased, xAxisTickFormatter, xAxisAngle]);
// Validation — all hooks must be above this point
const chartIcon = chartType === "bar" ? BarChart3 : LineChart;
if (!xAxisColumn) {
return <EmptyState message="Select an X-axis column to display the chart" />;
return (
<ChartBlankState icon={chartIcon} message="Select an X-axis column to display the chart" />
);
}
if (yAxisColumns.length === 0) {
return <EmptyState message="Select a Y-axis column to display the chart" />;
return (
<ChartBlankState icon={chartIcon} message="Select a Y-axis column to display the chart" />
);
}
if (rows.length === 0) {
return <EmptyState message="No data to display" />;
return <ChartBlankState icon={chartIcon} message="No data to display" />;
}
if (data.length === 0) {
return <EmptyState message="Unable to transform data for chart" />;
return <ChartBlankState icon={chartIcon} message="Unable to transform data for chart" />;
}
// Determine appropriate angle for X-axis labels based on granularity
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
// Base x-axis props shared by all chart types
const baseXAxisProps = {
tickFormatter: xAxisTickFormatter,
...(dateAxisTick
? {
tick: dateAxisTick,
tickLine: false,
tickFormatter: undefined,
// Only force every tick to render when there are duplicates to de-duplicate;
// otherwise let Recharts auto-space to avoid label collisions
...(hasDuplicateLabels ? { interval: 0 } : {}),
}
: { tickFormatter: xAxisTickFormatter }),
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
@@ -864,13 +1067,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
// This properly represents time gaps between data points
const xAxisPropsForLine = isDateBased
? {
type: "number" as const,
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
...baseXAxisProps,
}
type: "number" as const,
domain: timeDomain ?? (["auto", "auto"] as [string, string]),
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
...baseXAxisProps,
}
: baseXAxisProps;
// Bar charts always use categorical axis positioning
@@ -883,7 +1086,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
domain: yAxisDomain,
};
const showLegend = series.length > 0;
const showLegend = sortedSeries.length > 0;
if (chartType === "bar") {
return (
@@ -891,20 +1094,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
series={sortedSeries}
visibleSeries={visibleSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
beforeLegend={seriesLimitCallout}
>
<Chart.Bar
xAxisProps={xAxisPropsForBar}
yAxisProps={yAxisProps}
stackId={stacked ? "stack" : undefined}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
/>
</Chart.Root>
);
@@ -916,20 +1125,26 @@ export const QueryResultsChart = memo(function QueryResultsChart({
config={chartConfig}
data={data}
dataKey={xDataKey}
series={series}
series={sortedSeries}
visibleSeries={visibleSeries}
labelFormatter={legendLabelFormatter}
showLegend={showLegend}
maxLegendItems={fullLegend ? Infinity : 5}
legendAggregation={config.aggregation}
legendValueFormatter={tooltipValueFormatter}
minHeight="300px"
fillContainer
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
state={isLoading ? "loading" : "loaded"}
beforeLegend={seriesLimitCallout}
>
<Chart.Line
xAxisProps={xAxisPropsForLine}
yAxisProps={yAxisProps}
stacked={stacked && series.length > 1}
stacked={stacked && visibleSeries.length > 1}
tooltipLabelFormatter={tooltipLabelFormatter}
tooltipValueFormatter={tooltipValueFormatter}
lineType="linear"
/>
</Chart.Root>
@@ -937,9 +1152,13 @@ export const QueryResultsChart = memo(function QueryResultsChart({
});
/**
* Creates a Y-axis value formatter based on the data range
* Creates a Y-axis value formatter based on the data range and optional format hint
*/
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
function createYAxisFormatter(
data: Record<string, unknown>[],
series: string[],
format?: ColumnFormatType
) {
// Find min and max values across all series
let minVal = Infinity;
let maxVal = -Infinity;
@@ -956,6 +1175,46 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
const range = maxVal - minVal;
// Format-aware formatters
if (format === "bytes" || format === "decimalBytes") {
const divisor = format === "bytes" ? 1024 : 1000;
const units =
format === "bytes"
? ["B", "KiB", "MiB", "GiB", "TiB"]
: ["B", "KB", "MB", "GB", "TB"];
return (value: number): string => {
if (value === 0) return "0 B";
// Use consistent unit for all ticks based on max value
const i = Math.min(
Math.max(0, Math.floor(Math.log(Math.abs(maxVal || 1)) / Math.log(divisor))),
units.length - 1
);
const scaled = value / Math.pow(divisor, i);
return `${scaled.toFixed(scaled < 10 ? 1 : 0)} ${units[i]}`;
};
}
if (format === "percent") {
return (value: number): string => `${value.toFixed(range < 1 ? 2 : 1)}%`;
}
if (format === "duration") {
return (value: number): string => formatDurationMilliseconds(value, { style: "short" });
}
if (format === "durationSeconds") {
return (value: number): string =>
formatDurationMilliseconds(value * 1000, { style: "short" });
}
if (format === "costInDollars" || format === "cost") {
return (value: number): string => {
const dollars = format === "cost" ? value / 100 : value;
return formatCurrencyAccurate(dollars);
};
}
// Default formatter
return (value: number): string => {
// Use abbreviations for large numbers
if (Math.abs(value) >= 1_000_000) {
@@ -988,13 +1247,3 @@ function createYAxisFormatter(data: Record<string, unknown>[], series: string[])
return Math.round(value).toString();
};
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-full min-h-[300px] items-center justify-center">
<Paragraph variant="small" className="text-text-dimmed">
{message}
</Paragraph>
</div>
);
}
+41 -2
View File
@@ -284,7 +284,7 @@ export function TSQLEditor(opts: TSQLEditorProps) {
}}
/>
{showButtons && (
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-1.5">
{additionalActions && additionalActions}
{showFormatButton && (
<Button
@@ -338,11 +338,50 @@ export function TSQLEditor(opts: TSQLEditorProps) {
);
}
// SQL keywords that legitimately appear before parentheses with a space
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
"IN",
"NOT",
"EXISTS",
"OVER",
"USING",
"VALUES",
"BETWEEN",
"LIKE",
"AND",
"OR",
"ON",
"SET",
"INTO",
"TABLE",
"CASE",
"WHEN",
"THEN",
"ELSE",
"AS",
"FROM",
"WHERE",
"HAVING",
"JOIN",
"SELECT",
]);
export function autoFormatSQL(sql: string) {
return formatSQL(sql, {
let formatted = formatSQL(sql, {
language: "sql",
keywordCase: "upper",
indentStyle: "standard",
linesBetweenQueries: 2,
});
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
// Remove that space for anything that isn't a SQL keyword
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
return match;
}
return `${name}(`;
});
return formatted;
}
@@ -1,23 +1,25 @@
import { ChevronDownIcon, ChevronUpDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { IconFilter2, IconFilter2X, IconTable } from "@tabler/icons-react";
import { rankItem } from "@tanstack/match-sorter-utils";
import {
useReactTable,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
flexRender,
type ColumnDef,
useReactTable,
type CellContext,
type ColumnResizeMode,
type ColumnFiltersState,
type FilterFn,
type Column,
type SortingState,
type ColumnDef,
type ColumnFiltersState,
type ColumnResizeMode,
type FilterFn,
type SortDirection,
type SortingState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { AlertCircle, ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import { forwardRef, memo, useEffect, useMemo, useRef, useState } from "react";
import { EnvironmentLabel, EnvironmentSlug } from "~/components/environments/EnvironmentLabel";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
@@ -33,18 +35,15 @@ import { useCopy } from "~/hooks/useCopy";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { formatBytes, formatDecimalBytes, formatQuantity } from "~/utils/columnFormat";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
import { ChartBlankState } from "../primitives/charts/ChartBlankState";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "../primitives/Tooltip";
import { QueueName } from "../runs/v3/QueueName";
import {
FunnelIcon,
ChevronUpIcon,
ChevronDownIcon,
ChevronUpDownIcon,
} from "@heroicons/react/20/solid";
const MAX_STRING_DISPLAY_LENGTH = 64;
const ROW_HEIGHT = 33; // Estimated row height in pixels
@@ -54,7 +53,7 @@ const MIN_COLUMN_WIDTH = 60;
const MAX_COLUMN_WIDTH = 400;
const CHAR_WIDTH_PX = 7.5; // Approximate width of a monospace character at text-xs (12px)
const CELL_PADDING_PX = 40; // px-2 (8px) on each side + buffer for copy button
const HEADER_ICONS_WIDTH_PX = 72; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (16px)
const HEADER_ICONS_WIDTH_PX = 80; // Sort icon (16px) + filter icon (12px) + info icon (16px) + gaps (12px) + header padding (24px)
const SAMPLE_SIZE = 100; // Number of rows to sample for width calculation
// Type for row data
@@ -68,9 +67,10 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
if (value === null) return "NULL";
if (value === undefined) return "";
// Handle custom render types
if (column.customRenderType) {
switch (column.customRenderType) {
// Handle format hints (from prettyFormat() or auto-populated from customRenderType)
const formatType = column.format ?? column.customRenderType;
if (formatType) {
switch (formatType) {
case "duration":
if (typeof value === "number") {
return formatDurationMilliseconds(value, { style: "short" });
@@ -97,6 +97,26 @@ function getFormattedValue(value: unknown, column: OutputColumnMetadata): string
return value;
}
break;
case "bytes":
if (typeof value === "number") {
return formatBytes(value);
}
break;
case "decimalBytes":
if (typeof value === "number") {
return formatDecimalBytes(value);
}
break;
case "percent":
if (typeof value === "number") {
return `${value.toFixed(2)}%`;
}
break;
case "quantity":
if (typeof value === "number") {
return formatQuantity(value);
}
break;
}
}
@@ -160,10 +180,10 @@ const fuzzyFilter: FilterFn<RowData> = (row, columnId, value, addMeta) => {
cellValue === null
? "NULL"
: cellValue === undefined
? ""
: typeof cellValue === "object"
? JSON.stringify(cellValue)
: String(cellValue);
? ""
: typeof cellValue === "object"
? JSON.stringify(cellValue)
: String(cellValue);
// Build searchable strings - formatted value (if we have column metadata)
const formattedValue = meta?.outputColumn
@@ -224,6 +244,21 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
if (value === null) return 4; // "NULL"
if (value === undefined) return 9; // "UNDEFINED"
// Handle format hint types - estimate their rendered width
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes") {
// e.g., "1.50 GiB" or "256.00 MB"
return 12;
}
if (fmt === "percent") {
// e.g., "45.23%"
return 8;
}
if (fmt === "quantity") {
// e.g., "1.50M"
return 8;
}
// Handle custom render types - estimate their rendered width
if (column.customRenderType) {
switch (column.customRenderType) {
@@ -265,6 +300,8 @@ function getDisplayLength(value: unknown, column: OutputColumnMetadata): number
return typeof value === "string" ? Math.min(value.length, 20) : 12;
case "queue":
return typeof value === "string" ? Math.min(value.length, 25) : 15;
case "deploymentId":
return typeof value === "string" ? Math.min(value.length, 25) : 20;
}
}
@@ -396,6 +433,10 @@ function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
) {
return true;
}
const fmt = column.format;
if (fmt === "bytes" || fmt === "decimalBytes" || fmt === "percent" || fmt === "quantity") {
return true;
}
return isNumericType(column.type);
}
@@ -416,7 +457,7 @@ function CellValueWrapper({
return (
<span
className="flex-1"
className="flex flex-1 items-center"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
@@ -462,6 +503,7 @@ function CellValue({
</pre>
}
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
disableHoverableContent
/>
);
}
@@ -477,12 +519,45 @@ function CellValue({
return <pre className="text-text-dimmed">UNDEFINED</pre>;
}
// Check format hint for new format types (from prettyFormat())
if (column.format && !column.customRenderType) {
switch (column.format) {
case "bytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatBytes(value)}</span>;
}
break;
case "decimalBytes":
if (typeof value === "number") {
return <span className="tabular-nums">{formatDecimalBytes(value)}</span>;
}
break;
case "percent":
if (typeof value === "number") {
return <span className="tabular-nums">{value.toFixed(2)}%</span>;
}
break;
case "quantity":
if (typeof value === "number") {
return <span className="tabular-nums">{formatQuantity(value)}</span>;
}
break;
}
}
// First check customRenderType for special rendering
if (column.customRenderType) {
switch (column.customRenderType) {
case "runId": {
if (typeof value === "string") {
return <TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>;
return (
<SimpleTooltip
content="Jump to run"
disableHoverableContent
hidden={!hovered}
button={<TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>}
/>
);
}
break;
}
@@ -490,19 +565,17 @@ function CellValue({
const status = isTaskRunStatus(value)
? value
: isRunFriendlyStatus(value)
? runStatusFromFriendlyTitle(value)
: undefined;
? runStatusFromFriendlyTitle(value)
: undefined;
if (status) {
if (hovered) {
return (
<SimpleTooltip
content={descriptionForTaskRunStatus(status)}
disableHoverableContent
button={<TaskRunStatusCombo status={status} />}
/>
);
}
return <TaskRunStatusCombo status={status} />;
return (
<SimpleTooltip
content={descriptionForTaskRunStatus(status)}
disableHoverableContent
hidden={!hovered}
button={<TaskRunStatusCombo status={status} />}
/>
);
}
break;
}
@@ -573,6 +646,19 @@ function CellValue({
}
return <span>{String(value)}</span>;
}
case "deploymentId": {
if (typeof value === "string" && value.startsWith("deployment_")) {
return (
<SimpleTooltip
content="Jump to deployment"
disableHoverableContent
hidden={!hovered}
button={<TextLink to={`/deployments/${value}`}>{value}</TextLink>}
/>
);
}
return <span>{String(value)}</span>;
}
}
}
@@ -607,6 +693,7 @@ function CellValue({
{truncateString(arrayString)}
</span>
}
disableHoverableContent
/>
);
}
@@ -642,6 +729,7 @@ function CellValue({
</pre>
}
button={<span>{truncateString(stringValue)}</span>}
disableHoverableContent
/>
);
}
@@ -688,6 +776,7 @@ function JSONCellValue({ value }: { value: unknown }) {
button={
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
}
disableHoverableContent
/>
);
}
@@ -713,15 +802,16 @@ function CopyableCell({
return (
<div
className={cn(
"relative flex w-full items-center overflow-hidden px-2 py-1.5",
"bg-background-dimmed group-hover/row:bg-charcoal-800",
"relative flex h-full w-full items-center overflow-hidden px-2",
"bg-background-bright group-hover/row:bg-charcoal-750",
"font-mono text-xs text-text-dimmed group-hover/row:text-text-bright",
"[&_a:focus-visible]:underline [&_a:focus-visible]:underline-offset-[3px] [&_a:focus-visible]:outline-none",
alignment === "right" && "justify-end"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className="truncate">{children}</span>
<span className="flex items-center truncate">{children}</span>
{isHovered && (
<span
onClick={(e) => {
@@ -781,18 +871,21 @@ function HeaderCellContent({
onSortClick?: (event: React.MouseEvent) => void;
canSort?: boolean;
}) {
const [isHovered, setIsHovered] = useState(false);
const [isCellHovered, setIsCellHovered] = useState(false);
const [isFilterHovered, setIsFilterHovered] = useState(false);
const sortHighlighted = isCellHovered && !isFilterHovered;
return (
<div
className={cn(
"flex w-full items-center gap-1 overflow-hidden bg-background-dimmed py-1.5 pl-2 pr-1",
"flex w-full items-center gap-1 overflow-hidden bg-background-bright py-2 pl-2 pr-3",
"font-mono text-xs font-medium text-text-bright",
alignment === "right" && "justify-end",
canSort && "cursor-pointer select-none"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onMouseEnter={() => setIsCellHovered(true)}
onMouseLeave={() => setIsCellHovered(false)}
onClick={onSortClick}
>
{tooltip ? (
@@ -802,11 +895,14 @@ function HeaderCellContent({
})}
>
<span className="truncate text-left">{children}</span>
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
/>
<span className="flex flex-shrink-0">
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isCellHovered}
disableHoverableContent
/>
</span>
</div>
) : (
<span className="min-w-0 flex-1 truncate text-left">{children}</span>
@@ -814,7 +910,10 @@ function HeaderCellContent({
{/* Sort indicator */}
{canSort && (
<span
className={cn("flex-shrink-0", sortDirection ? "text-text-bright" : "text-text-dimmed")}
className={cn(
"flex-shrink-0 transition-colors",
sortHighlighted ? "text-text-bright" : "text-text-dimmed"
)}
>
{sortDirection === "asc" ? (
<ChevronUpIcon className="size-4" />
@@ -831,10 +930,12 @@ function HeaderCellContent({
e.stopPropagation();
onFilterClick();
}}
className="flex-shrink-0 rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
onMouseEnter={() => setIsFilterHovered(true)}
onMouseLeave={() => setIsFilterHovered(false)}
className="flex-shrink-0 rounded text-text-dimmed transition-colors focus-custom hover:text-text-bright"
title="Toggle column filters"
>
<FunnelIcon className="size-3" />
{showFilters ? <IconFilter2X className="size-4" /> : <IconFilter2 className="size-4" />}
</button>
)}
</div>
@@ -866,7 +967,7 @@ function FilterCell({
}, [shouldFocus, onFocused]);
return (
<div className="flex items-center bg-background-dimmed px-1.5 pb-1" style={{ width }}>
<div className="flex items-center bg-background-bright px-1.5 pb-2" style={{ width }}>
<DebouncedInput
ref={inputRef}
value={columnFilterValue ?? ""}
@@ -886,10 +987,15 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
rows,
columns,
prettyFormatting = true,
sorting: defaultSorting = [],
showHeaderOnEmpty = false,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
prettyFormatting?: boolean;
sorting?: SortingState;
/** When true, show column headers + "No results" on empty data. When false, show a blank state icon. */
showHeaderOnEmpty?: boolean;
}) {
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -899,7 +1005,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
// Track which column's filter should be focused
const [focusFilterColumn, setFocusFilterColumn] = useState<string | null>(null);
// State for column sorting
const [sorting, setSorting] = useState<SortingState>([]);
const [sorting, setSorting] = useState<SortingState>(defaultSorting);
// Create TanStack Table column definitions from OutputColumnMetadata
// Calculate column widths based on content
@@ -959,6 +1065,10 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
// Empty state
if (rows.length === 0) {
if (!showHeaderOnEmpty) {
return <ChartBlankState icon={IconTable} message="No data to display" />;
}
return (
<div
className="h-full min-h-0 w-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
@@ -966,7 +1076,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
>
<table style={{ display: "grid" }}>
<thead
className="bg-background-dimmed"
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
style={{
display: "grid",
position: "sticky",
@@ -987,63 +1097,24 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
width: header.getSize(),
}}
>
<HeaderCellContent
alignment={meta?.alignment ?? "left"}
tooltip={meta?.outputColumn.description}
onFilterClick={() => {
if (!showFilters) {
setFocusFilterColumn(header.id);
} else {
setColumnFilters([]);
}
setShowFilters(!showFilters);
}}
showFilters={showFilters}
hasActiveFilter={!!header.column.getFilterValue()}
sortDirection={header.column.getIsSorted()}
onSortClick={header.column.getToggleSortingHandler()}
canSort={header.column.getCanSort()}
>
<HeaderCellContent alignment={meta?.alignment ?? "left"}>
{flexRender(header.column.columnDef.header, header.getContext())}
</HeaderCellContent>
{/* Column resizer */}
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
"opacity-0 group-hover/header:opacity-100",
"bg-charcoal-600 hover:bg-indigo-500",
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
)}
/>
</th>
);
})}
</tr>
))}
{/* Filter row - shown when filters are toggled */}
{showFilters && (
<tr style={{ display: "flex", width: "100%" }}>
{table.getHeaderGroups()[0]?.headers.map((header) => (
<FilterCell
key={`filter-${header.id}`}
column={header.column}
width={header.getSize()}
shouldFocus={focusFilterColumn === header.id}
onFocused={() => setFocusFilterColumn(null)}
/>
))}
</tr>
)}
</thead>
<tbody style={{ display: "grid" }}>
<tr style={{ display: "flex" }}>
<td>
<Paragraph variant="extra-small" className="p-4 text-text-dimmed">
No results
</Paragraph>
<tr style={{ display: "flex", width: "100%" }}>
<td className="w-full px-3 py-6" colSpan={columns.length}>
<div className="flex items-center justify-center gap-1.5">
<AlertCircle className="size-5 text-text-dimmed/50" />
<Paragraph variant="small" className="text-text-dimmed">
This query returned no results
</Paragraph>
</div>
</td>
</tr>
</tbody>
@@ -1060,7 +1131,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
>
<table style={{ display: "grid" }}>
<thead
className="bg-background-dimmed after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
className="border-t border-grid-bright bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright"
style={{
display: "grid",
position: "sticky",
@@ -1107,7 +1178,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none",
"absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none",
"opacity-0 group-hover/header:opacity-100",
"bg-charcoal-600 hover:bg-indigo-500",
header.column.getIsResizing() && "bg-indigo-500 opacity-100"
@@ -1139,7 +1210,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}}
className="bg-background-dimmed divide-y divide-charcoal-700"
className="divide-y divide-charcoal-700 bg-background-bright after:absolute after:bottom-0 after:left-0 after:right-0 after:z-[1] after:h-px after:bg-grid-bright"
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = tableRows[virtualRow.index];
@@ -1147,12 +1218,13 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
<tr
key={row.id}
data-index={virtualRow.index}
className="group/row hover:bg-charcoal-800"
className="group/row hover:bg-charcoal-750"
style={{
display: "flex",
position: "absolute",
transform: `translateY(${virtualRow.start}px)`,
width: "100%",
height: `${virtualRow.size}px`,
}}
>
{row.getVisibleCells().map((cell) => {
@@ -0,0 +1,183 @@
/**
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
*
* HSL is a human-friendly color model:
* h: 0360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
* s: 0100 (saturation — 0 is gray, 100 is full color)
* l: 0100 (lightness — 0 is black, 50 is pure color, 100 is white)
*/
interface HSLColor {
h: number;
s: number;
l: number;
}
interface ChartColorDef {
name: string;
hsl: HSLColor;
}
// ---------------------------------------------------------------------------
// Palette — 30 distinct colors for chart series, defined in HSL
// ---------------------------------------------------------------------------
const CHART_COLOR_DEFS: ChartColorDef[] = [
// Primary colors (high contrast, spread across hue wheel)
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
// Extended palette
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
// Additional distinct colors (lighter variants)
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
];
// ---------------------------------------------------------------------------
// HSL ↔ Hex conversion
// ---------------------------------------------------------------------------
/** Convert an HSL color (h: 0360, s: 0100, l: 0100) to a hex string */
function hslToHex({ h, s, l }: HSLColor): string {
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
const m = lNorm - c / 2;
let r1: number, g1: number, b1: number;
if (hPrime < 1) {
r1 = c;
g1 = x;
b1 = 0;
} else if (hPrime < 2) {
r1 = x;
g1 = c;
b1 = 0;
} else if (hPrime < 3) {
r1 = 0;
g1 = c;
b1 = x;
} else if (hPrime < 4) {
r1 = 0;
g1 = x;
b1 = c;
} else if (hPrime < 5) {
r1 = x;
g1 = 0;
b1 = c;
} else {
r1 = c;
g1 = 0;
b1 = x;
}
const toHex = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
}
/** Convert a hex string to HSL (h: 0360, s: 0100, l: 0100) */
function hexToHsl(hex: string): HSLColor {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
const l = (max + min) / 2;
if (delta === 0) {
return { h: 0, s: 0, l: Math.round(l * 100) };
}
const s = delta / (1 - Math.abs(2 * l - 1));
let h: number;
if (max === r) {
h = 60 * (((g - b) / delta + 6) % 6);
} else if (max === g) {
h = 60 * ((b - r) / delta + 2);
} else {
h = 60 * ((r - g) / delta + 4);
}
return {
h: Math.round(h),
s: Math.round(s * 100),
l: Math.round(l * 100),
};
}
// ---------------------------------------------------------------------------
// Derived hex palette (for consumers that need plain hex strings)
// ---------------------------------------------------------------------------
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
/** Get the hex color for a series by its index (wraps around) */
export function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
// ---------------------------------------------------------------------------
// Hue-sorted palette (rainbow order for color pickers)
// ---------------------------------------------------------------------------
const SATURATION_THRESHOLD = 10;
/**
* Chart colors sorted by perceived hue — the natural rainbow order
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
*
* Very desaturated colors (like grays) are placed at the end since they don't
* have a strong hue.
*/
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
.sort((a, b) => {
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
// Push desaturated colors to the end
if (aIsGray && !bIsGray) return 1;
if (!aIsGray && bIsGray) return -1;
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
// Sort by hue, then by saturation (more vivid first), then by lightness
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
return a.hsl.l - b.hsl.l;
})
.map((def) => hslToHex(def.hsl));
@@ -67,9 +67,10 @@ export function darkTheme(): Extension {
},
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: selection,
},
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection":
{
backgroundColor: selection,
},
".cm-panels": { backgroundColor: darkBackground, color: ivory },
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
@@ -87,8 +88,8 @@ export function darkTheme(): Extension {
".cm-selectionMatch": { backgroundColor: "#aafe661a" },
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
backgroundColor: "#bad0f847",
outline: "1px solid #515a6b",
backgroundColor: "rgba(18, 19, 23, 0.9)",
outline: "1px solid rgba(81, 90, 107, 0.5)",
},
".cm-gutters": {
@@ -166,14 +167,20 @@ export function darkTheme(): Extension {
backgroundColor: scrollbarBg,
},
},
{ dark: true }
{ dark: true },
);
/// The highlighting style for code in the JSON Hero theme.
const jsonHeroHighlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: violet },
{
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
tag: [
tags.name,
tags.deleted,
tags.character,
tags.propertyName,
tags.macroName,
],
color: lilac,
},
{ tag: [tags.function(tags.variableName), tags.labelName], color: malibu },
@@ -123,6 +123,16 @@ function createFunctionCompletions(): Completion[] {
});
}
// Add special TSQL functions not in the ClickHouse function registry
functions.push({
label: "timeBucket",
type: "function",
detail: "auto time bucket (0 args)",
apply: "timeBucket()",
boost: 1.5,
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
});
return functions;
}
@@ -47,6 +47,7 @@ import { type VercelOnboardingData } from "~/presenters/v3/VercelSettingsPresent
import { vercelAppInstallPath, v3ProjectSettingsPath, githubAppInstallPath, vercelResourcePath } from "~/utils/pathBuilder";
import type { loader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.vercel";
import { useEffect, useState, useCallback, useRef } from "react";
import { usePostHogTracking } from "~/hooks/usePostHog";
function safeRedirectUrl(url: string): string | null {
try {
@@ -114,6 +115,7 @@ export function VercelOnboardingModal({
nextUrl?: string;
onDataReload?: (vercelStagingEnvironment?: string) => void;
}) {
const { capture, startSessionRecording } = usePostHogTracking();
const navigation = useNavigation();
const fetcher = useTypedFetcher<typeof loader>();
const envMappingFetcher = useFetcher();
@@ -172,6 +174,33 @@ export function VercelOnboardingModal({
prevIsOpenRef.current = isOpen;
}, [isOpen, state, computeInitialState]);
const trackOnboarding = useCallback(
(eventName: string, extraProperties?: Record<string, unknown>) => {
capture(eventName, {
origin: fromMarketplaceContext ? "marketplace" : "dashboard",
step: state,
organization_slug: organizationSlug,
project_slug: projectSlug,
...extraProperties,
});
},
[capture, fromMarketplaceContext, state, organizationSlug, projectSlug]
);
const hasTrackedStartRef = useRef(false);
const hasTrackedCompletionRef = useRef(false);
useEffect(() => {
if (isOpen && state === "project-selection" && !hasTrackedStartRef.current) {
hasTrackedStartRef.current = true;
startSessionRecording();
trackOnboarding("vercel onboarding started");
}
if (!isOpen) {
hasTrackedStartRef.current = false;
hasTrackedCompletionRef.current = false;
}
}, [isOpen, state, trackOnboarding, startSessionRecording]);
const [selectedVercelProject, setSelectedVercelProject] = useState<{
id: string;
name: string;
@@ -337,6 +366,9 @@ export function VercelOnboardingModal({
useEffect(() => {
if (state === "project-selection" && fetcher.data && "success" in fetcher.data && fetcher.data.success && fetcher.state === "idle") {
trackOnboarding("vercel onboarding project selected", {
vercel_project_name: selectedVercelProject?.name,
});
setState("loading-env-mapping");
if (onDataReload) {
onDataReload();
@@ -344,7 +376,7 @@ export function VercelOnboardingModal({
} else if (fetcher.data && "error" in fetcher.data && typeof fetcher.data.error === "string") {
setProjectSelectionError(fetcher.data.error);
}
}, [state, fetcher.data, fetcher.state, onDataReload]);
}, [state, fetcher.data, fetcher.state, onDataReload, trackOnboarding, selectedVercelProject?.name]);
// For marketplace origin, skip env-mapping step
useEffect(() => {
@@ -449,14 +481,23 @@ export function VercelOnboardingModal({
method: "post",
action: actionUrl,
});
}, [actionUrl, fetcher, onClose, nextUrl, fromMarketplaceContext]);
}, [actionUrl, fetcher, onClose, fromMarketplaceContext]);
const handleSkipEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: true,
staging_environment: null,
});
setVercelStagingEnvironment(null);
setState("loading-env-vars");
}, []);
}, [trackOnboarding]);
const handleUpdateEnvMapping = useCallback(() => {
trackOnboarding("vercel onboarding env mapping completed", {
skipped: false,
staging_environment: vercelStagingEnvironment?.displayName ?? null,
});
if (!vercelStagingEnvironment) {
setState("loading-env-vars");
return;
@@ -471,9 +512,11 @@ export function VercelOnboardingModal({
action: actionUrl,
});
}, [vercelStagingEnvironment, envMappingFetcher, actionUrl]);
}, [vercelStagingEnvironment, envMappingFetcher, actionUrl, trackOnboarding]);
const handleBuildSettingsNext = useCallback(() => {
trackOnboarding("vercel onboarding build settings completed");
if (nextUrl && fromMarketplaceContext && isGitHubConnectedForOnboarding) {
setIsRedirecting(true);
}
@@ -501,7 +544,7 @@ export function VercelOnboardingModal({
if (!isGitHubConnectedForOnboarding) {
setState("github-connection");
}
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl]);
}, [vercelStagingEnvironment, pullEnvVarsBeforeBuild, atomicBuilds, discoverEnvVars, syncEnvVarsMapping, nextUrl, fromMarketplaceContext, isGitHubConnectedForOnboarding, completeOnboardingFetcher, actionUrl, trackOnboarding]);
const handleFinishOnboarding = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -530,10 +573,14 @@ export function VercelOnboardingModal({
}, [completeOnboardingFetcher.data, completeOnboardingFetcher.state, state]);
useEffect(() => {
if (state === "completed") {
if (state === "completed" && !hasTrackedCompletionRef.current) {
hasTrackedCompletionRef.current = true;
trackOnboarding("vercel onboarding completed", {
github_connected: isGitHubConnectedForOnboarding,
});
onClose();
}
}, [state, onClose]);
}, [state, onClose, trackOnboarding, isGitHubConnectedForOnboarding]);
useEffect(() => {
if (state === "installing") {
@@ -565,6 +612,12 @@ export function VercelOnboardingModal({
}
}, [state, customEnvironments, vercelStagingEnvironment]);
useEffect(() => {
if (state === "project-selection" && availableProjects.length > 0 && !selectedVercelProject) {
setSelectedVercelProject(availableProjects[0]);
}
}, [state, availableProjects, selectedVercelProject]);
if (!isOpen || onboardingData?.authInvalid) {
return null;
}
@@ -578,7 +631,14 @@ export function VercelOnboardingModal({
if (isLoadingState) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && !fromMarketplaceContext && onClose()}>
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if (state as string !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
@@ -601,7 +661,14 @@ export function VercelOnboardingModal({
const showGitHubConnection = state === "github-connection";
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && !fromMarketplaceContext && onClose()}>
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open && !fromMarketplaceContext) {
if (state !== "completed") {
trackOnboarding("vercel onboarding abandoned");
}
onClose();
}
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2">
@@ -625,6 +692,7 @@ export function VercelOnboardingModal({
</Callout>
) : (
<Select
disabled={availableProjects.length === 1}
value={selectedVercelProject?.id || ""}
setValue={(value) => {
if (!Array.isArray(value)) {
@@ -634,6 +702,7 @@ export function VercelOnboardingModal({
}
}}
items={availableProjects}
filter={availableProjects.length > 5 ? { keys: ["name"] } : undefined}
variant="tertiary/medium"
placeholder="Select a Vercel project"
dropdownIcon
@@ -894,6 +963,10 @@ export function VercelOnboardingModal({
<Button
variant="primary/medium"
onClick={() => {
trackOnboarding("vercel onboarding env vars configured", {
env_vars_enabled: enabledEnvVars.length,
env_vars_total: syncableEnvVars.length,
});
if (fromMarketplaceContext) {
handleBuildSettingsNext();
} else {
@@ -21,7 +21,7 @@ export function MainBody({ children }: { children: React.ReactNode }) {
/** This container should be placed around the content on a page */
export function PageContainer({ children }: { children: React.ReactNode }) {
return <div className="grid grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
return <div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">{children}</div>;
}
export function PageBody({
+127 -352
View File
@@ -1,43 +1,30 @@
import { XMarkIcon, ArrowTopRightOnSquareIcon, CheckIcon } from "@heroicons/react/20/solid";
import { Link } from "@remix-run/react";
import {
type MachinePresetName,
formatDurationMilliseconds,
} from "@trigger.dev/core/v3";
import { XMarkIcon } from "@heroicons/react/20/solid";
import type { TaskRunStatus } from "@trigger.dev/database";
import { useEffect, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Spinner } from "~/components/primitives/Spinner";
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import * as Property from "~/components/primitives/PropertyTable";
import { TextLink } from "~/components/primitives/TextLink";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { CopyableText } from "~/components/primitives/CopyableText";
import { SimpleTooltip, InfoIconTooltip } from "~/components/primitives/Tooltip";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import {
TaskRunStatusCombo,
descriptionForTaskRunStatus,
} from "~/components/runs/v3/TaskRunStatus";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor } from "~/utils/logUtils";
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
import { RunTag } from "~/components/runs/v3/RunTag";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import type { TaskRunStatus } from "@trigger.dev/database";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import type { RunContext } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId.run";
type RunContextData = {
run: RunContext | null;
};
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { LogLevel } from "./LogLevel";
import { ExitIcon } from "~/assets/icons/ExitIcon";
type LogDetailViewProps = {
logId: string;
// If we have the log entry from the list, we can display it immediately
@@ -46,27 +33,38 @@ type LogDetailViewProps = {
searchTerm?: string;
};
type TabType = "details" | "run";
type LogAttributes = Record<string, unknown> & {
error?: {
message?: string;
};
};
function getDisplayMessage(log: {
message: string;
level: string;
attributes?: LogAttributes;
}): string {
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = log.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
return message;
}
function formatStringJSON(str: string): string {
return str
.replace(/\\n/g, "\n") // Converts literal "\n" to newline
.replace(/\\t/g, "\t"); // Converts literal "\t" to tab
}
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof logDetailLoader>();
const [activeTab, setActiveTab] = useState<TabType>("details");
const [error, setError] = useState<string | null>(null);
// Fetch full log details when logId changes
@@ -75,7 +73,9 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
setError(null);
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(logId)}`
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/logs/${encodeURIComponent(logId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, logId]);
@@ -93,6 +93,7 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
const isLoading = fetcher.state === "loading";
const log = fetcher.data ?? initialLog;
const runStatus = fetcher.data?.runStatus;
const runPath = v3RunSpanPath(
organization,
@@ -102,27 +103,6 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
{ 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, log, runPath, isLoading]);
if (isLoading && !log) {
return (
<div className="flex h-full items-center justify-center">
@@ -134,11 +114,16 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
if (!log) {
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b border-grid-dimmed p-4">
<div className="flex items-center justify-between border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2>Log Details</Header2>
<Button variant="minimal/small" onClick={onClose}>
<XMarkIcon className="size-5" />
</Button>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
<div className="flex flex-1 items-center justify-center">
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
@@ -148,103 +133,112 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-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>
<div className="flex items-center justify-between overflow-hidden border-b border-grid-dimmed py-2 pl-3 pr-2">
<Header2 className="truncate">{getDisplayMessage(log)}</Header2>
<Button
onClick={onClose}
variant="minimal/small"
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
className="pl-1"
/>
</div>
{/* Tabs */}
<div className="flex items-center justify-between border-b border-grid-dimmed px-4">
<TabContainer>
<TabButton
isActive={activeTab === "details"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("details")}
shortcut={{ key: "d" }}
>
Details
</TabButton>
<TabButton
isActive={activeTab === "run"}
layoutId="log-detail-tabs"
onClick={() => setActiveTab("run")}
shortcut={{ key: "r" }}
>
Run
</TabButton>
</TabContainer>
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" LeadingIcon={ArrowTopRightOnSquareIcon} shortcut={{ key: "v" }}>
View full run
</Button>
</Link>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === "details" && (
<DetailsTab log={log} runPath={runPath} searchTerm={searchTerm} />
)}
{activeTab === "run" && (
<RunTab log={log} runPath={runPath} />
)}
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<DetailsTab log={log} runPath={runPath} runStatus={runStatus} searchTerm={searchTerm} />
</div>
</div>
);
}
function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: string; searchTerm?: string }) {
const logWithExtras = log as LogEntry & {
function DetailsTab({
log,
runPath,
runStatus,
searchTerm,
}: {
log: LogEntry & {
attributes?: LogAttributes;
};
runPath: string;
runStatus?: TaskRunStatus;
searchTerm?: string;
}) {
let beautifiedAttributes: string | null = null;
if (logWithExtras.attributes) {
beautifiedAttributes = JSON.stringify(logWithExtras.attributes, null, 2);
if (log.attributes) {
beautifiedAttributes = JSON.stringify(log.attributes, null, 2);
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
}
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
// Determine message to show
let message = log.message ?? "";
if (log.level === "ERROR") {
const maybeErrorMessage = logWithExtras.attributes?.error?.message;
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
message = maybeErrorMessage;
}
}
const message = getDisplayMessage(log);
return (
<>
{/* Time */}
<div className="mb-6">
<Header3 className="mb-2">Timestamp</Header3>
<div className="text-sm text-text-dimmed">
<DateTimeAccurate date={log.startTime} />
</div>
</div>
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={log.runId} copyValue={log.runId} asChild />
<LinkButton
to={runPath}
variant="tertiary/small"
shortcut={{ key: "v" }}
className="mt-2"
>
View full run
</LinkButton>
</Property.Value>
</Property.Item>
{runStatus && (
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runStatus} />}
content={descriptionForTaskRunStatus(runStatus)}
disableHoverableContent
className="mt-1"
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText value={log.taskIdentifier} copyValue={log.taskIdentifier} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Level</Property.Label>
<Property.Value>
<LogLevel level={log.level} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Timestamp</Property.Label>
<Property.Value>
<DateTimeAccurate date={log.triggeredTimestamp} />
</Property.Value>
</Property.Item>
</Property.Table>
{/* Message */}
<div className="mb-6">
<div className="mb-6 mt-3">
<PacketDisplay
data={message}
dataType="application/json"
title="Message"
searchTerm={searchTerm}
wrap={true}
/>
</div>
@@ -262,222 +256,3 @@ function DetailsTab({ log, runPath, searchTerm }: { log: LogEntry; runPath: stri
</>
);
}
function RunTab({ log, runPath }: { log: LogEntry; runPath: string }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<RunContextData>();
// Fetch run details when tab is active
useEffect(() => {
if (!log.runId) return;
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/logs/${encodeURIComponent(log.id)}/run?runId=${encodeURIComponent(log.runId)}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organization.slug, project.slug, environment.slug, log.id, log.runId]);
const isLoading = fetcher.state === "loading";
const runData = fetcher.data?.run;
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner />
</div>
);
}
if (!runData) {
return (
<div className="flex flex-col items-center justify-center py-8">
<Paragraph className="text-text-dimmed">Run not found in database.</Paragraph>
</div>
);
}
return (
<div className="flex flex-col gap-4 py-3">
<Property.Table>
<Property.Item>
<Property.Label>Run ID</Property.Label>
<Property.Value>
<CopyableText value={runData.friendlyId} copyValue={runData.friendlyId} asChild />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Status</Property.Label>
<Property.Value>
<SimpleTooltip
button={<TaskRunStatusCombo status={runData.status as TaskRunStatus} />}
content={descriptionForTaskRunStatus(runData.status as TaskRunStatus)}
disableHoverableContent
/>
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Task</Property.Label>
<Property.Value>
<CopyableText
value={runData.taskIdentifier}
copyValue={runData.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
{runData.rootRun && (
<Property.Item>
<Property.Label>Root and parent run</Property.Label>
<Property.Value>
<CopyableText
value={runData.rootRun.taskIdentifier}
copyValue={runData.rootRun.taskIdentifier}
asChild
/>
</Property.Value>
</Property.Item>
)}
{runData.batch && (
<Property.Item>
<Property.Label>Batch</Property.Label>
<Property.Value>
<CopyableText
value={runData.batch.friendlyId}
copyValue={runData.batch.friendlyId}
asChild
/>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Version</Property.Label>
<Property.Value>
{runData.version ? (
environment.type === "DEVELOPMENT" ? (
<CopyableText value={runData.version} copyValue={runData.version} asChild />
) : (
<SimpleTooltip
button={
<TextLink
to={v3DeploymentVersionPath(
organization,
project,
environment,
runData.version
)}
className="group flex flex-wrap items-center gap-x-1 gap-y-0"
>
<CopyableText value={runData.version} copyValue={runData.version} asChild />
</TextLink>
}
content={"Jump to deployment"}
/>
)
) : (
<span className="flex items-center gap-1">
<span>Never started</span>
<InfoIconTooltip
content={"Runs get locked to the latest version when they start."}
contentClassName="normal-case tracking-normal"
/>
</span>
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Test run</Property.Label>
<Property.Value>
{runData.isTest ? <CheckIcon className="size-4 text-text-dimmed" /> : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Environment</Property.Label>
<Property.Value>
<EnvironmentCombo environment={environment} />
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Queue</Property.Label>
<Property.Value>
<div>Name: {runData.queue}</div>
<div>Concurrency key: {runData.concurrencyKey ? runData.concurrencyKey : ""}</div>
</Property.Value>
</Property.Item>
{runData.tags && runData.tags.length > 0 && (
<Property.Item>
<Property.Label>Tags</Property.Label>
<Property.Value>
<div className="mt-1 flex flex-wrap items-center gap-1 text-xs">
{runData.tags.map((tag: string) => (
<RunTag
key={tag}
tag={tag}
to={v3RunsPath(organization, project, environment, { tags: [tag] })}
tooltip={`Filter runs by ${tag}`}
/>
))}
</div>
</Property.Value>
</Property.Item>
)}
<Property.Item>
<Property.Label>Machine</Property.Label>
<Property.Value className="-ml-0.5">
{runData.machinePreset ? (
<MachineLabelCombo preset={runData.machinePreset as MachinePresetName} />
) : (
""
)}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Run invocation cost</Property.Label>
<Property.Value>
{runData.baseCostInCents > 0
? formatCurrencyAccurate(runData.baseCostInCents / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Compute cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 ? formatCurrencyAccurate(runData.costInCents / 100) : ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Total cost</Property.Label>
<Property.Value>
{runData.costInCents > 0 || runData.baseCostInCents > 0
? formatCurrencyAccurate((runData.baseCostInCents + runData.costInCents) / 100)
: ""}
</Property.Value>
</Property.Item>
<Property.Item>
<Property.Label>Usage duration</Property.Label>
<Property.Value>
{runData.usageDurationMs > 0
? formatDurationMilliseconds(runData.usageDurationMs, { style: "short" })
: ""}
</Property.Value>
</Property.Item>
</Property.Table>
</div>
);
}
@@ -0,0 +1,16 @@
import { cn } from "~/utils/cn";
import { getLevelColor } from "~/utils/logUtils";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
export function LogLevel({ level }: { level: LogEntry["level"] }) {
return (
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(level)
)}
>
{level}
</span>
);
}
@@ -16,6 +16,7 @@ import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
import { cn } from "~/utils/cn";
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
{ level: "TRACE", label: "Trace", color: "text-purple-400" },
{ level: "INFO", label: "Info", color: "text-blue-400" },
{ level: "WARN", label: "Warning", color: "text-warning" },
{ level: "ERROR", label: "Error", color: "text-error" },
@@ -33,6 +34,8 @@ function getLevelBadgeColor(level: LogLevel): string {
return "text-error bg-error/10 border-error/20";
case "WARN":
return "text-warning bg-warning/10 border-warning/20";
case "TRACE":
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
case "DEBUG":
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
case "INFO":
@@ -1,55 +1,49 @@
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
import { useNavigate } from "@remix-run/react";
import { motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { Input } from "~/components/primitives/Input";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { cn } from "~/utils/cn";
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
import { useSearchParams } from "~/hooks/useSearchParam";
export function LogsSearchInput() {
const location = useOptimisticLocation();
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);
const { value, replace, del } = useSearchParams();
// Get initial search value from URL
const searchParams = new URLSearchParams(location.search);
const initialSearch = searchParams.get("search") ?? "";
const initialSearch = value("search") ?? "";
const [text, setText] = useState(initialSearch);
const [isFocused, setIsFocused] = useState(false);
// Update text when URL search param changes (only when not focused to avoid overwriting user input)
useEffect(() => {
const params = new URLSearchParams(location.search);
const urlSearch = params.get("search") ?? "";
const urlSearch = value("search") ?? "";
if (urlSearch !== text && !isFocused) {
setText(urlSearch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search]);
}, [value, text, isFocused]);
const handleSubmit = useCallback(() => {
const params = new URLSearchParams(location.search);
if (text.trim()) {
params.set("search", text.trim());
replace({ search: text.trim() });
} else {
params.delete("search");
del("search");
}
// Reset cursor when searching
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [text, location.pathname, location.search, navigate]);
}, [text, replace, del]);
const handleClear = useCallback(() => {
setText("");
const params = new URLSearchParams(location.search);
params.delete("search");
params.delete("cursor");
params.delete("direction");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}, [location.pathname, location.search, navigate]);
const handleClear = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setText("");
del(["search", "cursor", "direction"]);
},
[del]
);
return (
<div className="flex items-center gap-1">
@@ -71,7 +65,7 @@ export function LogsSearchInput() {
value={text}
onChange={(e) => setText(e.target.value)}
fullWidth
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -86,22 +80,21 @@ export function LogsSearchInput() {
icon={<MagnifyingGlassIcon className="size-4" />}
accessory={
text.length > 0 ? (
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<div className="-mr-1 flex items-center gap-1">
<ShortcutKey shortcut={{ key: "enter" }} variant="small" />
<button
type="button"
onClick={handleClear}
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-3" />
</button>
</div>
) : undefined
}
/>
</motion.div>
{text.length > 0 && (
<button
type="button"
onClick={handleClear}
className="flex size-6 items-center justify-center rounded text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright"
title="Clear search"
>
<XMarkIcon className="size-4" />
</button>
)}
</div>
);
}
+15 -23
View File
@@ -2,16 +2,17 @@ import { ArrowPathIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/20/so
import { Link } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "~/components/primitives/Buttons";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import { getLevelColor, highlightSearchText } from "~/utils/logUtils";
import { highlightSearchText } from "~/utils/logUtils";
import { v3RunSpanPath } from "~/utils/pathBuilder";
import { DateTimeAccurate } from "../primitives/DateTime";
import { Paragraph } from "../primitives/Paragraph";
import { Spinner } from "../primitives/Spinner";
import { LogLevel } from "./LogLevel";
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
import {
@@ -48,14 +49,14 @@ function getLevelBoxShadow(level: LogEntry["level"]): string {
return "inset 2px 0 0 0 rgb(234, 179, 8)";
case "INFO":
return "inset 2px 0 0 0 rgb(59, 130, 246)";
case "TRACE":
return "inset 2px 0 0 0 rgb(168, 85, 247)";
case "DEBUG":
default:
return "none";
}
}
export function LogsTable({
logs,
searchTerm,
@@ -162,7 +163,7 @@ export function LogsTable({
boxShadow: getLevelBoxShadow(log.level),
}}
>
<DateTimeAccurate date={log.startTime} />
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
</TableCell>
<TableCell className="min-w-24">
<TruncatedCopyableValue value={log.runId} />
@@ -171,14 +172,7 @@ export function LogsTable({
<span className="font-mono text-xs">{log.taskIdentifier}</span>
</TableCell>
<TableCell onClick={handleRowClick} hasAction>
<span
className={cn(
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
getLevelColor(log.level)
)}
>
{log.level}
</span>
<LogLevel level={log.level} />
</TableCell>
<TableCell className="max-w-0 truncate" onClick={handleRowClick} hasAction>
<span className="block truncate font-mono text-xs" title={log.message}>
@@ -188,11 +182,13 @@ export function LogsTable({
<TableCellMenu
className="pl-32"
hiddenButtons={
<Link to={runPath} target="_blank" rel="noopener noreferrer">
<Button variant="minimal/small" TrailingIcon={ArrowTopRightOnSquareIcon}>
View run
</Button>
</Link>
<LinkButton
to={runPath}
variant="minimal/small"
TrailingIcon={ArrowTopRightOnSquareIcon}
>
View run
</LinkButton>
}
/>
</TableRow>
@@ -233,11 +229,7 @@ function BlankState({ isLoading, onRefresh }: { isLoading?: boolean; onRefresh?:
No logs match your filters. Try refreshing or modifying your filters.
</Paragraph>
<div className="flex items-center gap-2">
<Button
LeadingIcon={ArrowPathIcon}
variant="tertiary/medium"
onClick={handleRefresh}
>
<Button LeadingIcon={ArrowPathIcon} variant="tertiary/medium" onClick={handleRefresh}>
Refresh
</Button>
</div>
@@ -43,7 +43,7 @@ export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
shortcut={shortcut}
tooltipTitle="Filter by task"
>
Tasks
<span className="ml-0.5">Tasks</span>
</SelectTrigger>
}
searchValue={search}
@@ -114,7 +114,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();
@@ -0,0 +1,523 @@
import { DocumentDuplicateIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
import { ClipboardIcon } from "@heroicons/react/24/outline";
import { ChartBarIcon } from "@heroicons/react/24/solid";
import { type OutputColumnMetadata } from "@internal/tsql";
import { DialogClose } from "@radix-ui/react-dialog";
import { IconBraces, IconChartHistogram, IconFileTypeCsv } from "@tabler/icons-react";
import { assertNever } from "assert-never";
import { Maximize2 } from "lucide-react";
import { useCallback, useRef, useState, type ReactNode } from "react";
import { z } from "zod";
import { Card } from "~/components/primitives/charts/Card";
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { rowsToCSV, rowsToJSON } from "~/utils/dataExport";
import { QueryResultsChart } from "../code/QueryResultsChart";
import { TSQLResultsTable } from "../code/TSQLResultsTable";
import { Button } from "../primitives/Buttons";
import { Callout } from "../primitives/Callout";
import { BigNumberCard } from "../primitives/charts/BigNumberCard";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { LoadingBarDivider } from "../primitives/LoadingBarDivider";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
} from "../primitives/Popover";
const ChartType = z.union([z.literal("bar"), z.literal("line")]);
export type ChartType = z.infer<typeof ChartType>;
const SortDirection = z.union([z.literal("asc"), z.literal("desc")]);
export type SortDirection = z.infer<typeof SortDirection>;
const AggregationType = z.union([
z.literal("sum"),
z.literal("avg"),
z.literal("count"),
z.literal("min"),
z.literal("max"),
]);
export type AggregationType = z.infer<typeof AggregationType>;
const chartConfigOptions = {
chartType: ChartType,
xAxisColumn: z.string().nullable(),
yAxisColumns: z.string().array(),
groupByColumn: z.string().nullable(),
stacked: z.boolean(),
sortByColumn: z.string().nullable(),
sortDirection: SortDirection,
aggregation: AggregationType,
seriesColors: z.record(z.string()).optional(),
};
const ChartConfiguration = z.object({ ...chartConfigOptions });
export type ChartConfiguration = z.infer<typeof ChartConfiguration>;
const BigNumberAggregationType = z.union([
z.literal("sum"),
z.literal("avg"),
z.literal("count"),
z.literal("min"),
z.literal("max"),
z.literal("first"),
z.literal("last"),
]);
export type BigNumberAggregationType = z.infer<typeof BigNumberAggregationType>;
const BigNumberSortDirection = z.union([z.literal("asc"), z.literal("desc")]);
const bigNumberConfigOptions = {
column: z.string(),
aggregation: BigNumberAggregationType,
sortDirection: BigNumberSortDirection.optional(),
abbreviate: z.boolean().default(false),
prefix: z.string().optional(),
suffix: z.string().optional(),
};
const BigNumberConfiguration = z.object({ ...bigNumberConfigOptions });
export type BigNumberConfiguration = z.infer<typeof BigNumberConfiguration>;
export const QueryWidgetConfig = z.discriminatedUnion("type", [
z.object({
type: z.literal("table"),
prettyFormatting: z.boolean().default(true),
sorting: z
.array(
z.object({
desc: z.boolean(),
id: z.string(),
})
)
.default([]),
}),
z.object({
type: z.literal("chart"),
...chartConfigOptions,
}),
z.object({
type: z.literal("bignumber"),
...bigNumberConfigOptions,
}),
z.object({
type: z.literal("title"),
}),
]);
export type QueryWidgetConfig = z.infer<typeof QueryWidgetConfig>;
/** Result data containing rows and column metadata */
export type QueryWidgetData = {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
};
/** Widget configuration with optional result data (used for edit callbacks) */
export type WidgetData = {
title: string;
query: string;
display: QueryWidgetConfig;
/** The current result data from the widget */
resultData?: QueryWidgetData;
};
export type QueryWidgetProps = {
title: ReactNode;
/** String title for rename dialog (optional - if not provided, rename won't be available) */
titleString?: string;
/** The TSQL query string (used for "Copy query" in the menu) */
query?: string;
isLoading?: boolean;
error?: string;
data: QueryWidgetData;
config: QueryWidgetConfig;
/** The effective time range for the query (used to show full x-axis on time-based charts) */
timeRange?: { from: string; to: string };
accessory?: ReactNode;
isResizing?: boolean;
isDraggable?: boolean;
/** Additional className applied to the Card wrapper */
className?: string;
/** Callback when edit is clicked. Receives the current data. */
onEdit?: (data: QueryWidgetData) => void;
/** Callback when rename is clicked. Receives the new title. */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked. */
onDelete?: () => void;
/** Callback when duplicate is clicked. Receives the current data. */
onDuplicate?: (data: QueryWidgetData) => void;
/** When true, show table column headers even when there are no rows */
showTableHeaderOnEmpty?: boolean;
};
export function QueryWidget({
title,
titleString,
query,
accessory,
isLoading,
error,
isResizing,
isDraggable,
className,
onEdit,
onRename,
onDelete,
onDuplicate,
...props
}: QueryWidgetProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(titleString ?? "");
const containerRef = useRef<HTMLDivElement>(null);
const hasEditActions = onEdit || onRename || onDelete || onDuplicate;
const hasData = props.data.rows.length > 0;
// "v" to toggle fullscreen on hovered widget
useShortcutKeys({
shortcut: { key: "v" },
action: useCallback(() => {
const isHovered = containerRef.current?.matches(":hover");
if (!isFullscreen && !isHovered) return;
setIsFullscreen((prev) => !prev);
}, [isFullscreen]),
});
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text);
}, []);
const copyQuery = useCallback(() => {
if (query) {
copyToClipboard(query);
}
}, [query, copyToClipboard]);
const copyJSON = useCallback(() => {
copyToClipboard(rowsToJSON(props.data.rows));
}, [props.data.rows, copyToClipboard]);
const copyCSV = useCallback(() => {
copyToClipboard(rowsToCSV(props.data.rows, props.data.columns));
}, [props.data, copyToClipboard]);
return (
<div ref={containerRef} className="group h-full">
<Card className={cn("h-full overflow-hidden px-0 pb-0", className)}>
<Card.Header draggable={isDraggable}>
<div className="flex items-center gap-1.5">{title}</div>
<Card.Accessory>
<SimpleTooltip
button={
<span className="opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="minimal/small"
LeadingIcon={Maximize2}
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
onClick={() => setIsFullscreen(true)}
className="!px-1"
/>
</span>
}
content={
<span className="flex items-center gap-1">
Maximize
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
</span>
}
asChild
/>
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger
isOpen={isMenuOpen}
className={cn(
"transition-opacity",
isMenuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
/>
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{hasEditActions && (
<>
{onEdit && (
<PopoverMenuItem
icon={IconChartHistogram}
title="Edit chart"
onClick={() => {
onEdit(props.data);
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
)}
{onRename && (
<PopoverMenuItem
icon={PencilSquareIcon}
title="Rename"
onClick={() => {
setRenameValue(titleString ?? "");
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDuplicate && (
<PopoverMenuItem
icon={DocumentDuplicateIcon}
title="Duplicate chart"
onClick={() => {
onDuplicate(props.data);
setIsMenuOpen(false);
}}
className="pr-4"
/>
)}
</>
)}
{query && (
<PopoverMenuItem
icon={ClipboardIcon}
title="Copy query"
onClick={() => {
copyQuery();
setIsMenuOpen(false);
}}
/>
)}
<PopoverMenuItem
icon={IconBraces}
title="Copy JSON"
disabled={!hasData}
onClick={() => {
copyJSON();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
<PopoverMenuItem
icon={IconFileTypeCsv}
title="Copy CSV"
disabled={!hasData}
onClick={() => {
copyCSV();
setIsMenuOpen(false);
}}
leadingIconClassName="-ml-0.5 -mr-1"
/>
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete chart"
leadingIconClassName="text-error"
className="text-error hover:!bg-error/10"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
{accessory}
</Card.Accessory>
</Card.Header>
<LoadingBarDivider isLoading={isLoading ?? false} className="bg-transparent" />
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
{isResizing ? (
<div className="flex h-full flex-1 items-center justify-center p-3">
<div className="flex flex-col items-center gap-1 text-text-dimmed">
<ChartBarIcon className="size-10 text-text-dimmed" />{" "}
<span className="text-base font-medium">Resizing...</span>
</div>
</div>
) : error ? (
<div className="p-3">
<Callout variant="error">{error}</Callout>
</div>
) : (
<QueryWidgetBody
{...props}
title={title}
isFullscreen={isFullscreen}
setIsFullscreen={setIsFullscreen}
isLoading={isLoading ?? false}
/>
)}
</Card.Content>
</Card>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename chart</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Chart title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}
type QueryWidgetBodyProps = {
title: ReactNode;
data: QueryWidgetData;
config: QueryWidgetConfig;
timeRange?: { from: string; to: string };
isFullscreen: boolean;
setIsFullscreen: (open: boolean) => void;
isLoading: boolean;
showTableHeaderOnEmpty?: boolean;
};
function QueryWidgetBody({
title,
data,
config,
timeRange,
isFullscreen,
setIsFullscreen,
isLoading,
showTableHeaderOnEmpty,
}: QueryWidgetBodyProps) {
const type = config.type;
// Only show the loading state if we have no data yet (initial load).
// During a reload with existing data, keep showing the current data
// while the loading bar in the header indicates a refresh is in progress.
const hasData = data.rows.length > 0;
const showLoading = isLoading && !hasData;
switch (type) {
case "table": {
return (
<>
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
showHeaderOnEmpty={showTableHeaderOnEmpty}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent
fullscreen
className="flex flex-col gap-0 bg-background-bright px-0 pb-0"
>
<DialogHeader className="px-4">{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 pt-2.5">
<TSQLResultsTable
rows={data.rows}
columns={data.columns}
prettyFormatting={config.prettyFormatting}
sorting={config.sorting}
showHeaderOnEmpty={showTableHeaderOnEmpty}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "chart": {
return (
<>
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
onViewAllLegendItems={() => setIsFullscreen(true)}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
<QueryResultsChart
rows={data.rows}
columns={data.columns}
config={config}
timeRange={timeRange}
fullLegend
legendScrollable
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "bignumber": {
return (
<>
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent fullscreen className="flex flex-col bg-background-bright">
<DialogHeader>{title}</DialogHeader>
<div className="flex min-h-0 w-full flex-1 items-center justify-center pt-4">
<BigNumberCard
rows={data.rows}
columns={data.columns}
config={config}
isLoading={showLoading}
/>
</div>
</DialogContent>
</Dialog>
</>
);
}
case "title": {
// Title widgets are rendered by TitleWidget, not QueryWidget
return null;
}
default: {
assertNever(type);
}
}
}
@@ -0,0 +1,212 @@
import * as Ariakit from "@ariakit/react";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
import { useFetcher } from "@remix-run/react";
import { matchSorter } from "match-sorter";
import { type ReactNode, useMemo } from "react";
import { TaskIcon } from "~/assets/icons/TaskIcon";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import {
ComboBox,
SelectItem,
SelectList,
SelectPopover,
SelectProvider,
SelectTrigger,
} from "~/components/primitives/Select";
import { Spinner } from "~/components/primitives/Spinner";
import { useDebounceEffect } from "~/hooks/useDebounce";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type loader as queuesLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues";
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
const shortcut = { key: "q" };
export function QueuesFilter() {
const { values, replace, del } = useSearchParams();
const selectedQueues = values("queues");
if (selectedQueues.length === 0 || selectedQueues.every((v) => v === "")) {
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<SelectTrigger
icon={<RectangleStackIcon className="size-4" />}
variant="secondary/small"
shortcut={shortcut}
tooltipTitle="Filter by queue"
>
<span className="ml-1">Queues</span>
</SelectTrigger>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
return (
<FilterMenuProvider>
{(search, setSearch) => (
<QueuesDropdown
trigger={
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Queues"
icon={<RectangleStackIcon className="size-4" />}
value={appliedSummary(selectedQueues.map((v) => v.replace("task/", "")))}
onRemove={() => del(["queues"])}
variant="secondary/small"
/>
</Ariakit.Select>
}
searchValue={search}
clearSearchValue={() => setSearch("")}
/>
)}
</FilterMenuProvider>
);
}
function QueuesDropdown({
trigger,
clearSearchValue,
searchValue,
onClose,
}: {
trigger: ReactNode;
clearSearchValue: () => void;
searchValue: string;
onClose?: () => void;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const { values, replace } = useSearchParams();
const handleChange = (values: string[]) => {
clearSearchValue();
replace({
queues: values.length > 0 ? values : undefined,
});
};
const queueValues = values("queues").filter((v) => v !== "");
const selected = queueValues.length > 0 ? queueValues : undefined;
const fetcher = useFetcher<typeof queuesLoader>();
useDebounceEffect(
searchValue,
(s) => {
const searchParams = new URLSearchParams();
searchParams.set("per_page", "25");
if (searchValue) {
searchParams.set("query", s);
}
fetcher.load(
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
environment.slug
}/queues?${searchParams.toString()}`
);
},
250
);
const filtered = useMemo(() => {
// Use a Map to deduplicate by value
const itemsMap = new Map<string, { name: string; type: "custom" | "task"; value: string }>();
// Add selected items first (for items not yet loaded from fetcher)
for (const queueName of selected ?? []) {
const queueItem = fetcher.data?.queues.find((q) => q.name === queueName);
if (!queueItem) {
if (queueName.startsWith("task/")) {
itemsMap.set(queueName, {
name: queueName.replace("task/", ""),
type: "task",
value: queueName,
});
} else {
itemsMap.set(queueName, {
name: queueName,
type: "custom",
value: queueName,
});
}
}
}
// Add items from fetcher data
if (fetcher.data !== undefined) {
for (const q of fetcher.data.queues) {
const value = q.type === "task" ? `task/${q.name}` : q.name;
itemsMap.set(value, {
name: q.name,
type: q.type,
value,
});
}
}
const items = Array.from(itemsMap.values());
return matchSorter(items, searchValue, {
keys: ["name"],
});
}, [searchValue, fetcher.data, selected]);
return (
<SelectProvider value={selected ?? []} setValue={handleChange} virtualFocus={true}>
{trigger}
<SelectPopover
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
hideOnEscape={() => {
if (onClose) {
onClose();
return false;
}
return true;
}}
>
<ComboBox
value={searchValue}
render={(props) => (
<div className="flex items-center justify-stretch">
<input {...props} placeholder={"Filter by queues..."} />
{fetcher.state === "loading" && <Spinner color="muted" />}
</div>
)}
/>
<SelectList>
{filtered.length > 0
? filtered.map((queue) => (
<SelectItem
key={queue.value}
value={queue.value}
icon={
queue.type === "task" ? (
<TaskIcon className="size-4 shrink-0 text-blue-500" />
) : (
<RectangleStackIcon className="size-4 shrink-0 text-purple-500" />
)
}
>
{queue.name}
</SelectItem>
))
: null}
{filtered.length === 0 && fetcher.state !== "loading" && (
<SelectItem disabled>No queues found</SelectItem>
)}
</SelectList>
</SelectPopover>
</SelectProvider>
);
}
@@ -0,0 +1,181 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { useFetcher, useNavigate } from "@remix-run/react";
import { IconCheck } from "@tabler/icons-react";
import { useEffect, useState } from "react";
import { useEnvironment } from "~/hooks/useEnvironment";
import {
useCustomDashboards,
useOrganization,
useWidgetLimitPerDashboard,
} from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
import { Button } from "../primitives/Buttons";
import { Dialog, DialogContent, DialogHeader } from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Paragraph } from "../primitives/Paragraph";
import type { QueryWidgetConfig } from "./QueryWidget";
export type SaveToDashboardDialogProps = {
title: string;
query: string;
config: QueryWidgetConfig;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
};
export function SaveToDashboardDialog({
title,
query,
config,
isOpen,
onOpenChange,
}: SaveToDashboardDialogProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const customDashboards = useCustomDashboards();
const widgetLimit = useWidgetLimitPerDashboard();
const fetcher = useFetcher<{ success: boolean }>();
const navigate = useNavigate();
// Find the first dashboard that isn't at the widget limit
const firstAvailableDashboard = customDashboards.find((d) => d.widgetCount < widgetLimit);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
firstAvailableDashboard?.friendlyId ?? customDashboards[0]?.friendlyId ?? null
);
// Build the form action URL
const formAction = selectedDashboardId
? `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${selectedDashboardId}/widgets`
: "";
const isLoading = fetcher.state === "submitting";
// Check if selected dashboard is at widget limit
const selectedDashboard = customDashboards.find((d) => d.friendlyId === selectedDashboardId);
const isSelectedAtLimit = selectedDashboard
? selectedDashboard.widgetCount >= widgetLimit
: false;
// Navigate to the dashboard when the fetcher completes successfully
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.success && selectedDashboardId) {
onOpenChange(false);
navigate(
v3CustomDashboardPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug },
{ friendlyId: selectedDashboardId }
)
);
}
}, [fetcher.state, fetcher.data, selectedDashboardId, onOpenChange, navigate, organization.slug, project.slug, environment.slug]);
// Update selection if dashboards change
useEffect(() => {
if (customDashboards.length > 0 && !selectedDashboardId) {
const available = customDashboards.find((d) => d.widgetCount < widgetLimit);
setSelectedDashboardId(available?.friendlyId ?? customDashboards[0].friendlyId);
}
}, [customDashboards, selectedDashboardId, widgetLimit]);
if (customDashboards.length === 0) {
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<div className="!mt-1 space-y-4">
<Paragraph variant="small" className="text-text-dimmed">
You don't have any custom dashboards yet. Create one first from the sidebar menu.
</Paragraph>
<FormButtons
className="justify-end"
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Close</Button>
</DialogClose>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Add to dashboard</DialogHeader>
<fetcher.Form method="post" action={formAction} className="space-y-4">
<input type="hidden" name="action" value="add" />
<input type="hidden" name="title" value={title} />
<input type="hidden" name="query" value={query} />
<input type="hidden" name="config" value={JSON.stringify(config)} />
<div className="!mt-1 space-y-2">
<Paragraph variant="small" className="text-text-dimmed">
Select a dashboard to add this chart to:
</Paragraph>
<div className="max-h-64 space-y-1 overflow-y-auto">
{customDashboards.map((dashboard) => {
const isAtLimit = dashboard.widgetCount >= widgetLimit;
return (
<button
key={dashboard.friendlyId}
type="button"
onClick={() => !isAtLimit && setSelectedDashboardId(dashboard.friendlyId)}
disabled={isAtLimit}
className={cn(
"flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition",
isAtLimit
? "cursor-not-allowed opacity-50"
: selectedDashboardId === dashboard.friendlyId
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
)}
>
{selectedDashboardId === dashboard.friendlyId ? (
<IconCheck className="size-4 shrink-0 text-green-500" />
) : (
<span className="size-4 shrink-0" />
)}
<span className="flex-1 truncate">{dashboard.title}</span>
<span
className={cn(
"shrink-0 text-xs",
isAtLimit ? "text-error" : "text-text-dimmed"
)}
>
{dashboard.widgetCount}/{widgetLimit}
</span>
</button>
);
})}
</div>
</div>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isLoading || !selectedDashboardId || isSelectedAtLimit}
>
{isLoading ? "Saving..." : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,64 @@
import * as Ariakit from "@ariakit/react";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
import { SelectItem, SelectPopover, SelectProvider } from "~/components/primitives/Select";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import type { QueryScope } from "~/services/queryService.server";
import { CubeTransparentIcon, GlobeAltIcon } from "@heroicons/react/20/solid";
import { IconListLetters } from "@tabler/icons-react";
const scopeOptions = [
{ value: "environment", label: "Environment" },
{ value: "project", label: "Project" },
{ value: "organization", label: "Organization" },
] as const;
export function ScopeFilter() {
const { value, replace } = useSearchParams();
const scope = (value("scope") as QueryScope) ?? "environment";
const handleChange = (newScope: string) => {
replace({ scope: newScope === "environment" ? undefined : newScope });
};
return (
<SelectProvider value={scope} setValue={handleChange}>
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
<AppliedFilter
label="Scope"
icon={<CubeTransparentIcon className="size-4" />}
value={<ScopeItem scope={scope} />}
removable={false}
variant="secondary/small"
/>
</Ariakit.Select>
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<ScopeItem scope={option.value} />
</SelectItem>
))}
</SelectPopover>
</SelectProvider>
);
}
function ScopeItem({ scope }: { scope: QueryScope }) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
switch (scope) {
case "organization":
return `Org: ${organization.title}`;
case "project":
return `Project: ${project.name}`;
case "environment":
return <EnvironmentLabel environment={environment} />;
default:
return scope;
}
}
@@ -0,0 +1,125 @@
import { useState } from "react";
import { PencilIcon, TrashIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverVerticalEllipseTrigger,
} from "../primitives/Popover";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
export type TitleWidgetProps = {
title: string;
isDraggable?: boolean;
isResizing?: boolean;
/** Callback when rename is clicked. Receives the new title. */
onRename?: (newTitle: string) => void;
/** Callback when delete is clicked. */
onDelete?: () => void;
};
export function TitleWidget({
title,
isDraggable,
isResizing,
onRename,
onDelete,
}: TitleWidgetProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState(title);
const hasMenu = onRename || onDelete;
return (
<div className="h-full">
<div
className={cn(
"group flex h-full items-center gap-2 rounded-lg border border-grid-bright bg-background-bright px-4",
isDraggable && "drag-handle cursor-grab active:cursor-grabbing"
)}
>
<span className="min-w-0 flex-1 truncate text-lg font-medium text-text-bright">
{title}
</span>
{hasMenu && (
<div className="flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100">
<Popover open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<PopoverVerticalEllipseTrigger isOpen={isMenuOpen} />
<PopoverContent align="end" className="p-0">
<div className="flex flex-col gap-1 p-1">
{onRename && (
<PopoverMenuItem
icon={PencilIcon}
title="Rename"
onClick={() => {
setRenameValue(title);
setIsRenameDialogOpen(true);
setIsMenuOpen(false);
}}
/>
)}
{onDelete && (
<PopoverMenuItem
icon={TrashIcon}
title="Delete"
leadingIconClassName="text-error"
className="text-error hover:!bg-error/10"
onClick={() => {
onDelete();
setIsMenuOpen(false);
}}
/>
)}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
{/* Rename Dialog */}
{onRename && (
<Dialog open={isRenameDialogOpen} onOpenChange={setIsRenameDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Rename title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (renameValue.trim()) {
onRename(renameValue.trim());
setIsRenameDialogOpen(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!renameValue.trim()}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
);
}
@@ -0,0 +1,255 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import { motion } from "framer-motion";
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
import { PlusIcon } from "@heroicons/react/20/solid";
import { useEffect, useState } from "react";
import { type MatchedOrganization, useDashboardLimits } from "~/hooks/useOrganizations";
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
import { Feedback } from "~/components/Feedback";
import { Button, LinkButton } from "../primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "../primitives/Dialog";
import { FormButtons } from "../primitives/FormButtons";
import { Input } from "../primitives/Input";
import { InputGroup } from "../primitives/InputGroup";
import { Label } from "../primitives/Label";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
import { v3BillingPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
export function CreateDashboardButton({
organization,
project,
environment,
isCollapsed,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const navigation = useNavigation();
const limits = useDashboardLimits();
const plan = useCurrentPlan();
const isAtLimit = limits.used >= limits.limit;
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricDashboards;
const canExceed = typeof planLimits === "object" && planLimits.canExceed === true;
const canUpgrade = plan?.v3Subscription?.plan && !canExceed;
const formAction = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/create`;
// Close dialog when form submission starts (redirect is happening)
useEffect(() => {
if (navigation.formAction === formAction && navigation.state === "loading") {
setIsOpen(false);
}
}, [navigation.formAction, navigation.state, formAction]);
if (isCollapsed) return null;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<TooltipProvider disableHoverableContent>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<button
type="button"
className="flex h-full w-full items-center justify-center rounded text-text-dimmed transition focus-custom hover:bg-charcoal-600 hover:text-text-bright"
>
<PlusIcon className="size-4" />
</button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
Create dashboard
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isAtLimit ? (
<CreateDashboardUpgradeDialog
limits={limits}
canUpgrade={!!canUpgrade}
isFreePlan={plan?.v3Subscription?.isPaying === false}
organization={organization}
/>
) : (
<CreateDashboardDialog formAction={formAction} limits={limits} />
)}
</Dialog>
);
}
const PROGRESS_RING_R = 27.5;
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
const PROGRESS_COLOR_SUCCESS = "#28BF5C"; // mint-500 / success
const PROGRESS_COLOR_ERROR = "#E11D48"; // rose-600 / error
function CreateDashboardUpgradeDialog({
limits,
canUpgrade,
isFreePlan,
organization,
}: {
limits: { used: number; limit: number };
canUpgrade: boolean;
isFreePlan: boolean;
organization: MatchedOrganization;
}) {
if (isFreePlan) {
return (
<DialogContent>
<DialogHeader>Upgrade to unlock dashboards</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<ArrowUpCircleIcon className="ml-1 size-14 shrink-0 text-indigo-500" />
<DialogDescription className="pt-0">
Custom metric dashboards are available on paid plans. Upgrade to create dashboards and
track your task metrics.
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
</DialogFooter>
</DialogContent>
);
}
const percentage = Math.min(limits.used / limits.limit, 1);
const filled = percentage * PROGRESS_RING_CIRCUMFERENCE;
return (
<DialogContent>
<DialogHeader>Dashboard limit reached</DialogHeader>
<div className="flex items-center gap-4 pt-3">
<div className="relative ml-1 mt-2 shrink-0" style={{ width: 60, height: 60 }}>
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
/>
<motion.circle
className="fill-none"
strokeWidth="5"
r={PROGRESS_RING_R}
cx="30"
cy="30"
strokeLinecap="round"
initial={{
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: PROGRESS_COLOR_SUCCESS,
}}
animate={{
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
stroke: PROGRESS_COLOR_ERROR,
}}
transition={{ duration: 1.2, ease: "easeInOut" }}
/>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-lg text-text-dimmed">
{limits.limit}
</span>
</div>
<DialogDescription className="pt-0">
{canUpgrade ? (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
Upgrade your plan to create more.
</>
) : (
<>
{limits.limit === 1
? "Your plan includes 1 custom dashboard and it's already in use."
: `You've used all ${limits.limit} of your custom dashboards.`}{" "}
To create more, request a limit increase or visit the{" "}
<TextLink to={v3BillingPath(organization)}>billing page</TextLink> for pricing
details.
</>
)}
</DialogDescription>
</div>
<DialogFooter className="flex justify-between">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
{canUpgrade ? (
<LinkButton variant="primary/medium" to={v3BillingPath(organization)}>
Upgrade plan
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/medium">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
);
}
function CreateDashboardDialog({
formAction,
limits,
}: {
formAction: string;
limits: { used: number; limit: number };
}) {
const navigation = useNavigation();
const [title, setTitle] = useState("");
const isLoading = navigation.formAction === formAction;
return (
<DialogContent className="sm:max-w-sm">
<DialogHeader>Create dashboard</DialogHeader>
<Form method="post" action={formAction} className="space-y-4 pt-3">
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Dashboard"
required
/>
</InputGroup>
<Paragraph variant="extra-small" className="text-text-dimmed">
{limits.used}/{limits.limit} dashboards used
</Paragraph>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/medium" disabled={isLoading || !title.trim()}>
{isLoading ? "Creating..." : "Create"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
);
}
@@ -0,0 +1,123 @@
import { IconChartHistogram } from "@tabler/icons-react";
import { GripVerticalIcon, LineChartIcon } from "lucide-react";
import ReactGridLayout from "react-grid-layout";
import { type MatchedOrganization, useCustomDashboards } from "~/hooks/useOrganizations";
import { type UserWithDashboardPreferences } from "~/models/user.server";
import { v3CustomDashboardPath } from "~/utils/pathBuilder";
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
import { SideMenuItem } from "./SideMenuItem";
import { TreeConnectorBranch, TreeConnectorEnd } from "./TreeConnectors";
import { useReorderableList } from "./useReorderableList";
type SideMenuUser = Pick<UserWithDashboardPreferences, "dashboardPreferences"> & {
isImpersonating: boolean;
};
export function DashboardList({
organization,
project,
environment,
isCollapsed,
user,
}: {
organization: MatchedOrganization;
project: SideMenuProject;
environment: SideMenuEnvironment;
isCollapsed: boolean;
user: SideMenuUser;
}) {
const customDashboards = useCustomDashboards();
const initialOrder =
user.dashboardPreferences.sideMenu?.organizations?.[organization.id]?.orderedItems?.[
"customDashboards"
];
const {
orderedItems: orderedDashboards,
layout,
containerRef,
gridWidth,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
} = useReorderableList({
organizationId: organization.id,
listId: "customDashboards",
items: customDashboards,
itemKey: (d) => d.friendlyId,
initialOrder,
isImpersonating: user.isImpersonating,
});
return (
<div ref={containerRef}>
{canReorder ? (
<ReactGridLayout
layout={layout}
width={gridWidth}
gridConfig={{
cols: 1,
rowHeight: 32,
margin: [0, 0] as const,
containerPadding: [0, 0] as const,
}}
resizeConfig={{ enabled: false }}
dragConfig={{ enabled: !isCollapsed, handle: ".sidebar-drag-handle" }}
onDrag={handleDrag}
onDragStop={handleDragStop}
className="sidebar-reorder-grid"
autoSize
>
{orderedDashboards.map((dashboard, index) => {
const isLast = getIsLast(dashboard.friendlyId, index);
return (
<div key={dashboard.friendlyId}>
<SideMenuItem
name={dashboard.title}
icon={
isCollapsed
? IconChartHistogram
: isLast
? TreeConnectorEnd
: TreeConnectorBranch
}
activeIconColor={isCollapsed ? "text-customDashboards" : undefined}
inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined}
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
isCollapsed={isCollapsed}
action={
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
<GripVerticalIcon className="size-3.5" />
</div>
}
/>
</div>
);
})}
</ReactGridLayout>
) : (
orderedDashboards.map((dashboard, index) => {
const isLast = index === orderedDashboards.length - 1;
return (
<SideMenuItem
key={dashboard.friendlyId}
name={dashboard.title}
icon={
isCollapsed
? LineChartIcon
: isLast
? TreeConnectorEnd
: TreeConnectorBranch
}
activeIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
inactiveIconColor={isCollapsed ? "text-customDashboards" : "text-charcoal-700"}
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
isCollapsed={isCollapsed}
/>
);
})
)}
</div>
);
}
@@ -141,7 +141,14 @@ export function OrganizationSettingsSideMenu({
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git ref" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{buildInfo.gitRefName}
<a
href={`https://github.com/triggerdotdev/trigger.dev/tree/${buildInfo.gitRefName}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitRefName}
</a>
</Paragraph>
</div>
)}
@@ -149,7 +156,14 @@ export function OrganizationSettingsSideMenu({
<div className="flex flex-col gap-1">
<SideMenuHeader title="Git sha" />
<Paragraph variant="extra-small" className="px-2 text-text-dimmed">
{buildInfo.gitSha.slice(0, 9)}
<a
href={`https://github.com/triggerdotdev/trigger.dev/commit/${buildInfo.gitSha}`}
target="_blank"
rel="noopener noreferrer"
className="transition hover:text-text-bright"
>
{buildInfo.gitSha.slice(0, 9)}
</a>
</Paragraph>
</div>
)}
+482 -303
View File
@@ -2,6 +2,7 @@ import {
AdjustmentsHorizontalIcon,
ArrowPathRoundedSquareIcon,
ArrowRightOnRectangleIcon,
ArrowTopRightOnSquareIcon,
BeakerIcon,
BellAlertIcon,
ChartBarIcon,
@@ -9,6 +10,7 @@ import {
ClockIcon,
Cog8ToothIcon,
CogIcon,
ExclamationTriangleIcon,
FolderIcon,
FolderOpenIcon,
GlobeAmericasIcon,
@@ -20,11 +22,11 @@ import {
ServerStackIcon,
Squares2X2Icon,
TableCellsIcon,
UsersIcon
UsersIcon,
} from "@heroicons/react/20/solid";
import { Link, useFetcher, useNavigation } from "@remix-run/react";
import { LayoutGroup, motion } from "framer-motion";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
import simplur from "simplur";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
@@ -40,13 +42,12 @@ import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
import { useHasAdminAccess } from "~/hooks/useUser";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { useHasAdminAccess } from "~/hooks/useUser";
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";
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
import { cn } from "~/utils/cn";
import {
accountPath,
@@ -65,6 +66,7 @@ import {
v3ApiKeysPath,
v3BatchesPath,
v3BillingPath,
v3BuiltInDashboardPath,
v3BulkActionsPath,
v3DeploymentsPath,
v3EnvironmentPath,
@@ -88,23 +90,39 @@ import { ImpersonationBanner } from "../ImpersonationBanner";
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
import { Dialog, DialogTrigger } from "../primitives/Dialog";
import { Paragraph } from "../primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverMenuItem,
PopoverTrigger
} from "../primitives/Popover";
import { Popover, PopoverContent, PopoverMenuItem, PopoverTrigger } from "../primitives/Popover";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip, 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 { CreateDashboardButton } from "./DashboardDialogs";
import { DashboardList } from "./DashboardList";
import { EnvironmentSelector } from "./EnvironmentSelector";
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { type SideMenuSectionId } from "./sideMenuTypes";
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
/** Get the collapsed state for a specific side menu section from user preferences */
function getSectionCollapsed(
sideMenu: { collapsedSections?: Record<string, boolean> } | undefined,
sectionId: SideMenuSectionId
): boolean {
return sideMenu?.collapsedSections?.[sectionId] ?? false;
}
type SideMenuUser = Pick<
UserWithDashboardPreferences,
"email" | "admin" | "dashboardPreferences"
> & {
isImpersonating: boolean;
};
export type SideMenuProject = Pick<
@@ -138,7 +156,8 @@ export function SideMenu({
const preferencesFetcher = useFetcher();
const pendingPreferencesRef = useRef<{
isCollapsed?: boolean;
manageSectionCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const currentPlan = useCurrentPlan();
@@ -147,9 +166,15 @@ export function SideMenu({
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
const incidentStatus = useIncidentStatus();
const isV3Project = project.engine === "V1";
const persistSideMenuPreferences = useCallback(
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
(data: {
isCollapsed?: boolean;
sectionId?: SideMenuSectionId;
sectionCollapsed?: boolean;
}) => {
if (user.isImpersonating) return;
// Merge with any pending changes
@@ -170,8 +195,9 @@ export function SideMenu({
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -191,13 +217,18 @@ export function SideMenu({
}
if (user.isImpersonating) return;
const pending = pendingPreferencesRef.current;
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
const hasPendingChanges =
pending.isCollapsed !== undefined ||
(pending.sectionId !== undefined && pending.sectionCollapsed !== undefined);
if (hasPendingChanges) {
const formData = new FormData();
if (pending.isCollapsed !== undefined) {
formData.append("isCollapsed", String(pending.isCollapsed));
}
if (pending.manageSectionCollapsed !== undefined) {
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
if (pending.sectionId !== undefined && pending.sectionCollapsed !== undefined) {
formData.append("sectionId", pending.sectionId);
formData.append("sectionCollapsed", String(pending.sectionCollapsed));
}
preferencesFetcher.submit(formData, {
method: "POST",
@@ -214,9 +245,10 @@ export function SideMenu({
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
};
const handleManageSectionToggle = useCallback(
(collapsed: boolean) => {
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
/** Generic handler for any collapsible section - just pass the section ID */
const handleSectionToggle = useCallback(
(sectionId: SideMenuSectionId) => (collapsed: boolean) => {
persistSideMenuPreferences({ sectionId, sectionCollapsed: collapsed });
},
[persistSideMenuPreferences]
);
@@ -255,295 +287,441 @@ export function SideMenu({
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 className={cn("min-w-0", !isCollapsed && "flex-1")}>
<ProjectSelector
organizations={organizations}
organization={organization}
project={project}
user={user}
isCollapsed={isCollapsed}
/>
</div>
{isAdmin && !user.isImpersonating ? (
<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 ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
{isAdmin && !user.isImpersonating ? (
<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 ? (
<CollapsibleElement isCollapsed={isCollapsed}>
<ImpersonationBanner />
</CollapsibleElement>
) : null}
</div>
<div
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 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"
<div
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 w-full flex-col gap-4 overflow-hidden px-1">
<div className="w-full space-y-1">
<SideMenuHeader
title={"Environment"}
isCollapsed={isCollapsed}
collapsedTitle="Env"
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<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 className="flex items-center">
<EnvironmentSelector
organization={organization}
project={project}
environment={environment}
className="w-full"
isCollapsed={isCollapsed}
/>
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
<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) && (
<div className="w-full">
<SideMenuItem
name="Logs"
icon={LogsIcon}
activeIconColor="text-logs"
inactiveIconColor="text-logs"
to={v3LogsPath(organization, project, environment)}
data-action="logs"
badge={<AlphaBadge />}
name="Tasks"
icon={TaskIconSmall}
activeIconColor="text-tasks"
inactiveIconColor="text-tasks"
to={v3EnvironmentPath(organization, project, environment)}
data-action="tasks"
isCollapsed={isCollapsed}
/>
)}
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-tests"
inactiveIconColor="text-tests"
to={v3TestPath(organization, project, environment)}
data-action="test"
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}
/>
<SideMenuItem
name="Test"
icon={BeakerIcon}
activeIconColor="text-tests"
inactiveIconColor="text-tests"
to={v3TestPath(organization, project, environment)}
data-action="test"
isCollapsed={isCollapsed}
/>
</div>
{(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}
/>
<SideMenuSection
title="Insights"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(
user.dashboardPreferences.sideMenu,
"metrics"
)}
onCollapseToggle={handleSectionToggle("metrics")}
>
{(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="Query"
icon={TableCellsIcon}
activeIconColor="text-query"
inactiveIconColor="text-query"
to={queryPath(organization, project, environment)}
data-action="query"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Metrics"
icon={ChartBarIcon}
activeIconColor="text-metrics"
inactiveIconColor="text-metrics"
to={v3BuiltInDashboardPath(organization, project, environment, "overview")}
data-action="metrics-overview"
isCollapsed={isCollapsed}
action={
<CreateDashboardButton
organization={organization}
project={project}
environment={environment}
isCollapsed={isCollapsed}
/>
}
/>
<DashboardList
organization={organization}
project={project}
environment={environment}
isCollapsed={isCollapsed}
user={user}
/>
</SideMenuSection>
)}
</div>
<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-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-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-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
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-text-bright"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuSection
title="Manage"
isSideMenuCollapsed={isCollapsed}
itemSpacingClassName="space-y-0"
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "manage")}
onCollapseToggle={handleSectionToggle("manage")}
>
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
name="Bulk actions"
icon={ListCheckedIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={concurrencyPath(organization, project, environment)}
data-action="concurrency"
to={v3BulkActionsPath(organization, project, environment)}
data-action="bulk actions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="API keys"
icon={KeyIcon}
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-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3EnvironmentVariablesPath(organization, project, environment)}
data-action="environment variables"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Alerts"
icon={BellAlertIcon}
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-text-bright"
inactiveIconColor="text-text-dimmed"
to={branchesPath(organization, project, environment)}
data-action="preview-branches"
isCollapsed={isCollapsed}
/>
{isManagedCloud && (
<SideMenuItem
name="Concurrency"
icon={ConcurrencyIcon}
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-text-bright"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Limits"
icon={AdjustmentsHorizontalIcon}
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-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
</div>
</div>
<div>
<IncidentStatusPanel
isCollapsed={isCollapsed}
title={incidentStatus.title}
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
<V3DeprecationPanel
isCollapsed={isCollapsed}
isV3={isV3Project}
hasIncident={incidentStatus.hasIncident}
isManagedCloud={incidentStatus.isManagedCloud}
/>
<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"
)}
<SideMenuItem
name="Regions"
icon={GlobeAmericasIcon}
activeIconColor="text-text-bright"
inactiveIconColor="text-text-dimmed"
to={regionsPath(organization, project, environment)}
data-action="regions"
isCollapsed={isCollapsed}
/>
<SideMenuItem
name="Limits"
icon={AdjustmentsHorizontalIcon}
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-text-bright"
inactiveIconColor="text-text-dimmed"
to={v3ProjectSettingsPath(organization, project, environment)}
data-action="project-settings"
isCollapsed={isCollapsed}
/>
</SideMenuSection>
>
<HelpAndAI isCollapsed={isCollapsed} />
{isFreeUser && (
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
</CollapsibleHeight>
)}
</motion.div>
</div>
</div>
<div>
<IncidentStatusPanel isCollapsed={isCollapsed} />
</div>
);
}
function V3DeprecationPanel({
isCollapsed,
isV3,
hasIncident,
isManagedCloud,
}: {
isCollapsed: boolean;
isV3: boolean;
hasIncident: boolean;
isManagedCloud: boolean;
}) {
if (!isManagedCloud || !isV3 || hasIncident) {
return null;
}
return (
<Popover>
<div className="p-1">
<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")}
initial={false}
animate={{
height: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<HelpAndAI isCollapsed={isCollapsed} />
{isFreeUser && (
<CollapsibleHeight isCollapsed={isCollapsed}>
<FreePlanUsage
to={v3BillingPath(organization)}
percentage={currentPlan.v3Usage.usagePercentage}
/>
</CollapsibleHeight>
)}
<V3DeprecationContent />
</motion.div>
<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-amber-500/30 bg-amber-500/15 transition-colors hover:border-amber-500/50 hover:bg-amber-500/25">
<ExclamationTriangleIcon className="size-5 text-amber-400" />
</PopoverTrigger>
}
content="V3 deprecation warning"
side="right"
sideOffset={8}
disableHoverableContent
asChild
/>
</motion.div>
</div>
<PopoverContent side="right" sideOffset={8} align="start" className="w-52 !min-w-0 p-0">
<V3DeprecationContent />
</PopoverContent>
</Popover>
);
}
function V3DeprecationContent() {
return (
<div className="flex flex-col gap-2 rounded border border-amber-500/30 bg-amber-500/10 p-2 pt-1.5">
<div className="flex items-center gap-1 border-b border-amber-500/30 pb-1">
<ExclamationTriangleIcon className="size-4 text-amber-400" />
<Paragraph variant="small/bright" className="text-amber-300">
V3 deprecation warning
</Paragraph>
</div>
<Paragraph variant="extra-small/bright" className="text-amber-300">
This is a v3 project. V3 deploys will stop working on 1 April 2026. Full shutdown is 1 July
2026 where all v3 runs will stop executing. Migrate to v4 to avoid downtime.
</Paragraph>
<LinkButton
variant="secondary/small"
to="https://trigger.dev/docs/migrating-from-v3"
target="_blank"
fullWidth
TrailingIcon={ArrowTopRightOnSquareIcon}
trailingIconClassName="text-amber-300"
className="border-amber-500/30 bg-amber-500/15 hover:!border-amber-500/50 hover:!bg-amber-500/25"
>
<span className="text-amber-300">View migration guide</span>
</LinkButton>
</div>
);
}
@@ -890,7 +1068,12 @@ function CollapsibleHeight({
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
return (
<LayoutGroup>
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
<div
className={cn(
"flex w-full",
isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between"
)}
>
<ShortcutsAutoOpen />
<HelpAndFeedback isCollapsed={isCollapsed} />
<AskAI isCollapsed={isCollapsed} />
@@ -909,7 +1092,7 @@ function AnimatedChevron({
// 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) {
@@ -922,7 +1105,7 @@ function AnimatedChevron({
};
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)
@@ -938,7 +1121,7 @@ function AnimatedChevron({
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"
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 transition-colors group-hover:text-text-bright"
initial={false}
animate={{
x: getTranslateX(),
@@ -981,22 +1164,18 @@ function AnimatedChevron({
);
}
function CollapseToggle({
isCollapsed,
onToggle,
}: {
isCollapsed: boolean;
onToggle: () => void;
}) {
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"
)} />
<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>
@@ -17,6 +17,7 @@ export function SideMenuItem({
badge,
target,
isCollapsed = false,
action,
}: {
icon?: RenderIcon;
activeIconColor?: string;
@@ -28,59 +29,84 @@ export function SideMenuItem({
badge?: ReactNode;
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
isCollapsed?: boolean;
action?: ReactNode;
}) {
const pathName = usePathName();
const isActive = pathName === to;
return (
<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"
)}
/>
const link = (
<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 group-hover/menuitem: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 select-none text-2sm">{name}</span>
{badge && !isCollapsed && (
<motion.div
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
className="ml-1 flex shrink-0 items-center gap-1"
initial={false}
animate={{
width: isCollapsed ? 0 : "auto",
opacity: isCollapsed ? 0 : 1,
opacity: 1,
}}
transition={{ duration: 0.2, ease: "easeOut" }}
transition={{ duration: 0.15, 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)}
/>
)}
{badge}
</motion.div>
</Link>
}
)}
{trailingIcon && !isCollapsed && (
<Icon
icon={trailingIcon}
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
/>
)}
</motion.div>
</Link>
);
if (action) {
return (
<div className="group/menuitem relative h-8 w-full">
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
buttonClassName="!h-8 block w-full"
hidden={!isCollapsed}
asChild
disableHoverableContent
/>
{!isCollapsed && (
<div className="absolute top-1 right-1 bottom-1 flex aspect-square items-center justify-center rounded group-hover/menuitem:bg-charcoal-750">
{action}
</div>
)}
</div>
);
}
return (
<SimpleTooltip
button={link}
content={name}
side="right"
sideOffset={8}
@@ -10,6 +10,8 @@ type Props = {
/** When true, hides the section header and shows only children */
isSideMenuCollapsed?: boolean;
itemSpacingClassName?: string;
/** Optional action element (e.g., + button) to render on the right side of the header */
headerAction?: React.ReactNode;
};
/** A collapsible section for the side menu
@@ -22,6 +24,7 @@ export function SideMenuSection({
children,
isSideMenuCollapsed = false,
itemSpacingClassName = "space-y-px",
headerAction,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
@@ -37,23 +40,26 @@ export function SideMenuSection({
<div className="relative w-full">
{/* Header - fades out when sidebar is collapsed */}
<motion.div
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" }}
className="group/section flex cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 transition hover:bg-charcoal-750"
initial={false}
animate={{
opacity: isSideMenuCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeOut" }}
onClick={isSideMenuCollapsed ? undefined : handleToggle}
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
>
<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>
<div className="flex items-center gap-1 text-text-dimmed transition group-hover/section:text-text-bright">
<h2 className="whitespace-nowrap text-xs">{title}</h2>
<motion.div
initial={isCollapsed}
animate={{ rotate: isCollapsed ? -90 : 0 }}
transition={{ duration: 0.2 }}
>
<ToggleArrowIcon className="size-2" />
</motion.div>
</div>
{headerAction && <div className="flex items-center">{headerAction}</div>}
</motion.div>
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
<motion.div
@@ -0,0 +1,29 @@
import { cn } from "~/utils/cn";
// Tree connector icons for sub-items. The SVG viewBox is 20x20 matching the size-5 icon area.
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
export function TreeConnectorBranch({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
export function TreeConnectorEnd({ className }: { className?: string }) {
return (
<svg
className={cn("overflow-visible", className, "text-charcoal-600")}
viewBox="0 0 20 20"
fill="none"
>
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
</svg>
);
}
@@ -0,0 +1,7 @@
import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum(["manage", "metrics"]);
// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
@@ -0,0 +1,129 @@
import { useFetcher } from "@remix-run/react";
import { type Ref, useCallback, useEffect, useMemo, useState } from "react";
import { type Layout, useContainerWidth } from "react-grid-layout";
/**
* Generic hook for managing a reorderable list in the side menu.
*
* Handles order state, sorting, grid layout, drag callbacks, and persistence
* via the `/resources/preferences/sidemenu` resource route.
*
* @param organizationId - Organization ID for scoping the persisted order
* @param listId - Identifier for this list (e.g. "customDashboards")
* @param items - The items to reorder
* @param itemKey - Extract a stable string key from each item
* @param initialOrder - Initial order from stored preferences (if any)
* @param isImpersonating - Skip persistence when impersonating
*/
export function useReorderableList<T>({
organizationId,
listId,
items,
itemKey,
initialOrder,
isImpersonating,
}: {
organizationId: string;
listId: string;
items: T[];
itemKey: (item: T) => string;
initialOrder: string[] | undefined;
isImpersonating: boolean;
}) {
const orderFetcher = useFetcher();
const [order, setOrder] = useState<string[]>(
() => initialOrder ?? items.map(itemKey)
);
// Sync order when organizationId changes (component may not remount)
useEffect(() => {
setOrder(initialOrder ?? items.map(itemKey));
}, [organizationId]);
// Sort items by stored order, new items go to end
const orderedItems = useMemo(() => {
const orderMap = new Map(order.map((id, i) => [id, i]));
return [...items].sort((a, b) => {
const aIdx = orderMap.get(itemKey(a)) ?? Infinity;
const bIdx = orderMap.get(itemKey(b)) ?? Infinity;
return aIdx - bIdx;
});
}, [items, order, itemKey]);
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
const layout = useMemo(
() =>
orderedItems.map((item, i) => ({
i: itemKey(item),
x: 0,
y: i,
w: 1,
h: 1,
})),
[orderedItems, itemKey]
);
// Width measurement for ReactGridLayout
const {
width: gridWidth,
containerRef,
mounted: gridMounted,
} = useContainerWidth({ initialWidth: 216 });
const canReorder = orderedItems.length >= 2;
// Track layout during drag for real-time visual updates
const [dragLayout, setDragLayout] = useState<Layout | null>(null);
const handleDrag = useCallback((layout: Layout) => {
setDragLayout(layout);
}, []);
// Handle drag stop - extract new order from layout y-positions
const handleDragStop = useCallback(
(layout: Layout) => {
setDragLayout(null);
const sorted = [...layout].sort((a, b) => a.y - b.y);
const newOrder = sorted.map((item) => item.i);
if (JSON.stringify(newOrder) === JSON.stringify(order)) return;
setOrder(newOrder);
// Persist immediately
if (!isImpersonating) {
const formData = new FormData();
formData.append("organizationId", organizationId);
formData.append("listId", listId);
formData.append("itemOrder", JSON.stringify(newOrder));
orderFetcher.submit(formData, {
method: "POST",
action: "/resources/preferences/sidemenu",
});
}
},
[order, organizationId, listId, isImpersonating, orderFetcher]
);
// Compute which item is visually last (during drag or at rest)
const getIsLast = useCallback(
(key: string, index: number) => {
if (dragLayout) {
const maxY = Math.max(...dragLayout.map((l) => l.y));
return dragLayout.find((l) => l.i === key)?.y === maxY;
}
return index === orderedItems.length - 1;
},
[dragLayout, orderedItems.length]
);
return {
orderedItems,
layout,
containerRef: containerRef as Ref<HTMLDivElement>,
gridWidth,
gridMounted,
canReorder,
handleDrag,
handleDragStop,
getIsLast,
};
}
@@ -27,6 +27,7 @@ type AppliedFilterProps = {
onRemove?: () => void;
variant?: Variant;
className?: string;
valueClassName?: string;
};
export function AppliedFilter({
@@ -37,6 +38,7 @@ export function AppliedFilter({
onRemove,
variant = "secondary/small",
className,
valueClassName,
}: AppliedFilterProps) {
const variantClassName = variants[variant];
return (
@@ -48,14 +50,18 @@ export function AppliedFilter({
className
)}
>
<div className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}>
<div
className={cn("flex items-start leading-4", label === undefined ? "gap-1.5" : "gap-0.5")}
>
<div className="-mt-[0.5px] flex items-center gap-1">
{icon}
{label && <div className="text-text-bright">
<span>{label}</span>:
</div>}
{label && (
<div className="text-text-bright">
<span>{label}</span>:
</div>
)}
</div>
<div className="text-text-dimmed">
<div className={cn("text-text-dimmed", valueClassName)}>
<div>{value}</div>
</div>
</div>
@@ -51,6 +51,7 @@ const ClientTabs = React.forwardRef<
<ClientTabsContext.Provider value={contextValue}>
<TabsPrimitive.Root
ref={ref}
activationMode="manual"
onValueChange={handleValueChange}
{...controlledProps}
{...props}
@@ -96,6 +97,7 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group relative flex h-full grow items-center justify-center focus-custom disabled:pointer-events-none disabled:opacity-50",
"flex-1 basis-0",
@@ -134,6 +136,7 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"group flex flex-col items-center pt-1 focus-custom disabled:pointer-events-none disabled:opacity-50",
className
@@ -143,7 +146,7 @@ const ClientTabsTrigger = React.forwardRef<
<span
className={cn(
"text-sm transition duration-200",
isActive ? "text-text-bright" : "text-text-dimmed hover:text-text-bright"
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
)}
>
{children}
@@ -170,8 +173,9 @@ const ClientTabsTrigger = React.forwardRef<
return (
<TabsPrimitive.Trigger
ref={ref}
tabIndex={0}
className={cn(
"ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap border-r border-charcoal-700 px-2 text-sm transition-all first:pl-0 last:border-none focus-custom data-[state=active]:text-indigo-500 data-[state=inactive]:text-text-dimmed data-[state=inactive]:hover:text-text-bright disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
@@ -188,9 +192,11 @@ const ClientTabsContent = React.forwardRef<
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
tabIndex={-1}
className={cn(
"ring-offset-background focus-visible:ring-ring mt-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
className
"mt-1 outline-none",
className,
"data-[state=inactive]:hidden"
)}
{...props}
/>
@@ -196,7 +196,11 @@ export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: Date
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
// Helper function to check if two dates are on the same day
@@ -270,14 +274,18 @@ const DateTimeAccurateInner = ({
return hideDate
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
if (!showTooltip)
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
const tooltipContent = (
<TooltipContent
@@ -290,7 +298,11 @@ const DateTimeAccurateInner = ({
return (
<SimpleTooltip
button={<span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>}
button={
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
}
content={tooltipContent}
side="right"
asChild={true}
@@ -326,9 +338,13 @@ function formatDateTimeAccurate(
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
const datePart = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
timeZone,
}).format(date);
const timePart = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
@@ -338,7 +354,7 @@ function formatDateTimeAccurate(
hour12,
}).format(date);
return formattedDateTime;
return `${datePart} ${timePart}`;
}
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
@@ -347,7 +363,11 @@ export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const realDate = typeof date === "string" ? new Date(date) : date;
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
return <span suppressHydrationWarning>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</span>;
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
function formatDateTimeShort(
@@ -7,7 +7,7 @@ export function FormButtons({
className,
}: {
cancelButton?: React.ReactNode;
confirmButton: React.ReactNode;
confirmButton?: React.ReactNode;
defaultAction?: { name: string; value: string; disabled?: boolean };
className?: string;
}) {
@@ -29,7 +29,7 @@ export function FormButtons({
aria-hidden="true"
/>
)}
{cancelButton ? cancelButton : <div />} {confirmButton}
{cancelButton ? cancelButton : <div />} {confirmButton ?? null}
</div>
);
}
@@ -1,13 +1,15 @@
import { AnimatePresence, useAnimate, usePresence } from "framer-motion";
import { useEffect } from "react";
import { cn } from "~/utils/cn";
type LoadingBarDividerProps = {
isLoading: boolean;
className?: string;
};
export function LoadingBarDivider({ isLoading }: LoadingBarDividerProps) {
export function LoadingBarDivider({ isLoading, className }: LoadingBarDividerProps) {
return (
<div className="relative h-px w-full overflow-hidden bg-grid-bright">
<div className={cn("relative h-px w-full overflow-hidden bg-grid-bright", className)}>
<AnimationDivider isLoading={isLoading} />
</div>
);
@@ -243,20 +243,41 @@ function PopoverArrowTrigger({
);
}
const popoverVerticalEllipseVariants = {
minimal: {
trigger:
"size-6 rounded-[3px] text-text-dimmed hover:bg-tertiary hover:text-text-bright",
icon: "size-5",
},
secondary: {
trigger:
"size-6 rounded border border-charcoal-600 bg-secondary text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550",
icon: "size-4",
},
} as const;
type PopoverVerticalEllipseVariant = keyof typeof popoverVerticalEllipseVariants;
function PopoverVerticalEllipseTrigger({
isOpen,
variant = "minimal",
className,
...props
}: { isOpen?: boolean } & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
}: {
isOpen?: boolean;
variant?: PopoverVerticalEllipseVariant;
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
const styles = popoverVerticalEllipseVariants[variant];
return (
<PopoverTrigger
{...props}
className={cn(
"group flex items-center justify-end gap-1 rounded-[3px] p-0.5 text-text-dimmed transition focus-custom hover:bg-tertiary hover:text-text-bright",
"group flex items-center justify-center transition focus-custom",
styles.trigger,
className
)}
>
<EllipsisVerticalIcon className={cn("size-5 transition group-hover:text-text-bright")} />
<EllipsisVerticalIcon className={cn(styles.icon, "transition")} />
</PopoverTrigger>
);
}
@@ -33,7 +33,7 @@ const ResizableHandle = ({
// 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:left-0 data-[handle-orientation=vertical]:after:top-1/2",
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
className
@@ -42,9 +42,9 @@ const ResizableHandle = ({
{...props}
>
{/* 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" />
<div className="absolute left-[0.0625rem] top-0 z-20 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:hidden" />
{/* Vertical orientation line indicator */}
<div className="absolute left-0 top-[0.0625rem] 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" />
<div className="absolute left-0 top-[0.0625rem] z-20 hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-indigo-500 group-data-[handle-orientation=vertical]:block" />
{withHandle && (
<>
{/* Horizontal orientation dots (vertical arrangement) */}
@@ -1,5 +1,6 @@
import { RadioGroup } from "@headlessui/react";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { cn } from "~/utils/cn";
const sizes = {
@@ -63,7 +64,7 @@ const variants = {
type VariantType = keyof typeof variants;
type Options = {
label: string;
label: ReactNode;
value: string;
};
@@ -8,12 +8,15 @@ import { cn } from "~/utils/cn";
import { useOperatingSystem } from "./OperatingSystemProvider";
import { KeyboardEnterIcon } from "~/assets/icons/KeyboardEnterIcon";
const small =
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border transition uppercase";
const medium =
"justify-center min-w-[1.25rem] min-h-[1.25rem] text-[0.65rem] font-mono font-medium rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1.5 border border-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-dimmed/60 transition uppercase";
export const variants = {
small:
"justify-center text-[0.6rem] font-mono font-medium min-w-[1rem] min-h-[1rem] rounded-[2px] tabular-nums px-1 ml-1 -mr-0.5 flex items-center gap-x-1 border border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60 transition uppercase",
small: cn(small, "border-text-dimmed/40 text-text-dimmed group-hover:text-text-bright/80 group-hover:border-text-dimmed/60"),
"small/bright": cn(small, "bg-charcoal-750 text-text-bright border-charcoal-650"),
medium: cn(medium, "group-hover:border-charcoal-550"),
"medium/bright": cn(medium, "bg-charcoal-750 text-text-bright border-charcoal-650"),
};
@@ -54,10 +57,10 @@ export function ShortcutKey({ shortcut, variant, className }: ShortcutKeyProps)
);
}
function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "medium/bright") {
function keyString(key: string, isMac: boolean, variant: ShortcutKeyVariant) {
key = key.toLowerCase();
const className = variant === "small" ? "w-2.5 h-4" : "w-2.5 h-4.5";
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-2.5 h-4.5";
switch (key) {
case "enter":
@@ -86,9 +89,9 @@ function keyString(key: string, isMac: boolean, variant: "small" | "medium" | "m
function modifierString(
modifier: Modifier,
isMac: boolean,
variant: "small" | "medium" | "medium/bright"
variant: ShortcutKeyVariant
): string | JSX.Element {
const className = variant === "small" ? "w-2.5 h-4" : "w-3.5 h-5";
const className = variant.startsWith("small") ? "w-2.5 h-4" : "w-3.5 h-5";
switch (modifier) {
case "alt":
@@ -87,7 +87,7 @@ function SimpleTooltip({
<TooltipTrigger
type="button"
tabIndex={-1}
className={cn("h-fit", buttonClassName)}
className={cn(!asChild && "h-fit", buttonClassName)}
style={buttonStyle}
asChild={asChild}
>
@@ -1,46 +0,0 @@
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import { Spinner } from "../Spinner";
interface BigNumberProps {
animate?: boolean;
loading?: boolean;
value?: number;
valueClassName?: string;
defaultValue?: number;
suffix?: string;
suffixClassName?: string;
}
export function BigNumber({
value,
defaultValue,
valueClassName,
suffix,
suffixClassName,
animate = false,
loading = false,
}: BigNumberProps) {
const v = value ?? defaultValue;
return (
<div
className={cn(
"h-full text-[3.75rem] font-normal tabular-nums leading-none text-text-bright",
valueClassName
)}
>
{loading ? (
<div className="grid h-full place-items-center">
<Spinner className="size-6" />
</div>
) : v !== undefined ? (
<div className="flex items-baseline gap-1">
{animate ? <AnimatedNumber value={v} /> : v}
{suffix && <div className={cn("text-xs", suffixClassName)}>{suffix}</div>}
</div>
) : (
""
)}
</div>
);
}
@@ -0,0 +1,191 @@
import type { ColumnFormatType, OutputColumnMetadata } from "@internal/tsql";
import { Hash } from "lucide-react";
import { useMemo } from "react";
import type {
BigNumberAggregationType,
BigNumberConfiguration,
} from "~/components/metrics/QueryWidget";
import { createValueFormatter } from "~/utils/columnFormat";
import { AnimatedNumber } from "../AnimatedNumber";
import { ChartBlankState } from "./ChartBlankState";
import { Spinner } from "../Spinner";
interface BigNumberCardProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: BigNumberConfiguration;
isLoading?: boolean;
}
/**
* Extracts numeric values from a specific column across all rows,
* optionally sorting them first.
*/
function extractColumnValues(
rows: Record<string, unknown>[],
column: string,
sortDirection?: "asc" | "desc"
): number[] {
const values: number[] = [];
const sortedRows = sortDirection
? [...rows].sort((a, b) => {
const aVal = toNumber(a[column]);
const bVal = toNumber(b[column]);
return sortDirection === "asc" ? aVal - bVal : bVal - aVal;
})
: rows;
for (const row of sortedRows) {
const val = row[column];
if (typeof val === "number") {
values.push(val);
} else if (typeof val === "string") {
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
values.push(parsed);
}
}
}
return values;
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: BigNumberAggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
case "first":
return values[0];
case "last":
return values[values.length - 1];
}
}
/**
* Computes the display value and unit suffix for abbreviated display.
* Returns the divided-down number (e.g. 1.5 for 1500) and the suffix (e.g. "K"),
* along with the appropriate decimal places for formatting.
*/
function abbreviateValue(value: number): {
displayValue: number;
unitSuffix?: string;
decimalPlaces: number;
} {
if (Math.abs(value) >= 1_000_000_000) {
const v = value / 1_000_000_000;
return { displayValue: v, unitSuffix: "B", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000_000) {
const v = value / 1_000_000;
return { displayValue: v, unitSuffix: "M", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
if (Math.abs(value) >= 1_000) {
const v = value / 1_000;
return { displayValue: v, unitSuffix: "K", decimalPlaces: v % 1 === 0 ? 0 : 1 };
}
return { displayValue: value, decimalPlaces: getDecimalPlaces(value) };
}
/**
* Determines decimal places for plain (non-abbreviated) display.
*/
function getDecimalPlaces(value: number): number {
if (Number.isInteger(value)) return 0;
const abs = Math.abs(value);
if (abs >= 100) return 0;
if (abs >= 10) return 1;
if (abs >= 1) return 2;
if (abs >= 0.01) return 3;
return 4;
}
export function BigNumberCard({ rows, columns, config, isLoading = false }: BigNumberCardProps) {
const { column, aggregation, sortDirection, abbreviate = true, prefix, suffix } = config;
const result = useMemo(() => {
if (rows.length === 0) return null;
const values = extractColumnValues(rows, column, sortDirection);
if (values.length === 0) return null;
return aggregateValues(values, aggregation);
}, [rows, column, aggregation, sortDirection]);
// Look up column format for format-aware display
const columnValueFormatter = useMemo(() => {
const columnMeta = columns.find((c) => c.name === column);
const formatType = (columnMeta?.format ?? columnMeta?.customRenderType) as
| ColumnFormatType
| undefined;
return createValueFormatter(formatType);
}, [columns, column]);
if (isLoading) {
return (
<div className="grid h-full place-items-center [container-type:size]">
<Spinner className="size-6" />
</div>
);
}
if (result === null) {
return <ChartBlankState icon={Hash} message="No data to display" />;
}
// Use format-aware formatter when available
if (columnValueFormatter) {
return (
<div className="h-full w-full [container-type:size]">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<span>{columnValueFormatter(result)}</span>
{suffix && <span className="text-[0.4em] text-text-dimmed">{suffix}</span>}
</div>
</div>
</div>
);
}
const { displayValue, unitSuffix, decimalPlaces } = abbreviate
? abbreviateValue(result)
: { displayValue: result, unitSuffix: undefined, decimalPlaces: getDecimalPlaces(result) };
return (
<div className="h-full w-full [container-type:size]">
<div className="grid h-full w-full place-items-center">
<div className="flex items-baseline gap-[0.15em] whitespace-nowrap text-[clamp(24px,12cqw,96px)] font-normal tabular-nums leading-none text-text-bright">
{prefix && <span>{prefix}</span>}
<AnimatedNumber value={displayValue} decimalPlaces={decimalPlaces} />
{(unitSuffix || suffix) && (
<span className="text-[0.4em] text-text-dimmed">
{unitSuffix}
{unitSuffix && suffix ? " " : ""}
{suffix}
</span>
)}
</div>
</div>
</div>
);
}
@@ -15,9 +15,16 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
);
};
const CardHeader = ({ children }: { children: ReactNode }) => {
const CardHeader = ({ children, draggable }: { children: ReactNode; draggable?: boolean }) => {
return (
<Header3 className="mb-3 flex items-center justify-between gap-2 px-3">{children}</Header3>
<Header3
className={cn(
"drag-handle mb-3 flex items-center justify-between gap-2 pl-4 pr-3",
draggable && "cursor-grab active:cursor-grabbing"
)}
>
{children}
</Header3>
);
};
@@ -104,6 +104,8 @@ const ChartTooltipContent = React.forwardRef<
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
}
>(
(
@@ -121,6 +123,7 @@ const ChartTooltipContent = React.forwardRef<
color,
nameKey,
labelKey,
valueFormatter,
},
ref
) => {
@@ -221,9 +224,11 @@ const ChartTooltipContent = React.forwardRef<
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
{item.value != null && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
{valueFormatter && typeof item.value === "number"
? valueFormatter(item.value)
: item.value.toLocaleString()}
</span>
)}
</div>
@@ -1,9 +1,8 @@
import React, { useCallback, useMemo } from "react";
import React, { useCallback } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ReferenceArea,
ReferenceLine,
XAxis,
@@ -11,20 +10,11 @@ import {
type XAxisProps,
type YAxisProps,
} from "recharts";
import {
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
type ChartState,
} from "~/components/primitives/charts/Chart";
import { cn } from "~/utils/cn";
import { ChartBarLoading, ChartBarInvalid, ChartBarNoData } from "./ChartLoading";
import { ChartTooltip, ChartTooltipContent } from "~/components/primitives/charts/Chart";
import { useChartContext } from "./ChartContext";
import { ChartRoot, useHasNoData } from "./ChartRoot";
// Legend is now rendered by ChartRoot outside the chart container
import { ChartBarInvalid, ChartBarLoading, ChartBarNoData } from "./ChartLoading";
import { useHasNoData } from "./ChartRoot";
import { ZoomTooltip, useZoomHandlers } from "./ChartZoom";
import { getBarOpacity } from "./hooks/useHighlightState";
import type { ZoomRange } from "./hooks/useZoomSelection";
//TODO: fix the first and last bars in a stack not having rounded corners
@@ -48,6 +38,8 @@ export type ChartBarRendererProps = {
referenceLine?: ReferenceLineProps;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
@@ -72,10 +64,11 @@ export function ChartBarRenderer({
yAxisProps: yAxisPropsProp,
referenceLine,
tooltipLabelFormatter,
tooltipValueFormatter,
width,
height,
}: ChartBarRendererProps) {
const { config, data, dataKey, dataKeys, state, highlight, zoom, showLegend } = useChartContext();
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, zoom, showLegend } = useChartContext();
const hasNoData = useHasNoData();
const zoomHandlers = useZoomHandlers();
const enableZoom = zoom !== null;
@@ -123,9 +116,8 @@ export function ChartBarRenderer({
onMouseDown={zoomHandlers.onMouseDown}
onMouseMove={(e: any) => {
zoomHandlers.onMouseMove?.(e);
// Update active payload for legend
if (e?.activePayload?.length) {
highlight.setActivePayload(e.activePayload);
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
} else {
highlight.setTooltipActive(false);
@@ -162,26 +154,27 @@ export function ChartBarRenderer({
domain={["auto", (dataMax: number) => dataMax * 1.15]}
{...yAxisPropsProp}
/>
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
{!showLegend && (
<ChartTooltip
cursor={{ fill: "#2C3034" }}
content={
tooltipLabelFormatter ? (
<ChartTooltipContent />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
refAreaLeft={zoom?.refAreaLeft}
refAreaRight={zoom?.refAreaRight}
invalidSelection={zoom?.invalidSelection}
/>
)
}
labelFormatter={tooltipLabelFormatter}
allowEscapeViewBox={{ x: false, y: true }}
/>
)}
{/* When legend is shown below the chart, render tooltip with cursor only (no content popup).
Otherwise render the full tooltip with zoom instructions. */}
<ChartTooltip
cursor={{ fill: "rgba(255, 255, 255, 0.06)" }}
content={
showLegend ? (
() => null
) : tooltipLabelFormatter ? (
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
) : (
<ZoomTooltip
isSelecting={zoom?.isSelecting}
refAreaLeft={zoom?.refAreaLeft}
refAreaRight={zoom?.refAreaRight}
invalidSelection={zoom?.invalidSelection}
/>
)
}
labelFormatter={tooltipLabelFormatter}
allowEscapeViewBox={{ x: false, y: true }}
/>
{/* Zoom selection area - rendered before bars to appear behind them */}
{enableZoom && zoom?.refAreaLeft !== null && zoom?.refAreaRight !== null && (
@@ -194,7 +187,12 @@ export function ChartBarRenderer({
/>
)}
{dataKeys.map((key, index, array) => {
{visibleSeries.map((key, index, array) => {
const dimmed =
!zoom?.isSelecting &&
highlight.activeBarKey !== null &&
highlight.activeBarKey !== key;
return (
<Bar
key={key}
@@ -210,7 +208,7 @@ export function ChartBarRenderer({
] as [number, number, number, number]
}
activeBar={false}
fillOpacity={1}
fillOpacity={dimmed ? 0.2 : 1}
onClick={(data, index, e) => handleBarClick(data, e)}
onMouseEnter={(entry, index) => {
if (entry.tooltipPayload?.[0]) {
@@ -220,20 +218,7 @@ export function ChartBarRenderer({
}}
onMouseLeave={highlight.reset}
isAnimationActive={false}
>
{data.map((_, dataIndex) => {
// Don't dim bars during zoom selection
const opacity = zoom?.isSelecting ? 1 : getBarOpacity(key, dataIndex, highlight);
return (
<Cell
key={`cell-${key}-${dataIndex}`}
fill={config[key]?.color}
fillOpacity={opacity}
/>
);
})}
</Bar>
/>
);
})}
@@ -0,0 +1,23 @@
import { cn } from "~/utils/cn";
import { Paragraph } from "../Paragraph";
export function ChartBlankState({
icon: Icon,
message,
className,
}: {
icon?: React.ComponentType<{ className?: string }>;
message: string;
className?: string;
}) {
return (
<div className={cn("flex h-full w-full items-center justify-center", className)}>
<div className="-mt-3 flex flex-col items-center gap-2">
{Icon && <Icon className="size-12 text-charcoal-700" />}
<Paragraph variant="small" className="text-text-dimmed/70">
{message}
</Paragraph>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useMemo } from "react";
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import type { ChartConfig, ChartState } from "./Chart";
import { useHighlightState, type UseHighlightStateReturn } from "./hooks/useHighlightState";
import {
@@ -17,6 +17,8 @@ export type ChartContextValue = {
dataKey: string;
/** Computed series keys (all config keys except dataKey) */
dataKeys: string[];
/** Subset of dataKeys actually rendered as SVG elements (defaults to dataKeys) */
visibleSeries: string[];
// Display state
state?: ChartState;
@@ -25,9 +27,12 @@ export type ChartContextValue = {
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
labelFormatter?: LabelFormatter;
// Highlight state
// Highlight state (does NOT include activePayload — see PayloadContext)
highlight: UseHighlightStateReturn;
/** Update the active payload for the legend. Pass tooltipIndex to skip redundant updates. */
setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void;
// Zoom state (only present when zoom is enabled)
zoom: UseZoomSelectionReturn | null;
@@ -40,6 +45,12 @@ export type ChartContextValue = {
const ChartCompoundContext = createContext<ChartContextValue | null>(null);
/**
* Separate context for activePayload so that frequent payload updates
* only re-render the legend, not the entire chart (bars, lines, etc.).
*/
const PayloadContext = createContext<any[] | null>(null);
export function useChartContext(): ChartContextValue {
const context = useContext(ChartCompoundContext);
if (!context) {
@@ -48,12 +59,19 @@ export function useChartContext(): ChartContextValue {
return context;
}
/** Read the active payload (only re-renders when payload changes). */
export function useActivePayload(): any[] | null {
return useContext(PayloadContext);
}
export type ChartProviderProps = {
config: ChartConfig;
data: any[];
dataKey: string;
/** Series keys to render (if not provided, derived from config keys) */
series?: string[];
/** Subset of series to render as SVG elements on the chart (legend still shows all series) */
visibleSeries?: string[];
state?: ChartState;
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
labelFormatter?: LabelFormatter;
@@ -71,6 +89,7 @@ export function ChartProvider({
data,
dataKey,
series,
visibleSeries: visibleSeriesProp,
state,
labelFormatter,
enableZoom = false,
@@ -81,27 +100,67 @@ export function ChartProvider({
const highlight = useHighlightState();
const zoomState = useZoomSelection();
// activePayload lives in its own state + context so updates don't re-render bars
const [activePayload, setActivePayloadRaw] = useState<any[] | null>(null);
const activeTooltipIndexRef = useRef<number | null>(null);
const setActivePayload = useCallback(
(payload: any[] | null, tooltipIndex?: number | null) => {
const idx = tooltipIndex ?? null;
if (idx !== null && idx === activeTooltipIndexRef.current) {
return;
}
activeTooltipIndexRef.current = idx;
setActivePayloadRaw(payload);
},
[]
);
// Reset the tooltip index ref when highlight resets (mouse leaves chart)
const originalReset = highlight.reset;
const resetWithPayload = useCallback(() => {
activeTooltipIndexRef.current = null;
setActivePayloadRaw(null);
originalReset();
}, [originalReset]);
const highlightWithReset = useMemo(
() => ({ ...highlight, reset: resetWithPayload }),
[highlight, resetWithPayload]
);
// Compute series keys (use provided series or derive from config)
const dataKeys = useMemo(
() => series ?? Object.keys(config).filter((k) => k !== dataKey),
[series, config, dataKey]
);
const visibleSeries = useMemo(
() => visibleSeriesProp ?? dataKeys,
[visibleSeriesProp, dataKeys]
);
const value = useMemo<ChartContextValue>(
() => ({
config,
data,
dataKey,
dataKeys,
visibleSeries,
state,
labelFormatter,
highlight,
highlight: highlightWithReset,
setActivePayload,
zoom: enableZoom ? zoomState : null,
onZoomChange: enableZoom ? onZoomChange : undefined,
showLegend,
}),
[config, data, dataKey, dataKeys, state, labelFormatter, highlight, zoomState, enableZoom, onZoomChange, showLegend]
[config, data, dataKey, dataKeys, visibleSeries, state, labelFormatter, highlightWithReset, setActivePayload, zoomState, enableZoom, onZoomChange, showLegend]
);
return <ChartCompoundContext.Provider value={value}>{children}</ChartCompoundContext.Provider>;
return (
<ChartCompoundContext.Provider value={value}>
<PayloadContext.Provider value={activePayload}>{children}</PayloadContext.Provider>
</ChartCompoundContext.Provider>
);
}
@@ -1,10 +1,19 @@
import React, { useMemo } from "react";
import { useChartContext } from "./ChartContext";
import type { AggregationType } from "~/components/metrics/QueryWidget";
import { useActivePayload, useChartContext } from "./ChartContext";
import { useSeriesTotal } from "./ChartRoot";
import { Button } from "../Buttons";
import { Paragraph } from "../Paragraph";
import { aggregateValues } from "./aggregation";
import { cn } from "~/utils/cn";
import { AnimatedNumber } from "../AnimatedNumber";
import { SimpleTooltip } from "../Tooltip";
const aggregationLabels: Record<AggregationType, string> = {
sum: "Sum",
avg: "Average",
count: "Count",
min: "Min",
max: "Max",
};
export type ChartLegendCompoundProps = {
/** Maximum number of legend items to show before collapsing */
@@ -13,8 +22,12 @@ export type ChartLegendCompoundProps = {
hidden?: boolean;
/** Additional className */
className?: string;
/** Label for the total row */
/** Label for the total row (derived from aggregation when not provided) */
totalLabel?: string;
/** Aggregation method controls the header label and how totals are computed */
aggregation?: AggregationType;
/** Optional formatter for numeric values (e.g. bytes, duration) */
valueFormatter?: (value: number) => string;
/** Callback when "View all" button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
@@ -37,64 +50,90 @@ export function ChartLegendCompound({
maxItems = Infinity,
hidden = false,
className,
totalLabel = "Total",
totalLabel,
aggregation,
valueFormatter,
onViewAllLegendItems,
scrollable = false,
}: ChartLegendCompoundProps) {
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
const totals = useSeriesTotal();
const activePayload = useActivePayload();
const totals = useSeriesTotal(aggregation);
// Calculate grand total (sum of all series totals)
// Derive the effective label from the aggregation type when no explicit label is provided
const effectiveTotalLabel = totalLabel ?? (aggregation ? aggregationLabels[aggregation] : "Total");
// Calculate grand total by aggregating across all per-series values
const grandTotal = useMemo(() => {
return dataKeys.reduce((sum, key) => sum + (totals[key] || 0), 0);
}, [totals, dataKeys]);
const values = dataKeys.map((key) => totals[key] || 0);
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [totals, dataKeys, aggregation]);
// Calculate current total based on hover state
const currentTotal = useMemo(() => {
if (!highlight.activePayload?.length) return grandTotal;
// Calculate current total based on hover state (null when hovering a gap-filled point)
const currentTotal = useMemo((): number | null => {
if (!activePayload?.length) return grandTotal;
// Sum all values from the hovered data point
return highlight.activePayload.reduce((sum, item) => {
if (item.value !== undefined && dataKeys.includes(item.dataKey as string)) {
return sum + (Number(item.value) || 0);
}
return sum;
}, 0);
}, [highlight.activePayload, grandTotal, dataKeys]);
// Collect all series values from the hovered data point, preserving nulls
const rawValues = activePayload
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
.map((item) => item.value);
// Get the label for the total row - x-axis value when hovering, totalLabel otherwise
// Filter to non-null values only
const values = rawValues
.filter((v): v is number => v != null)
.map((v) => Number(v) || 0);
// All null → gap-filled point, return null to show dash
if (values.length === 0) return null;
if (!aggregation) {
// Default: sum
return values.reduce((a, b) => a + b, 0);
}
return aggregateValues(values, aggregation);
}, [activePayload, grandTotal, dataKeys, aggregation]);
// Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise
const currentTotalLabel = useMemo(() => {
if (!highlight.activePayload?.length) return totalLabel;
if (!activePayload?.length) return effectiveTotalLabel;
// Get the x-axis label from the payload's original data
const firstPayloadItem = highlight.activePayload[0];
const firstPayloadItem = activePayload[0];
const xAxisValue = firstPayloadItem?.payload?.[dataKey];
if (xAxisValue === undefined) return totalLabel;
if (xAxisValue === undefined) return effectiveTotalLabel;
// Apply the formatter if provided, otherwise just stringify the value
const stringValue = String(xAxisValue);
return labelFormatter ? labelFormatter(stringValue) : stringValue;
}, [highlight.activePayload, dataKey, totalLabel, labelFormatter]);
}, [activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
// Get current data for the legend based on hover state
const currentData = useMemo(() => {
if (!highlight.activePayload?.length) return totals;
// Get current data for the legend based on hover state (values may be null for gap-filled points)
const currentData = useMemo((): Record<string, number | null> => {
if (!activePayload?.length) return totals;
// If we have activePayload data from hovering over a bar
const hoverData = highlight.activePayload.reduce((acc, item) => {
if (item.dataKey && item.value !== undefined) {
acc[item.dataKey] = Number(item.value) || 0;
}
return acc;
}, {} as Record<string, number>);
// If we have activePayload data from hovering over a bar/line
const hoverData = activePayload.reduce(
(acc, item) => {
if (item.dataKey && item.value !== undefined) {
// Preserve null for gap-filled points instead of coercing to 0
acc[item.dataKey] = item.value != null ? Number(item.value) || 0 : null;
}
return acc;
},
{} as Record<string, number | null>
);
// Return a merged object - totals for keys not in the hover data
return {
...totals,
...hoverData,
};
}, [highlight.activePayload, totals]);
}, [activePayload, totals]);
// Prepare legend items with capped display
const legendItems = useMemo(() => {
@@ -128,15 +167,11 @@ export function ChartLegendCompound({
return null;
}
const isHovering = (highlight.activePayload?.length ?? 0) > 0;
const isHovering = (activePayload?.length ?? 0) > 0;
return (
<div
className={cn(
"flex flex-col pt-4 text-sm",
scrollable && "max-h-[50%] min-h-0",
className
)}
className={cn("flex flex-col px-2 pb-2 pt-4 text-sm", scrollable && "max-h-[50%] min-h-0", className)}
>
{/* Total row */}
<div
@@ -147,7 +182,15 @@ export function ChartLegendCompound({
>
<span className="font-medium">{currentTotalLabel}</span>
<span className="font-medium tabular-nums">
<AnimatedNumber value={currentTotal} duration={0.25} />
{currentTotal != null ? (
valueFormatter ? (
valueFormatter(currentTotal)
) : (
<AnimatedNumber value={currentTotal} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
@@ -155,17 +198,23 @@ export function ChartLegendCompound({
<div className="mx-2 my-1 shrink-0 border-t border-charcoal-750" />
{/* 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")}>
<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 total = currentData[item.dataKey] ?? null;
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"
"relative flex w-full cursor-default items-center justify-between gap-2 rounded px-2 py-1 transition",
(total == null || total === 0) && "opacity-50"
)}
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
onMouseLeave={() => highlight.reset()}
@@ -177,25 +226,47 @@ export function ChartLegendCompound({
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>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<SimpleTooltip
button={
<div className="flex min-w-0 items-center gap-1.5">
{item.color && (
<div
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
<span
className={cn(
"truncate",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
{item.label}
</span>
</div>
}
content={item.label}
side="top"
disableHoverableContent
className="max-w-xs break-words"
buttonClassName="cursor-default min-w-0"
/>
<span
className={cn(
"self-start tabular-nums",
isActive ? "text-text-bright" : "text-text-dimmed"
)}
>
<AnimatedNumber value={total} duration={0.25} />
{total != null ? (
valueFormatter ? (
valueFormatter(total)
) : (
<AnimatedNumber value={total} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
</div>
@@ -207,11 +278,15 @@ export function ChartLegendCompound({
(legendItems.hoveredHiddenItem ? (
<HoveredHiddenItemRow
item={legendItems.hoveredHiddenItem}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? null}
remainingCount={legendItems.remaining - 1}
valueFormatter={valueFormatter}
/>
) : (
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
<ViewAllDataRow
remainingCount={legendItems.remaining}
onViewAll={onViewAllLegendItems}
/>
))}
</div>
</div>
@@ -225,33 +300,37 @@ type ViewAllDataRowProps = {
function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
return (
<Button
variant="minimal/small"
fullWidth
iconSpacing="justify-between"
className="px-2 py-1"
<div
className="relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition hover:bg-charcoal-850"
onClick={onViewAll}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onViewAll?.();
}
}}
>
<div className="flex items-center gap-1.5 text-text-dimmed">
<div className="h-3 w-1 rounded-[2px] border border-charcoal-600" />
<Paragraph variant="extra-small" className="tabular-nums">
{remainingCount} more
</Paragraph>
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<div className="w-1 shrink-0 self-stretch rounded-[2px] border border-charcoal-600" />
<span className="text-text-dimmed tabular-nums">{remainingCount} more</span>
</div>
<span className="self-start text-indigo-500">View all</span>
</div>
<Paragraph variant="extra-small" className="text-indigo-500">
View all
</Paragraph>
</Button>
</div>
);
}
type HoveredHiddenItemRowProps = {
item: { dataKey: string; color?: string; label: React.ReactNode };
value: number;
value: number | null;
remainingCount: number;
valueFormatter?: (value: number) => string;
};
function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItemRowProps) {
function HoveredHiddenItemRow({ item, value, remainingCount, valueFormatter }: HoveredHiddenItemRowProps) {
return (
<div className="relative flex w-full items-center justify-between gap-2 rounded px-2 py-1">
{/* Active highlight background */}
@@ -261,11 +340,11 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
style={{ backgroundColor: item.color }}
/>
)}
<div className="relative flex w-full items-center justify-between gap-2">
<div className="relative flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
{item.color && (
<div
className="h-3 w-1 shrink-0 rounded-[2px]"
className="w-1 shrink-0 self-stretch rounded-[2px]"
style={{ backgroundColor: item.color }}
/>
)}
@@ -273,7 +352,15 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem
{remainingCount > 0 && <span className="text-text-dimmed">+{remainingCount} more</span>}
</div>
<span className="tabular-nums text-text-bright">
<AnimatedNumber value={value} duration={0.25} />
{value != null ? (
valueFormatter ? (
valueFormatter(value)
) : (
<AnimatedNumber value={value} duration={0.25} />
)
) : (
"\u2013"
)}
</span>
</div>
</div>
@@ -51,6 +51,8 @@ export type ChartLineRendererProps = {
stacked?: boolean;
/** Custom tooltip label formatter */
tooltipLabelFormatter?: (label: string, payload: any[]) => string;
/** Optional formatter for numeric tooltip values (e.g. bytes, duration) */
tooltipValueFormatter?: (value: number) => string;
/** Width injected by ResponsiveContainer */
width?: number;
/** Height injected by ResponsiveContainer */
@@ -75,10 +77,11 @@ export function ChartLineRenderer({
yAxisProps: yAxisPropsProp,
stacked = false,
tooltipLabelFormatter,
tooltipValueFormatter,
width,
height,
}: ChartLineRendererProps) {
const { config, data, dataKey, dataKeys, state, highlight, showLegend } = useChartContext();
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, showLegend } = useChartContext();
const hasNoData = useHasNoData();
// Render loading/error states
@@ -130,7 +133,7 @@ export function ChartLineRenderer({
};
// Render stacked area chart if stacked prop is true
if (stacked && dataKeys.length > 1) {
if (stacked && visibleSeries.length > 1) {
return (
<AreaChart
data={data}
@@ -142,9 +145,8 @@ export function ChartLineRenderer({
right: 12,
}}
onMouseMove={(e: any) => {
// Update active payload for legend
if (e?.activePayload?.length) {
highlight.setActivePayload(e.activePayload);
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
} else {
highlight.setTooltipActive(false);
@@ -155,16 +157,20 @@ export function ChartLineRenderer({
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
{!showLegend && (
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={tooltipLabelFormatter}
/>
)}
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={
showLegend ? (
() => null
) : (
<ChartTooltipContent indicator="line" valueFormatter={tooltipValueFormatter} />
)
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{dataKeys.map((key) => (
{visibleSeries.map((key) => (
<Area
key={key}
type={lineType}
@@ -188,13 +194,13 @@ export function ChartLineRenderer({
width={width}
height={height}
margin={{
top: 5,
left: 12,
right: 12,
}}
onMouseMove={(e: any) => {
// Update active payload for legend
if (e?.activePayload?.length) {
highlight.setActivePayload(e.activePayload);
setActivePayload(e.activePayload, e.activeTooltipIndex);
highlight.setTooltipActive(true);
} else {
highlight.setTooltipActive(false);
@@ -205,23 +211,27 @@ export function ChartLineRenderer({
<CartesianGrid vertical={false} stroke="#272A2E" strokeDasharray="3 3" />
<XAxis {...xAxisConfig} />
<YAxis {...yAxisConfig} />
{/* Hide tooltip when legend is shown - legend displays hover data instead */}
{!showLegend && (
<ChartTooltip
cursor={false}
content={<ChartTooltipContent />}
labelFormatter={tooltipLabelFormatter}
/>
)}
{/* When legend is shown below, render tooltip with cursor only (no content popup) */}
<ChartTooltip
cursor={{ stroke: "rgba(255, 255, 255, 0.1)", strokeWidth: 1 }}
content={
showLegend ? (
() => null
) : (
<ChartTooltipContent valueFormatter={tooltipValueFormatter} />
)
}
labelFormatter={tooltipLabelFormatter}
/>
{/* Note: Legend is now rendered by ChartRoot outside the chart container */}
{dataKeys.map((key) => (
{visibleSeries.map((key) => (
<Line
key={key}
dataKey={key}
type={lineType}
stroke={config[key]?.color}
strokeWidth={1}
dot={false}
dot={{ r: 1.5, fill: config[key]?.color, strokeWidth: 0 }}
activeDot={{ r: 4 }}
isAnimationActive={false}
/>
@@ -1,5 +1,6 @@
import React, { useMemo } from "react";
import type * as RechartsPrimitive from "recharts";
import type { AggregationType } from "~/components/metrics/QueryWidget";
import { ChartContainer, type ChartConfig, type ChartState } from "./Chart";
import { ChartProvider, useChartContext, type LabelFormatter } from "./ChartContext";
import { ChartLegendCompound } from "./ChartLegendCompound";
@@ -12,6 +13,8 @@ export type ChartRootProps = {
dataKey: string;
/** Series keys to render (if not provided, derived from config keys) */
series?: string[];
/** Subset of series to render as SVG elements on the chart (legend still shows all series) */
visibleSeries?: string[];
state?: ChartState;
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
labelFormatter?: LabelFormatter;
@@ -29,12 +32,18 @@ export type ChartRootProps = {
maxLegendItems?: number;
/** Label for the total row in the legend */
legendTotalLabel?: string;
/** Aggregation method used by the legend to compute totals (defaults to sum behavior) */
legendAggregation?: AggregationType;
/** Optional formatter for numeric legend values (e.g. bytes, duration) */
legendValueFormatter?: (value: number) => string;
/** Callback when "View all" legend button is clicked */
onViewAllLegendItems?: () => void;
/** When true, constrains legend to max 50% height with scrolling */
legendScrollable?: boolean;
/** When true, chart fills its parent container height and distributes space between chart and legend */
fillContainer?: boolean;
/** Content rendered between the chart and the legend */
beforeLegend?: React.ReactNode;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
};
@@ -64,6 +73,7 @@ export function ChartRoot({
data,
dataKey,
series,
visibleSeries,
state,
labelFormatter,
enableZoom = false,
@@ -73,9 +83,12 @@ export function ChartRoot({
showLegend = false,
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
beforeLegend,
children,
}: ChartRootProps) {
return (
@@ -84,6 +97,7 @@ export function ChartRoot({
data={data}
dataKey={dataKey}
series={series}
visibleSeries={visibleSeries}
state={state}
labelFormatter={labelFormatter}
enableZoom={enableZoom}
@@ -96,9 +110,12 @@ export function ChartRoot({
showLegend={showLegend}
maxLegendItems={maxLegendItems}
legendTotalLabel={legendTotalLabel}
legendAggregation={legendAggregation}
legendValueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
legendScrollable={legendScrollable}
fillContainer={fillContainer}
beforeLegend={beforeLegend}
>
{children}
</ChartRootInner>
@@ -112,9 +129,12 @@ type ChartRootInnerProps = {
showLegend?: boolean;
maxLegendItems?: number;
legendTotalLabel?: string;
legendAggregation?: AggregationType;
legendValueFormatter?: (value: number) => string;
onViewAllLegendItems?: () => void;
legendScrollable?: boolean;
fillContainer?: boolean;
beforeLegend?: React.ReactNode;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
};
@@ -124,9 +144,12 @@ function ChartRootInner({
showLegend = false,
maxLegendItems = 5,
legendTotalLabel,
legendAggregation,
legendValueFormatter,
onViewAllLegendItems,
legendScrollable = false,
fillContainer = false,
beforeLegend,
children,
}: ChartRootInnerProps) {
const { config, zoom } = useChartContext();
@@ -160,11 +183,14 @@ function ChartRootInner({
{children}
</ChartContainer>
</div>
{beforeLegend}
{/* Legend rendered outside the chart container */}
{showLegend && (
<ChartLegendCompound
maxItems={maxLegendItems}
totalLabel={legendTotalLabel}
aggregation={legendAggregation}
valueFormatter={legendValueFormatter}
onViewAllLegendItems={onViewAllLegendItems}
scrollable={legendScrollable}
/>
@@ -194,18 +220,79 @@ export function useHasNoData(): boolean {
}
/**
* Hook to calculate totals for each series across all data points.
* Hook to calculate aggregated values for each series across all data points.
* When no aggregation is provided, defaults to sum (original behavior).
* Useful for legend displays.
*/
export function useSeriesTotal(): Record<string, number> {
export function useSeriesTotal(aggregation?: AggregationType): Record<string, number> {
const { data, dataKeys } = useChartContext();
return useMemo(() => {
return data.reduce((acc, item) => {
for (const seriesKey of dataKeys) {
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
// Sum (default) and count use additive accumulation
if (!aggregation || aggregation === "sum" || aggregation === "count") {
return data.reduce(
(acc, item) => {
for (const seriesKey of dataKeys) {
acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0);
}
return acc;
},
{} as Record<string, number>
);
}
if (aggregation === "avg") {
const sums: Record<string, number> = {};
const counts: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
const rawVal = item[seriesKey];
if (rawVal == null) continue; // skip gap-filled nulls
const val = Number(rawVal);
sums[seriesKey] = (sums[seriesKey] || 0) + val;
counts[seriesKey] = (counts[seriesKey] || 0) + 1;
}
}
return acc;
}, {} as Record<string, number>);
}, [data, dataKeys]);
const result: Record<string, number> = {};
for (const key of dataKeys) {
result[key] = counts[key] ? sums[key]! / counts[key]! : 0;
}
return result;
}
if (aggregation === "min") {
const result: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
if (item[seriesKey] == null) continue; // skip gap-filled nulls
const val = Number(item[seriesKey]);
if (result[seriesKey] === undefined || val < result[seriesKey]) {
result[seriesKey] = val;
}
}
}
// Default to 0 for series with no data
for (const key of dataKeys) {
if (result[key] === undefined) result[key] = 0;
}
return result;
}
// aggregation === "max"
const result: Record<string, number> = {};
for (const item of data) {
for (const seriesKey of dataKeys) {
if (item[seriesKey] == null) continue; // skip gap-filled nulls
const val = Number(item[seriesKey]);
if (result[seriesKey] === undefined || val > result[seriesKey]) {
result[seriesKey] = val;
}
}
}
// Default to 0 for series with no data
for (const key of dataKeys) {
if (result[key] === undefined) result[key] = 0;
}
return result;
}, [data, dataKeys, aggregation]);
}
@@ -0,0 +1,23 @@
import type { AggregationType } from "~/components/metrics/QueryWidget";
/**
* Aggregate an array of numbers using the specified aggregation function.
*
* Shared utility so both QueryResultsChart (data transformation) and chart
* legend components can reuse the same logic without circular imports.
*/
export function aggregateValues(values: number[], aggregation: AggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
}
}
@@ -1,23 +1,19 @@
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
export type HighlightState = {
/** The currently highlighted series key (e.g., "completed", "failed") */
activeBarKey: string | null;
/** The index of the specific data point being hovered (null when hovering legend) */
activeDataPointIndex: number | null;
/** The payload data from the hovered element */
activePayload: any[] | null;
/** Whether the tooltip is currently active */
tooltipActive: boolean;
};
export type HighlightActions = {
/** Set the hovered bar (specific data point) */
setHoveredBar: (key: string, index: number, payload?: any[]) => void;
setHoveredBar: (key: string, index: number) => void;
/** Set the hovered legend item (highlights all bars of that type) */
setHoveredLegendItem: (key: string) => void;
/** Set the active payload (for tooltip data) */
setActivePayload: (payload: any[] | null) => void;
/** Set tooltip active state */
setTooltipActive: (active: boolean) => void;
/** Reset all highlight state */
@@ -29,60 +25,55 @@ export type UseHighlightStateReturn = HighlightState & HighlightActions;
const initialState: HighlightState = {
activeBarKey: null,
activeDataPointIndex: null,
activePayload: null,
tooltipActive: false,
};
/**
* Hook to manage highlight state for chart elements.
* Handles both bar hover (specific data point) and legend hover (all bars of a type).
*
* activePayload is intentionally NOT managed here — it lives in a separate context
* so that payload updates (frequent during mouse movement) don't cause bar re-renders.
*/
export function useHighlightState(): UseHighlightStateReturn {
const [state, setState] = useState<HighlightState>(initialState);
const setHoveredBar = useCallback((key: string, index: number, payload?: any[]) => {
const setHoveredBar = useCallback((key: string, index: number) => {
setState({
activeBarKey: key,
activeDataPointIndex: index,
activePayload: payload ?? null,
tooltipActive: true,
});
}, []);
const setHoveredLegendItem = useCallback((key: string) => {
setState((prev) => ({
...prev,
activeBarKey: key,
activeDataPointIndex: null, // null indicates legend hover (all bars of this type)
}));
}, []);
const setActivePayload = useCallback((payload: any[] | null) => {
setState((prev) => ({
...prev,
activePayload: payload,
}));
setState((prev) => {
if (prev.activeBarKey === key && prev.activeDataPointIndex === null) return prev;
return { ...prev, activeBarKey: key, activeDataPointIndex: null };
});
}, []);
const setTooltipActive = useCallback((active: boolean) => {
setState((prev) => ({
...prev,
tooltipActive: active,
}));
setState((prev) => {
if (prev.tooltipActive === active) return prev;
return { ...prev, tooltipActive: active };
});
}, []);
const reset = useCallback(() => {
setState(initialState);
}, []);
return {
...state,
setHoveredBar,
setHoveredLegendItem,
setActivePayload,
setTooltipActive,
reset,
};
return useMemo(
() => ({
...state,
setHoveredBar,
setHoveredLegendItem,
setTooltipActive,
reset,
}),
[state, setHoveredBar, setHoveredLegendItem, setTooltipActive, reset]
);
}
/**
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
export type ZoomRange = {
start: string;
@@ -175,14 +175,17 @@ export function useZoomSelection(): UseZoomSelectionReturn {
setState(initialState);
}, []);
return {
...state,
startSelection,
updateSelection,
finishSelection,
cancelSelection,
toggleInspectionLine,
clearInspectionLine,
reset,
};
return useMemo(
() => ({
...state,
startSelection,
updateSelection,
finishSelection,
cancelSelection,
toggleInspectionLine,
clearInspectionLine,
reset,
}),
[state, startSelection, updateSelection, finishSelection, cancelSelection, toggleInspectionLine, clearInspectionLine, reset]
);
}
File diff suppressed because it is too large Load Diff
@@ -12,11 +12,13 @@ export function PacketDisplay({
dataType,
title,
searchTerm,
wrap,
}: {
data: string;
dataType: string;
title: string;
searchTerm?: string;
wrap?: boolean;
}) {
switch (dataType) {
case "application/store": {
@@ -54,6 +56,7 @@ export function PacketDisplay({
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
@@ -67,6 +70,7 @@ export function PacketDisplay({
showLineNumbers={false}
showTextWrapping
searchTerm={searchTerm}
wrap={wrap}
/>
);
}
@@ -201,6 +201,7 @@ function ReplayForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -357,6 +358,35 @@ function ReplayForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{replayData.regions.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
<Select
{...conform.select(region)}
variant="tertiary/small"
placeholder={replayData.disableVersionSelection ? "" : undefined}
dropdownIcon
items={replayData.regions}
defaultValue={replayData.region ?? undefined}
disabled={replayData.disableVersionSelection}
>
{replayData.regions.map((r) => (
<SelectItem key={r.name} value={r.name}>
{r.description ? `${r.name}${r.description}` : r.name}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{replayData.disableVersionSelection ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -4,6 +4,7 @@ import {
InformationCircleIcon,
RectangleStackIcon,
Squares2X2Icon,
TableCellsIcon,
TagIcon,
} from "@heroicons/react/20/solid";
import { AttemptIcon } from "~/assets/icons/AttemptIcon";
@@ -47,8 +48,6 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
) {
return <TablerIcon name={spanNameIcon.iconName} className={className} />;
}
<InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
}
if (!name) return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />;
@@ -81,6 +80,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
return <WaitpointTokenIcon className={cn(className, "text-sky-500")} />;
case "function":
return <FunctionIcon className={cn(className, "text-text-dimmed")} />;
case "query":
return <TableCellsIcon className={cn(className, "text-query")} />;
//log levels
case "debug":
case "log":
@@ -110,7 +111,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
case "task-hook-catchError":
return <FunctionIcon className={cn(className, "text-error")} />;
case "streams":
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
}
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
@@ -8,7 +8,7 @@ import {
startOfMonth,
startOfWeek,
subDays,
subWeeks
subWeeks,
} from "date-fns";
import parse from "parse-duration";
import { startTransition, useCallback, useEffect, useState, type ReactNode } from "react";
@@ -105,7 +105,7 @@ const timePeriods = [
{
label: "30 days",
value: "30d",
}
},
];
const timeUnits = [
@@ -214,6 +214,48 @@ export const timeFilters = ({
};
};
export function timeFilterFromTo(props: {
period?: string;
from?: string | number;
to?: string | number;
defaultPeriod: string;
}): { from: Date; to: Date; isDefault: boolean } {
const time = timeFilters(props);
const periodMs = time.period ? parse(time.period) : undefined;
if (periodMs) {
return {
from: new Date(Date.now() - periodMs),
to: new Date(),
isDefault: time.isDefault,
};
}
if (time.from && time.to) {
return {
from: time.from,
to: time.to,
isDefault: time.isDefault,
};
}
if (time.from) {
return {
from: time.from,
to: new Date(),
isDefault: time.isDefault,
};
}
const defaultPeriodMs = parse(props.defaultPeriod) ?? 24 * 60 * 60 * 1_000;
return {
from: new Date(Date.now() - defaultPeriodMs),
to: time.to ?? new Date(),
isDefault: time.isDefault,
};
}
export function timeFilterRenderValues({
from,
to,
@@ -257,7 +299,12 @@ export function timeFilterRenderValues({
case "range":
{
//If the day is the same, only show the time for the `to` date
const isSameDay = from && to && from.getDate() === to.getDate() && from.getMonth() === to.getMonth() && from.getFullYear() === to.getFullYear();
const isSameDay =
from &&
to &&
from.getDate() === to.getDate() &&
from.getMonth() === to.getMonth() &&
from.getFullYear() === to.getFullYear();
valueLabel = (
<span>
@@ -279,8 +326,8 @@ export function timeFilterRenderValues({
rangeType === "range" || rangeType === "period"
? labelName
: rangeType === "from"
? `${labelName} after`
: `${labelName} before`;
? `${labelName} after`
: `${labelName} before`;
return { label, valueLabel, rangeType };
}
@@ -305,6 +352,8 @@ export interface TimeFilterProps {
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;
/** Optional className override for the value text in the filter pill */
valueClassName?: string;
}
export function TimeFilter({
@@ -317,6 +366,7 @@ export function TimeFilter({
applyShortcut,
onValueChange,
maxPeriodDays,
valueClassName,
}: TimeFilterProps = {}) {
const { value } = useSearchParams();
const periodValue = period ?? value("period");
@@ -343,6 +393,7 @@ export function TimeFilter({
value={constrained.valueLabel}
removable={false}
variant="secondary/small"
valueClassName={valueClassName}
/>
</Ariakit.Select>
}
@@ -443,7 +494,8 @@ export function TimeDropdown({
if (!maxPeriodDays) return false;
if (activeSection === "duration") {
const periodToCheck = selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
const periodToCheck =
selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
if (!periodToCheck) return false;
return periodToDays(periodToCheck) > maxPeriodDays;
} else {
@@ -452,33 +504,26 @@ export function TimeDropdown({
}
})();
const applySelection = useCallback(() => {
setValidationError(null);
const applyPeriod = useCallback(
(periodToApply: string) => {
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) {
setValidationError("Please enter a valid custom duration");
if (maxPeriodDays && periodToDays(periodToApply) > maxPeriodDays) {
setValidationError(
`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`
);
return;
}
let periodToApply = selectedPeriod;
if (selectedPeriod === "custom") {
periodToApply = `${customValue}${customUnit}`;
}
const values: TimeFilterApplyValues = { period: periodToApply, from: undefined, to: undefined };
const values: TimeFilterApplyValues = {
period: periodToApply,
from: undefined,
to: undefined,
};
if (onValueChange) {
// Controlled mode - just call the handler
onValueChange(values);
} else {
// URL mode - navigate
replace({
period: periodToApply,
cursor: undefined,
@@ -492,6 +537,30 @@ export function TimeDropdown({
setToValue(undefined);
setOpen(false);
onApply?.(values);
},
[maxPeriodDays, onValueChange, replace, onApply]
);
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) {
setValidationError("Please enter a valid custom duration");
return;
}
const periodToApply =
selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
applyPeriod(periodToApply);
} else {
// Validate date range
if (!fromValue && !toValue) {
@@ -538,7 +607,8 @@ export function TimeDropdown({
onApply,
onValueChange,
exceedsMaxPeriod,
maxPeriodDays
maxPeriodDays,
applyPeriod,
]);
return (
@@ -579,9 +649,9 @@ export function TimeDropdown({
? "border-indigo-500 "
: "border-charcoal-650 hover:border-charcoal-600",
validationError &&
activeSection === "duration" &&
selectedPeriod === "custom" &&
"border-error"
activeSection === "duration" &&
selectedPeriod === "custom" &&
"border-error"
)}
onClick={(e) => e.stopPropagation()}
>
@@ -649,7 +719,7 @@ export function TimeDropdown({
setCustomValue(parsed.value.toString());
setCustomUnit(parsed.unit);
}
setValidationError(null);
applyPeriod(p.value);
}}
fullWidth
type="button"
@@ -800,7 +870,14 @@ export function TimeDropdown({
{exceedsMaxPeriod && organization && (
<Callout
variant="pricing"
cta={<LinkButton variant="primary/small" to={organizationBillingPath({ slug: organization.slug })}>Upgrade</LinkButton>}
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].`}
@@ -810,7 +887,7 @@ export function TimeDropdown({
{/* Action buttons */}
<div className="flex justify-between gap-1 border-t border-grid-bright px-0 pt-3">
<Button
variant="tertiary/small"
variant="secondary/small"
onClick={(e) => {
e.preventDefault();
setFromValue(from);
@@ -824,11 +901,15 @@ export function TimeDropdown({
</Button>
<Button
variant="primary/small"
shortcut={applyShortcut ? applyShortcut : {
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}}
shortcut={
applyShortcut
? applyShortcut
: {
modifiers: ["mod"],
key: "Enter",
enabledOnInputElements: true,
}
}
onClick={(e) => {
e.preventDefault();
applySelection();
@@ -12,6 +12,7 @@ import {
XCircleIcon,
} from "@heroicons/react/20/solid";
import type { TaskRunStatus } from "@trigger.dev/database";
import { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
import assertNever from "assert-never";
import { HourglassIcon } from "lucide-react";
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
@@ -248,26 +249,9 @@ export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRun
return result[0] as TaskRunStatus;
}
export const runFriendlyStatus = [
"Delayed",
"Queued",
"Pending version",
"Dequeued",
"Executing",
"Waiting",
"Reattempting",
"Paused",
"Canceled",
"Interrupted",
"Completed",
"Failed",
"System failure",
"Crashed",
"Expired",
"Timed out",
] as const;
export type RunFriendlyStatus = (typeof runFriendlyStatus)[number];
// runFriendlyStatus and RunFriendlyStatus are imported from @trigger.dev/core/v3
// and re-exported here for backward compatibility.
export { runFriendlyStatus, type RunFriendlyStatus } from "@trigger.dev/core/v3";
/**
* Check if a value is a valid TaskRunStatus
@@ -304,3 +288,42 @@ export const runStatusTitleFromStatus: Record<TaskRunStatus, RunFriendlyStatus>
};
const titlesStatusesArray = Object.entries(runStatusTitleFromStatus);
/**
* Hex color for each TaskRunStatus, mirroring `runStatusClassNameColor` but as
* concrete hex values for non-CSS contexts (e.g. chart series colors).
*/
const RUN_STATUS_HEX_COLORS: Record<TaskRunStatus, string> = {
PENDING: "#5F6570", // charcoal-500
DELAYED: "#6B7580", // charcoal ~450
PENDING_VERSION: "#f59e0b", // amber-500
WAITING_FOR_DEPLOY: "#d97706", // amber-600
EXECUTING: "#3b82f6", // blue-500
RETRYING_AFTER_FAILURE: "#2f6fec", // blue ~550
DEQUEUED: "#4D8EF5", // blue ~475
WAITING_TO_RESUME: "#555D67", // charcoal ~550
PAUSED: "#fbbf24", // amber-400
CANCELED: "#78828C", // charcoal ~400
EXPIRED: "#848D96", // charcoal ~350
INTERRUPTED: "#D52C4D", // rose — evenly spaced (error)
COMPLETED_SUCCESSFULLY: "#28BF5C", // mint-500 (success)
COMPLETED_WITH_ERRORS: "#DE405C", // rose — evenly spaced (error)
SYSTEM_FAILURE: "#E7536C", // rose — evenly spaced (error)
CRASHED: "#cc193d", // rose — darkest (error)
TIMED_OUT: "#F0667B", // rose — lightest (error)
};
/**
* Get the hex color for a run status value. Accepts either a raw TaskRunStatus
* (e.g. "COMPLETED_SUCCESSFULLY") or a friendly name (e.g. "Completed").
* Returns `undefined` when the value is not a recognised status.
*/
export function getRunStatusHexColor(value: string): string | undefined {
if (isTaskRunStatus(value)) {
return RUN_STATUS_HEX_COLORS[value];
}
if (isRunFriendlyStatus(value)) {
return RUN_STATUS_HEX_COLORS[runStatusFromFriendlyTitle(value)];
}
return undefined;
}
+25 -7
View File
@@ -372,6 +372,7 @@ const EnvironmentSchema = z
// Development OTEL environment variables
DEV_OTEL_EXPORTER_OTLP_ENDPOINT: z.string().optional(),
DEV_OTEL_METRICS_ENDPOINT: z.string().optional(),
// If this is set to 1, then the below variables are used to configure the batch processor for spans and logs
DEV_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
DEV_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
@@ -382,6 +383,9 @@ const EnvironmentSchema = z
DEV_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
DEV_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
DEV_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
DEV_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
DEV_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
DEV_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
PROD_OTEL_BATCH_PROCESSING_ENABLED: z.string().default("0"),
PROD_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE: z.string().default("64"),
@@ -392,6 +396,9 @@ const EnvironmentSchema = z
PROD_OTEL_LOG_SCHEDULED_DELAY_MILLIS: z.string().default("200"),
PROD_OTEL_LOG_EXPORT_TIMEOUT_MILLIS: z.string().default("30000"),
PROD_OTEL_LOG_MAX_QUEUE_SIZE: z.string().default("512"),
PROD_OTEL_METRICS_EXPORT_INTERVAL_MILLIS: z.string().optional(),
PROD_OTEL_METRICS_EXPORT_TIMEOUT_MILLIS: z.string().optional(),
PROD_OTEL_METRICS_COLLECTION_INTERVAL_MILLIS: z.string().optional(),
TRIGGER_OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
TRIGGER_OTEL_LOG_ATTRIBUTE_COUNT_LIMIT: z.string().default("1024"),
@@ -1180,7 +1187,7 @@ const EnvironmentSchema = z
CLICKHOUSE_LOG_LEVEL: z.enum(["log", "error", "warn", "info", "debug"]).default("info"),
CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
// Logs List Query Settings (for paginated log views)
// Logs Query Settings
CLICKHOUSE_LOGS_LIST_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_000_000_000),
CLICKHOUSE_LOGS_LIST_MAX_BYTES_BEFORE_EXTERNAL_SORT: z.coerce
.number()
@@ -1190,15 +1197,20 @@ const EnvironmentSchema = z
CLICKHOUSE_LOGS_LIST_MAX_ROWS_TO_READ: z.coerce.number().int().default(10_000_000),
CLICKHOUSE_LOGS_LIST_MAX_EXECUTION_TIME: z.coerce.number().int().default(120),
// Logs Detail Query Settings (for single log views)
CLICKHOUSE_LOGS_DETAIL_MAX_MEMORY_USAGE: z.coerce.number().int().default(64_000_000),
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"),
// Logs page ClickHouse URL (for logs queries)
LOGS_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
// Query page ClickHouse limits (for TSQL queries)
QUERY_CLICKHOUSE_URL: z
.string()
.optional()
.transform((v) => v ?? process.env.CLICKHOUSE_URL),
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),
@@ -1208,7 +1220,10 @@ const EnvironmentSchema = z
// Query page concurrency limits
QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(3),
QUERY_GLOBAL_CONCURRENCY_LIMIT: z.coerce.number().int().default(50),
QUERY_GLOBAL_CONCURRENCY_LIMIT: z.coerce.number().int().default(100),
// Metric widget concurrency limits
METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(30),
EVENTS_CLICKHOUSE_URL: z
.string()
@@ -1221,6 +1236,9 @@ const EnvironmentSchema = z
EVENTS_CLICKHOUSE_COMPRESSION_REQUEST: z.string().default("1"),
EVENTS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(1000),
EVENTS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
METRICS_CLICKHOUSE_BATCH_SIZE: z.coerce.number().int().default(10000),
METRICS_CLICKHOUSE_FLUSH_INTERVAL_MS: z.coerce.number().int().default(1000),
METRICS_CLICKHOUSE_MAX_CONCURRENCY: z.coerce.number().int().default(3),
EVENTS_CLICKHOUSE_INSERT_STRATEGY: z.enum(["insert", "insert_async"]).default("insert"),
EVENTS_CLICKHOUSE_WAIT_FOR_ASYNC_INSERT: z.string().default("1"),
EVENTS_CLICKHOUSE_ASYNC_INSERT_MAX_DATA_SIZE: z.coerce.number().int().default(10485760),
+515
View File
@@ -0,0 +1,515 @@
import { useReducer, useCallback, useRef, useEffect } from "react";
import { nanoid } from "nanoid";
import type {
DashboardLayout,
LayoutItem,
Widget,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import type { WidgetData, QueryWidgetConfig } from "~/components/metrics/QueryWidget";
// ============================================================================
// Types
// ============================================================================
type EditorMode =
| null
| { type: "add" }
| { type: "edit"; widgetId: string; widget: WidgetData };
type DashboardState = {
/** The layout items (positions/sizes) */
layout: LayoutItem[];
/** The widget configurations keyed by widget ID */
widgets: Record<string, Widget>;
/** Current editor mode (add/edit/closed) */
editorMode: EditorMode;
};
// ============================================================================
// Actions
// ============================================================================
type DashboardAction =
| { type: "ADD_WIDGET"; payload: { id: string; widget: Widget; layoutItem: LayoutItem } }
| { type: "UPDATE_WIDGET"; payload: { id: string; widget: Widget } }
| { type: "RENAME_WIDGET"; payload: { id: string; title: string } }
| { type: "DELETE_WIDGET"; payload: { id: string } }
| { type: "DUPLICATE_WIDGET"; payload: { id: string; newId: string } }
| { type: "UPDATE_LAYOUT"; payload: { layout: LayoutItem[] } }
| { type: "RESET_STATE"; payload: { layout: LayoutItem[]; widgets: Record<string, Widget> } }
| { type: "OPEN_ADD_EDITOR" }
| { type: "OPEN_EDIT_EDITOR"; payload: { widgetId: string; widget: WidgetData } }
| { type: "CLOSE_EDITOR" };
// ============================================================================
// Reducer
// ============================================================================
function dashboardReducer(state: DashboardState, action: DashboardAction): DashboardState {
switch (action.type) {
case "ADD_WIDGET":
return {
...state,
layout: [...state.layout, action.payload.layoutItem],
widgets: {
...state.widgets,
[action.payload.id]: action.payload.widget,
},
editorMode: null,
};
case "UPDATE_WIDGET":
return {
...state,
widgets: {
...state.widgets,
[action.payload.id]: action.payload.widget,
},
editorMode: null,
};
case "RENAME_WIDGET": {
const existingWidget = state.widgets[action.payload.id];
if (!existingWidget) return state;
return {
...state,
widgets: {
...state.widgets,
[action.payload.id]: {
...existingWidget,
title: action.payload.title,
},
},
};
}
case "DELETE_WIDGET": {
const { [action.payload.id]: _, ...remainingWidgets } = state.widgets;
return {
...state,
layout: state.layout.filter((item) => item.i !== action.payload.id),
widgets: remainingWidgets,
};
}
case "DUPLICATE_WIDGET": {
const original = state.widgets[action.payload.id];
const originalLayout = state.layout.find((l) => l.i === action.payload.id);
if (!original || !originalLayout) return state;
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
// Deep copy the widget to ensure no shared references
// This prevents edits to one widget from affecting the duplicate
const duplicatedWidget: Widget = {
title: `${original.title} (Copy)`,
query: original.query,
display: JSON.parse(JSON.stringify(original.display)) as QueryWidgetConfig,
};
return {
...state,
layout: [
...state.layout,
{ ...originalLayout, i: action.payload.newId, y: maxBottom, x: 0 },
],
widgets: {
...state.widgets,
[action.payload.newId]: duplicatedWidget,
},
};
}
case "UPDATE_LAYOUT":
return { ...state, layout: action.payload.layout };
case "RESET_STATE":
return {
...state,
layout: action.payload.layout,
widgets: action.payload.widgets,
};
case "OPEN_ADD_EDITOR":
return { ...state, editorMode: { type: "add" } };
case "OPEN_EDIT_EDITOR":
return {
...state,
editorMode: { type: "edit", widgetId: action.payload.widgetId, widget: action.payload.widget },
};
case "CLOSE_EDITOR":
return { ...state, editorMode: null };
default:
return state;
}
}
// ============================================================================
// Hook Options
// ============================================================================
export type UseDashboardEditorOptions = {
/** Initial dashboard layout data from the server */
initialData: DashboardLayout;
/** URL for widget actions (add, update, delete, duplicate) */
widgetActionUrl: string;
/** URL for layout updates. If empty or not provided, uses current page URL. */
layoutActionUrl?: string;
/** Maximum number of widgets allowed per dashboard. If not provided, no limit is enforced. */
widgetLimit?: number;
/** Callback when a sync error occurs */
onSyncError?: (error: Error, action: string) => void;
/** Callback when a widget action is blocked by the limit */
onWidgetLimitReached?: () => void;
};
// ============================================================================
// Sync Queue Types
// ============================================================================
type WidgetSyncTask = {
type: "widget";
action: string;
data: Record<string, string>;
};
type LayoutSyncTask = {
type: "layout";
layout: LayoutItem[];
};
type SyncTask = WidgetSyncTask | LayoutSyncTask;
// ============================================================================
// Hook
// ============================================================================
export function useDashboardEditor({
initialData,
widgetActionUrl,
layoutActionUrl,
widgetLimit,
onSyncError,
onWidgetLimitReached,
}: UseDashboardEditorOptions) {
const [state, dispatch] = useReducer(dashboardReducer, {
layout: initialData.layout,
widgets: initialData.widgets,
editorMode: null,
});
// Refs for debouncing and tracking initialization
const layoutDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInitializedRef = useRef(false);
const currentLayoutJsonRef = useRef<string>(JSON.stringify(initialData.layout));
// Sync queue to prevent race conditions
const syncQueueRef = useRef<SyncTask[]>([]);
const isSyncingRef = useRef(false);
// Reset state when initialData changes (e.g., navigating to different dashboard)
const initialDataJson = JSON.stringify({ layout: initialData.layout, widgets: initialData.widgets });
useEffect(() => {
// Cancel any pending layout save
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
layoutDebounceRef.current = null;
}
// Clear the sync queue when switching dashboards
syncQueueRef.current = [];
// Reset state to new initial data
dispatch({
type: "RESET_STATE",
payload: { layout: initialData.layout, widgets: initialData.widgets },
});
// Update refs
currentLayoutJsonRef.current = JSON.stringify(initialData.layout);
isInitializedRef.current = false;
// Allow saves after a short delay to skip initial mount callbacks
const initTimeout = setTimeout(() => {
isInitializedRef.current = true;
}, 100);
return () => {
clearTimeout(initTimeout);
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
}
};
}, [initialDataJson]);
// -------------------------------------------------------------------------
// Sync queue processor - ensures only one sync runs at a time
// -------------------------------------------------------------------------
const processNextSync = useCallback(async () => {
// If already syncing or queue is empty, do nothing
if (isSyncingRef.current || syncQueueRef.current.length === 0) {
return;
}
isSyncingRef.current = true;
const task = syncQueueRef.current.shift()!;
try {
if (task.type === "widget") {
const formData = new FormData();
formData.set("action", task.action);
Object.entries(task.data).forEach(([k, v]) => formData.set(k, v));
const response = await fetch(widgetActionUrl, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to ${task.action} widget: ${errorText}`);
}
} else if (task.type === "layout") {
const formData = new FormData();
formData.set("action", "layout");
formData.set("layout", JSON.stringify(task.layout));
// Use current page URL if layoutActionUrl is not provided
const url = layoutActionUrl || window.location.pathname;
const response = await fetch(url, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error("Failed to update layout: " + errorText);
}
}
} catch (error) {
console.error(`Dashboard sync error:`, error);
const actionName = task.type === "widget" ? task.action : "layout";
onSyncError?.(error instanceof Error ? error : new Error(String(error)), actionName);
} finally {
isSyncingRef.current = false;
// Process next item in queue
processNextSync();
}
}, [widgetActionUrl, layoutActionUrl, onSyncError]);
// -------------------------------------------------------------------------
// Queue helpers
// -------------------------------------------------------------------------
const queueWidgetSync = useCallback(
(action: string, data: Record<string, string>) => {
syncQueueRef.current.push({ type: "widget", action, data });
processNextSync();
},
[processNextSync]
);
const queueLayoutSync = useCallback(
(layout: LayoutItem[]) => {
// For layout syncs, we only care about the latest state
// Remove any pending layout syncs and add the new one
syncQueueRef.current = syncQueueRef.current.filter((task) => task.type !== "layout");
syncQueueRef.current.push({ type: "layout", layout });
processNextSync();
},
[processNextSync]
);
// -------------------------------------------------------------------------
// Action handlers
// -------------------------------------------------------------------------
// Count only non-title widgets for limit checks (title widgets are free)
const countedWidgets = Object.values(state.widgets).filter(
(w) => w.display.type !== "title"
).length;
const addWidget = useCallback(
(title: string, query: string, config: QueryWidgetConfig) => {
// Guard: check widget limit (title widgets don't count)
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
onWidgetLimitReached?.();
return;
}
const id = nanoid(8);
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 15 };
const widget: Widget = { title, query, display: config };
// Update local state immediately
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
// Queue sync to server (processed sequentially)
// Send the client-generated ID so the server uses the same ID
queueWidgetSync("add", {
widgetId: id,
title,
query,
config: JSON.stringify(config),
});
},
[state.layout, countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
);
const addTitleWidget = useCallback(
(title: string) => {
const id = nanoid(8);
const maxBottom = Math.max(0, ...state.layout.map((l) => l.y + l.h));
// Title widgets are fixed at h=2 and full width
const layoutItem: LayoutItem = { i: id, x: 0, y: maxBottom, w: 12, h: 2 };
const config: QueryWidgetConfig = { type: "title" };
const widget: Widget = { title, query: "", display: config };
// Update local state immediately
dispatch({ type: "ADD_WIDGET", payload: { id, widget, layoutItem } });
// Queue sync to server (processed sequentially)
// Send the client-generated ID so the server uses the same ID
queueWidgetSync("add", {
widgetId: id,
title,
query: "",
config: JSON.stringify(config),
});
},
[state.layout, queueWidgetSync]
);
const updateWidget = useCallback(
(widgetId: string, title: string, query: string, config: QueryWidgetConfig) => {
const widget: Widget = { title, query, display: config };
// Update local state immediately
dispatch({ type: "UPDATE_WIDGET", payload: { id: widgetId, widget } });
// Queue sync to server (processed sequentially)
queueWidgetSync("update", {
widgetId,
title,
query,
config: JSON.stringify(config),
});
},
[queueWidgetSync]
);
const deleteWidget = useCallback(
(widgetId: string) => {
// Update local state immediately
dispatch({ type: "DELETE_WIDGET", payload: { id: widgetId } });
// Queue sync to server (processed sequentially)
queueWidgetSync("delete", { widgetId });
},
[queueWidgetSync]
);
const duplicateWidget = useCallback(
(widgetId: string) => {
// Guard: check widget limit (title widgets don't count)
if (widgetLimit !== undefined && countedWidgets >= widgetLimit) {
onWidgetLimitReached?.();
return;
}
const newId = nanoid(8);
// Update local state immediately
dispatch({ type: "DUPLICATE_WIDGET", payload: { id: widgetId, newId } });
// Queue sync to server (processed sequentially)
// Send the client-generated newId so the server uses the same ID for the duplicate
queueWidgetSync("duplicate", { widgetId, newId });
},
[countedWidgets, widgetLimit, onWidgetLimitReached, queueWidgetSync]
);
const renameWidget = useCallback(
(widgetId: string, title: string) => {
// Update local state immediately
dispatch({ type: "RENAME_WIDGET", payload: { id: widgetId, title } });
// Queue sync to server (processed sequentially)
queueWidgetSync("rename", { widgetId, title });
},
[queueWidgetSync]
);
const updateLayout = useCallback(
(newLayout: LayoutItem[]) => {
// Skip if not yet initialized (prevents saving during mount/navigation)
if (!isInitializedRef.current) {
return;
}
const newLayoutJson = JSON.stringify(newLayout);
// Skip if layout hasn't actually changed
if (newLayoutJson === currentLayoutJsonRef.current) {
return;
}
// Update local state immediately
dispatch({ type: "UPDATE_LAYOUT", payload: { layout: newLayout } });
// Clear existing debounce timeout
if (layoutDebounceRef.current) {
clearTimeout(layoutDebounceRef.current);
}
// Debounce before queueing - this ensures rapid layout changes
// (like dragging) don't queue up many requests
layoutDebounceRef.current = setTimeout(() => {
currentLayoutJsonRef.current = newLayoutJson;
// Queue layout sync (replaces any pending layout sync in queue)
queueLayoutSync(newLayout);
}, 500);
},
[queueLayoutSync]
);
const openAddEditor = useCallback(() => {
dispatch({ type: "OPEN_ADD_EDITOR" });
}, []);
const openEditEditor = useCallback((widgetId: string, widget: WidgetData) => {
dispatch({ type: "OPEN_EDIT_EDITOR", payload: { widgetId, widget } });
}, []);
const closeEditor = useCallback(() => {
dispatch({ type: "CLOSE_EDITOR" });
}, []);
// -------------------------------------------------------------------------
// Return value
// -------------------------------------------------------------------------
return {
/** Current dashboard state */
state,
/** Action dispatchers */
actions: {
addWidget,
addTitleWidget,
updateWidget,
renameWidget,
deleteWidget,
duplicateWidget,
updateLayout,
openAddEditor,
openEditEditor,
closeEditor,
},
};
}
@@ -0,0 +1,35 @@
import { useEffect, useRef } from "react";
type UseElementVisibilityOptions = {
onVisibilityChange?: (isVisible: boolean) => void;
};
export function useElementVisibility({
onVisibilityChange,
}: UseElementVisibilityOptions = {}) {
const ref = useRef<HTMLDivElement>(null);
const isVisibleRef = useRef(false);
const callbackRef = useRef(onVisibilityChange);
callbackRef.current = onVisibilityChange;
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
const nowVisible = entry.isIntersecting;
if (isVisibleRef.current !== nowVisible) {
isVisibleRef.current = nowVisible;
callbackRef.current?.(nowVisible);
}
},
{ threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, isVisibleRef };
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
type UseIntervalOptions = {
/** If passed, will refresh every interval MS */
interval?: number;
onLoad?: boolean;
onFocus?: boolean;
disabled?: boolean;
callback: () => void;
};
export function useInterval({
interval,
onLoad = true,
onFocus = true,
disabled = false,
callback,
}: UseIntervalOptions) {
// Always keep the latest callback in a ref so the effects below
// never close over a stale version.
const latestCallback = useRef(callback);
useEffect(() => {
latestCallback.current = callback;
}, [callback]);
// On interval
useEffect(() => {
if (!interval || interval <= 0 || disabled) return;
const intervalId = setInterval(() => {
latestCallback.current();
}, interval);
return () => clearInterval(intervalId);
}, [interval, disabled]);
// On focus
useEffect(() => {
if (!onFocus || disabled) return;
const handleFocus = () => {
if (document.visibilityState === "visible") {
latestCallback.current();
}
};
// Revalidate when the page becomes visible
document.addEventListener("visibilitychange", handleFocus);
// Revalidate when the window gains focus
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener("visibilitychange", handleFocus);
window.removeEventListener("focus", handleFocus);
};
}, [onFocus, disabled]);
// On load
useEffect(() => {
if (disabled || !onLoad) return;
latestCallback.current();
}, [disabled, onLoad]);
}
+26
View File
@@ -61,3 +61,29 @@ export function useIsImpersonating(matches?: UIMatch[]) {
});
return data?.isImpersonating === true;
}
export type CustomDashboard = UseDataFunctionReturn<typeof orgLoader>["customDashboards"][number];
export function useCustomDashboards(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.customDashboards ?? [];
}
export function useDashboardLimits(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.dashboardLimits ?? { used: 0, limit: 3 };
}
export function useWidgetLimitPerDashboard(matches?: UIMatch[]) {
const data = useTypedMatchesData<typeof orgLoader>({
id: "routes/_app.orgs.$organizationSlug",
matches,
});
return data?.widgetLimitPerDashboard ?? 16;
}
+16 -1
View File
@@ -1,6 +1,6 @@
import { useLocation } from "@remix-run/react";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useOrganizationChanged } from "./useOrganizations";
import { useOptionalUser, useUserChanged } from "./useUser";
import { useProjectChanged } from "./useProject";
@@ -68,3 +68,18 @@ export const usePostHog = (apiKey?: string, logging = false, debug = false): voi
posthog.capture("$pageview");
}, [location, logging]);
};
export function usePostHogTracking() {
const capture = useCallback(
(eventName: string, properties?: Record<string, unknown>) => {
posthog.capture(eventName, properties);
},
[]
);
const startSessionRecording = useCallback(() => {
posthog.startSessionRecording();
}, []);
return { capture, startSessionRecording };
}
@@ -0,0 +1,57 @@
import { useEffect } from "react";
import { useRevalidator, useSearchParams } from "@remix-run/react";
type UseRevalidateOnParamOptions = {
/** The query param(s) that trigger revalidation */
param: string | string[];
/** Callback fired when revalidation is triggered */
onRevalidate?: () => void;
};
/**
* Hook that triggers revalidation when specific query params are present,
* then removes those params from the URL.
*
* Usage:
* ```ts
* // Revalidate when ?_revalidate is present
* useRevalidateOnParam({ param: "_revalidate" });
*
* // With callback to close a modal
* useRevalidateOnParam({
* param: "_revalidate",
* onRevalidate: () => setEditorMode(null),
* });
* ```
*
* The redirect should include the param:
* ```ts
* return redirect(`${dashboardPath}?_revalidate=${Date.now()}`);
* ```
*/
export function useRevalidateOnParam({ param, onRevalidate }: UseRevalidateOnParamOptions) {
const [searchParams, setSearchParams] = useSearchParams();
const revalidator = useRevalidator();
const paramArray = Array.isArray(param) ? param : [param];
useEffect(() => {
// Check if any of the trigger params are present
const hasParam = paramArray.some((p) => searchParams.has(p));
if (hasParam) {
// Trigger revalidation
revalidator.revalidate();
// Call the callback if provided
onRevalidate?.();
// Remove the trigger params from the URL
const newParams = new URLSearchParams(searchParams);
paramArray.forEach((p) => newParams.delete(p));
// Update URL without the params (replace to avoid adding to history)
setSearchParams(newParams, { replace: true });
}
}, [searchParams, setSearchParams, revalidator, paramArray, onRevalidate]);
}
@@ -308,3 +308,26 @@ export async function findDisplayableEnvironment(
return displayableEnvironment(environment, userId);
}
export async function hasAccessToEnvironment({
environmentId,
projectId,
organizationId,
userId,
}: {
environmentId: string;
projectId: string;
organizationId: string;
userId: string;
}): Promise<boolean> {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
id: environmentId,
projectId: projectId,
organizationId: organizationId,
organization: { members: { some: { userId } } },
},
});
return environment !== null;
}
@@ -0,0 +1,225 @@
import { type BuiltInDashboard } from "./MetricDashboardPresenter.server";
import { z } from "zod";
const overviewDashboard: BuiltInDashboard = {
key: "overview",
title: "Metrics",
layout: {
version: "1",
layout: [
{ i: "9lDDdebQ", x: 3, y: 0, w: 3, h: 4 },
{ i: "VhAgNlB0", x: 0, y: 0, w: 3, h: 4 },
{ i: "iI5EnhJW", x: 6, y: 0, w: 3, h: 4 },
{ i: "HtSgJEmp", x: 0, y: 17, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "rRbzv-Aq", x: 6, y: 4, w: 6, h: 13 },
{ i: "j3yFSxLM", x: 0, y: 33, w: 6, h: 11 },
{ i: "IKB8cENo", x: 6, y: 33, w: 6, h: 11 },
{ i: "-fHz3CyQ", x: 0, y: 56, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "hnKsN482", x: 0, y: 58, w: 12, h: 15 },
{ i: "if6dds8T", x: 0, y: 19, w: 12, h: 14 },
{ i: "i3q1Awfz", x: 0, y: 4, w: 6, h: 13 },
{ i: "Kh0w0fjy", x: 6, y: 44, w: 6, h: 12 },
{ i: "zybRTAdz", x: 0, y: 44, w: 6, h: 12 },
{ i: "ff2nVxxt", x: 0, y: 73, w: 12, h: 15 },
{ i: "Dib0ywb4", x: 0, y: 88, w: 12, h: 2, minH: 2, maxH: 2 },
{ i: "YsWiQENd", x: 0, y: 90, w: 12, h: 15 },
{ i: "lc-guCvo", x: 0, y: 105, w: 12, h: 15 },
{ i: "xyQl3FAd", x: 9, y: 0, w: 3, h: 4 },
],
widgets: {
"9lDDdebQ": {
title: "Total runs",
query: "SELECT\r\n count() AS total_runs\r\nFROM\r\n runs",
display: { type: "bignumber", column: "total_runs", aggregation: "sum", abbreviate: false },
},
VhAgNlB0: {
title: "Success %",
query:
"SELECT\r\n round(countIf (status = 'Completed') * 100.0 / countIf (is_finished = 1), 2) AS success_percentage\r\nFROM\r\n runs",
display: {
type: "bignumber",
column: "success_percentage",
aggregation: "avg",
abbreviate: true,
suffix: "%",
},
},
iI5EnhJW: {
title: "Failed runs",
query:
"SELECT\r\n count() AS total_runs\r\nFROM\r\n runs\r\nWHERE status IN ('Failed', 'System failure', 'Crashed')",
display: { type: "bignumber", column: "total_runs", aggregation: "sum", abbreviate: false },
},
HtSgJEmp: { title: "Failed runs", query: "", display: { type: "title" } },
"rRbzv-Aq": {
title: "Runs by status",
query:
"SELECT\r\n timeBucket (),\r\n status,\r\n count() AS run_count\r\nFROM\r\n runs\r\nGROUP BY\r\n timeBucket,\r\n status\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["run_count"],
groupByColumn: "status",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
j3yFSxLM: {
title: "Top failing tasks",
query:
"SELECT\r\n task_identifier AS task,\r\n count() AS runs,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) AS failures,\r\n concat(round((countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) * 100, 2), '%') AS failure_rate,\r\n avg(attempt_count - 1) AS avg_retries\r\nFROM\r\n runs\r\nGROUP BY\r\n task_identifier\r\nORDER BY\r\n (countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) DESC;",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
IKB8cENo: {
title: "Top failing tags",
query:
"SELECT\r\n arrayJoin(tags) AS tag,\r\n count() AS runs,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) AS failures,\r\n concat(round((countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) * 100, 2), '%') AS failure_rate,\r\n avg(attempt_count - 1) AS avg_retries\r\nFROM\r\n runs\r\nGROUP BY\r\n tag\r\nORDER BY\r\n (countIf (status IN ('Failed', 'Crashed', 'System failure')) / count()) DESC;",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
"-fHz3CyQ": { title: "Usage and cost", query: "", display: { type: "title" } },
hnKsN482: {
title: "Cost by task",
query:
"SELECT\r\n timeBucket() as time_period,\r\n task_identifier,\r\n sum(total_cost) AS total_cost\r\nFROM\r\n runs\r\nGROUP BY\r\n time_period,\r\n task_identifier\r\nORDER BY\r\n time_period",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "time_period",
yAxisColumns: ["total_cost"],
groupByColumn: "task_identifier",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
if6dds8T: {
title: "Failed runs by task",
query:
"SELECT\r\n timeBucket () as time_period,\r\n task_identifier,\r\n count() AS run_count\r\nFROM\r\n runs\r\nWHERE status IN ('Failed', 'Crashed', 'System failure')\r\nGROUP BY\r\n time_period,\r\n task_identifier\r\nORDER BY\r\n time_period",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "time_period",
yAxisColumns: ["run_count"],
groupByColumn: "task_identifier",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
i3q1Awfz: {
title: "Run success %",
query:
"SELECT\r\n timeBucket (),\r\n count() as total,\r\n countIf (status = 'Completed') / total * 100 AS completed,\r\n countIf (status IN ('Failed', 'Crashed', 'System failure')) / total * 100 AS failed,\r\nFROM\r\n runs\r\nGROUP BY\r\n timeBucket\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["failed", "completed"],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
seriesColors: { failed: "#f43f5e" },
},
},
Kh0w0fjy: {
title: "Top errors",
query:
"SELECT\r\n concat(error.name, '(\"', error.message, '\")') AS error,\r\n count() AS count\r\nFROM\r\n runs\r\nWHERE\r\n runs.error != NULL\r\n AND runs.error.name != NULL\r\nGROUP BY\r\n error\r\nORDER BY\r\n count DESC",
display: { type: "table", prettyFormatting: true, sorting: [] },
},
zybRTAdz: {
title: "Top errors over time",
query:
"SELECT\r\n timeBucket(),\r\n concat(error.name, '(\"', error.message, '\")') AS error,\r\n count() AS count\r\nFROM\r\n runs\r\nWHERE\r\n runs.error != NULL\r\n AND runs.error.name != NULL\r\nGROUP BY\r\n timeBucket,\r\n error\r\nORDER BY\r\n count DESC",
display: {
type: "chart",
chartType: "bar",
xAxisColumn: "timebucket",
yAxisColumns: ["count"],
groupByColumn: "error",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: { count: "#ef4343" },
},
},
ff2nVxxt: {
title: "Cost by machine",
query:
"SELECT\r\n timeBucket() as time_period,\r\n machine,\r\n sum(total_cost) AS total_cost\r\nFROM\r\n runs\r\nWHERE machine != ''\r\nGROUP BY\r\n time_period,\r\n machine\r\nORDER BY\r\n time_period",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "time_period",
yAxisColumns: ["total_cost"],
groupByColumn: "machine",
stacked: true,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
},
},
Dib0ywb4: { title: "Versions", query: "", display: { type: "title" } },
YsWiQENd: {
title: "Runs by version",
query:
"SELECT\r\n timeBucket (),\r\n task_version,\r\n count() as runs\r\nFROM\r\n runs\r\nWHERE task_version != ''\r\nGROUP BY\r\n timeBucket,\r\n task_version\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["runs"],
groupByColumn: "task_version",
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
seriesColors: {},
},
},
"lc-guCvo": {
title: "Version success %",
query:
"SELECT\r\n timeBucket (),\r\n task_version,\r\n count() as total,\r\n countIf (status = 'Completed') / total * 100 AS success\r\nFROM\r\n runs\r\nWHERE task_version != ''\r\nGROUP BY\r\n timeBucket,\r\n task_version\r\nORDER BY\r\n timeBucket",
display: {
type: "chart",
chartType: "line",
xAxisColumn: "timebucket",
yAxisColumns: ["success"],
groupByColumn: "task_version",
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "avg",
seriesColors: {},
},
},
xyQl3FAd: {
title: "Queued",
query:
"SELECT\r\n count() AS queued\r\nFROM\r\n runs\r\nWHERE status IN ('Dequeued', 'Queued')",
display: { type: "bignumber", column: "queued", aggregation: "sum", abbreviate: false },
},
},
},
};
const builtInDashboards: BuiltInDashboard[] = [overviewDashboard];
export function builtInDashboard(key: string): BuiltInDashboard {
const dashboard = builtInDashboards.find((d) => d.key === key);
if (!dashboard) {
throw new Error(`No built-in dashboard "${key}"`);
}
return dashboard;
}
@@ -68,6 +68,9 @@ export type LimitsResult = {
batchProcessingConcurrency: QuotaInfo;
devQueueSize: QuotaInfo;
deployedQueueSize: QuotaInfo;
metricDashboards: QuotaInfo | null;
metricWidgetsPerDashboard: QuotaInfo | null;
queryPeriodDays: QuotaInfo | null;
};
features: {
hasStagingEnvironment: FeatureInfo;
@@ -155,6 +158,11 @@ export class LimitsPresenter extends BasePresenter {
},
});
// Get metric dashboard count for this org
const metricDashboardCount = await this._replica.metricsDashboard.count({
where: { organizationId },
});
// Get current rate limit tokens for this environment's API key
const apiRateLimitTokens = await getRateLimitRemainingTokens(
"api",
@@ -174,6 +182,9 @@ export class LimitsPresenter extends BasePresenter {
const branchesLimit = limits?.branches?.number ?? null;
const logRetentionDaysLimit = limits?.logRetentionDays?.number ?? null;
const realtimeConnectionsLimit = limits?.realtimeConcurrentConnections?.number ?? null;
const metricDashboardsLimit = limits?.metricDashboards?.number ?? null;
const metricWidgetsPerDashboardLimit = limits?.metricWidgetsPerDashboard?.number ?? null;
const queryPeriodDaysLimit = limits?.queryPeriodDays?.number ?? null;
const includedUsage = limits?.includedUsage ?? null;
const hasStagingEnvironment = limits?.hasStagingEnvironment ?? false;
const supportLevel = limits?.support ?? "community";
@@ -296,6 +307,40 @@ export class LimitsPresenter extends BasePresenter {
currentUsage: 0, // Would need to query Redis for this
source: organization.maximumDeployedQueueSize ? "override" : "default",
},
metricDashboards:
metricDashboardsLimit !== null
? {
name: "Metric dashboards",
description: "Maximum number of custom metric dashboards per organization",
limit: metricDashboardsLimit,
currentUsage: metricDashboardCount,
source: "plan",
canExceed: limits?.metricDashboards?.canExceed,
isUpgradable: true,
}
: null,
metricWidgetsPerDashboard:
metricWidgetsPerDashboardLimit !== null
? {
name: "Charts per dashboard",
description: "Maximum number of charts per metrics dashboard",
limit: metricWidgetsPerDashboardLimit,
currentUsage: 0, // Varies per dashboard
source: "plan",
canExceed: limits?.metricWidgetsPerDashboard?.canExceed,
isUpgradable: true,
}
: null,
queryPeriodDays:
queryPeriodDaysLimit !== null
? {
name: "Query period",
description: "Maximum number of days a query can look back",
limit: queryPeriodDaysLimit,
currentUsage: 0, // Not applicable - this is a duration, not a count
source: "plan",
}
: null,
},
features: {
hasStagingEnvironment: {
@@ -81,12 +81,17 @@ export class LogDetailPresenter {
// Ignore parse errors
}
const durationMs = (typeof log.duration === "number" ? log.duration : Number(log.duration)) / 1_000_000;
return {
// Use :: separator to match LogsListPresenter format
id: `${log.trace_id}::${log.span_id}::${log.run_id}::${log.start_time}`,
runId: log.run_id,
taskIdentifier: log.task_identifier,
startTime: convertClickhouseDateTime64ToJsDate(log.start_time).toISOString(),
triggeredTimestamp: new Date(
convertClickhouseDateTime64ToJsDate(log.start_time).getTime() + durationMs
).toISOString(),
traceId: log.trace_id,
spanId: log.span_id,
parentSpanId: log.parent_span_id || null,
@@ -1,13 +1,11 @@
import { z } from "zod";
import { type ClickHouse } from "@internal/clickhouse";
import {
type PrismaClientOrTransaction,
} from "@trigger.dev/database";
import { type ClickHouse, type WhereCondition } from "@internal/clickhouse";
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
import parseDuration from "parse-duration";
import { type Direction } from "~/components/ListPagination";
import { timeFilters } from "~/components/runs/v3/SharedFilters";
import { timeFilterFromTo, timeFilters } from "~/components/runs/v3/SharedFilters";
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
@@ -28,14 +26,9 @@ type ErrorAttributes = {
};
function escapeClickHouseString(val: string): string {
return val
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/%/g, "\\%")
.replace(/_/g, "\\_");
return val.replace(/\\/g, "\\\\").replace(/\//g, "\\/").replace(/%/g, "\\%").replace(/_/g, "\\_");
}
export type LogsListOptions = {
userId?: string;
projectId: string;
@@ -50,7 +43,6 @@ export type LogsListOptions = {
retentionLimitDays?: number;
// search
search?: string;
includeDebugLogs?: boolean;
// pagination
direction?: Direction;
cursor?: string;
@@ -69,7 +61,6 @@ export const LogsListOptionsSchema = z.object({
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(),
cursor: z.string().optional(),
pageSize: z.number().int().positive().max(1000).optional(),
@@ -83,14 +74,16 @@ export type LogsListAppliedFilters = LogsList["filters"];
// Cursor is a base64 encoded JSON of the pagination keys
type LogCursor = {
organizationId: string;
environmentId: string;
unixTimestamp: number;
triggeredTimestamp: string; // DateTime64(9) string
traceId: string;
};
const LogCursorSchema = z.object({
organizationId: z.string(),
environmentId: z.string(),
unixTimestamp: z.number(),
triggeredTimestamp: z.string(),
traceId: z.string(),
});
@@ -115,34 +108,19 @@ function decodeCursor(cursor: string): LogCursor | null {
// Convert display level to ClickHouse kinds and statuses
function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?: string[] } {
switch (level) {
case "TRACE":
return { kinds: ["SPAN"] };
case "DEBUG":
return { kinds: ["DEBUG_EVENT", "LOG_DEBUG"] };
return { kinds: ["LOG_DEBUG"] };
case "INFO":
return { kinds: ["LOG_INFO", "LOG_LOG"] };
case "WARN":
return { kinds: ["LOG_WARN"] };
case "ERROR":
return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] };
return { kinds: ["LOG_ERROR", "SPAN_EVENT"], statuses: ["ERROR"] };
}
}
function convertDateToNanoseconds(date: Date): bigint {
return BigInt(date.getTime()) * 1_000_000n;
}
function formatNanosecondsForClickhouse(ns: bigint): string {
const nsString = ns.toString();
// Handle negative numbers (dates before 1970-01-01)
if (nsString.startsWith("-")) {
const absString = nsString.slice(1);
const padded = absString.padStart(19, "0");
return "-" + padded.slice(0, 10) + "." + padded.slice(10);
}
// Pad positive numbers to 19 digits to ensure correct slicing
const padded = nsString.padStart(19, "0");
return padded.slice(0, 10) + "." + padded.slice(10);
}
export class LogsListPresenter extends BasePresenter {
constructor(
private readonly replica: PrismaClientOrTransaction,
@@ -166,29 +144,20 @@ export class LogsListPresenter extends BasePresenter {
to,
cursor,
pageSize = DEFAULT_PAGE_SIZE,
includeDebugLogs = true,
defaultPeriod,
retentionLimitDays,
}: LogsListOptions
) {
const time = timeFilters({
const time = timeFilterFromTo({
period,
from,
to,
defaultPeriod,
defaultPeriod: defaultPeriod ?? "1h",
});
let effectiveFrom = time.from;
let effectiveTo = time.to;
if (!effectiveFrom && !effectiveTo && time.period) {
const periodMs = parseDuration(time.period);
if (periodMs) {
effectiveFrom = new Date(Date.now() - periodMs);
effectiveTo = new Date();
}
}
// Apply retention limit if provided
let wasClampedByRetention = false;
if (retentionLimitDays !== undefined && effectiveFrom) {
@@ -252,7 +221,12 @@ export class LogsListPresenter extends BasePresenter {
);
}
const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
const queryBuilder = this.clickhouse.taskEventsSearch.logsListQueryBuilder();
// This should be removed once we clear the old inserts, 30 DAYS, the materialized view excludes events without trace_id)
queryBuilder.where("trace_id != ''", {
environmentId,
});
queryBuilder.where("environment_id = {environmentId: String}", {
environmentId,
@@ -263,29 +237,17 @@ export class LogsListPresenter extends BasePresenter {
});
queryBuilder.where("project_id = {projectId: String}", { projectId });
if (effectiveFrom) {
const fromNs = convertDateToNanoseconds(effectiveFrom);
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
queryBuilder.where("start_time >= {fromTime: String}", {
fromTime: formatNanosecondsForClickhouse(fromNs),
queryBuilder.where("triggered_timestamp >= {triggeredAtStart: DateTime64(3)}", {
triggeredAtStart: convertDateToClickhouseDateTime(effectiveFrom),
});
}
if (effectiveTo) {
const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
const toNs = convertDateToNanoseconds(clampedTo);
queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
queryBuilder.where("start_time <= {toTime: String}", {
toTime: formatNanosecondsForClickhouse(toNs),
queryBuilder.where("triggered_timestamp <= {triggeredAtEnd: DateTime64(3)}", {
triggeredAtEnd: convertDateToClickhouseDateTime(clampedTo),
});
}
@@ -307,81 +269,55 @@ export class LogsListPresenter extends BasePresenter {
queryBuilder.where(
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
{
searchPattern: `%${searchTerm}%`
searchPattern: `%${searchTerm}%`,
}
);
}
if (levels && levels.length > 0) {
const conditions: string[] = [];
const params: Record<string, string[]> = {};
const conditions: WhereCondition[] = [];
for (const level of levels) {
const filter = levelToKindsAndStatuses(level);
const levelConditions: string[] = [];
for (let i = 0; i < levels.length; i++) {
const filter = levelToKindsAndStatuses(levels[i]);
if (filter.kinds && filter.kinds.length > 0) {
const kindsKey = `kinds_${level}`;
let kindCondition = `kind IN {${kindsKey}: Array(String)}`;
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
levelConditions.push(kindCondition);
params[kindsKey] = filter.kinds;
conditions.push({
clause: `kind IN {kinds_${i}: Array(String)} AND status NOT IN {excluded_statuses: Array(String)}`,
params: {
[`kinds_${i}`]: filter.kinds,
excluded_statuses: ["ERROR", "CANCELLED"],
},
});
}
if (filter.statuses && filter.statuses.length > 0) {
const statusesKey = `statuses_${level}`;
levelConditions.push(`status IN {${statusesKey}: Array(String)}`);
params[statusesKey] = filter.statuses;
}
if (levelConditions.length > 0) {
conditions.push(`(${levelConditions.join(" OR ")})`);
conditions.push({
clause: `status IN {statuses_${i}: Array(String)}`,
params: { [`statuses_${i}`]: filter.statuses },
});
}
}
if (conditions.length > 0) {
queryBuilder.where(`(${conditions.join(" OR ")})`, params);
}
queryBuilder.whereOr(conditions);
}
// Debug logs are available only to admins
if (includeDebugLogs === false) {
queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", {
debugKinds: ["DEBUG_EVENT"],
});
queryBuilder.where("NOT ((kind = 'LOG_INFO') AND (attributes_text = '{}'))");
}
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
// Cursor-based pagination using lexicographic comparison on (triggered_timestamp, trace_id).
// Since ORDER BY is DESC, "next page" means rows that sort *after* the cursor, i.e. less-than.
// The OR handles the tiebreaker: rows with an earlier timestamp always qualify, and rows
// with the *same* timestamp only qualify if their trace_id is also smaller.
// Equivalent to: WHERE (triggered_timestamp, trace_id) < (cursor.triggered_timestamp, cursor.trace_id)
const decodedCursor = cursor ? decodeCursor(cursor) : null;
if (decodedCursor) {
queryBuilder.where(
"(environment_id, toUnixTimestamp(start_time), trace_id) < ({cursorEnvId: String}, {cursorUnixTimestamp: Int64}, {cursorTraceId: String})",
`(triggered_timestamp < {cursorTriggeredTimestamp: String} OR (triggered_timestamp = {cursorTriggeredTimestamp: String} AND trace_id < {cursorTraceId: String}))`,
{
cursorEnvId: decodedCursor.environmentId,
cursorUnixTimestamp: decodedCursor.unixTimestamp,
cursorTriggeredTimestamp: decodedCursor.triggeredTimestamp,
cursorTraceId: decodedCursor.traceId,
}
);
}
queryBuilder.orderBy("environment_id DESC, toUnixTimestamp(start_time) DESC, trace_id DESC");
queryBuilder.orderBy("triggered_timestamp DESC, trace_id DESC");
// Limit + 1 to check if there are more results
queryBuilder.limit(pageSize + 1);
@@ -399,10 +335,10 @@ 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({
organizationId,
environmentId,
unixTimestamp,
triggeredTimestamp: lastLog.triggered_timestamp,
traceId: lastLog.trace_id,
});
}
@@ -430,6 +366,9 @@ export class LogsListPresenter extends BasePresenter {
runId: log.run_id,
taskIdentifier: log.task_identifier,
startTime: convertClickhouseDateTime64ToJsDate(log.start_time).toISOString(),
triggeredTimestamp: convertClickhouseDateTime64ToJsDate(
log.triggered_timestamp
).toISOString(),
traceId: log.trace_id,
spanId: log.span_id,
parentSpanId: log.parent_span_id || null,
@@ -468,10 +407,13 @@ export class LogsListPresenter extends BasePresenter {
hasFilters,
hasAnyLogs: transformedLogs.length > 0,
searchTerm: search,
retention: retentionLimitDays !== undefined ? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
} : undefined,
retention:
retentionLimitDays !== undefined
? {
limitDays: retentionLimitDays,
wasClamped: wasClampedByRetention,
}
: undefined,
};
}
}
@@ -0,0 +1,123 @@
import { BasePresenter } from "./basePresenter.server";
import { type QueryScope } from "~/services/queryService.server";
import { getLimit } from "~/services/platform.v3.server";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import { builtInDashboard } from "./BuiltInDashboards.server";
import { QueryWidgetConfig } from "~/components/metrics/QueryWidget";
export type MetricFilters = {
/** Org, project, environment */
scope: QueryScope;
/** Time filter settings */
filterPeriod: string | null;
filterFrom: Date | null;
filterTo: Date | null;
/** Tasks */
taskIdentifiers?: string[];
/** Queues */
queues?: string[];
/** Tags */
tags?: string[];
};
export const LayoutItem = z.object({
i: z.string(),
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
minH: z.number().optional(),
maxH: z.number().optional(),
});
export type LayoutItem = z.infer<typeof LayoutItem>;
export const Widget = z.object({
title: z.string(),
query: z.string().default(""),
display: QueryWidgetConfig,
});
export type Widget = z.infer<typeof Widget>;
export const DashboardLayout = z.discriminatedUnion("version", [
z.object({
version: z.literal("1"),
layout: z.array(LayoutItem),
widgets: z.record(Widget),
}),
]);
export type DashboardLayout = z.infer<typeof DashboardLayout>;
export type CustomDashboard = {
friendlyId: string;
title: string;
layout: DashboardLayout;
defaultPeriod: string;
};
export type BuiltInDashboard = {
key: string;
title: string;
layout: DashboardLayout;
};
/** Returns the dashboard layout */
export class MetricDashboardPresenter extends BasePresenter {
public async customDashboard({
friendlyId,
organizationId,
}: {
friendlyId: string;
organizationId: string;
}): Promise<CustomDashboard> {
const dashboard = await this._replica.metricsDashboard.findFirst({
where: { friendlyId, organizationId },
});
if (!dashboard) {
throw new Error("No dashboard found");
}
const layout = this.#getLayout(dashboard.layout);
const defaultPeriod = await getDashboardDefaultPeriod(organizationId);
return {
friendlyId: dashboard.friendlyId,
title: dashboard.title,
layout,
defaultPeriod,
};
}
public async builtInDashboard({ organizationId, key }: { organizationId: string; key: string }) {
const defaultPeriod = await getDashboardDefaultPeriod(organizationId);
const dashboard = builtInDashboard(key);
return {
...dashboard,
defaultPeriod,
};
}
#getLayout(layoutData: string): DashboardLayout {
const json = JSON.parse(layoutData);
const parsedLayout = DashboardLayout.safeParse(json);
if (!parsedLayout.success) {
throw fromZodError(parsedLayout.error);
}
return parsedLayout.data;
}
}
/** Dashboard-specific default period (1 day), capped to the org's max query period */
async function getDashboardDefaultPeriod(organizationId: string): Promise<string> {
const idealDefaultPeriodDays = 1;
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
if (maxQueryPeriod < idealDefaultPeriodDays) {
return `${maxQueryPeriod}d`;
}
return `${idealDefaultPeriodDays}d`;
}
@@ -291,7 +291,7 @@ function Upgradable({
}: ConcurrencyResult) {
const lastSubmission = useActionData();
const [form, { environments: formEnvironments }] = useForm({
id: "purchase-concurrency",
id: "allocate-concurrency",
// TODO: type this
lastSubmission: lastSubmission as any,
onValidate({ formData }) {
@@ -446,7 +446,9 @@ function Upgradable({
</TableRow>
</TableBody>
</Table>
<FormError id={formEnvironments.id}>{formEnvironments.error}</FormError>
<FormError id={formEnvironments.id}>
{formEnvironments.error ?? formEnvironments.initialError?.[""]?.[0]}
</FormError>
</div>
<Form className="flex flex-col gap-2" method="post" {...form.props} id="allocate">
<input type="hidden" name="action" value="allocate" />
@@ -664,7 +666,9 @@ function PurchaseConcurrencyModal({
onChange={(e) => setAmountValue(Number(e.target.value))}
disabled={isLoading}
/>
<FormError id={amount.errorId}>{amount.error}</FormError>
<FormError id={amount.errorId}>
{amount.error ?? amount.initialError?.[""]?.[0]}
</FormError>
<FormError>{form.error}</FormError>
</InputGroup>
</Fieldset>
@@ -511,6 +511,11 @@ function QuotasSection({
if (quotas.devQueueSize.limit !== null) quotaRows.push(quotas.devQueueSize);
if (quotas.deployedQueueSize.limit !== null) quotaRows.push(quotas.deployedQueueSize);
// Metric & query quotas
if (quotas.metricDashboards) quotaRows.push(quotas.metricDashboards);
if (quotas.metricWidgetsPerDashboard) quotaRows.push(quotas.metricWidgetsPerDashboard);
if (quotas.queryPeriodDays) quotaRows.push(quotas.queryPeriodDays);
return (
<div className="flex flex-col gap-3">
<Header2 className="flex items-center gap-1">
@@ -555,13 +560,16 @@ function QuotaRow({
isOnTopPlan: boolean;
billingPath: string;
}) {
// For log retention, we don't show current usage as it's a duration, not a count
const isRetentionQuota = quota.name === "Log retention";
// For log retention and query period, we don't show current usage as it's a duration, not a count
// For widgets per dashboard, the usage varies per dashboard so we don't show a single number
const isDurationQuota = quota.name === "Log retention" || quota.name === "Query period";
const isPerItemQuota = quota.name === "Charts per dashboard";
const isRetentionQuota = isDurationQuota || isPerItemQuota;
const percentage =
!isRetentionQuota && quota.limit && quota.limit > 0 ? quota.currentUsage / quota.limit : null;
// Special handling for Log retention
if (quota.name === "Log retention") {
// Special handling for duration-based quotas (Log retention, Query period)
if (isDurationQuota) {
const canUpgrade = !isOnTopPlan;
return (
<TableRow>
@@ -570,7 +578,9 @@ function QuotaRow({
<InfoIconTooltip content={quota.description} disableHoverableContent />
</TableCell>
<TableCell alignment="right" className="font-medium tabular-nums">
{quota.limit !== null ? `${formatNumber(quota.limit)} days` : "Unlimited"}
{quota.limit !== null
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
: "Unlimited"}
</TableCell>
<TableCell alignment="right" className="tabular-nums text-text-dimmed">
@@ -648,8 +658,8 @@ function QuotaRow({
</TableCell>
<TableCell alignment="right" className="font-medium tabular-nums">
{quota.limit !== null
? isRetentionQuota
? `${formatNumber(quota.limit)} days`
? isDurationQuota
? `${formatNumber(quota.limit)} ${quota.limit === 1 ? "day" : "days"}`
: formatNumber(quota.limit)
: "Unlimited"}
</TableCell>
@@ -16,7 +16,7 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { LogsListPresenter, LogEntry } from "~/presenters/v3/LogsListPresenter.server";
import type { LogLevel } from "~/utils/logUtils";
import { $replica, prisma } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { logsClickhouseClient } from "~/services/clickhouseInstance.server";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
@@ -36,12 +36,11 @@ import {
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[] = ["DEBUG", "INFO", "WARN", "ERROR"];
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR"];
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
@@ -95,7 +94,6 @@ async function hasLogsPageAccess(
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const userId = user.id;
const isAdmin = user.admin || user.isImpersonating;
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
@@ -126,7 +124,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
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");
@@ -137,7 +134,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const plan = await getCurrentPlan(project.organizationId);
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
const presenter = new LogsListPresenter($replica, clickhouseClient);
const presenter = new LogsListPresenter($replica, logsClickhouseClient);
const listPromise = presenter
.call(project.organizationId, environment.id, {
@@ -150,7 +147,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
period,
from,
to,
includeDebugLogs: isAdmin && showDebug,
defaultPeriod: "1h",
retentionLimitDays
})
@@ -163,15 +159,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return typeddefer({
data: listPromise,
isAdmin,
showDebug,
defaultPeriod: "1h",
retentionLimitDays,
});
};
export default function Page() {
const { data, isAdmin, showDebug, defaultPeriod, retentionLimitDays } =
const { data, defaultPeriod, retentionLimitDays } =
useTypedLoaderData<typeof loader>();
return (
@@ -199,8 +193,6 @@ export default function Page() {
errorElement={
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
@@ -218,8 +210,6 @@ export default function Page() {
return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
<FiltersBar
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
@@ -235,15 +225,11 @@ export default function Page() {
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
<FiltersBar
list={result}
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
retentionLimitDays={retentionLimitDays}
/>
<LogsList
list={result}
isAdmin={isAdmin}
showDebug={showDebug}
defaultPeriod={defaultPeriod}
/>
</div>
@@ -258,14 +244,10 @@ export default function Page() {
function FiltersBar({
list,
isAdmin,
showDebug,
defaultPeriod,
retentionLimitDays,
}: {
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>;
isAdmin: boolean;
showDebug: boolean;
defaultPeriod?: string;
retentionLimitDays: number;
}) {
@@ -280,16 +262,6 @@ function FiltersBar({
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">
@@ -329,16 +301,6 @@ function FiltersBar({
</>
)}
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Switch
variant="small"
label="Debug"
checked={showDebug}
onCheckedChange={handleDebugToggle}
/>
)}
</div>
</div>
);
}
@@ -347,8 +309,6 @@ function LogsList({
list,
}: {
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>; //exclude error, it is handled
isAdmin: boolean;
showDebug: boolean;
defaultPeriod?: string;
}) {
const navigation = useNavigation();
@@ -362,7 +322,10 @@ function LogsList({
const [nextCursor, setNextCursor] = useState<string | undefined>(list.pagination.next);
// Selected log state - managed locally to avoid triggering navigation
const [selectedLogId, setSelectedLogId] = useState<string | undefined>();
const [selectedLogId, setSelectedLogId] = useState<string | undefined>(() => {
const params = new URLSearchParams(location.search);
return params.get("log") ?? undefined;
});
// Track which filter state (search params) the current fetcher request corresponds to
const fetcherFilterStateRef = useRef<string>(location.search);
@@ -373,8 +336,9 @@ function LogsList({
useEffect(() => {
setAccumulatedLogs([]);
setNextCursor(undefined);
// Close side panel when filters change to avoid showing a log that's no longer visible
setSelectedLogId(undefined);
// Preserve log selection from URL param, clear if not present
const params = new URLSearchParams(location.search);
setSelectedLogId(params.get("log") ?? undefined);
}, [location.search]);
// Populate accumulated logs when new data arrives
@@ -0,0 +1,295 @@
import type { TaskTriggerSource } from "@trigger.dev/database";
import { $replica } from "~/db.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { requireUser } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import {
type LayoutItem,
type Widget,
MetricDashboardPresenter,
} from "~/presenters/v3/MetricDashboardPresenter.server";
import { type LoaderFunctionArgs } from "@remix-run/node";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { z } from "zod";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import ReactGridLayout from "react-grid-layout";
import { MetricWidget } from "../resources.metric";
import { TitleWidget } from "~/components/metrics/TitleWidget";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useEnvironment } from "~/hooks/useEnvironment";
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
import { ScopeFilter } from "~/components/metrics/ScopeFilter";
import { QueuesFilter } from "~/components/metrics/QueuesFilter";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type WidgetData } from "~/components/metrics/QueryWidget";
import { QueryScopeSchema } from "~/v3/querySchemas";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardKey: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardKey } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const presenter = new MetricDashboardPresenter();
const [dashboard, possibleTasks] = await Promise.all([
presenter.builtInDashboard({
organizationId: project.organizationId,
key: dashboardKey,
}),
getAllTaskIdentifiers($replica, environment.id),
]);
return typedjson({
...dashboard,
possibleTasks: possibleTasks
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
.sort((a, b) => a.slug.localeCompare(b.slug)),
});
};
export default function Page() {
const {
key,
title,
layout: dashboardLayout,
defaultPeriod,
possibleTasks,
} = useTypedLoaderData<typeof loader>();
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
</NavBar>
<PageBody scrollable={false}>
<div className="h-full">
<MetricDashboard
key={key}
layout={dashboardLayout.layout}
widgets={dashboardLayout.widgets}
defaultPeriod={defaultPeriod}
editable={false}
possibleTasks={possibleTasks}
/>
</div>
</PageBody>
</PageContainer>
);
}
export function MetricDashboard({
layout,
widgets,
defaultPeriod,
editable,
possibleTasks,
onLayoutChange,
onEditWidget,
onRenameWidget,
onDeleteWidget,
onDuplicateWidget,
}: {
/** The layout items (positions/sizes) - fully controlled from parent */
layout: LayoutItem[];
/** The widget configurations keyed by widget ID - fully controlled from parent */
widgets: Record<string, Widget>;
defaultPeriod: string;
editable: boolean;
/** Possible tasks for filtering */
possibleTasks?: { slug: string; triggerSource: TaskTriggerSource }[];
onLayoutChange?: (layout: LayoutItem[]) => void;
onEditWidget?: (widgetId: string, widget: WidgetData) => void;
onRenameWidget?: (widgetId: string, newTitle: string) => void;
onDeleteWidget?: (widgetId: string) => void;
onDuplicateWidget?: (widgetId: string, widget: WidgetData) => void;
}) {
const { value, values } = useSearchParams();
const { width, containerRef, mounted } = useContainerWidth();
const [resizingItemId, setResizingItemId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const isInteracting = resizingItemId !== null || isDragging;
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
const period = value("period");
const from = value("from");
const to = value("to");
const parsedScope = QueryScopeSchema.safeParse(value("scope") ?? "environment");
const scope = parsedScope.success ? parsedScope.data : "environment";
const tasks = values("tasks").filter((v) => v !== "");
const queues = values("queues").filter((v) => v !== "");
const handleLayoutChange = useCallback(
(newLayout: readonly LayoutItem[]) => {
const mutableLayout = [...newLayout];
onLayoutChange?.(mutableLayout);
},
[onLayoutChange]
);
// Apply constraints for title widgets: fixed height of 2, allow horizontal resize only
const constrainedLayout = useMemo(
() =>
layout.map((item) => {
const widget = widgets[item.i];
if (widget?.display.type === "title") {
return { ...item, h: 2, minH: 2, maxH: 2 };
}
return item;
}),
[layout, widgets]
);
return (
<div className="grid max-h-full grid-rows-[auto_1fr] overflow-hidden">
<div className="flex items-center gap-1 border-b border-b-grid-bright py-2 pl-2 pr-3">
<ScopeFilter />
<LogsTaskFilter possibleTasks={possibleTasks ?? []} />
<QueuesFilter />
<TimeFilter
defaultPeriod={defaultPeriod}
labelName="Period"
hideLabel
maxPeriodDays={maxPeriodDays}
valueClassName="text-text-bright"
/>
</div>
<div
ref={containerRef}
className={cn(
"overflow-y-auto scrollbar-thin scrollbar-track-charcoal-800 scrollbar-thumb-charcoal-700",
isInteracting && "select-none"
)}
>
{mounted && (
<ReactGridLayout
layout={constrainedLayout}
width={width}
gridConfig={{ cols: 12, rowHeight: 30 }}
resizeConfig={{
enabled: editable,
handles: ["se"],
}}
dragConfig={{ enabled: editable, handle: ".drag-handle" }}
onLayoutChange={handleLayoutChange}
onResizeStart={(_layout, oldItem) => setResizingItemId(oldItem?.i ?? null)}
onResizeStop={() => setResizingItemId(null)}
onDragStart={() => setIsDragging(true)}
onDragStop={() => setIsDragging(false)}
>
{Object.entries(widgets).map(([key, widget]) => (
<div key={key}>
{widget.display.type === "title" ? (
<TitleWidget
title={widget.title}
isDraggable={editable}
isResizing={resizingItemId === key}
onRename={
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
}
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
/>
) : (
<MetricWidget
widgetKey={key}
title={widget.title}
query={widget.query}
scope={scope}
period={period ?? defaultPeriod}
from={from ?? null}
to={to ?? null}
taskIdentifiers={tasks.length > 0 ? tasks : undefined}
queues={queues.length > 0 ? queues : undefined}
config={widget.display}
organizationId={organization.id}
projectId={project.id}
environmentId={environment.id}
refreshIntervalMs={60_000}
isResizing={resizingItemId === key}
isDraggable={editable}
onEdit={
onEditWidget
? (resultData) => onEditWidget(key, { ...widget, resultData })
: undefined
}
onRename={
onRenameWidget ? (newTitle) => onRenameWidget(key, newTitle) : undefined
}
onDelete={onDeleteWidget ? () => onDeleteWidget(key) : undefined}
onDuplicate={
onDuplicateWidget
? (resultData) => onDuplicateWidget(key, { ...widget, resultData })
: undefined
}
/>
)}
</div>
))}
</ReactGridLayout>
)}
</div>
</div>
);
}
function useContainerWidth(initialWidth = 1280) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(initialWidth);
const [mounted, setMounted] = useState(false);
const measureWidth = useCallback(() => {
if (containerRef.current) {
setWidth(containerRef.current.offsetWidth);
}
}, []);
useEffect(() => {
measureWidth();
setMounted(true);
const element = containerRef.current;
if (!element) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
resizeObserver.observe(element);
return () => resizeObserver.disconnect();
}, [measureWidth]);
return { width, containerRef, mounted };
}
@@ -0,0 +1,772 @@
import { ArrowUpCircleIcon, PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useNavigation } from "@remix-run/react";
import { IconChartHistogram, IconEdit, IconTypography } from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { toast } from "sonner";
import { z } from "zod";
import { defaultChartConfig } from "~/components/code/ChartConfigPanel";
import { Feedback } from "~/components/Feedback";
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Header3 } from "~/components/primitives/Headers";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Popover,
PopoverContent,
PopoverVerticalEllipseTrigger,
} from "~/components/primitives/Popover";
import { Sheet, SheetContent } from "~/components/primitives/SheetV3";
import { ToastUI } from "~/components/primitives/Toast";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { QueryEditor, type QueryEditorSaveData } from "~/components/query/QueryEditor";
import { $replica, prisma } from "~/db.server";
import { env } from "~/env.server";
import { useDashboardEditor } from "~/hooks/useDashboardEditor";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization, useWidgetLimitPerDashboard } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getAllTaskIdentifiers } from "~/models/task.server";
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import {
EnvironmentParamSchema,
queryPath,
v3BillingPath,
v3BuiltInDashboardPath,
} from "~/utils/pathBuilder";
import { MetricDashboard } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { Type } from "lucide-react";
const ParamSchema = EnvironmentParamSchema.extend({
dashboardId: z.string(),
});
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
statusText: "Project not found",
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
statusText: "Environment not found",
});
}
const dashboardPresenter = new MetricDashboardPresenter();
const queryPresenter = new QueryPresenter();
const [dashboard, { defaultQuery, history }, possibleTasks] = await Promise.all([
dashboardPresenter.customDashboard({
friendlyId: dashboardId,
organizationId: project.organizationId,
}),
queryPresenter.call({
organizationId: project.organizationId,
}),
getAllTaskIdentifiers($replica, environment.id),
]);
// Admins and impersonating users can use EXPLAIN
const isAdmin = user.admin || user.isImpersonating;
// Compute widget count from dashboard layout
const widgetCount = Object.keys(dashboard.layout.widgets).length;
return typedjson({
...dashboard,
// Query editor data
queryDefaultQuery: defaultQuery,
queryHistory: history,
isAdmin,
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
possibleTasks: possibleTasks
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
.sort((a, b) => a.slug.localeCompare(b.slug)),
widgetCount,
});
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
// Load the dashboard
const dashboard = await prisma.metricsDashboard.findFirst({
where: {
friendlyId: dashboardId,
organizationId: project.organizationId,
},
});
if (!dashboard) {
throw new Response("Dashboard not found", { status: 404 });
}
const formData = await request.formData();
const action = formData.get("action");
switch (action) {
case "delete": {
await prisma.metricsDashboard.delete({
where: { id: dashboard.id },
});
return redirectWithSuccessMessage(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
{ slug: projectParam },
{ slug: envParam },
"overview"
),
request,
`Deleted "${dashboard.title}" dashboard`
);
}
case "rename": {
const newTitle = formData.get("title");
if (typeof newTitle !== "string" || newTitle.trim().length === 0) {
throw new Response("Title is required", { status: 400 });
}
await prisma.metricsDashboard.update({
where: { id: dashboard.id },
data: { title: newTitle.trim() },
});
return typedjson({ success: true });
}
default: {
throw new Response("Invalid action", { status: 400 });
}
}
};
export default function Page() {
const {
friendlyId,
title,
layout: dashboardLayout,
defaultPeriod,
queryDefaultQuery,
queryHistory,
isAdmin,
maxRows,
possibleTasks,
widgetCount: initialWidgetCount,
} = useTypedLoaderData<typeof loader>();
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const plan = useCurrentPlan();
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
// Widget limits
const widgetLimitPerDashboard = useWidgetLimitPerDashboard();
const planLimits = (plan?.v3Subscription?.plan?.limits as any)?.metricWidgetsPerDashboard;
const canExceedWidgets = typeof planLimits === "object" && planLimits.canExceed === true;
// Build the action URLs - both use the resource route to avoid full page renders on POST
const widgetActionUrl = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboards/${friendlyId}/widgets`;
const layoutActionUrl = widgetActionUrl;
// Handle sync errors by showing a toast
const handleSyncError = useCallback((error: Error, action: string) => {
const actionMessages: Record<string, string> = {
add: "Failed to add widget",
update: "Failed to update widget",
delete: "Failed to delete widget",
duplicate: "Failed to duplicate widget",
layout: "Failed to save layout",
};
const message = actionMessages[action] || "Failed to save changes";
toast.custom((t) => (
<ToastUI
variant="error"
message={`${message}. Your changes may not be saved.`}
t={t as string}
title="Sync Error"
/>
));
}, []);
// Add title dialog state
const [showAddTitleDialog, setShowAddTitleDialog] = useState(false);
const [newTitleValue, setNewTitleValue] = useState("");
// Widget limit dialog state (triggered when hook blocks add/duplicate)
const [showWidgetLimitDialog, setShowWidgetLimitDialog] = useState(false);
const handleWidgetLimitReached = useCallback(() => {
setShowWidgetLimitDialog(true);
}, []);
// Use the dashboard editor hook for all state management
const { state, actions } = useDashboardEditor({
initialData: dashboardLayout,
widgetActionUrl,
layoutActionUrl,
widgetLimit: canExceedWidgets ? undefined : widgetLimitPerDashboard,
onSyncError: handleSyncError,
onWidgetLimitReached: handleWidgetLimitReached,
});
// Reactive widget count from editor state (title widgets don't count against limits)
const currentWidgetCount = Object.values(state.widgets).filter(
(w) => w.display.type !== "title"
).length;
const totalWidgetCount = Object.keys(state.widgets).length;
const widgetLimits = { used: currentWidgetCount, limit: widgetLimitPerDashboard };
const widgetIsAtLimit = currentWidgetCount >= widgetLimitPerDashboard;
const widgetLimitRatio =
widgetLimits.limit > 0 ? widgetLimits.used / widgetLimits.limit : widgetLimits.used > 0 ? 1 : 0;
const widgetLimitPercent = Math.min(100, Math.max(0, Math.round(widgetLimitRatio * 100)));
const widgetCanUpgrade = plan?.v3Subscription?.plan && !canExceedWidgets;
// Build the query action URL for the editor
const queryActionUrl = queryPath(
{ slug: organization.slug },
{ slug: project.slug },
{ slug: environment.slug }
);
// Handle save from the QueryEditor
const handleSave = useCallback(
(data: QueryEditorSaveData) => {
if (state.editorMode?.type === "add") {
actions.addWidget(data.title, data.query, data.config);
} else if (state.editorMode?.type === "edit") {
actions.updateWidget(state.editorMode.widgetId, data.title, data.query, data.config);
}
},
[state.editorMode, actions]
);
// Render save button for the QueryEditor
const renderSaveForm = useCallback(
(data: QueryEditorSaveData) => {
const isAdd = state.editorMode?.type === "add";
return (
<Button
type="button"
variant="primary/small"
disabled={!data.query}
onClick={() => handleSave(data)}
>
{isAdd ? "Add to dashboard" : "Save changes"}
</Button>
);
},
[state.editorMode, handleSave]
);
// Prepare editor props when in editor mode
const editorProps = state.editorMode
? (() => {
const mode =
state.editorMode.type === "add"
? { type: "dashboard-add" as const, dashboardId: friendlyId, dashboardName: title }
: {
type: "dashboard-edit" as const,
dashboardId: friendlyId,
dashboardName: title,
widgetId: state.editorMode.widgetId,
widgetName: state.editorMode.widget.title,
};
// For edit mode, use the widget's existing values as defaults
const editorDefaultQuery =
state.editorMode.type === "edit" ? state.editorMode.widget.query : queryDefaultQuery;
const editorDefaultChartConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "chart"
? {
chartType: state.editorMode.widget.display.chartType,
xAxisColumn: state.editorMode.widget.display.xAxisColumn,
yAxisColumns: state.editorMode.widget.display.yAxisColumns,
groupByColumn: state.editorMode.widget.display.groupByColumn,
stacked: state.editorMode.widget.display.stacked,
sortByColumn: state.editorMode.widget.display.sortByColumn,
sortDirection: state.editorMode.widget.display.sortDirection,
aggregation: state.editorMode.widget.display.aggregation,
seriesColors: state.editorMode.widget.display.seriesColors,
}
: defaultChartConfig;
const editorDefaultBigNumberConfig =
state.editorMode.type === "edit" && state.editorMode.widget.display.type === "bignumber"
? {
column: state.editorMode.widget.display.column,
aggregation: state.editorMode.widget.display.aggregation,
sortDirection: state.editorMode.widget.display.sortDirection,
abbreviate: state.editorMode.widget.display.abbreviate,
prefix: state.editorMode.widget.display.prefix,
suffix: state.editorMode.widget.display.suffix,
}
: undefined;
const editorDefaultResultsView =
state.editorMode.type === "edit" ? state.editorMode.widget.display.type : "table";
// Pass the existing result data when editing
const editorDefaultData =
state.editorMode.type === "edit" ? state.editorMode.widget.resultData : undefined;
return {
mode,
editorDefaultQuery,
editorDefaultChartConfig,
editorDefaultBigNumberConfig,
editorDefaultResultsView,
editorDefaultData,
};
})()
: null;
return (
<PageContainer>
<NavBar>
<PageTitle title={title} />
<PageAccessories>
{totalWidgetCount > 0 &&
(widgetIsAtLimit ? (
<>
<Dialog>
<DialogTrigger asChild>
<Button variant="primary/small" LeadingIcon={PlusIcon}>
Add chart
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this
dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
) : (
<>
<Button
variant="primary/small"
LeadingIcon={IconChartHistogram}
className="pl-1.5"
iconSpacing="gap-x-1.5"
onClick={actions.openAddEditor}
>
Add chart
</Button>
<Button
variant="secondary/small"
LeadingIcon={Type}
className="pl-1.5"
iconSpacing="gap-x-1"
onClick={() => {
setNewTitleValue("");
setShowAddTitleDialog(true);
}}
>
Add title
</Button>
</>
))}
<Popover>
<PopoverVerticalEllipseTrigger variant="secondary" />
<PopoverContent className="w-fit min-w-[10rem] p-1" align="end">
<div className="flex flex-col gap-1">
<RenameDashboardDialog title={title} />
<DeleteDashboardDialog title={title} />
</div>
</PopoverContent>
</Popover>
</PageAccessories>
</NavBar>
<PageBody scrollable={false}>
<div className="flex h-full flex-col">
<div className="min-h-0 flex-1">
{totalWidgetCount === 0 ? (
<MainCenteredContainer className="max-w-md">
<InfoPanel
icon={IconChartHistogram}
iconClassName="text-metrics"
panelClassName="max-full"
title="Add your first chart"
accessory={
<Button
variant="primary/small"
LeadingIcon={PlusIcon}
onClick={actions.openAddEditor}
>
Add chart
</Button>
}
>
<Paragraph variant="small">
Charts let you visualize your task metrics. Write a query to pull data from your
runs, then choose how to display it on this dashboard.
</Paragraph>
</InfoPanel>
</MainCenteredContainer>
) : (
<MetricDashboard
key={friendlyId}
layout={state.layout}
widgets={state.widgets}
defaultPeriod={defaultPeriod}
editable={true}
possibleTasks={possibleTasks}
onLayoutChange={actions.updateLayout}
onEditWidget={actions.openEditEditor}
onRenameWidget={actions.renameWidget}
onDeleteWidget={actions.deleteWidget}
onDuplicateWidget={actions.duplicateWidget}
/>
)}
</div>
<div className="flex w-full items-start justify-between">
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
<SimpleTooltip
button={
<div className="size-6">
<svg className="h-full w-full -rotate-90 overflow-visible">
<circle
className="fill-none stroke-grid-bright"
strokeWidth="4"
r="10"
cx="12"
cy="12"
/>
<circle
className={`fill-none ${
widgetIsAtLimit ? "stroke-error" : "stroke-success"
}`}
strokeWidth="4"
r="10"
cx="12"
cy="12"
strokeDasharray={`${widgetLimitRatio * 62.8} 62.8`}
strokeDashoffset="0"
strokeLinecap="round"
/>
</svg>
</div>
}
content={`${widgetLimitPercent}%`}
/>
<div className="flex w-full items-center justify-between gap-6">
{widgetIsAtLimit ? (
<Header3 className="text-error">
You've used all {widgetLimits.limit} of your available widgets. Upgrade your
plan to enable more.
</Header3>
) : (
<Header3>
You've used {widgetLimits.used}/{widgetLimits.limit} of your charts
</Header3>
)}
{widgetCanUpgrade ? (
<LinkButton
to={v3BillingPath(organization)}
variant="secondary/small"
LeadingIcon={ArrowUpCircleIcon}
leadingIconClassName="text-indigo-500"
>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="secondary/small">Request more</Button>}
defaultValue="help"
/>
)}
</div>
</div>
</div>
</div>
</PageBody>
{/* Query Editor Sheet - opens on top of the dashboard */}
<Sheet open={!!state.editorMode} onOpenChange={(open) => !open && actions.closeEditor()}>
<SheetContent
side="right"
className="w-[90vw] max-w-none rounded-l-lg border-l border-grid-dimmed p-0 sm:max-w-none"
>
{editorProps && (
<QueryEditor
defaultQuery={editorProps.editorDefaultQuery}
defaultScope="environment"
defaultPeriod={defaultPeriod}
defaultResultsView={
editorProps.editorDefaultResultsView === "chart"
? "graph"
: editorProps.editorDefaultResultsView === "bignumber"
? "bignumber"
: "table"
}
defaultChartConfig={editorProps.editorDefaultChartConfig}
defaultBigNumberConfig={editorProps.editorDefaultBigNumberConfig}
defaultData={editorProps.editorDefaultData}
history={queryHistory}
isAdmin={isAdmin}
maxRows={maxRows}
queryActionUrl={queryActionUrl}
mode={editorProps.mode}
maxPeriodDays={maxPeriodDays}
save={renderSaveForm}
onClose={actions.closeEditor}
/>
)}
</SheetContent>
</Sheet>
{/* Add title dialog */}
<Dialog open={showAddTitleDialog} onOpenChange={setShowAddTitleDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>Add title</DialogHeader>
<form
className="space-y-4 pt-3"
onSubmit={(e) => {
e.preventDefault();
if (newTitleValue.trim()) {
actions.addTitleWidget(newTitleValue.trim());
setShowAddTitleDialog(false);
}
}}
>
<InputGroup>
<Label>Title</Label>
<Input
value={newTitleValue}
onChange={(e) => setNewTitleValue(e.target.value)}
placeholder="Section title"
autoFocus
/>
</InputGroup>
<DialogFooter>
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
<Button type="submit" variant="primary/medium" disabled={!newTitleValue.trim()}>
Add
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Widget limit dialog - triggered by hook when add/duplicate is blocked */}
<Dialog open={showWidgetLimitDialog} onOpenChange={setShowWidgetLimitDialog}>
<DialogContent>
<DialogHeader>You've exceeded your widget limit</DialogHeader>
<DialogDescription>
You've used {widgetLimits.used}/{widgetLimits.limit} widgets on this dashboard.
</DialogDescription>
<DialogFooter>
{widgetCanUpgrade ? (
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
Upgrade
</LinkButton>
) : (
<Feedback
button={<Button variant="primary/small">Request more</Button>}
defaultValue="help"
/>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</PageContainer>
);
}
function RenameDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [newTitle, setNewTitle] = useState(title);
const isRenaming =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "rename";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
// Sync newTitle state when title changes (after successful rename)
useEffect(() => {
setNewTitle(title);
}, [title]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={IconEdit}
fullWidth
textAlignLeft
className="pl-0.5 pr-3"
leadingIconClassName="gap-x-0"
>
Rename dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>Rename dashboard</DialogHeader>
<Form method="post" className="space-y-4">
<input type="hidden" name="action" value="rename" />
<InputGroup>
<Label>Title</Label>
<Input
name="title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Dashboard title"
required
autoFocus
/>
</InputGroup>
<FormButtons
confirmButton={
<Button
type="submit"
variant="primary/medium"
disabled={isRenaming || !newTitle.trim()}
>
{isRenaming ? "Saving…" : "Save"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button variant="tertiary/medium">Cancel</Button>
</DialogClose>
}
/>
</Form>
</DialogContent>
</Dialog>
);
}
function DeleteDashboardDialog({ title }: { title: string }) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const isDeleting =
navigation.state !== "idle" &&
navigation.formMethod === "post" &&
navigation.formData?.get("action") === "delete";
// Close dialog when navigation completes
useEffect(() => {
if (navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
fullWidth
textAlignLeft
>
Delete dashboard
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>Delete dashboard</DialogHeader>
<div className="mb-2 mt-4 flex flex-col gap-2">
<Paragraph>
Are you sure you want to delete <strong>"{title}"</strong>? This action cannot be undone
and all widgets on this dashboard will be permanently removed.
</Paragraph>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<Form method="post">
<Button
type="submit"
name="action"
value="delete"
variant="danger/medium"
LeadingIcon={TrashIcon}
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</Form>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,8 @@
import { useState } from "react";
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
import { AIQueryInput } from "~/components/code/AIQueryInput";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import type { AITimeFilter } from "./types";
export function AITabContent({
@@ -28,6 +30,8 @@ export function AITabContent({
"Top 50 most expensive runs this week",
"Average execution duration by task this week",
"Run counts by tag in the past 7 days",
"CPU utilization over time by task",
"Peak memory usage per run",
];
return (
@@ -41,8 +45,8 @@ export function AITabContent({
/>
<div className="pt-4">
<Header3 className="mb-2 text-text-bright">Example prompts</Header3>
<div className="space-y-2">
<Header3 className="mb-3 text-text-bright">Example prompts</Header3>
<div className="flex flex-wrap gap-2">
{examplePrompts.map((example) => (
<button
key={example}
@@ -53,9 +57,12 @@ export function AITabContent({
key: (prev?.key ?? 0) + 1,
}));
}}
className="block w-full rounded-md border border-grid-dimmed bg-charcoal-800 px-3 py-2 text-left text-sm text-text-dimmed transition-colors hover:border-grid-bright hover:bg-charcoal-750 hover:text-text-bright"
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500 focus-custom focus-visible:!rounded-full"
>
{example}
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
<Paragraph variant="small" className="text-left transition group-hover:text-text-bright">
{example}
</Paragraph>
</button>
))}
</div>
@@ -63,4 +70,3 @@ export function AITabContent({
</div>
);
}
@@ -1,6 +1,9 @@
import { useState } from "react";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import type { QueryScope } from "~/services/queryService.server";
import { querySchemas } from "~/v3/querySchemas";
import { TryableCodeBlock } from "./TRQLGuideContent";
// Example queries for the Examples tab
@@ -9,6 +12,7 @@ export const exampleQueries: Array<{
description: string;
query: string;
scope: QueryScope;
table: string;
}> = [
{
title: "Failed runs by task (past 7 days)",
@@ -23,6 +27,7 @@ GROUP BY task_identifier
ORDER BY failed_count DESC
LIMIT 20`,
scope: "environment",
table: "runs",
},
{
title: "Execution duration p50 by task (past 7d)",
@@ -37,6 +42,21 @@ GROUP BY task_identifier
ORDER BY p50_duration_ms DESC
LIMIT 20`,
scope: "environment",
table: "runs",
},
{
title: "Runs over time",
description:
"Count of runs bucketed over time. The bucket size adjusts automatically to the time range.",
query: `SELECT
timeBucket(),
count() AS run_count
FROM runs
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000`,
scope: "environment",
table: "runs",
},
{
title: "Most expensive 100 runs (past 7d)",
@@ -54,17 +74,75 @@ WHERE triggered_at > now() - INTERVAL 7 DAY
ORDER BY total_cost DESC
LIMIT 100`,
scope: "environment",
table: "runs",
},
{
title: "CPU utilization over time",
description: "Track process CPU utilization bucketed over time.",
query: `SELECT
timeBucket(),
avg(metric_value) AS avg_cpu
FROM metrics
WHERE metric_name = 'process.cpu.utilization'
GROUP BY timeBucket
ORDER BY timeBucket
LIMIT 1000`,
scope: "environment",
table: "metrics",
},
{
title: "Memory usage by task (past 7d)",
description: "Average memory usage per task identifier over the last 7 days.",
query: `SELECT
task_identifier,
avg(metric_value) AS avg_memory
FROM metrics
WHERE metric_name = 'system.memory.usage'
AND bucket_start > now() - INTERVAL 7 DAY
GROUP BY task_identifier
ORDER BY avg_memory DESC
LIMIT 20`,
scope: "environment",
table: "metrics",
},
{
title: "Available metric names",
description: "List all distinct metric names collected in your environment.",
query: `SELECT
metric_name,
count() AS sample_count
FROM metrics
GROUP BY metric_name
ORDER BY sample_count DESC
LIMIT 100`,
scope: "environment",
table: "metrics",
},
];
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
export function ExamplesContent({
onTryExample,
}: {
onTryExample: (query: string, scope: QueryScope) => void;
}) {
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
const filtered = exampleQueries.filter((e) => e.table === selectedTable);
return (
<div className="space-y-6">
{exampleQueries.map((example) => (
<div className="sticky top-0 z-10 bg-background-bright pb-3">
<SegmentedControl
name="examples-table-selector"
value={selectedTable}
options={tableOptions}
variant="secondary/small"
fullWidth
onChange={setSelectedTable}
/>
</div>
{filtered.map((example) => (
<div key={example.title}>
<Header3 className="mb-1 text-text-bright">{example.title}</Header3>
<Paragraph variant="small" className="mb-2 text-text-dimmed">
@@ -1,9 +1,12 @@
import { useState } from "react";
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
import { Button } from "~/components/primitives/Buttons";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import { Popover, PopoverTrigger } from "~/components/primitives/Popover";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type { QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
import { timeFilterRenderValues } from "~/components/runs/v3/SharedFilters";
import { ChevronUpDownIcon } from "@heroicons/react/20/solid";
import { cn } from "~/utils/cn";
const SQL_KEYWORDS = [
"SELECT",
@@ -91,23 +94,32 @@ export function QueryHistoryPopover({
<PopoverTrigger asChild>
<Button
type="button"
variant="tertiary/small"
variant="secondary/small"
LeadingIcon={ClockRotateLeftIcon}
leadingIconClassName="-mr-1.5"
TrailingIcon={ChevronUpDownIcon}
disabled={history.length === 0}
>
History
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[400px] min-w-0 overflow-hidden p-0"
<PopoverPrimitive.Content
className={cn(
"z-50 w-[400px] min-w-0 overflow-hidden rounded border border-charcoal-700 bg-background-bright p-0 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
)}
align="start"
sideOffset={6}
style={{ maxHeight: "var(--radix-popover-content-available-height)" }}
>
<div className="max-h-80 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="max-h-[40rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<div className="p-1">
{history.map((item) => {
// Format time filter display
const { valueLabel } = timeFilterRenderValues({ period: item.filterPeriod ?? undefined, from: item.filterFrom ?? undefined, to: item.filterTo ?? undefined });
const { valueLabel } = timeFilterRenderValues({
period: item.filterPeriod ?? undefined,
from: item.filterFrom ?? undefined,
to: item.filterTo ?? undefined,
});
return (
<button
@@ -117,21 +129,16 @@ export function QueryHistoryPopover({
onQuerySelected(item);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
className="flex w-full flex-col gap-1 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-750"
>
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
<div className="flex w-full flex-col items-start">
{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="mb-1 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-[#9b99ff]">
{highlightSQL(item.query)}
<p className="mb-1 truncate text-left font-mono text-xs text-text-bright">
{item.query.split("\n")[0].slice(0, 60)}
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
@@ -140,13 +147,17 @@ export function QueryHistoryPopover({
{item.userName && <span>· {item.userName}</span>}
</div>
</div>
<div className="w-full border-l-2 border-charcoal-600 pl-2.5">
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
{highlightSQL(item.query)}
</p>
</div>
</button>
);
})}
</div>
</div>
</PopoverContent>
</PopoverPrimitive.Content>
</Popover>
);
}
@@ -488,6 +488,11 @@ ORDER BY run_count DESC`,
<FunctionCategory
title="Date/time functions"
functions={[
{
name: "timeBucket()",
desc: "Auto-bucket by time period. Uses the table's time column with an interval based on the query's time range.",
example: "SELECT timeBucket(), count() FROM runs GROUP BY timeBucket",
},
{ name: "now()", desc: "Current date and time", example: "now()" },
{ name: "today()", desc: "Current date", example: "today()" },
{ name: "yesterday()", desc: "Yesterday's date", example: "yesterday()" },
@@ -1,8 +1,10 @@
import { useState } from "react";
import type { ColumnSchema } from "@internal/tsql";
import { Badge } from "~/components/primitives/Badge";
import { CopyableText } from "~/components/primitives/CopyableText";
import { Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { querySchemas } from "~/v3/querySchemas";
function ColumnHelpItem({ col }: { col: ColumnSchema }) {
@@ -42,26 +44,36 @@ function ColumnHelpItem({ col }: { col: ColumnSchema }) {
);
}
const tableOptions = querySchemas.map((s) => ({ label: s.name, value: s.name }));
export function TableSchemaContent() {
const [selectedTable, setSelectedTable] = useState(querySchemas[0].name);
const table = querySchemas.find((s) => s.name === selectedTable) ?? querySchemas[0];
return (
<div>
{querySchemas.map((table) => (
<div key={table.name} className="mb-6">
<div className="mb-2">
<Header3 className="font-mono text-text-bright">{table.name}</Header3>
{table.description && (
<Paragraph variant="small" className="mt-1 text-text-dimmed">
{table.description}
</Paragraph>
)}
</div>
<div className="flex flex-col gap-2 divide-y divide-grid-dimmed">
{Object.values(table.columns).map((col) => (
<ColumnHelpItem key={col.name} col={col} />
))}
</div>
</div>
))}
<div className="sticky top-0 z-10 bg-background-bright pb-3">
<SegmentedControl
name="table-schema-selector"
value={selectedTable}
options={tableOptions}
variant="secondary/small"
fullWidth
onChange={setSelectedTable}
/>
</div>
<div className="mb-2">
{table.description && (
<Paragraph variant="small" className="text-text-dimmed">
{table.description}
</Paragraph>
)}
</div>
<div className="flex flex-col gap-2 divide-y divide-grid-dimmed">
{Object.values(table.columns).map((col) => (
<ColumnHelpItem key={col.name} col={col} />
))}
</div>
</div>
);
}
@@ -54,7 +54,7 @@ import {
TestTaskPresenter,
} from "~/presenters/v3/TestTaskPresenter.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { docsPath, v3RunSpanPath, v3TaskParamsSchema, v3TestPath } from "~/utils/pathBuilder";
import { TestTaskService } from "~/v3/services/testTask.server";
@@ -75,14 +75,15 @@ import { DialogClose, DialogDescription } from "@radix-ui/react-dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { $replica } from "~/db.server";
import { clickhouseClient } from "~/services/clickhouseInstance.server";
import { RegionsPresenter, type Region } from "~/presenters/v3/RegionsPresenter.server";
type FormAction = "create-template" | "delete-template" | "run-scheduled" | "run-standard";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, taskParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response(undefined, {
status: 404,
@@ -90,7 +91,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
throw new Response(undefined, {
status: 404,
@@ -100,14 +101,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const presenter = new TestTaskPresenter($replica, clickhouseClient);
try {
const result = await presenter.call({
userId,
projectId: project.id,
taskIdentifier: taskParam,
environment: environment,
});
const [result, regionsResult] = await Promise.all([
presenter.call({
userId: user.id,
projectId: project.id,
taskIdentifier: taskParam,
environment: environment,
}),
new RegionsPresenter().call({
userId: user.id,
projectSlug: projectParam,
isAdmin: user.admin || user.isImpersonating,
}),
]);
return typedjson(result);
return typedjson({ ...result, regions: regionsResult.regions });
} catch (error) {
return redirectWithErrorMessage(
v3TestPath({ slug: organizationSlug }, { slug: projectParam }, environment),
@@ -118,15 +126,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const { organizationSlug, projectParam, envParam } = v3TaskParamsSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
return redirectBackWithErrorMessage(request, "Project not found");
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
if (!environment) {
return redirectBackWithErrorMessage(request, "Environment not found");
@@ -290,6 +298,7 @@ export default function Page() {
templates={result.taskRunTemplates}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
@@ -304,6 +313,7 @@ export default function Page() {
possibleTimezones={result.possibleTimezones}
disableVersionSelection={result.disableVersionSelection}
allowArbitraryQueues={result.allowArbitraryQueues}
regions={result.regions}
/>
);
}
@@ -324,6 +334,7 @@ function StandardTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: StandardTaskResult["task"];
queues: Required<StandardTaskResult>["queue"][];
@@ -332,6 +343,7 @@ function StandardTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
const { value, replace } = useSearchParams();
@@ -373,6 +385,12 @@ function StandardTaskForm({
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
@@ -381,6 +399,12 @@ function StandardTaskForm({
);
const [tagsValue, setTagsValue] = useState<string[]>(lastRun?.runTags ?? []);
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name}${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -409,6 +433,7 @@ function StandardTaskForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -580,6 +605,45 @@ function StandardTaskForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -803,6 +867,7 @@ function ScheduledTaskForm({
templates,
disableVersionSelection,
allowArbitraryQueues,
regions,
}: {
task: ScheduledTaskResult["task"];
runs: ScheduledRun[];
@@ -812,6 +877,7 @@ function ScheduledTaskForm({
templates: RunTemplate[];
disableVersionSelection: boolean;
allowArbitraryQueues: boolean;
regions: Region[];
}) {
const environment = useEnvironment();
@@ -833,6 +899,12 @@ function ScheduledTaskForm({
);
const [queueValue, setQueueValue] = useState<string | undefined>(lastRun?.queue);
const [machineValue, setMachineValue] = useState<string | undefined>(lastRun?.machinePreset);
const isDev = environment.type === "DEVELOPMENT";
const defaultRegion = regions.find((r) => r.isDefault);
const [regionValue, setRegionValue] = useState<string | undefined>(
isDev ? undefined : defaultRegion?.name
);
const [maxAttemptsValue, setMaxAttemptsValue] = useState<number | undefined>(
lastRun?.maxAttempts
);
@@ -843,6 +915,12 @@ function ScheduledTaskForm({
const [showTemplateCreatedSuccessMessage, setShowTemplateCreatedSuccessMessage] = useState(false);
const regionItems = regions.map((r) => ({
value: r.name,
label: r.description ? `${r.name} — ${r.description}` : r.name,
isDefault: r.isDefault,
}));
const queueItems = queues.map((q) => ({
value: q.type === "task" ? `task/${q.name}` : q.name,
label: q.name,
@@ -879,6 +957,7 @@ function ScheduledTaskForm({
tags,
version,
machine,
region,
prioritySeconds,
},
] = useForm({
@@ -1101,6 +1180,45 @@ function ScheduledTaskForm({
)}
<FormError id={version.errorId}>{version.error}</FormError>
</InputGroup>
{regionItems.length > 1 && (
<InputGroup>
<Label htmlFor={region.id} variant="small">
Region
</Label>
{/* Our Select primitive uses Ariakit under the hood, which treats
value={undefined} as uncontrolled, keeping stale internal state when
switching environments. The key forces a remount so it reinitializes
with the correct defaultValue. */}
<Select
key={`region-${environment.id}`}
{...conform.select(region)}
variant="tertiary/small"
placeholder={isDev ? "" : undefined}
dropdownIcon
items={regionItems}
defaultValue={isDev ? undefined : defaultRegion?.name}
value={isDev ? undefined : regionValue}
setValue={isDev ? undefined : (e) => {
if (Array.isArray(e)) return;
setRegionValue(e);
}}
disabled={isDev}
>
{regionItems.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
{r.isDefault ? " (default)" : ""}
</SelectItem>
))}
</Select>
{isDev ? (
<Hint>Region is not available in the development environment.</Hint>
) : (
<Hint>Overrides the region for this run.</Hint>
)}
<FormError id={region.errorId}>{region.error}</FormError>
</InputGroup>
)}
<InputGroup>
<Label htmlFor={queue.id} variant="small">
Queue
@@ -3,6 +3,7 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
import { prisma } from "~/db.server";
import { useOptionalOrganization } from "~/hooks/useOrganizations";
import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
@@ -87,9 +88,18 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
firstDayOfNextMonth.setUTCDate(1);
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
const [plan, usage] = await Promise.all([
const [plan, usage, customDashboards] = await Promise.all([
getCurrentPlan(organization.id),
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
prisma.metricsDashboard.findMany({
where: { organizationId: organization.id },
select: {
friendlyId: true,
title: true,
layout: true,
},
orderBy: { createdAt: "desc" },
}),
]);
let hasExceededFreeTier = false;
@@ -99,6 +109,39 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
usagePercentage = usage.cents / plan.v3Subscription.plan.limits.includedUsage;
}
// Derive metric dashboard limit from plan, fallback to 3
const metricDashboardsLimitValue = plan?.v3Subscription?.plan?.limits?.metricDashboards;
const dashboardLimit =
typeof metricDashboardsLimitValue === "number"
? metricDashboardsLimitValue
: metricDashboardsLimitValue?.number ?? 3;
// Derive widget-per-dashboard limit from plan, fallback to 16
const metricWidgetsLimitValue = plan?.v3Subscription?.plan?.limits?.metricWidgetsPerDashboard;
const widgetLimitPerDashboard =
typeof metricWidgetsLimitValue === "number"
? metricWidgetsLimitValue
: metricWidgetsLimitValue?.number ?? 16;
// Compute widget counts per dashboard from layout JSON
const customDashboardsWithWidgetCount = customDashboards.map((d) => {
let widgetCount = 0;
try {
const layout = JSON.parse(String(d.layout)) as Record<string, unknown>;
const widgets = layout.widgets;
if (widgets && typeof widgets === "object") {
widgetCount = Object.keys(widgets as Record<string, unknown>).length;
}
} catch {
// ignore parse errors
}
return {
friendlyId: d.friendlyId,
title: d.title,
widgetCount,
};
});
return typedjson({
organizations,
organization,
@@ -106,6 +149,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
environment,
isImpersonating: !!impersonationId,
currentPlan: { ...plan, v3Usage: { ...usage, hasExceededFreeTier, usagePercentage } },
customDashboards: customDashboardsWithWidgetCount,
dashboardLimits: {
used: customDashboards.length,
limit: dashboardLimit,
},
widgetLimitPerDashboard,
});
};

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