From 61fd36b432552a09d2e4320bee1cfabaccb2d6fb Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 11:06:54 +0000 Subject: [PATCH 01/12] Remove LIMIT from built-in dashboard queries --- .../presenters/v3/BuiltInDashboards.server.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts index 961cf99117..4e5f78afc6 100644 --- a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts +++ b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts @@ -29,13 +29,13 @@ const overviewDashboard: BuiltInDashboard = { widgets: { "9lDDdebQ": { title: "Total runs", - query: "SELECT\r\n count() AS total_runs\r\nFROM\r\n runs\r\nLIMIT\r\n 100", + 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\r\nLIMIT\r\n 100", + "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", @@ -47,14 +47,14 @@ const overviewDashboard: BuiltInDashboard = { 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')\r\nLIMIT\r\n 100", + "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\r\nLIMIT\r\n 100", + "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", @@ -70,20 +70,20 @@ const overviewDashboard: BuiltInDashboard = { 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\r\nLIMIT\r\n 100;", + "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\r\nLIMIT\r\n 100;", + "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\r\nLIMIT\r\n 100", + "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", @@ -99,7 +99,7 @@ const overviewDashboard: BuiltInDashboard = { 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\r\nLIMIT\r\n 100", + "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", @@ -132,13 +132,13 @@ const overviewDashboard: BuiltInDashboard = { 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\r\nLIMIT\r\n 100", + "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\r\nLIMIT\r\n 100", + "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", @@ -155,7 +155,7 @@ const overviewDashboard: BuiltInDashboard = { 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\r\nLIMIT\r\n 100", + "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", @@ -206,7 +206,7 @@ const overviewDashboard: BuiltInDashboard = { xyQl3FAd: { title: "Queued", query: - "SELECT\r\n count() AS queued\r\nFROM\r\n runs\r\nWHERE status IN ('Dequeued', 'Queued')\r\nLIMIT\r\n 100", + "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 }, }, }, From 9b0e0cb8b1558969f5b1b320981953452264f566 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 11:13:12 +0000 Subject: [PATCH 02/12] Concurrency now per project, use env vars --- apps/webapp/app/env.server.ts | 5 ++++- .../route.tsx | 7 ++++++- apps/webapp/app/routes/resources.metric.tsx | 3 ++- apps/webapp/app/services/queryService.server.ts | 2 +- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index a6d8905f28..5a321c58b6 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1212,7 +1212,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() diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index d518f75e4f..848546a518 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -1,4 +1,8 @@ -import { redirect, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { + redirect, + type ActionFunctionArgs, + type LoaderFunctionArgs, +} from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { QueryEditor } from "~/components/query/QueryEditor"; @@ -89,6 +93,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { isImpersonating: user.isImpersonating, organizationSlug, }); + if (!canAccess) { return typedjson( { diff --git a/apps/webapp/app/routes/resources.metric.tsx b/apps/webapp/app/routes/resources.metric.tsx index ffee45a038..a037fb7882 100644 --- a/apps/webapp/app/routes/resources.metric.tsx +++ b/apps/webapp/app/routes/resources.metric.tsx @@ -12,6 +12,7 @@ import { } from "~/components/metrics/QueryWidget"; import { useElementVisibility } from "~/hooks/useElementVisibility"; import { useInterval } from "~/hooks/useInterval"; +import { env } from "~/env.server"; const Scope = z.union([z.literal("environment"), z.literal("organization"), z.literal("project")]); @@ -107,7 +108,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { taskIdentifiers, queues, // Set higher concurrency if many widgets are on screen at once - customOrgConcurrencyLimit: 15, + customOrgConcurrencyLimit: env.METRIC_WIDGET_DEFAULT_ORG_CONCURRENCY_LIMIT, }); if (!queryResult.success) { diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts index 66cec330a8..0287785358 100644 --- a/apps/webapp/app/services/queryService.server.ts +++ b/apps/webapp/app/services/queryService.server.ts @@ -138,7 +138,7 @@ export async function executeQuery( // Acquire concurrency slot const acquireResult = await queryConcurrencyLimiter.acquire({ - key: organizationId, + key: projectId, requestId, keyLimit: orgLimit, globalLimit: GLOBAL_CONCURRENCY_LIMIT, From 4ae8eedee7150c58b04dbc22f1dc9c5919eb1899 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 11:31:09 +0000 Subject: [PATCH 03/12] Fix: fallback to default period for widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously it was using 7d by default… even though the Metrics default is 1d 🤦‍♂️ --- .../route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx index 974f7805b1..4429e0c06e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.metrics.$dashboardKey/route.tsx @@ -226,7 +226,7 @@ export function MetricDashboard({ title={widget.title} query={widget.query} scope={scope} - period={period ?? null} + period={period ?? defaultPeriod} from={from ?? null} to={to ?? null} taskIdentifiers={tasks.length > 0 ? tasks : undefined} From 9342a7677d1f3f5e80b313bd8f8c5c73262127d7 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 12:09:17 +0000 Subject: [PATCH 04/12] =?UTF-8?q?Release=20the=20concurrency=20for=20the?= =?UTF-8?q?=20project=20too=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/services/queryService.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts index 0287785358..4f572e7428 100644 --- a/apps/webapp/app/services/queryService.server.ts +++ b/apps/webapp/app/services/queryService.server.ts @@ -328,7 +328,7 @@ export async function executeQuery( } finally { // Always release the concurrency slot await queryConcurrencyLimiter.release({ - key: organizationId, + key: projectId, requestId, }); } From 01f3694e4e84b41a0ac1d699251d00c52ec5ea8f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 12:10:04 +0000 Subject: [PATCH 05/12] Sort the series for the graphs In the graph largest at the bottom, the legend is largest at the top --- .../app/components/code/QueryResultsChart.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 17fba0383d..44ab0d9feb 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -775,6 +775,24 @@ export const QueryResultsChart = memo(function QueryResultsChart({ return sortData(unsortedData, sortByColumn, sortDirection, xDataKey); }, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]); + // 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(); + 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]); + // 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) @@ -809,7 +827,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ // 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, @@ -817,7 +835,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ }; }); return cfg; - }, [series, groupByIsRunStatus, config.seriesColors]); + }, [sortedSeries, groupByIsRunStatus, config.seriesColors]); // Custom tooltip label formatter for better date display const tooltipLabelFormatter = useMemo(() => { @@ -1002,7 +1020,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ domain: yAxisDomain, }; - const showLegend = series.length > 0; + const showLegend = sortedSeries.length > 0; if (chartType === "bar") { return ( @@ -1010,7 +1028,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ config={chartConfig} data={data} dataKey={xDataKey} - series={series} + series={sortedSeries} labelFormatter={legendLabelFormatter} showLegend={showLegend} maxLegendItems={fullLegend ? Infinity : 5} @@ -1036,7 +1054,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ config={chartConfig} data={data} dataKey={xDataKey} - series={series} + series={sortedSeries} labelFormatter={legendLabelFormatter} showLegend={showLegend} maxLegendItems={fullLegend ? Infinity : 5} @@ -1049,7 +1067,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ 1} + stacked={stacked && sortedSeries.length > 1} tooltipLabelFormatter={tooltipLabelFormatter} lineType="linear" /> From b96cc3561bc8fc1411f0b0547888b7a53d9d9063 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 12:10:20 +0000 Subject: [PATCH 06/12] Use avg aggregation for some of the built in charts --- apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts index 4e5f78afc6..da95eeacc0 100644 --- a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts +++ b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts @@ -39,7 +39,7 @@ const overviewDashboard: BuiltInDashboard = { display: { type: "bignumber", column: "success_percentage", - aggregation: "sum", + aggregation: "avg", abbreviate: true, suffix: "%", }, @@ -113,7 +113,7 @@ const overviewDashboard: BuiltInDashboard = { }, }, i3q1Awfz: { - title: "Run success", + 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: { @@ -125,7 +125,7 @@ const overviewDashboard: BuiltInDashboard = { stacked: false, sortByColumn: null, sortDirection: "asc", - aggregation: "sum", + aggregation: "avg", seriesColors: { failed: "#f43f5e" }, }, }, @@ -199,7 +199,7 @@ const overviewDashboard: BuiltInDashboard = { stacked: false, sortByColumn: null, sortDirection: "asc", - aggregation: "sum", + aggregation: "avg", seriesColors: {}, }, }, From b74f92d052c7751c8bc128e4df626380bb3448bc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 12:38:52 +0000 Subject: [PATCH 07/12] Aggregation improvements for the legend --- .../app/components/code/QueryResultsChart.tsx | 24 +----- .../primitives/charts/ChartLegendCompound.tsx | 62 ++++++++++----- .../primitives/charts/ChartRoot.tsx | 79 +++++++++++++++++-- .../primitives/charts/aggregation.ts | 23 ++++++ 4 files changed, 143 insertions(+), 45 deletions(-) create mode 100644 apps/webapp/app/components/primitives/charts/aggregation.ts diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 44ab0d9feb..14aa591b92 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -3,7 +3,8 @@ import { memo, useMemo } from "react"; import type { ChartConfig } from "~/components/primitives/charts/Chart"; import { Chart } from "~/components/primitives/charts/ChartCompound"; import { Paragraph } from "../primitives/Paragraph"; -import { AggregationType, ChartConfiguration } from "../metrics/QueryWidget"; +import type { AggregationType, ChartConfiguration } from "../metrics/QueryWidget"; +import { aggregateValues } from "../primitives/charts/aggregation"; import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus"; import { getSeriesColor } from "./chartColors"; @@ -671,25 +672,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 */ @@ -1032,6 +1014,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ labelFormatter={legendLabelFormatter} showLegend={showLegend} maxLegendItems={fullLegend ? Infinity : 5} + legendAggregation={config.aggregation} minHeight="300px" fillContainer onViewAllLegendItems={onViewAllLegendItems} @@ -1058,6 +1041,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ labelFormatter={legendLabelFormatter} showLegend={showLegend} maxLegendItems={fullLegend ? Infinity : 5} + legendAggregation={config.aggregation} minHeight="300px" fillContainer onViewAllLegendItems={onViewAllLegendItems} diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index 8d525aa1a0..c49903e8db 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -1,9 +1,19 @@ import React, { useMemo } from "react"; +import type { AggregationType } from "~/components/metrics/QueryWidget"; import { useChartContext } from "./ChartContext"; import { useSeriesTotal } from "./ChartRoot"; +import { aggregateValues } from "./aggregation"; import { cn } from "~/utils/cn"; import { AnimatedNumber } from "../AnimatedNumber"; +const aggregationLabels: Record = { + sum: "Sum", + avg: "Average", + count: "Count", + min: "Min", + max: "Max", +}; + export type ChartLegendCompoundProps = { /** Maximum number of legend items to show before collapsing */ maxItems?: number; @@ -11,8 +21,10 @@ 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; /** Callback when "View all" button is clicked */ onViewAllLegendItems?: () => void; /** When true, constrains legend to max 50% height with scrolling */ @@ -35,45 +47,59 @@ export function ChartLegendCompound({ maxItems = Infinity, hidden = false, className, - totalLabel = "Total", + totalLabel, + aggregation, onViewAllLegendItems, scrollable = false, }: ChartLegendCompoundProps) { const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext(); - const totals = useSeriesTotal(); + 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; - // 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 + const values = highlight.activePayload + .filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string)) + .map((item) => Number(item.value) || 0); + + if (values.length === 0) return 0; + + if (!aggregation) { + // Default: sum + return values.reduce((a, b) => a + b, 0); + } + return aggregateValues(values, aggregation); + }, [highlight.activePayload, grandTotal, dataKeys, aggregation]); - // Get the label for the total row - x-axis value when hovering, totalLabel otherwise + // Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise const currentTotalLabel = useMemo(() => { - if (!highlight.activePayload?.length) return totalLabel; + if (!highlight.activePayload?.length) return effectiveTotalLabel; // Get the x-axis label from the payload's original data const firstPayloadItem = highlight.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]); + }, [highlight.activePayload, dataKey, effectiveTotalLabel, labelFormatter]); // Get current data for the legend based on hover state const currentData = useMemo(() => { diff --git a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx index d1496a2ffa..a7f623fff7 100644 --- a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx @@ -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"; @@ -29,6 +30,8 @@ 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; /** Callback when "View all" legend button is clicked */ onViewAllLegendItems?: () => void; /** When true, constrains legend to max 50% height with scrolling */ @@ -73,6 +76,7 @@ export function ChartRoot({ showLegend = false, maxLegendItems = 5, legendTotalLabel, + legendAggregation, onViewAllLegendItems, legendScrollable = false, fillContainer = false, @@ -96,6 +100,7 @@ export function ChartRoot({ showLegend={showLegend} maxLegendItems={maxLegendItems} legendTotalLabel={legendTotalLabel} + legendAggregation={legendAggregation} onViewAllLegendItems={onViewAllLegendItems} legendScrollable={legendScrollable} fillContainer={fillContainer} @@ -112,6 +117,7 @@ type ChartRootInnerProps = { showLegend?: boolean; maxLegendItems?: number; legendTotalLabel?: string; + legendAggregation?: AggregationType; onViewAllLegendItems?: () => void; legendScrollable?: boolean; fillContainer?: boolean; @@ -124,6 +130,7 @@ function ChartRootInner({ showLegend = false, maxLegendItems = 5, legendTotalLabel, + legendAggregation, onViewAllLegendItems, legendScrollable = false, fillContainer = false, @@ -165,6 +172,7 @@ function ChartRootInner({ @@ -194,18 +202,75 @@ 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 { +export function useSeriesTotal(aggregation?: AggregationType): Record { const { data, dataKeys } = useChartContext(); return useMemo(() => { - return data.reduce((acc, item) => { + // 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 + ); + } + + if (aggregation === "avg") { + const sums: Record = {}; + const counts: Record = {}; + for (const item of data) { + for (const seriesKey of dataKeys) { + const val = Number(item[seriesKey] || 0); + sums[seriesKey] = (sums[seriesKey] || 0) + val; + counts[seriesKey] = (counts[seriesKey] || 0) + 1; + } + } + const result: Record = {}; + for (const key of dataKeys) { + result[key] = counts[key] ? sums[key]! / counts[key]! : 0; + } + return result; + } + + if (aggregation === "min") { + const result: Record = {}; + for (const item of data) { + for (const seriesKey of dataKeys) { + const val = Number(item[seriesKey] || 0); + 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 = {}; + for (const item of data) { for (const seriesKey of dataKeys) { - acc[seriesKey] = (acc[seriesKey] || 0) + Number(item[seriesKey] || 0); + const val = Number(item[seriesKey] || 0); + if (result[seriesKey] === undefined || val > result[seriesKey]) { + result[seriesKey] = val; + } } - return acc; - }, {} as Record); - }, [data, dataKeys]); + } + // 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]); } diff --git a/apps/webapp/app/components/primitives/charts/aggregation.ts b/apps/webapp/app/components/primitives/charts/aggregation.ts new file mode 100644 index 0000000000..48269833d2 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/aggregation.ts @@ -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); + } +} From 1f56ac03002f5a5c9d615958b550fb2205220a86 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 12:59:44 +0000 Subject: [PATCH 08/12] Only render points on charts when there is data --- .../app/components/code/QueryResultsChart.tsx | 9 +-- .../primitives/charts/ChartLegendCompound.tsx | 62 ++++++++++++------- .../primitives/charts/ChartRoot.tsx | 10 ++- 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 14aa591b92..4f7899a740 100644 --- a/apps/webapp/app/components/code/QueryResultsChart.tsx +++ b/apps/webapp/app/components/code/QueryResultsChart.tsx @@ -244,17 +244,18 @@ function fillTimeGaps( } filledData.push(point); } else { - // Create a zero-filled data point - const zeroPoint: Record = { + // 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 = { [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); } } diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index c49903e8db..3b63ba1f7a 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -68,16 +68,22 @@ export function ChartLegendCompound({ return aggregateValues(values, aggregation); }, [totals, dataKeys, aggregation]); - // Calculate current total based on hover state - const currentTotal = useMemo(() => { + // Calculate current total based on hover state (null when hovering a gap-filled point) + const currentTotal = useMemo((): number | null => { if (!highlight.activePayload?.length) return grandTotal; - // Collect all series values from the hovered data point - const values = highlight.activePayload + // Collect all series values from the hovered data point, preserving nulls + const rawValues = highlight.activePayload .filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string)) - .map((item) => Number(item.value) || 0); + .map((item) => item.value); - if (values.length === 0) return 0; + // 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 @@ -101,17 +107,21 @@ export function ChartLegendCompound({ return labelFormatter ? labelFormatter(stringValue) : stringValue; }, [highlight.activePayload, dataKey, effectiveTotalLabel, labelFormatter]); - // Get current data for the legend based on hover state - const currentData = useMemo(() => { + // Get current data for the legend based on hover state (values may be null for gap-filled points) + const currentData = useMemo((): Record => { if (!highlight.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); + // If we have activePayload data from hovering over a bar/line + const hoverData = highlight.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 + ); // Return a merged object - totals for keys not in the hover data return { @@ -167,7 +177,11 @@ export function ChartLegendCompound({ > {currentTotalLabel} - + {currentTotal != null ? ( + + ) : ( + "\u2013" + )} @@ -183,7 +197,7 @@ export function ChartLegendCompound({ )} > {legendItems.visible.map((item) => { - const total = currentData[item.dataKey] ?? 0; + const total = currentData[item.dataKey] ?? null; const isActive = highlight.activeBarKey === item.dataKey; return ( @@ -191,7 +205,7 @@ export function ChartLegendCompound({ 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" + (total == null || total === 0) && "opacity-50" )} onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)} onMouseLeave={() => highlight.reset()} @@ -221,7 +235,11 @@ export function ChartLegendCompound({ isActive ? "text-text-bright" : "text-text-dimmed" )} > - + {total != null ? ( + + ) : ( + "\u2013" + )} @@ -233,7 +251,7 @@ export function ChartLegendCompound({ (legendItems.hoveredHiddenItem ? ( ) : ( @@ -279,7 +297,7 @@ function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) { type HoveredHiddenItemRowProps = { item: { dataKey: string; color?: string; label: React.ReactNode }; - value: number; + value: number | null; remainingCount: number; }; @@ -305,7 +323,7 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem {remainingCount > 0 && +{remainingCount} more} - + {value != null ? : "\u2013"} diff --git a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx index a7f623fff7..9b5eb3ccb8 100644 --- a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx @@ -228,7 +228,9 @@ export function useSeriesTotal(aggregation?: AggregationType): Record = {}; for (const item of data) { for (const seriesKey of dataKeys) { - const val = Number(item[seriesKey] || 0); + 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; } @@ -244,7 +246,8 @@ export function useSeriesTotal(aggregation?: AggregationType): Record = {}; for (const item of data) { for (const seriesKey of dataKeys) { - const val = Number(item[seriesKey] || 0); + if (item[seriesKey] == null) continue; // skip gap-filled nulls + const val = Number(item[seriesKey]); if (result[seriesKey] === undefined || val < result[seriesKey]) { result[seriesKey] = val; } @@ -261,7 +264,8 @@ export function useSeriesTotal(aggregation?: AggregationType): Record = {}; for (const item of data) { for (const seriesKey of dataKeys) { - const val = Number(item[seriesKey] || 0); + if (item[seriesKey] == null) continue; // skip gap-filled nulls + const val = Number(item[seriesKey]); if (result[seriesKey] === undefined || val > result[seriesKey]) { result[seriesKey] = val; } From 24da9657d9a43fe5c1ededbff814f16d1aa82364 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 13:08:53 +0000 Subject: [PATCH 09/12] Line chart: render dots for points --- apps/webapp/app/components/primitives/charts/ChartLine.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index 50803fd5c7..7bfd9090ca 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -186,6 +186,7 @@ export function ChartLineRenderer({ width={width} height={height} margin={{ + top: 5, left: 12, right: 12, }} @@ -217,7 +218,7 @@ export function ChartLineRenderer({ 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} /> From 2bde68c0f7622134c2977206e79bb46b40d84d43 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 13:36:17 +0000 Subject: [PATCH 10/12] Legend items truncate and show tooltip --- .../primitives/charts/ChartLegendCompound.tsx | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index 3b63ba1f7a..54d725a281 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -5,6 +5,7 @@ import { useSeriesTotal } from "./ChartRoot"; import { aggregateValues } from "./aggregation"; import { cn } from "~/utils/cn"; import { AnimatedNumber } from "../AnimatedNumber"; +import { SimpleTooltip } from "../Tooltip"; const aggregationLabels: Record = { sum: "Sum", @@ -204,7 +205,7 @@ export function ChartLegendCompound({
highlight.setHoveredLegendItem(item.dataKey)} @@ -217,18 +218,32 @@ export function ChartLegendCompound({ style={{ backgroundColor: item.color }} /> )} -
-
- {item.color && ( -
- )} - - {item.label} - -
+
+ + {item.color && ( +
+ )} + + {item.label} + +
+ } + content={item.label} + side="top" + disableHoverableContent + className="max-w-xs break-words" + buttonClassName="cursor-default min-w-0" + /> Date: Fri, 13 Feb 2026 14:17:04 +0000 Subject: [PATCH 11/12] Better preserve the chart config when the query gets changed --- .../app/components/code/ChartConfigPanel.tsx | 7 ++-- .../app/components/query/QueryEditor.tsx | 35 +++++++++++++++---- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/webapp/app/components/code/ChartConfigPanel.tsx b/apps/webapp/app/components/code/ChartConfigPanel.tsx index 93f27dff07..d4b64682e3 100644 --- a/apps/webapp/app/components/code/ChartConfigPanel.tsx +++ b/apps/webapp/app/components/code/ChartConfigPanel.tsx @@ -147,8 +147,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) => { diff --git a/apps/webapp/app/components/query/QueryEditor.tsx b/apps/webapp/app/components/query/QueryEditor.tsx index 321f4a449b..3ffe889cc4 100644 --- a/apps/webapp/app/components/query/QueryEditor.tsx +++ b/apps/webapp/app/components/query/QueryEditor.tsx @@ -506,18 +506,39 @@ export function QueryEditor({ const isLoading = fetcher.state === "submitting" || fetcher.state === "loading"; - // Create a stable key from columns to detect schema changes - const columnsKey = results?.columns - ? results.columns.map((c) => `${c.name}:${c.type}`).join(",") + // Stable string key of result column names (types excluded — they can vary between runs) + const columnNamesKey = results?.columns + ? results.columns.map((c) => c.name).join(",") : ""; - // Reset chart config only when column schema actually changes - // This allows re-running queries with different WHERE clauses without losing config + // Use a ref so the effect can read chartConfig without re-firing on every config tweak + const chartConfigRef = useRef(chartConfig); + chartConfigRef.current = chartConfig; + + // Reset chart config only when a column referenced by the current config is no + // longer present in the results. This means: + // - Re-running the same query preserves all settings + // - Adding new columns preserves settings (existing config columns still exist) + // - Removing/renaming a column used in the config triggers a reset useEffect(() => { - if (columnsKey && !initialChartConfig) { + if (!columnNamesKey || initialChartConfig) return; + + const config = chartConfigRef.current; + const configColumns = [ + config.xAxisColumn, + ...config.yAxisColumns, + config.groupByColumn, + config.sortByColumn, + ].filter((col): col is string => col != null); + + // Nothing configured yet — ChartConfigPanel will auto-select defaults + if (configColumns.length === 0) return; + + const availableColumns = new Set(columnNamesKey.split(",")); + if (configColumns.some((col) => !availableColumns.has(col))) { setChartConfig(defaultChartConfig); } - }, [columnsKey, initialChartConfig]); + }, [columnNamesKey, initialChartConfig]); const handleChartConfigChange = useCallback((config: ChartConfiguration) => { setChartConfig(config); From d24a81ba16496aafe74344cf16128340fb460312 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Fri, 13 Feb 2026 14:32:13 +0000 Subject: [PATCH 12/12] Fix concurrency error to say project not org --- apps/webapp/app/services/queryService.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/services/queryService.server.ts b/apps/webapp/app/services/queryService.server.ts index 4f572e7428..676be9f2f0 100644 --- a/apps/webapp/app/services/queryService.server.ts +++ b/apps/webapp/app/services/queryService.server.ts @@ -147,7 +147,7 @@ export async function executeQuery( if (!acquireResult.success) { const errorMessage = acquireResult.reason === "key_limit" - ? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.` + ? `You've exceeded your query concurrency of ${orgLimit} for this project. Please try again later.` : "We're experiencing a lot of queries at the moment. Please try again later."; return { success: false, error: new QueryError(errorMessage, { query: options.query }) }; }