+ {/* Always show at least one dropdown, even if yAxisColumns is empty */}
+ {(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map(
+ (col, index) => (
+
+
+ {index > 0 && (
+
+ )}
+
+ )
+ )}
+
+ {/* Add another series button - only show when we have at least one series and not grouped */}
+ {config.yAxisColumns.length > 0 &&
+ config.yAxisColumns.length < yAxisOptions.length &&
+ !config.groupByColumn && (
+
+ )}
+
+ {config.groupByColumn && config.yAxisColumns.length === 1 && (
+
+ Remove group by to add multiple series
+
+ )}
+
)}
@@ -370,39 +436,44 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
- {/* Group By */}
+ {/* Group By - disabled when multiple series are selected */}
-
+ {config.yAxisColumns.length > 1 ? (
+
+ Not available with multiple series
+
+ ) : (
+
+ )}
- {/* Stacked toggle (only when grouped) */}
- {config.groupByColumn && (
-
+ {/* Stacked toggle (when grouped or multiple series) */}
+ {(config.groupByColumn || config.yAxisColumns.length > 1) && (
+ updateConfig({ stacked: checked })}
/>
@@ -438,7 +509,7 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
{/* Sort Direction (only when sorting) */}
{config.sortByColumn && (
-
+ updateConfig({ sortDirection: direction })}
@@ -452,7 +523,7 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
return (
-
+
{label && {label}}
{children}
diff --git a/apps/webapp/app/components/code/QueryResultsChart.tsx b/apps/webapp/app/components/code/QueryResultsChart.tsx
index c09a2c4757..106ede3a2a 100644
--- a/apps/webapp/app/components/code/QueryResultsChart.tsx
+++ b/apps/webapp/app/components/code/QueryResultsChart.tsx
@@ -1,24 +1,7 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { memo, useMemo } from "react";
-import {
- Area,
- AreaChart,
- Bar,
- BarChart,
- CartesianGrid,
- Line,
- LineChart,
- XAxis,
- YAxis,
-} from "recharts";
-import {
- type ChartConfig,
- ChartContainer,
- ChartLegend,
- ChartLegendContent,
- ChartTooltip,
- ChartTooltipContent,
-} from "~/components/primitives/Chart";
+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";
@@ -789,6 +772,20 @@ export const QueryResultsChart = memo(function QueryResultsChart({
};
}, []);
+ // Label formatter for the legend (formats x-axis values)
+ const legendLabelFormatter = useMemo(() => {
+ if (!isDateBased || !timeGranularity) return undefined;
+ return (value: string) => {
+ // For date-based axes, the value is a timestamp
+ const timestamp = Number(value);
+ if (!isNaN(timestamp)) {
+ const date = new Date(timestamp);
+ return formatDateForTooltip(date, timeGranularity);
+ }
+ return value;
+ };
+ }, [isDateBased, timeGranularity]);
+
// Validation
if (!xAxisColumn) {
return ;
@@ -806,122 +803,85 @@ export const QueryResultsChart = memo(function QueryResultsChart({
return ;
}
- const commonProps = {
- data,
- margin: { top: 10, right: 10, left: 10, bottom: 10 },
- };
-
// Determine appropriate angle for X-axis labels based on granularity
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
- // Build xAxisProps - different config for date-based (continuous) vs categorical axes
- const xAxisProps = isDateBased
+ // Base x-axis props shared by all chart types
+ const baseXAxisProps = {
+ tickFormatter: xAxisTickFormatter,
+ angle: xAxisAngle,
+ textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
+ height: xAxisHeight,
+ };
+
+ // Line charts use continuous time scale for date-based data
+ // This properly represents time gaps between data points
+ const xAxisPropsForLine = isDateBased
? {
- dataKey: xDataKey,
type: "number" as const,
- domain: timeDomain ?? ["auto", "auto"],
+ 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,
- fontSize: 12,
- tickLine: false,
- tickMargin: 8,
- axisLine: false,
- tick: { fill: "var(--color-text-dimmed)" },
- tickFormatter: xAxisTickFormatter,
- angle: xAxisAngle,
- textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
- height: xAxisHeight,
+ ...baseXAxisProps,
}
- : {
- dataKey: xDataKey,
- fontSize: 12,
- tickLine: false,
- tickMargin: 8,
- axisLine: false,
- tick: { fill: "var(--color-text-dimmed)" },
- angle: xAxisAngle,
- textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
- height: xAxisHeight,
- };
+ : baseXAxisProps;
+
+ // Bar charts always use categorical axis positioning
+ // This ensures bars are evenly distributed regardless of data point count
+ // (prevents massive bars when there are only a few data points)
+ const xAxisPropsForBar = baseXAxisProps;
const yAxisProps = {
- fontSize: 12,
- tickLine: false,
- tickMargin: 8,
- axisLine: false,
- tick: { fill: "var(--color-text-dimmed)" },
tickFormatter: yAxisFormatter,
+ domain: [0, "auto"] as [number, string],
};
+ const showLegend = series.length > 0;
+
+ if (chartType === "bar") {
+ return (
+
+
+
+ );
+ }
+
+ // Line or stacked area chart
return (
-
- {chartType === "bar" ? (
-
-
-
-
- }
- labelFormatter={tooltipLabelFormatter}
- cursor={{ fill: "var(--color-charcoal-800)", opacity: 0.5 }}
- />
- {series.length > 1 && } />}
- {series.map((s, i) => (
-
- ))}
-
- ) : stacked && series.length > 1 ? (
-
-
-
-
- }
- labelFormatter={tooltipLabelFormatter}
- />
- } />
- {series.map((s, i) => (
-
- ))}
-
- ) : (
-
-
-
-
- } labelFormatter={tooltipLabelFormatter} />
- {series.length > 1 && } />}
- {series.map((s, i) => (
-
- ))}
-
- )}
-
+
+ 1}
+ tooltipLabelFormatter={tooltipLabelFormatter}
+ lineType="linear"
+ />
+
);
});
diff --git a/apps/webapp/app/components/code/TSQLResultsTable.tsx b/apps/webapp/app/components/code/TSQLResultsTable.tsx
index 60facc737f..123d74c609 100644
--- a/apps/webapp/app/components/code/TSQLResultsTable.tsx
+++ b/apps/webapp/app/components/code/TSQLResultsTable.tsx
@@ -1,18 +1,27 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
+import { rankItem } from "@tanstack/match-sorter-utils";
+import {
+ useReactTable,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ flexRender,
+ type ColumnDef,
+ type CellContext,
+ type ColumnResizeMode,
+ type ColumnFiltersState,
+ type FilterFn,
+ type Column,
+ type SortingState,
+ type SortDirection,
+} from "@tanstack/react-table";
+import { useVirtualizer } from "@tanstack/react-virtual";
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
-import { memo, useState } from "react";
-import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
+import { 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";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
-import {
- CopyableTableCell,
- Table,
- TableBody,
- TableCell,
- TableHeader,
- TableHeaderCell,
- TableRow,
-} from "~/components/primitives/Table";
import {
descriptionForTaskRunStatus,
isRunFriendlyStatus,
@@ -20,16 +29,302 @@ import {
runStatusFromFriendlyTitle,
TaskRunStatusCombo,
} from "~/components/runs/v3/TaskRunStatus";
+import { useCopy } from "~/hooks/useCopy";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
+import { cn } from "~/utils/cn";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
-import { SimpleTooltip } from "../primitives/Tooltip";
+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
+
+// Column width calculation constants
+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 SAMPLE_SIZE = 100; // Number of rows to sample for width calculation
+
+// Type for row data
+type RowData = Record;
+
+/**
+ * Fuzzy filter function using match-sorter ranking
+ */
+/**
+ * Get the formatted display string for a value based on its column type
+ * This mirrors the formatting logic in CellValue component
+ */
+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) {
+ case "duration":
+ if (typeof value === "number") {
+ return formatDurationMilliseconds(value, { style: "short" });
+ }
+ break;
+ case "durationSeconds":
+ if (typeof value === "number") {
+ return formatDurationMilliseconds(value * 1000, { style: "short" });
+ }
+ break;
+ case "cost":
+ if (typeof value === "number") {
+ return formatCurrencyAccurate(value / 100);
+ }
+ break;
+ case "costInDollars":
+ if (typeof value === "number") {
+ return formatCurrencyAccurate(value);
+ }
+ break;
+ case "runStatus":
+ // Include friendly status names for searching
+ if (typeof value === "string") {
+ return value;
+ }
+ break;
+ }
+ }
+
+ // Handle DateTime types - format for display
+ if (isDateTimeType(column.type)) {
+ if (typeof value === "string") {
+ try {
+ const date = new Date(value);
+ // Format as a searchable string: "15 Jan 2026 12:34:56"
+ return date.toLocaleString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ } catch {
+ return String(value);
+ }
+ }
+ }
+
+ // Handle numeric types - format with separators
+ if (isNumericType(column.type) && typeof value === "number") {
+ return formatNumber(value);
+ }
+
+ // Handle booleans
+ if (isBooleanType(column.type)) {
+ if (typeof value === "boolean") {
+ return value ? "true" : "false";
+ }
+ if (typeof value === "number") {
+ return value === 1 ? "true" : "false";
+ }
+ }
+
+ // Handle objects/arrays
+ if (typeof value === "object") {
+ return JSON.stringify(value);
+ }
+
+ return String(value);
+}
+
+const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => {
+ // Get the cell value
+ const cellValue = row.getValue(columnId);
+ const searchValue = String(value).toLowerCase();
+
+ // Handle empty search
+ if (!searchValue) return true;
+
+ // Get the column metadata from the cell
+ const cell = row.getAllCells().find((c) => c.column.id === columnId);
+ const meta = cell?.column.columnDef.meta as ColumnMeta | undefined;
+
+ // Build searchable strings - raw value
+ const rawValue =
+ cellValue === null
+ ? "NULL"
+ : cellValue === undefined
+ ? ""
+ : typeof cellValue === "object"
+ ? JSON.stringify(cellValue)
+ : String(cellValue);
+
+ // Build searchable strings - formatted value (if we have column metadata)
+ const formattedValue = meta?.outputColumn
+ ? getFormattedValue(cellValue, meta.outputColumn)
+ : rawValue;
+
+ // Combine both values for searching (separated by space to allow matching either)
+ const combinedSearchText = `${rawValue} ${formattedValue}`.toLowerCase();
+
+ // Rank against the combined text
+ const itemRank = rankItem(combinedSearchText, searchValue);
+
+ // Store the ranking info
+ addMeta({ itemRank });
+
+ // Return if the item should be filtered in/out
+ return itemRank.passed;
+};
+
+/**
+ * Debounced input component for filter inputs
+ */
+const DebouncedInput = forwardRef<
+ HTMLInputElement,
+ {
+ value: string;
+ onChange: (value: string) => void;
+ debounce?: number;
+ } & Omit, "onChange">
+>(function DebouncedInput({ value: initialValue, onChange, debounce = 300, ...props }, ref) {
+ const [value, setValue] = useState(initialValue);
+
+ useEffect(() => {
+ setValue(initialValue);
+ }, [initialValue]);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ onChange(value);
+ }, debounce);
+
+ return () => clearTimeout(timeout);
+ }, [value, debounce, onChange]);
+
+ return setValue(e.target.value)} />;
+});
+
+// Extended column meta to store OutputColumnMetadata
+interface ColumnMeta {
+ outputColumn: OutputColumnMetadata;
+ alignment: "left" | "right";
+}
+
+/**
+ * Get the approximate display length (in characters) of a value based on its type and formatting
+ */
+function getDisplayLength(value: unknown, column: OutputColumnMetadata): number {
+ if (value === null) return 4; // "NULL"
+ if (value === undefined) return 9; // "UNDEFINED"
+
+ // Handle custom render types - estimate their rendered width
+ if (column.customRenderType) {
+ switch (column.customRenderType) {
+ case "runId":
+ // Run IDs are typically like "run_abc123xyz"
+ return typeof value === "string" ? Math.min(value.length, MAX_STRING_DISPLAY_LENGTH) : 15;
+ case "runStatus":
+ // Status badges have icon + text, approximate width
+ return 12;
+ case "duration":
+ if (typeof value === "number") {
+ // Format and measure: "1h 23m 45s" style
+ const formatted = formatDurationMilliseconds(value, { style: "short" });
+ return formatted.length;
+ }
+ return 10;
+ case "durationSeconds":
+ if (typeof value === "number") {
+ const formatted = formatDurationMilliseconds(value * 1000, { style: "short" });
+ return formatted.length;
+ }
+ return 10;
+ case "cost":
+ case "costInDollars":
+ // Currency format: "$1,234.56"
+ if (typeof value === "number") {
+ const amount = column.customRenderType === "cost" ? value / 100 : value;
+ return formatCurrencyAccurate(amount).length;
+ }
+ return 12;
+ case "machine":
+ // Machine preset names like "small-1x"
+ return typeof value === "string" ? value.length : 10;
+ case "environmentType":
+ // Environment labels: "PRODUCTION", "STAGING", etc.
+ return 12;
+ case "project":
+ case "environment":
+ return typeof value === "string" ? Math.min(value.length, 20) : 12;
+ case "queue":
+ return typeof value === "string" ? Math.min(value.length, 25) : 15;
+ }
+ }
+
+ // Handle by ClickHouse type
+ if (isDateTimeType(column.type)) {
+ // DateTime format: "Jan 15, 2026, 12:34:56 PM"
+ return 24;
+ }
+
+ if (column.type === "JSON" || column.type.startsWith("Array")) {
+ if (typeof value === "object") {
+ const jsonStr = JSON.stringify(value);
+ return Math.min(jsonStr.length, MAX_STRING_DISPLAY_LENGTH);
+ }
+ }
+
+ if (isBooleanType(column.type)) {
+ return 5; // "true" or "false"
+ }
+
+ if (isNumericType(column.type)) {
+ if (typeof value === "number") {
+ return formatNumber(value).length;
+ }
+ }
+
+ // Default: string length capped at max display length
+ const strValue = String(value);
+ return Math.min(strValue.length, MAX_STRING_DISPLAY_LENGTH);
+}
+
+/**
+ * Calculate the optimal width for a column based on its content
+ */
+function calculateColumnWidth(
+ columnName: string,
+ rows: RowData[],
+ column: OutputColumnMetadata
+): number {
+ // Start with header length
+ let maxLength = columnName.length;
+
+ // Sample rows to find max content length
+ const sampleRows = rows.slice(0, SAMPLE_SIZE);
+ for (const row of sampleRows) {
+ const value = row[columnName];
+ const displayLength = getDisplayLength(value, column);
+ if (displayLength > maxLength) {
+ maxLength = displayLength;
+ }
+ }
+
+ // Calculate pixel width: characters * char width + padding
+ const calculatedWidth = Math.ceil(maxLength * CHAR_WIDTH_PX + CELL_PADDING_PX);
+
+ // Apply min/max bounds
+ return Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, calculatedWidth));
+}
/**
* Truncate a string for display, adding ellipsis if it exceeds max length
@@ -87,6 +382,21 @@ function isBooleanType(type: string): boolean {
return type === "Bool" || type === "Nullable(Bool)";
}
+/**
+ * Check if a column should be right-aligned (numeric columns, duration, cost)
+ */
+function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
+ if (
+ column.customRenderType === "duration" ||
+ column.customRenderType === "durationSeconds" ||
+ column.customRenderType === "cost" ||
+ column.customRenderType === "costInDollars"
+ ) {
+ return true;
+ }
+ return isNumericType(column.type);
+}
+
/**
* Wrapper component that tracks hover state and passes it to CellValue
* This optimizes rendering by only enabling tooltips when the cell is hovered
@@ -175,7 +485,6 @@ function CellValue({
break;
}
case "runStatus": {
- // We have mapped the status to a friendly status so we need to map back to render the normal component
const status = isTaskRunStatus(value)
? value
: isRunFriendlyStatus(value)
@@ -191,7 +500,6 @@ function CellValue({
/>
);
}
-
return ;
}
break;
@@ -216,13 +524,11 @@ function CellValue({
return {String(value)};
case "cost":
if (typeof value === "number") {
- // Assume cost values are in cents
return {formatCurrencyAccurate(value / 100)};
}
return {String(value)};
case "costInDollars":
if (typeof value === "number") {
- // Value is already in dollars, no conversion needed
return {formatCurrencyAccurate(value)};
}
return {String(value)};
@@ -271,7 +577,6 @@ function CellValue({
// Fall back to rendering based on ClickHouse type
const { type } = column;
- // DateTime types
if (isDateTimeType(type)) {
if (typeof value === "string") {
return ;
@@ -279,12 +584,10 @@ function CellValue({
return {String(value)};
}
- // JSON type
if (type === "JSON") {
return ;
}
- // Array types
if (type.startsWith("Array")) {
const arrayString = JSON.stringify(value);
const isTruncated = arrayString.length > MAX_STRING_DISPLAY_LENGTH;
@@ -308,7 +611,6 @@ function CellValue({
return {arrayString};
}
- // Boolean types
if (isBooleanType(type)) {
if (typeof value === "boolean") {
return {value ? "true" : "false"};
@@ -319,7 +621,6 @@ function CellValue({
return {String(value)};
}
- // Numeric types
if (isNumericType(type)) {
if (typeof value === "number") {
return {formatNumber(value)};
@@ -327,7 +628,6 @@ function CellValue({
return {String(value)};
}
- // Default to string rendering with truncation for long values
const stringValue = String(value);
const isTruncated = stringValue.length > MAX_STRING_DISPLAY_LENGTH;
@@ -366,10 +666,10 @@ function EnvironmentCellValue({ value }: { value: string }) {
return {value};
}
- return ;
+ return ;
}
-function JSONCellValue({ value }: { value: any }) {
+function JSONCellValue({ value }: { value: unknown }) {
const jsonString = JSON.stringify(value);
const isTruncated = jsonString.length > MAX_STRING_DISPLAY_LENGTH;
@@ -392,20 +692,190 @@ function JSONCellValue({ value }: { value: any }) {
}
/**
- * Check if a column should be right-aligned (numeric columns, duration, cost)
+ * Copyable cell component for virtualized rows
*/
-function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
- // Check for custom render types that display numeric values
- if (
- column.customRenderType === "duration" ||
- column.customRenderType === "durationSeconds" ||
- column.customRenderType === "cost" ||
- column.customRenderType === "costInDollars"
- ) {
- return true;
- }
+function CopyableCell({
+ value,
+ alignment,
+ children,
+}: {
+ value: string;
+ alignment: "left" | "right";
+ children: React.ReactNode;
+}) {
+ const [isHovered, setIsHovered] = useState(false);
+ const { copy, copied } = useCopy(value);
- return isNumericType(column.type);
+ return (
+