Log Results Sorting (#1232)

This commit is contained in:
Brandon Pereira 2025-10-07 09:35:42 -06:00 committed by GitHub
parent b46ae2f204
commit b34480411e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 605 additions and 234 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
Add Sorting Feature to all search tables

View file

@ -363,13 +363,31 @@ function InsertsTab({
</Text>
<DBTableChart
config={{
dateRange: searchedTimeRange,
select: [
`count() as "Part Count"`,
`sum(rows) as Rows`,
'database as Database',
'table as Table',
'partition as Partition',
].join(','),
{
aggFn: 'count',
valueExpression: '',
alias: 'Part Count',
},
{
aggFn: 'sum',
valueExpression: 'rows',
alias: 'Rows',
},
{
valueExpression: 'database',
alias: 'Database',
},
{
valueExpression: 'table',
alias: 'Table',
},
{
valueExpression: 'partition',
alias: 'Partition',
},
],
from: {
databaseName: 'system',
tableName: 'parts',
@ -661,10 +679,22 @@ function ClickhousePage() {
<DBTableChart
config={{
select: [
`count() as "Count"`,
`sum(query_duration_ms) as "Total Duration (ms)"`,
`any(query) as "Query Example"`,
].join(','),
{
aggFn: 'count',
valueExpression: '',
alias: `Count`,
},
{
aggFn: 'sum',
valueExpression: 'query_duration_ms',
alias: `Total Duration (ms)`,
},
{
aggFn: 'any',
valueExpression: 'query',
alias: `Query Example`,
},
],
dateRange: searchedTimeRange,
from,
where: `(

View file

@ -56,6 +56,7 @@ import {
} from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { useIsFetching } from '@tanstack/react-query';
import { SortingState } from '@tanstack/react-table';
import CodeMirror from '@uiw/react-codemirror';
import { ContactSupportText } from '@/components/ContactSupportText';
@ -75,10 +76,7 @@ import { Tags } from '@/components/Tags';
import { TimePicker } from '@/components/TimePicker';
import WhereLanguageControlled from '@/components/WhereLanguageControlled';
import { IS_LOCAL_MODE } from '@/config';
import {
useAliasMapFromChartConfig,
useQueriedChartConfig,
} from '@/hooks/useChartConfig';
import { useAliasMapFromChartConfig } from '@/hooks/useChartConfig';
import { useExplainQuery } from '@/hooks/useExplainQuery';
import { withAppNav } from '@/layout';
import {
@ -104,7 +102,10 @@ import PatternTable from './components/PatternTable';
import SourceSchemaPreview from './components/SourceSchemaPreview';
import { useTableMetadata } from './hooks/useMetadata';
import { useSqlSuggestions } from './hooks/useSqlSuggestions';
import { parseAsStringWithNewLines } from './utils/queryParsers';
import {
parseAsSortingStateString,
parseAsStringWithNewLines,
} from './utils/queryParsers';
import api from './api';
import { LOCAL_STORE_CONNECTIONS_KEY } from './connection';
import { DBSearchPageAlertModal } from './DBSearchPageAlertModal';
@ -1155,6 +1156,24 @@ function DBSearchPage() {
[onSubmit],
);
const onSortingChange = useCallback(
(sortState: SortingState | null) => {
setIsLive(false);
const sort = sortState?.at(0);
setSearchedConfig({
orderBy: sort
? `${sort.id} ${sort.desc ? 'DESC' : 'ASC'}`
: defaultOrderBy,
});
},
[setIsLive, defaultOrderBy, setSearchedConfig],
);
// Parse the orderBy string into a SortingState. We need the string
// version in other places so we keep this parser separate.
const orderByConfig = parseAsSortingStateString.parse(
searchedConfig.orderBy ?? '',
);
const handleTimeRangeSelect = useCallback(
(d1: Date, d2: Date) => {
onTimeRangeSelect(d1, d2);
@ -1831,6 +1850,8 @@ function DBSearchPage() {
onError={handleTableError}
denoiseResults={denoiseResults}
collapseAllRows={collapseAllRows}
onSortingChange={onSortingChange}
initialSortBy={orderByConfig ? [orderByConfig] : []}
/>
)}
</>

View file

@ -8,12 +8,14 @@ import {
Getter,
Row,
Row as TableRow,
SortingState,
useReactTable,
} from '@tanstack/react-table';
import { ColumnDef } from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { CsvExportButton } from './components/CsvExportButton';
import TableHeader from './components/DBTable/TableHeader';
import { useCsvExport } from './hooks/useCsvExport';
import { UNDEFINED_WIDTH } from './tableUtils';
import type { NumberFormat } from './types';
@ -24,11 +26,13 @@ export const Table = ({
groupColumnName,
columns,
getRowSearchLink,
onSortClick,
tableBottom,
sorting,
onSortingChange,
}: {
data: any[];
columns: {
id: string;
dataKey: string;
displayName: string;
sortOrder?: 'asc' | 'desc';
@ -38,8 +42,9 @@ export const Table = ({
}[];
groupColumnName?: string;
getRowSearchLink?: (row: any) => string;
onSortClick?: (columnNumber: number) => void;
tableBottom?: React.ReactNode;
sorting: SortingState;
onSortingChange: (sorting: SortingState) => void;
}) => {
const MIN_COLUMN_WIDTH_PX = 100;
//we need a reference to the scrolling element for logic down below
@ -78,54 +83,60 @@ export const Table = ({
: []),
...columns
.filter(c => c.visible !== false)
.map(({ dataKey, displayName, numberFormat, columnWidthPercent }, i) => ({
accessorKey: dataKey,
header: displayName,
accessorFn: (row: any) => row[dataKey],
cell: ({
getValue,
row,
}: {
getValue: Getter<number>;
row: Row<any>;
}) => {
const value = getValue();
let formattedValue: string | number | null = value ?? null;
if (numberFormat) {
formattedValue = formatNumber(value, numberFormat);
}
if (getRowSearchLink == null) {
return formattedValue;
}
.map(
(
{ id, dataKey, displayName, numberFormat, columnWidthPercent },
i,
) => ({
id: id,
accessorKey: dataKey,
header: displayName,
accessorFn: (row: any) => row[dataKey],
cell: ({
getValue,
row,
}: {
getValue: Getter<number>;
row: Row<any>;
}) => {
const value = getValue();
let formattedValue: string | number | null = value ?? null;
if (numberFormat) {
formattedValue = formatNumber(value, numberFormat);
}
if (getRowSearchLink == null) {
return formattedValue;
}
return (
<Link
href={getRowSearchLink(row.original)}
passHref
className={'align-top overflow-hidden py-1 pe-3'}
style={{
display: 'block',
color: 'inherit',
textDecoration: 'none',
}}
>
{formattedValue}
</Link>
);
},
size:
i === numColumns - 2
? UNDEFINED_WIDTH
: tableWidth != null && columnWidthPercent != null
? Math.max(
tableWidth * (columnWidthPercent / 100),
MIN_COLUMN_WIDTH_PX,
)
: tableWidth != null
? tableWidth / numColumns
: 200,
enableResizing: i !== numColumns - 2,
})),
return (
<Link
href={getRowSearchLink(row.original)}
passHref
className={'align-top overflow-hidden py-1 pe-3'}
style={{
display: 'block',
color: 'inherit',
textDecoration: 'none',
}}
>
{formattedValue}
</Link>
);
},
size:
i === numColumns - 2
? UNDEFINED_WIDTH
: tableWidth != null && columnWidthPercent != null
? Math.max(
tableWidth * (columnWidthPercent / 100),
MIN_COLUMN_WIDTH_PX,
)
: tableWidth != null
? tableWidth / numColumns
: 200,
enableResizing: i !== numColumns - 2,
}),
),
];
const table = useReactTable({
@ -134,6 +145,19 @@ export const Table = ({
getCoreRowModel: getCoreRowModel(),
enableColumnResizing: true,
columnResizeMode: 'onChange',
enableSorting: true,
manualSorting: true,
onSortingChange: v => {
if (typeof v === 'function') {
const newSortVal = v(sorting);
onSortingChange?.(newSortVal ?? null);
} else {
onSortingChange?.(v ?? null);
}
},
state: {
sorting,
},
});
const { rows } = table.getRowModel();
@ -172,7 +196,7 @@ export const Table = ({
return (
<div
className="overflow-auto h-100 fs-8 bg-inherit"
className="overflow-auto h-100 fs-8 bg-inherit dark:bg-dark"
ref={tableContainerRef}
>
<table className="w-100 bg-inherit" style={{ tableLayout: 'fixed' }}>
@ -187,87 +211,33 @@ export const Table = ({
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, headerIndex) => {
const sortOrder = columns[headerIndex - 1]?.sortOrder;
return (
<th
className="overflow-hidden text-truncate"
<TableHeader
key={header.id}
colSpan={header.colSpan}
style={{
width:
header.getSize() === UNDEFINED_WIDTH
? '100%'
: header.getSize(),
minWidth: 100,
position: 'relative',
}}
>
{header.isPlaceholder ? null : (
<Flex justify="space-between">
<div>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</div>
<Flex gap="sm">
{headerIndex > 0 && onSortClick != null && (
<div
role="button"
onClick={() => onSortClick(headerIndex - 1)}
header={header}
isLast={headerIndex === headerGroup.headers.length - 1}
lastItemButtons={
<>
{headerIndex === headerGroup.headers.length - 1 && (
<div className="d-flex align-items-center">
<UnstyledButton
onClick={() => setWrapLinesEnabled(prev => !prev)}
>
{sortOrder === 'asc' ? (
<Text c="green">
<i className="bi bi-sort-numeric-up-alt"></i>
</Text>
) : sortOrder === 'desc' ? (
<Text c="green">
<i className="bi bi-sort-numeric-down-alt"></i>
</Text>
) : (
<Text c="dark.2">
<i className="bi bi-sort-numeric-down-alt"></i>
</Text>
)}
</div>
)}
{header.column.getCanResize() &&
headerIndex !== headerGroup.headers.length - 1 && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer text-gray-600 cursor-grab ${
header.column.getIsResizing()
? 'isResizing'
: ''
}`}
>
<i className="bi bi-three-dots-vertical" />
</div>
)}
{headerIndex === headerGroup.headers.length - 1 && (
<div className="d-flex align-items-center">
<UnstyledButton
onClick={() =>
setWrapLinesEnabled(prev => !prev)
}
>
<i className="bi bi-text-wrap" />
</UnstyledButton>
<CsvExportButton
data={csvData}
filename="HyperDX_table_results"
className="fs-8 text-muted-hover ms-2"
title="Download table as CSV"
>
<i className="bi bi-download" />
</CsvExportButton>
</div>
)}
</Flex>
</Flex>
)}
</th>
<i className="bi bi-text-wrap" />
</UnstyledButton>
<CsvExportButton
data={csvData}
filename="HyperDX_table_results"
className="fs-8 text-muted-hover ms-2"
title="Download table as CSV"
>
<i className="bi bi-download" />
</CsvExportButton>
</div>
)}
</>
}
/>
);
})}
</tr>

