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/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx index 17fba0383d..4f7899a740 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"; @@ -243,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); } } @@ -671,25 +673,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 */ @@ -775,6 +758,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 +810,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 +818,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 +1003,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ domain: yAxisDomain, }; - const showLegend = series.length > 0; + const showLegend = sortedSeries.length > 0; if (chartType === "bar") { return ( @@ -1010,10 +1011,11 @@ export const QueryResultsChart = memo(function QueryResultsChart({ config={chartConfig} data={data} dataKey={xDataKey} - series={series} + series={sortedSeries} labelFormatter={legendLabelFormatter} showLegend={showLegend} maxLegendItems={fullLegend ? Infinity : 5} + legendAggregation={config.aggregation} minHeight="300px" fillContainer onViewAllLegendItems={onViewAllLegendItems} @@ -1036,10 +1038,11 @@ export const QueryResultsChart = memo(function QueryResultsChart({ config={chartConfig} data={data} dataKey={xDataKey} - series={series} + series={sortedSeries} labelFormatter={legendLabelFormatter} showLegend={showLegend} maxLegendItems={fullLegend ? Infinity : 5} + legendAggregation={config.aggregation} minHeight="300px" fillContainer onViewAllLegendItems={onViewAllLegendItems} @@ -1049,7 +1052,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({ 1} + stacked={stacked && sortedSeries.length > 1} tooltipLabelFormatter={tooltipLabelFormatter} lineType="linear" /> diff --git a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx index 8d525aa1a0..54d725a281 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx @@ -1,8 +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"; +import { SimpleTooltip } from "../Tooltip"; + +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 */ @@ -11,8 +22,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,57 +48,81 @@ 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(() => { + // 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; - // 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 = highlight.activePayload + .filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string)) + .map((item) => item.value); + + // 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); + }, [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(() => { + // 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 { @@ -141,7 +178,11 @@ export function ChartLegendCompound({ > {currentTotalLabel} - + {currentTotal != null ? ( + + ) : ( + "\u2013" + )} @@ -157,15 +198,15 @@ 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 (
highlight.setHoveredLegendItem(item.dataKey)} onMouseLeave={() => highlight.reset()} @@ -177,25 +218,43 @@ 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" + /> - + {total != null ? ( + + ) : ( + "\u2013" + )}
@@ -207,7 +266,7 @@ export function ChartLegendCompound({ (legendItems.hoveredHiddenItem ? ( ) : ( @@ -253,7 +312,7 @@ function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) { type HoveredHiddenItemRowProps = { item: { dataKey: string; color?: string; label: React.ReactNode }; - value: number; + value: number | null; remainingCount: number; }; @@ -279,7 +338,7 @@ function HoveredHiddenItemRow({ item, value, remainingCount }: HoveredHiddenItem {remainingCount > 0 && +{remainingCount} more}
- + {value != null ? : "\u2013"}
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} /> diff --git a/apps/webapp/app/components/primitives/charts/ChartRoot.tsx b/apps/webapp/app/components/primitives/charts/ChartRoot.tsx index d1496a2ffa..9b5eb3ccb8 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,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 { +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 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; + } + } + 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) { + 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 = {}; + for (const item of data) { for (const seriesKey of dataKeys) { - acc[seriesKey] = (acc[seriesKey] || 0) + 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; + } } - 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); + } +} 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); 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/presenters/v3/BuiltInDashboards.server.ts b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts index 961cf99117..da95eeacc0 100644 --- a/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts +++ b/apps/webapp/app/presenters/v3/BuiltInDashboards.server.ts @@ -29,17 +29,17 @@ 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", - aggregation: "sum", + aggregation: "avg", abbreviate: true, suffix: "%", }, @@ -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", @@ -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,20 +125,20 @@ const overviewDashboard: BuiltInDashboard = { stacked: false, sortByColumn: null, sortDirection: "asc", - aggregation: "sum", + 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\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", @@ -199,14 +199,14 @@ const overviewDashboard: BuiltInDashboard = { stacked: false, sortByColumn: null, sortDirection: "asc", - aggregation: "sum", + aggregation: "avg", seriesColors: {}, }, }, 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 }, }, }, 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} 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..676be9f2f0 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, @@ -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 }) }; } @@ -328,7 +328,7 @@ export async function executeQuery( } finally { // Always release the concurrency slot await queryConcurrencyLimiter.release({ - key: organizationId, + key: projectId, requestId, }); }