diff --git a/apps/webapp/app/assets/icons/AbacusIcon.tsx b/apps/webapp/app/assets/icons/AbacusIcon.tsx new file mode 100644 index 0000000000..f0b7bfdf7b --- /dev/null +++ b/apps/webapp/app/assets/icons/AbacusIcon.tsx @@ -0,0 +1,71 @@ +export function AbacusIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx b/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx new file mode 100644 index 0000000000..c49aa8cb0c --- /dev/null +++ b/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx @@ -0,0 +1,22 @@ +export function ArrowTopRightBottomLeftIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx index e7624ba5b1..0bb4d0ab27 100644 --- a/apps/webapp/app/components/code/AIQueryInput.tsx +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -20,16 +20,25 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; +interface AITimeFilter { + period?: string; + from?: string; + to?: string; +} + type StreamEventType = | { type: "thinking"; content: string } | { type: "tool_call"; tool: string; args: unknown } - | { type: "result"; success: true; query: string } + | { type: "time_filter"; filter: AITimeFilter } + | { type: "result"; success: true; query: string; timeFilter?: AITimeFilter } | { type: "result"; success: false; error: string }; export type AIQueryMode = "new" | "edit"; interface AIQueryInputProps { onQueryGenerated: (query: string) => void; + /** Called when the AI sets a time filter - updates URL search params */ + onTimeFilterChange?: (filter: AITimeFilter) => void; /** Set this to a prompt to auto-populate and immediately submit */ autoSubmitPrompt?: string; /** Change this to force re-submission even if prompt is the same */ @@ -40,6 +49,7 @@ interface AIQueryInputProps { export function AIQueryInput({ onQueryGenerated, + onTimeFilterChange, autoSubmitPrompt, autoSubmitKey, getCurrentQuery, @@ -174,10 +184,22 @@ export function AIQueryInput({ setThinking((prev) => prev + event.content); break; case "tool_call": - setThinking((prev) => prev + `\nValidating query...\n`); + if (event.tool === "setTimeFilter") { + setThinking((prev) => prev + `\nSetting time filter...\n`); + } else { + setThinking((prev) => prev + `\nValidating query...\n`); + } + break; + case "time_filter": + // Apply time filter immediately when the AI sets it + onTimeFilterChange?.(event.filter); break; case "result": if (event.success) { + // Apply time filter if included in result (backup in case time_filter event was missed) + if (event.timeFilter) { + onTimeFilterChange?.(event.timeFilter); + } onQueryGenerated(event.query); setPrompt(""); setLastResult("success"); @@ -189,7 +211,7 @@ export function AIQueryInput({ break; } }, - [onQueryGenerated] + [onQueryGenerated, onTimeFilterChange] ); const handleSubmit = useCallback( diff --git a/apps/webapp/app/components/code/ChartConfigPanel.tsx b/apps/webapp/app/components/code/ChartConfigPanel.tsx index 7c4e9d672a..7d563e781d 100644 --- a/apps/webapp/app/components/code/ChartConfigPanel.tsx +++ b/apps/webapp/app/components/code/ChartConfigPanel.tsx @@ -1,5 +1,5 @@ import type { OutputColumnMetadata } from "@internal/clickhouse"; -import { BarChart, LineChart } from "lucide-react"; +import { BarChart, LineChart, Plus, XIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { cn } from "~/utils/cn"; import { Header3 } from "../primitives/Headers"; @@ -239,9 +239,9 @@ export function ChartConfigPanel({ columns, config, onChange, className }: Chart } return ( -
+
{/* Chart Type */} -
+
-
+
{/* X-Axis */} updateConfig({ yAxisColumns: value ? [value] : [] })} - variant="tertiary/small" - placeholder="Select column" - items={yAxisOptions} - dropdownIcon - className="min-w-[140px]" - > - {(items) => - items.map((item) => ( - - - {item.label} - - - - )) - } - +
+ {/* 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 ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {children} + {isHovered && ( + { + e.stopPropagation(); + e.preventDefault(); + copy(); + }} + className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer" + > + + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy"} + disableHoverableContent + /> + + )} +
+ ); +} + +/** + * Header cell component with tooltip support and filter toggle + */ +function HeaderCellContent({ + alignment, + tooltip, + children, + onFilterClick, + showFilters, + hasActiveFilter, + sortDirection, + onSortClick, + canSort, +}: { + alignment: "left" | "right"; + tooltip?: React.ReactNode; + children: React.ReactNode; + onFilterClick?: () => void; + showFilters?: boolean; + hasActiveFilter?: boolean; + sortDirection?: SortDirection | false; + onSortClick?: (event: React.MouseEvent) => void; + canSort?: boolean; +}) { + const [isHovered, setIsHovered] = useState(false); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onSortClick} + > + {tooltip ? ( +
+ {children} + +
+ ) : ( + {children} + )} + {/* Sort indicator */} + {canSort && ( + + {sortDirection === "asc" ? ( + + ) : sortDirection === "desc" ? ( + + ) : ( + + )} + + )} + {onFilterClick && ( + + )} +
+ ); +} + +/** + * Filter input cell for the filter row + */ +function FilterCell({ + column, + width, + shouldFocus, + onFocused, +}: { + column: Column; + width: number; + shouldFocus?: boolean; + onFocused?: () => void; +}) { + const columnFilterValue = column.getFilterValue() as string; + const inputRef = useRef(null); + + useEffect(() => { + if (shouldFocus && inputRef.current) { + inputRef.current.focus(); + onFocused?.(); + } + }, [shouldFocus, onFocused]); + + return ( +
+ column.setFilterValue(value)} + placeholder="Filter..." + className={cn( + "w-full rounded border border-charcoal-700 bg-charcoal-800 px-2 py-1", + "text-xs text-text-bright placeholder:text-text-dimmed", + "focus:border-indigo-500/50 focus:outline-none focus:ring-1 focus:ring-indigo-500/50" + )} + /> +
+ ); } export const TSQLResultsTable = memo(function TSQLResultsTable({ @@ -417,52 +887,293 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({ columns: OutputColumnMetadata[]; prettyFormatting?: boolean; }) { + const tableContainerRef = useRef(null); + + // State for column filters and filter row visibility + const [columnFilters, setColumnFilters] = useState([]); + const [showFilters, setShowFilters] = useState(false); + // Track which column's filter should be focused + const [focusFilterColumn, setFocusFilterColumn] = useState(null); + // State for column sorting + const [sorting, setSorting] = useState([]); + + // Create TanStack Table column definitions from OutputColumnMetadata + // Calculate column widths based on content + const columnDefs = useMemo[]>( + () => + columns.map((col) => ({ + id: col.name, + accessorKey: col.name, + header: () => col.name, + cell: (info: CellContext) => ( + + ), + meta: { + outputColumn: col, + alignment: isRightAlignedColumn(col) ? "right" : "left", + } as ColumnMeta, + size: calculateColumnWidth(col.name, rows, col), + filterFn: fuzzyFilter, + })), + [columns, rows, prettyFormatting] + ); + + // Initialize TanStack Table + // Column resize mode: 'onChange' for real-time feedback, 'onEnd' for performance + const columnResizeMode: ColumnResizeMode = "onChange"; + + const table = useReactTable({ + data: rows, + columns: columnDefs, + columnResizeMode, + state: { + columnFilters, + sorting, + }, + onColumnFiltersChange: setColumnFilters, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + }); + + const { rows: tableRows } = table.getRowModel(); + + // Set up the virtualizer + const rowVirtualizer = useVirtualizer({ + count: tableRows.length, + estimateSize: () => ROW_HEIGHT, + getScrollElement: () => tableContainerRef.current, + overscan: 20, + }); + if (!columns.length) return null; + // Empty state + if (rows.length === 0) { + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as ColumnMeta | undefined; + return ( + + ); + })} + + ))} + {/* Filter row - shown when filters are toggled */} + {showFilters && ( + + {table.getHeaderGroups()[0]?.headers.map((header) => ( + setFocusFilterColumn(null)} + /> + ))} + + )} + + + + + + +
+ { + if (!showFilters) { + setFocusFilterColumn(header.id); + } else { + setColumnFilters([]); + } + setShowFilters(!showFilters); + }} + showFilters={showFilters} + hasActiveFilter={!!header.column.getFilterValue()} + sortDirection={header.column.getIsSorted()} + onSortClick={header.column.getToggleSortingHandler()} + canSort={header.column.getCanSort()} + > + {flexRender(header.column.columnDef.header, header.getContext())} + + {/* Column resizer */} +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cn( + "absolute right-0 top-0 h-full w-0.5 cursor-col-resize touch-none select-none", + "opacity-0 group-hover/header:opacity-100", + "bg-charcoal-600 hover:bg-indigo-500", + header.column.getIsResizing() && "bg-indigo-500 opacity-100" + )} + /> +
+ + No results + +
+
+ ); + } + return ( - - - - {columns.map((col) => ( - - {col.name} - +
+
+ + {/* Main header row */} + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as ColumnMeta | undefined; + return ( + + ); + })} + ))} - - - - {rows.length === 0 ? ( - - - - No results - - - - ) : ( - rows.map((row, i) => ( - - {columns.map((col) => ( - - - + {/* Filter row - shown when filters are toggled */} + {showFilters && ( + + {table.getHeaderGroups()[0]?.headers.map((header) => ( + setFocusFilterColumn(null)} + /> ))} - - )) - )} - -
+ { + if (!showFilters) { + setFocusFilterColumn(header.id); + } else { + setColumnFilters([]); + } + setShowFilters(!showFilters); + }} + showFilters={showFilters} + hasActiveFilter={!!header.column.getFilterValue()} + sortDirection={header.column.getIsSorted()} + onSortClick={header.column.getToggleSortingHandler()} + canSort={header.column.getCanSort()} + > + {flexRender(header.column.columnDef.header, header.getContext())} + + {/* Column resizer */} +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cn( + "absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none", + "opacity-0 group-hover/header:opacity-100", + "bg-charcoal-600 hover:bg-indigo-500", + header.column.getIsResizing() && "bg-indigo-500 opacity-100" + )} + /> +
+ + )} + + + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const row = tableRows[virtualRow.index]; + return ( + + {row.getVisibleCells().map((cell) => { + const meta = cell.column.columnDef.meta as ColumnMeta | undefined; + return ( + + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + ); + })} + + ); + })} + + +
); }); diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 08bbe443dd..929655f546 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -138,6 +138,10 @@ export function EnvironmentLabel({ return content; } +export function EnvironmentSlug({ environment }: { environment: Environment & { slug: string } }) { + return {environment.slug}; +} + export function environmentTitle(environment: Environment, username?: string) { if (environment.branchName) { return environment.branchName; diff --git a/apps/webapp/app/components/primitives/AnimatedNumber.tsx b/apps/webapp/app/components/primitives/AnimatedNumber.tsx index f2f309a526..2d1ff7ea7b 100644 --- a/apps/webapp/app/components/primitives/AnimatedNumber.tsx +++ b/apps/webapp/app/components/primitives/AnimatedNumber.tsx @@ -1,16 +1,16 @@ import { animate, motion, useMotionValue, useTransform } from "framer-motion"; import { useEffect } from "react"; -export function AnimatedNumber({ value }: { value: number }) { +export function AnimatedNumber({ value, duration = 0.5 }: { value: number; duration?: number }) { const motionValue = useMotionValue(value); let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString()); useEffect(() => { animate(motionValue, value, { - duration: 0.5, + duration, ease: "easeInOut", }); - }, [value]); + }, [value, duration]); return {display}; } diff --git a/apps/webapp/app/components/primitives/Popover.tsx b/apps/webapp/app/components/primitives/Popover.tsx index 02454864c4..93f93057e3 100644 --- a/apps/webapp/app/components/primitives/Popover.tsx +++ b/apps/webapp/app/components/primitives/Popover.tsx @@ -189,37 +189,54 @@ function PopoverSideMenuTrigger({ ); } +const popoverArrowTriggerVariants = { + minimal: { + trigger: "text-text-dimmed hover:bg-charcoal-700 hover:text-text-bright", + text: "group-hover:text-text-bright", + icon: "text-text-dimmed group-hover:text-text-bright", + }, + tertiary: { + trigger: "bg-tertiary text-text-bright hover:bg-charcoal-600", + text: "text-text-bright", + icon: "text-text-bright", + }, +} as const; + +type PopoverArrowTriggerVariant = keyof typeof popoverArrowTriggerVariants; + function PopoverArrowTrigger({ isOpen, children, fullWidth = false, overflowHidden = false, + variant = "minimal", className, ...props }: { isOpen?: boolean; fullWidth?: boolean; overflowHidden?: boolean; + variant?: PopoverArrowTriggerVariant; } & React.ComponentPropsWithoutRef) { + const variantStyles = popoverArrowTriggerVariants[variant]; + return ( {children} - + ); } @@ -253,3 +270,5 @@ export { PopoverTrigger, PopoverVerticalEllipseTrigger, }; + +export type { PopoverArrowTriggerVariant }; diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 51fc54e5af..f87863a45b 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -2,18 +2,65 @@ import { RadioGroup } from "@headlessui/react"; import { motion } from "framer-motion"; import { cn } from "~/utils/cn"; -const variants = { +const sizes = { + small: { + control: "h-6", + option: "px-2 text-xs", + container: "gap-x-0.5", + }, + medium: { + control: "h-10", + option: "px-3 py-[0.13rem] text-sm", + container: "p-1 gap-x-0.5", + }, +}; + +const theme = { primary: { base: "bg-charcoal-700", active: "text-text-bright hover:bg-charcoal-750/50", + inactive: "text-text-dimmed transition hover:text-text-bright", + selected: "absolute inset-0 rounded-[2px] outline outline-3 outline-primary", }, secondary: { base: "bg-charcoal-700/50", - active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50", + active: "text-text-bright", + inactive: "text-text-dimmed transition hover:text-text-bright", + selected: "absolute inset-0 rounded bg-charcoal-700 border border-charcoal-600", }, }; -type Variants = keyof typeof variants; +type Size = keyof typeof sizes; +type Theme = keyof typeof theme; + +type VariantStyle = { + base: string; + active: string; + inactive: string; + option: string; + container: string; + selected: string; +}; + +function createVariant(sizeName: Size, themeName: Theme): VariantStyle { + return { + base: cn(sizes[sizeName].control, theme[themeName].base), + active: theme[themeName].active, + inactive: theme[themeName].inactive, + option: sizes[sizeName].option, + container: sizes[sizeName].container, + selected: theme[themeName].selected, + }; +} + +const variants = { + "primary/small": createVariant("small", "primary"), + "primary/medium": createVariant("medium", "primary"), + "secondary/small": createVariant("small", "secondary"), + "secondary/medium": createVariant("medium", "secondary"), +} as const; + +type VariantType = keyof typeof variants; type Options = { label: string; @@ -25,7 +72,7 @@ type SegmentedControlProps = { value?: string; defaultValue?: string; options: Options[]; - variant?: Variants; + variant?: VariantType; fullWidth?: boolean; onChange?: (value: string) => void; }; @@ -35,15 +82,18 @@ export default function SegmentedControl({ value, defaultValue, options, - variant = "secondary", + variant = "secondary/medium", fullWidth, onChange, }: SegmentedControlProps) { + const variantStyle = variants[variant]; + const isPrimary = variant.startsWith("primary"); + return (
@@ -58,31 +108,36 @@ export default function SegmentedControl({ }} className="w-full" > -
+
{options.map((option) => ( + className={({ checked }) => cn( "relative flex h-full grow cursor-pointer text-center font-normal focus-custom", - checked - ? variants[variant].active - : "text-text-dimmed transition hover:text-text-bright" + checked ? variantStyle.active : variantStyle.inactive ) } > {({ checked }) => ( <> -
-
+
+
{option.label}
- {checked && variant === "primary" && ( + {checked && ( )}
diff --git a/apps/webapp/app/components/primitives/Tooltip.tsx b/apps/webapp/app/components/primitives/Tooltip.tsx index 0b6b641d43..9c94673896 100644 --- a/apps/webapp/app/components/primitives/Tooltip.tsx +++ b/apps/webapp/app/components/primitives/Tooltip.tsx @@ -123,7 +123,7 @@ export function InfoIconTooltip({ enabled?: boolean; }) { const icon = ( - + ); if (!enabled) return icon; diff --git a/apps/webapp/app/components/primitives/charts/BigNumber.tsx b/apps/webapp/app/components/primitives/charts/BigNumber.tsx new file mode 100644 index 0000000000..ab12e9326f --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/BigNumber.tsx @@ -0,0 +1,46 @@ +import { cn } from "~/utils/cn"; +import { AnimatedNumber } from "../AnimatedNumber"; +import { Spinner } from "../Spinner"; + +interface BigNumberProps { + animate?: boolean; + loading?: boolean; + value?: number; + valueClassName?: string; + defaultValue?: number; + suffix?: string; + suffixClassName?: string; +} + +export function BigNumber({ + value, + defaultValue, + valueClassName, + suffix, + suffixClassName, + animate = false, + loading = false, +}: BigNumberProps) { + const v = value ?? defaultValue; + return ( +
+ {loading ? ( +
+ +
+ ) : v !== undefined ? ( +
+ {animate ? : v} + {suffix &&
{suffix}
} +
+ ) : ( + "–" + )} +
+ ); +} diff --git a/apps/webapp/app/components/primitives/charts/Card.tsx b/apps/webapp/app/components/primitives/charts/Card.tsx new file mode 100644 index 0000000000..429c51e331 --- /dev/null +++ b/apps/webapp/app/components/primitives/charts/Card.tsx @@ -0,0 +1,34 @@ +import { type ReactNode } from "react"; +import { cn } from "~/utils/cn"; +import { Header3 } from "../Headers"; + +export const Card = ({ children, className }: { children: ReactNode; className?: string }) => { + return ( +
+ {children} +
+ ); +}; + +const CardHeader = ({ children }: { children: ReactNode }) => { + return ( + {children} + ); +}; + +const CardContent = ({ children, className }: { children: ReactNode; className?: string }) => { + return
{children}
; +}; + +const CardAccessory = ({ children }: { children: ReactNode }) => { + return
{children}
; +}; + +Card.Header = CardHeader; +Card.Content = CardContent; +Card.Accessory = CardAccessory; diff --git a/apps/webapp/app/components/primitives/Chart.tsx b/apps/webapp/app/components/primitives/charts/Chart.tsx similarity index 59% rename from apps/webapp/app/components/primitives/Chart.tsx rename to apps/webapp/app/components/primitives/charts/Chart.tsx index 1f1fec3bbe..0d7cd741a6 100644 --- a/apps/webapp/app/components/primitives/Chart.tsx +++ b/apps/webapp/app/components/primitives/charts/Chart.tsx @@ -1,16 +1,18 @@ -"use client"; - import * as React from "react"; import * as RechartsPrimitive from "recharts"; import { cn } from "~/utils/cn"; +import { AnimatedNumber } from "../AnimatedNumber"; // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: "", dark: ".dark" } as const; +export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined; + export type ChartConfig = { [k in string]: { label?: React.ReactNode; icon?: React.ComponentType; + value?: number; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record } @@ -49,15 +51,13 @@ const ChartContainer = React.forwardRef< data-chart={chartId} ref={ref} className={cn( - "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-text-dimmed/50 [&_.recharts-cartesian-grid_line]:stroke-grid-bright/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-grid-dimmed [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-grid-dimmed [&_.recharts-radial-bar-background-sector]:fill-grid-dimmed [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-grid-dimmed [&_.recharts-reference-line-line]:stroke-grid-dimmed [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", className )} {...props} > - - {children} - + {children}
); @@ -65,7 +65,7 @@ const ChartContainer = React.forwardRef< ChartContainer.displayName = "Chart"; const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); + const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color); if (!colorConfig.length) { return null; @@ -74,8 +74,9 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { return (