View file

@ -1,5 +1,6 @@
import React from 'react';
import { useCSVDownloader } from 'react-papaparse';
import { UnstyledButton } from '@mantine/core';
interface CsvExportButtonProps {
data: Record<string, any>[];
@ -57,9 +58,8 @@ export const CsvExportButton: React.FC<CsvExportButtonProps> = ({
}
return (
<div
<UnstyledButton
className={className}
role="button"
title={title}
onClick={handleClick}
{...props}
@ -88,6 +88,6 @@ export const CsvExportButton: React.FC<CsvExportButtonProps> = ({
>
{children}
</CSVDownloader>
</div>
</UnstyledButton>
);
};

View file

@ -7,7 +7,7 @@ import React, {
useState,
} from 'react';
import cx from 'classnames';
import { format, formatDistance } from 'date-fns';
import { formatDistance } from 'date-fns';
import { isString } from 'lodash';
import curry from 'lodash/curry';
import ms from 'ms';
@ -37,13 +37,20 @@ import {
import { splitAndTrimWithBracket } from '@hyperdx/common-utils/dist/utils';
import {
Box,
Button,
Code,
Flex,
Group,
Modal,
Text,
Tooltip as MantineTooltip,
UnstyledButton,
} from '@mantine/core';
import {
IconChevronDown,
IconChevronUp,
IconDotsVertical,
} from '@tabler/icons-react';
import { FetchNextPageOptions, useQuery } from '@tanstack/react-query';
import {
ColumnDef,
@ -51,6 +58,7 @@ import {
flexRender,
getCoreRowModel,
Row as TableRow,
SortingState,
TableOptions,
useReactTable,
} from '@tanstack/react-table';
@ -58,7 +66,10 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import api from '@/api';
import { searchChartConfigDefaults } from '@/defaults';
import { useRenderedSqlChartConfig } from '@/hooks/useChartConfig';
import {
useAliasMapFromChartConfig,
useRenderedSqlChartConfig,
} from '@/hooks/useChartConfig';
import { useCsvExport } from '@/hooks/useCsvExport';
import { useTableMetadata } from '@/hooks/useMetadata';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
@ -76,6 +87,7 @@ import {
useWindowSize,
} from '@/utils';
import TableHeader from './DBTable/TableHeader';
import { SQLPreview } from './ChartSQLPreview';
import { CsvExportButton } from './CsvExportButton';
import {
@ -301,9 +313,12 @@ export const RawLogTable = memo(
source,
onExpandedRowsChange,
collapseAllRows,
enableSorting = false,
onSortingChange,
sortOrder,
showExpandButton = true,
}: {
wrapLines: boolean;
wrapLines?: boolean;
displayedColumns: string[];
onSettingsClick?: () => void;
onInstructionsClick?: () => void;
@ -319,7 +334,7 @@ export const RawLogTable = memo(
hasNextPage?: boolean;
highlightedLineId?: string;
onScroll?: (scrollTop: number) => void;
isLive: boolean;
isLive?: boolean;
onShowPatternsClick?: () => void;
tableId?: string;
columnNameMap?: Record<string, string>;
@ -338,6 +353,9 @@ export const RawLogTable = memo(
collapseAllRows?: boolean;
showExpandButton?: boolean;
renderRowDetails?: (row: Record<string, any>) => React.ReactNode;
enableSorting?: boolean;
sortOrder?: SortingState;
onSortingChange?: (v: SortingState | null) => void;
}) => {
const generateRowMatcher = generateRowId;
@ -383,6 +401,9 @@ export const RawLogTable = memo(
//we need a reference to the scrolling element for logic down below
const tableContainerRef = useRef<HTMLDivElement>(null);
// Get the alias map from the config so we resolve correct column ids
const { data: aliasMap } = useAliasMapFromChartConfig(config);
// Reset scroll when live tail is enabled for the first time
const prevIsLive = usePrevious(isLive);
useEffect(() => {
@ -437,6 +458,10 @@ export const RawLogTable = memo(
column,
jsColumnType,
},
// If the column is an alias, wrap in quotes.
id: aliasMap?.[column] ? `"${column}"` : column,
// TODO: add support for sorting on Dynamic JSON fields
enableSorting: jsColumnType !== JSDataType.Dynamic,
accessorFn: curry(retrieveColumnValue)(column), // Columns can contain '.' and will not work with accessorKey
header: `${columnNameMap?.[column] ?? column}${isDate ? (isUTC ? ' (UTC)' : ' (Local)') : ''}`,
cell: info => {
@ -544,9 +569,22 @@ export const RawLogTable = memo(
columns,
getCoreRowModel: getCoreRowModel(),
// debugTable: true,
enableSorting,
manualSorting: true,
onSortingChange: v => {
if (typeof v === 'function') {
const newSortVal = v(sortOrder ?? []);
onSortingChange?.(newSortVal ?? null);
} else {
onSortingChange?.(v ?? null);
}
},
state: {
sorting: sortOrder ?? [],
},
enableColumnResizing: true,
columnResizeMode: 'onChange' as ColumnResizeMode,
};
} satisfies TableOptions<any>;
const columnSizeProps = {
state: {
@ -562,6 +600,9 @@ export const RawLogTable = memo(
columns,
dedupedRows,
tableId,
sortOrder,
enableSorting,
onSortingChange,
columnSizeStorage,
setColumnSizeStorage,
]);
@ -701,62 +742,15 @@ export const RawLogTable = memo(
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header, headerIndex) => {
const isLast = headerIndex === headerGroup.headers.length - 1;
return (
<th
className="overflow-hidden text-truncate bg-hdx-dark"
<TableHeader
key={header.id}
colSpan={header.colSpan}
style={{
width:
header.getSize() === UNDEFINED_WIDTH
? '100%'
: header.getSize(),
// Allow unknown width columns to shrink to 0
minWidth:
header.getSize() === UNDEFINED_WIDTH
? 0
: header.getSize(),
position: 'relative',
}}
>
{header.isPlaceholder ? null : (
<div>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</div>
)}
{header.column.getCanResize() &&
headerIndex !== headerGroup.headers.length - 1 && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resizer text-gray-600 cursor-col-resize ${
header.column.getIsResizing() ? 'isResizing' : ''
}`}
style={{
position: 'absolute',
right: 4,
top: 0,
bottom: 0,
width: 12,
}}
>
<i className="bi bi-three-dots-vertical" />
</div>
)}
{headerIndex === headerGroup.headers.length - 1 && (
<div
className="d-flex align-items-center"
style={{
position: 'absolute',
right: 8,
top: 0,
bottom: 0,
}}
>
{tableId != null &&
header={header}
isLast={isLast}
lastItemButtons={
<>
{tableId &&
Object.keys(columnSizeStorage).length > 0 && (
<div
className="fs-8 text-muted-hover disabled"
@ -778,7 +772,6 @@ export const RawLogTable = memo(
)}
<UnstyledButton
onClick={() => setWrapLinesEnabled(prev => !prev)}
className="ms-2"
>
<MantineTooltip label="Wrap lines">
<i className="bi bi-text-wrap" />
@ -788,7 +781,7 @@ export const RawLogTable = memo(
<CsvExportButton
data={csvData}
filename={`hyperdx_search_results_${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}`}
className="fs-6 text-muted-hover ms-2"
className="fs-6 text-muted-hover "
>
<MantineTooltip
label={`Download table as CSV (max ${maxRows.toLocaleString()} rows)${isLimited ? ' - data truncated' : ''}`}
@ -798,16 +791,16 @@ export const RawLogTable = memo(
</CsvExportButton>
{onSettingsClick != null && (
<div
className="fs-8 text-muted-hover ms-2"
className="fs-8 text-muted-hover"
role="button"
onClick={() => onSettingsClick()}
>
<i className="bi bi-gear-fill" />
</div>
)}
</div>
)}
</th>
</>
}
/>
);
})}
</tr>
@ -998,7 +991,7 @@ export const RawLogTable = memo(
) : hasNextPage == false &&
isLoading == false &&
dedupedRows.length === 0 ? (
<div className="my-3">
<div className="my-3" data-testid="db-row-table-no-results">
No results found.
<Text mt="sm" c="gray.3">
Try checking the query explainer in the search bar if
@ -1142,6 +1135,8 @@ function DBSqlRowTableComponent({
collapseAllRows,
showExpandButton = true,
renderRowDetails,
onSortingChange,
initialSortBy,
}: {
config: ChartConfigWithDateRange;
sourceId?: string;
@ -1158,12 +1153,50 @@ function DBSqlRowTableComponent({
onExpandedRowsChange?: (hasExpandedRows: boolean) => void;
collapseAllRows?: boolean;
showExpandButton?: boolean;
initialSortBy?: SortingState;
onSortingChange?: (v: SortingState | null) => void;
}) {
const { data: me } = api.useMe();
const mergedConfig = useConfigWithPrimaryAndPartitionKey({
...searchChartConfigDefaults(me?.team),
...config,
});
const [orderBy, setOrderBy] = useState<SortingState[number] | null>(
initialSortBy?.[0] ?? null,
);
const orderByArray = useMemo(() => (orderBy ? [orderBy] : []), [orderBy]);
const _onSortingChange = useCallback(
(v: SortingState | null) => {
onSortingChange?.(v);
setOrderBy(v?.[0] ?? null);
},
[setOrderBy, onSortingChange],
);
const prevSourceId = usePrevious(sourceId);
useEffect(() => {
if (prevSourceId && prevSourceId !== sourceId) {
_onSortingChange(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceId]);
const mergedConfigObj = useMemo(() => {
const base = {
...searchChartConfigDefaults(me?.team),
...config,
};
if (orderByArray.length) {
base.orderBy = orderByArray.map(o => {
return {
valueExpression: o.id,
ordering: o.desc ? 'DESC' : 'ASC',
};
});
}
return base;
}, [me, config, orderByArray]);
const mergedConfig = useConfigWithPrimaryAndPartitionKey(mergedConfigObj);
const { data, fetchNextPage, hasNextPage, isFetching, isError, error } =
useOffsetPaginatedQuery(mergedConfig ?? config, {
@ -1360,12 +1393,15 @@ function DBSqlRowTableComponent({
columnTypeMap={columnMap}
dateRange={config.dateRange}
loadingDate={loadingDate}
config={config}
config={mergedConfigObj}
onChildModalOpen={onChildModalOpen}
source={source}
onExpandedRowsChange={onExpandedRowsChange}
collapseAllRows={collapseAllRows}
showExpandButton={showExpandButton}
enableSorting={true}
onSortingChange={_onSortingChange}
sortOrder={orderByArray}
/>
</>
);

View file

@ -5,6 +5,7 @@ import {
ChartConfigWithDateRange,
TSource,
} from '@hyperdx/common-utils/dist/types';
import { SortingState } from '@tanstack/react-table';
import { useSource } from '@/source';
import TabBar from '@/TabBar';
@ -35,6 +36,8 @@ interface Props {
collapseAllRows?: boolean;
isNestedPanel?: boolean;
breadcrumbPath?: BreadcrumbEntry[];
onSortingChange?: (v: SortingState | null) => void;
initialSortBy?: SortingState;
}
export default function DBSqlRowTableWithSideBar({
@ -51,6 +54,8 @@ export default function DBSqlRowTableWithSideBar({
isNestedPanel,
breadcrumbPath,
onSidebarOpen,
onSortingChange,
initialSortBy,
}: Props) {
const { data: sourceData } = useSource({ id: sourceId });
const [rowId, setRowId] = useQueryState('rowWhere');
@ -89,7 +94,9 @@ export default function DBSqlRowTableWithSideBar({
enabled={enabled}
isLive={isLive ?? true}
queryKeyPrefix={'dbSqlRowTable'}
onSortingChange={onSortingChange}
denoiseResults={denoiseResults}
initialSortBy={initialSortBy}
renderRowDetails={r => {
if (!sourceData) {
return <div className="p-3 text-muted">Loading...</div>;

View file

@ -0,0 +1,102 @@
import cx from 'classnames';
import { Button, Group, Text } from '@mantine/core';
import {
IconArrowDown,
IconArrowUp,
IconDotsVertical,
} from '@tabler/icons-react';
import { flexRender, Header } from '@tanstack/react-table';
import { UNDEFINED_WIDTH } from '@/tableUtils';
export default function TableHeader({
isLast,
header,
lastItemButtons,
}: {
isLast: boolean;
header: Header<any, any>;
lastItemButtons?: React.ReactNode;
}) {
return (
<th
className="overflow-hidden bg-hdx-dark"
key={header.id}
colSpan={header.colSpan}
style={{
width: header.getSize() === UNDEFINED_WIDTH ? '100%' : header.getSize(),
// Allow unknown width columns to shrink to 0
minWidth: header.getSize() === UNDEFINED_WIDTH ? 0 : header.getSize(),
}}
>
<Group wrap="nowrap" gap={0} align="center">
{!header.column.getCanSort() ? (
<Text truncate="end" size="xs" flex="1">
{flexRender(header.column.columnDef.header, header.getContext())}
</Text>
) : (
<Button
size="xxs"
p={1}
variant="subtle"
color="gray"
onClick={header.column.getToggleSortingHandler()}
flex="1"
justify="space-between"
data-testid="raw-log-table-sort-button"
>
<>
{header.isPlaceholder ? null : (
<Text truncate="end" size="xs" flex="1" c="white">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Text>
)}
{header.column.getIsSorted() && (
<div
data-testid="raw-log-table-sort-indicator"
className={
header.column.getIsSorted() === 'asc'
? 'sorted-asc'
: 'sorted-desc'
}
>
<>
{header.column.getIsSorted() === 'asc' ? (
<IconArrowUp size={12} />
) : (
<IconArrowDown size={12} />
)}
</>
</div>
)}
</>
</Button>
)}
<Group gap={0} wrap="nowrap" align="center">
{header.column.getCanResize() && !isLast && (
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cx(
`resizer text-gray-600 cursor-col-resize`,
header.column.getIsResizing() && 'isResizing',
)}
>
<IconDotsVertical size={12} />
</div>
)}
{isLast && (
<Group gap={2} wrap="nowrap">
{lastItemButtons}
</Group>
)}
</Group>
</Group>
</th>
);
}

View file

@ -1,10 +1,12 @@
import { useMemo, useRef } from 'react';
import { useMemo, useState } from 'react';
import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse';
import {
ChartConfigWithDateRange,
ChartConfigWithOptDateRange,
ChatConfigWithOptTimestamp,
} from '@hyperdx/common-utils/dist/types';
import { Box, Code, Text } from '@mantine/core';
import { SortingState } from '@tanstack/react-table';
import { Table } from '@/HDXMultiSeriesTableChart';
import useOffsetPaginatedQuery from '@/hooks/useOffsetPaginatedQuery';
@ -15,17 +17,17 @@ import { SQLPreview } from './ChartSQLPreview';
// TODO: Support clicking in to view matched events
export default function DBTableChart({
config,
onSortClick,
getRowSearchLink,
enabled = true,
queryKeyPrefix,
}: {
config: ChartConfigWithOptDateRange;
onSortClick?: (seriesIndex: number) => void;
config: ChatConfigWithOptTimestamp;
getRowSearchLink?: (row: any) => string;
queryKeyPrefix?: string;
enabled?: boolean;
}) {
const [sort, setSort] = useState<SortingState>([]);
const queriedConfig = (() => {
const _config = omit(config, ['granularity']);
if (!_config.limit) {
@ -34,6 +36,15 @@ export default function DBTableChart({
if (_config.groupBy && typeof _config.groupBy === 'string') {
_config.orderBy = _config.groupBy;
}
if (sort.length) {
_config.orderBy = sort?.map(o => {
return {
valueExpression: o.id,
ordering: o.desc ? 'DESC' : 'ASC',
};
});
}
return _config;
})();
@ -44,6 +55,21 @@ export default function DBTableChart({
});
const { observerRef: fetchMoreRef } = useIntersectionObserver(fetchNextPage);
// Returns an array of aliases, so we can check if something is using an alias
const aliasMap = useMemo(() => {
// If the config.select is a string, we can't infer this.
// One day, we could potentially run this through chSqlToAliasMap but AST parsing
// doesn't work for most DBTableChart queries.
if (typeof config.select === 'string') {
return [];
}
return config.select.reduce((acc, select) => {
if (select.alias) {
acc.push(select.alias);
}
return acc;
}, [] as string[]);
}, [config?.select]);
const columns = useMemo(() => {
const rows = data?.data ?? [];
if (rows.length === 0) {
@ -54,12 +80,15 @@ export default function DBTableChart({
if (queriedConfig.groupBy && typeof queriedConfig.groupBy === 'string') {
groupByKeys = queriedConfig.groupBy.split(',').map(v => v.trim());
}
return Object.keys(rows?.[0]).map(key => ({
// If it's an alias, wrap in quotes to support a variety of formats (ex "Time (ms)", "Req/s", etc)
id: aliasMap.includes(key) ? `"${key}"` : key,
dataKey: key,
displayName: key,
numberFormat: groupByKeys.includes(key) ? undefined : config.numberFormat,
}));
}, [config.numberFormat, data]);
}, [config.numberFormat, aliasMap, queriedConfig.groupBy, data]);
return isLoading && !data ? (
<div className="d-flex h-100 w-100 align-items-center justify-content-center text-muted">
@ -101,6 +130,8 @@ export default function DBTableChart({
data={data?.data ?? []}
columns={columns}
getRowSearchLink={getRowSearchLink}
sorting={sort}
onSortingChange={setSort}
tableBottom={
hasNextPage && (
<Text ref={fetchMoreRef} ta="center">

View file

@ -196,6 +196,7 @@ export const createExpandButtonColumn = (
},
size: 32,
enableResizing: false,
enableSorting: false,
meta: {
className: 'text-center',
},

View file

@ -1,4 +1,141 @@
import { appendSelectWithPrimaryAndPartitionKey } from '@/components/DBRowTable';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
appendSelectWithPrimaryAndPartitionKey,
RawLogTable,
} from '@/components/DBRowTable';
import * as useChartConfigModule from '../../hooks/useChartConfig';
describe('RawLogTable', () => {
beforeEach(() => {
jest.clearAllMocks();
jest
.spyOn(useChartConfigModule, 'useAliasMapFromChartConfig')
.mockReturnValue({
data: {},
isLoading: false,
error: null,
} as any);
});
it('should render no results message when no results found', async () => {
renderWithMantine(
<RawLogTable
displayedColumns={['col1', 'col2']}
rows={[]}
isLoading={false}
dedupRows={false}
hasNextPage={false}
onRowDetailsClick={() => {}}
generateRowId={() => ''}
columnTypeMap={new Map()}
/>,
);
expect(await screen.findByTestId('db-row-table-no-results')).toBeTruthy();
});
describe('Sorting', () => {
const baseProps = {
displayedColumns: ['col1', 'col2'],
rows: [
{
col1: 'value1',
col2: 'value2',
},
],
isLoading: false,
dedupRows: false,
hasNextPage: false,
onRowDetailsClick: () => {},
generateRowId: () => '',
columnTypeMap: new Map(),
};
it('Should not allow changing sort if disabled', () => {
renderWithMantine(<RawLogTable {...baseProps} />);
expect(
screen.queryByTestId('raw-log-table-sort-button'),
).not.toBeInTheDocument();
});
it('Should allow changing sort', async () => {
const callback = jest.fn();
renderWithMantine(
<RawLogTable {...baseProps} enableSorting onSortingChange={callback} />,
);
const sortElements = await screen.findAllByTestId(
'raw-log-table-sort-button',
);
expect(sortElements).toHaveLength(2);
await userEvent.click(sortElements.at(0)!);
expect(callback).toHaveBeenCalledWith([
{
desc: false,
id: 'col1',
},
]);
});
it('Should show sort indicator', async () => {
renderWithMantine(
<RawLogTable
{...baseProps}
enableSorting
sortOrder={[
{
desc: false,
id: 'col1',
},
]}
/>,
);
const sortElements = await screen.findByTestId(
'raw-log-table-sort-indicator',
);
expect(sortElements).toBeInTheDocument();
expect(sortElements).toHaveClass('sorted-asc');
});
it('Should reference alias map when possible', async () => {
jest
.spyOn(useChartConfigModule, 'useAliasMapFromChartConfig')
.mockReturnValue({
data: {
col1: 'col1_alias',
col2: 'col2_alias',
},
isLoading: false,
error: null,
} as any);
const callback = jest.fn();
renderWithMantine(
<RawLogTable {...baseProps} enableSorting onSortingChange={callback} />,
);
const sortElements = await screen.findAllByTestId(
'raw-log-table-sort-button',
);
expect(sortElements).toHaveLength(2);
await userEvent.click(sortElements.at(0)!);
expect(callback).toHaveBeenCalledWith([
{
desc: false,
id: '"col1"',
},
]);
});
});
});
describe('appendSelectWithPrimaryAndPartitionKey', () => {
it('should extract columns from partition key with nested function call', () => {

View file

@ -7,7 +7,7 @@ import {
ColumnMetaType,
} from '@hyperdx/common-utils/dist/clickhouse';
import { renderChartConfig } from '@hyperdx/common-utils/dist/renderChartConfig';
import { ChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types';
import { ChatConfigWithOptTimestamp } from '@hyperdx/common-utils/dist/types';
import {
isFirstOrderByAscending,
isTimestampExpressionInFirstOrderBy,
@ -26,12 +26,12 @@ import { omit } from '@/utils';
type TQueryKey = readonly [
string,
ChartConfigWithDateRange,
ChatConfigWithOptTimestamp,
number | undefined,
];
function queryKeyFn(
prefix: string,
config: ChartConfigWithDateRange,
config: ChatConfigWithOptTimestamp,
queryTimeout?: number,
): TQueryKey {
return [prefix, config, queryTimeout];
@ -130,7 +130,7 @@ function generateTimeWindowsAscending(startDate: Date, endDate: Date) {
// Get time window from page param
function getTimeWindowFromPageParam(
config: ChartConfigWithDateRange,
config: ChatConfigWithOptTimestamp,
pageParam: TPageParam,
): TimeWindow {
const [startDate, endDate] = config.dateRange;
@ -148,7 +148,7 @@ function getTimeWindowFromPageParam(
function getNextPageParam(
lastPage: TQueryFnData | null,
allPages: TQueryFnData[],
config: ChartConfigWithDateRange,
config: ChatConfigWithOptTimestamp,
): TPageParam | undefined {
if (lastPage == null) {
return undefined;
@ -428,7 +428,7 @@ function flattenData(data: TData | undefined): TQueryFnData | null {
}
export default function useOffsetPaginatedQuery(
config: ChartConfigWithDateRange,
config: ChatConfigWithOptTimestamp,
{
isLive,
enabled = true,

View file

@ -1,4 +1,5 @@
import { createParser } from 'nuqs';
import { SortingState } from '@tanstack/react-table';
// Note: this can be deleted once we upgrade to nuqs v2.2.3
// https://github.com/47ng/nuqs/pull/783
@ -6,3 +7,24 @@ export const parseAsStringWithNewLines = createParser<string>({
parse: value => value.replace(/%0A/g, '\n'),
serialize: value => value.replace(/\n/g, '%0A'),
});
export const parseAsSortingStateString = createParser<SortingState[number]>({
parse: value => {
if (!value) {
return null;
}
const keys = value.split(' ');
const direction = keys.pop();
const key = keys.join(' ');
return {
id: key,
desc: direction === 'DESC',
};
},
serialize: value => {
if (!value) {
return '';
}
return `${value.id} ${value.desc ? 'DESC' : 'ASC'}`;
},
});

View file

@ -470,11 +470,11 @@ export abstract class BaseClickhouseClient {
}
// eslint-disable-next-line no-console
console.log('--------------------------------------------------------');
console.debug('--------------------------------------------------------');
// eslint-disable-next-line no-console
console.log('Sending Query:', debugSql);
console.debug('Sending Query:', debugSql);
// eslint-disable-next-line no-console
console.log('--------------------------------------------------------');
console.debug('--------------------------------------------------------');
}
protected processClickhouseSettings(

View file

@ -421,6 +421,13 @@ export type DateRange = {
};
export type ChartConfigWithDateRange = ChartConfig & DateRange;
export type ChatConfigWithOptTimestamp = Omit<
ChartConfigWithDateRange,
'timestampValueExpression'
> & {
timestampValueExpression?: string;
};
// For non-time-based searches (ex. grab 1 row)
export type ChartConfigWithOptDateRange = Omit<
ChartConfig,

View file

@ -5,6 +5,7 @@ import { z } from 'zod';
import {
ChartConfigWithDateRange,
ChatConfigWithOptTimestamp,
DashboardFilter,
DashboardFilterSchema,
DashboardSchema,
@ -546,10 +547,11 @@ export const removeTrailingDirection = (s: string) => {
};
export const isTimestampExpressionInFirstOrderBy = (
config: ChartConfigWithDateRange,
config: ChatConfigWithOptTimestamp,
) => {
const firstOrderingItem = getFirstOrderingItem(config.orderBy);
if (!firstOrderingItem) return false;
if (!firstOrderingItem || config.timestampValueExpression == null)
return false;
const firstOrderingExpression =
typeof firstOrderingItem === 'string'