Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/pattern-tile-refresh-pulse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@hyperdx/app': patch
---

fix: keep pattern tiles on screen while a dashboard refreshes

During a refresh, event pattern tiles dropped their patterns and showed a
loading state until the new sample was mined. They now keep the current
patterns on screen and pulse while the refetch runs, like the other dashboard
tiles.
9 changes: 9 additions & 0 deletions .changeset/search-tile-refresh-pulse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@hyperdx/app': patch
---

fix: keep search tile rows on screen while a dashboard refreshes

During a refresh, search tiles dropped their rows and showed a loading state
until the new results arrived. They now keep the current rows on screen and
pulse while the refetch runs, like the other dashboard tiles.
2 changes: 2 additions & 0 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,7 @@ const Tile = ({
queryKeyPrefix={'search'}
variant="default"
errorVariant="collapsible"
keepPreviousData
/>
</ChartContainer>
)}
Expand Down Expand Up @@ -1467,6 +1468,7 @@ const Tile = ({
granularity: undefined,
}}
totalCountQueryKeyPrefix={`dashboard-patterns-${chart.id}`}
keepPreviousData
/>
</ChartContainer>
)}
Expand Down
44 changes: 33 additions & 11 deletions packages/app/src/components/DBRowTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ export const RawLogTable = memo(
getRowWhere,
variant = 'default',
onRemoveColumn,
className,
}: {
wrapLines?: boolean;
displayedColumns: string[];
Expand Down Expand Up @@ -421,6 +422,9 @@ export const RawLogTable = memo(
getRowWhere?: (row: Record<string, any>) => RowWhereResult;
variant?: DBRowTableVariant;
onRemoveColumn?: (column: string) => void;
// Extra classes for the scroll container, e.g. `effect-pulse` while the
// tile is refreshing.
className?: string;
}) => {
const dedupedRows = useMemo(() => {
const lIds = new Set();
Expand Down Expand Up @@ -986,9 +990,13 @@ export const RawLogTable = memo(
/>
<div
data-testid="search-results-table"
className={cx(styles.tableWrapper, {
[styles.muted]: variant === 'muted',
})}
className={cx(
styles.tableWrapper,
{
[styles.muted]: variant === 'muted',
},
className,
)}
onScroll={e => {
fetchMoreOnBottomReached(e.target as HTMLDivElement);

Expand Down Expand Up @@ -1580,6 +1588,7 @@ function DBSqlRowTableComponent({
tableId,
errorVariant,
onResolvedColumnsChange,
keepPreviousData,
}: {
config: BuilderChartConfigWithDateRange;
sourceId?: string;
Expand Down Expand Up @@ -1613,6 +1622,8 @@ function DBSqlRowTableComponent({
tableId?: string;
errorVariant?: ChartErrorStateVariant;
onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void;
/** Keep the current rows on screen while a refresh loads a new date range */
keepPreviousData?: boolean;
}) {
const { data: me } = api.useMe();
const { toggleColumn, displayedColumns: contextDisplayedColumns } =
Expand Down Expand Up @@ -1672,14 +1683,22 @@ function DBSqlRowTableComponent({

const mergedConfig = useConfigWithAdditionalSelect(mergedConfigObj, sourceId);

const { data, fetchNextPage, hasNextPage, isFetching, isError, error } =
useOffsetPaginatedQuery(mergedConfig ?? config, {
enabled:
enabled && mergedConfig != null && getSelectLength(config.select) > 0,
isLive,
queryKeyPrefix,
enableSmallFirstWindow,
});
const {
data,
fetchNextPage,
hasNextPage,
isFetching,
isError,
error,
isPlaceholderData,
} = useOffsetPaginatedQuery(mergedConfig ?? config, {
enabled:
enabled && mergedConfig != null && getSelectLength(config.select) > 0,
isLive,
queryKeyPrefix,
enableSmallFirstWindow,
keepPreviousData,
});

// The first N columns are the select columns from the user
// We can't use names as CH may rewrite the names
Expand Down Expand Up @@ -1934,6 +1953,9 @@ function DBSqlRowTableComponent({
variant={variant}
onRemoveColumn={toggleColumn ? onRemoveColumnFromTable : undefined}
tableId={tableId}
className={
keepPreviousData && isPlaceholderData ? 'effect-pulse' : undefined
}
/>
</>
);
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/components/DBSqlRowTableWithSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ interface Props {
selectionResetKey?: string;
onSelectedRowsChange?: (hasSelectedRows: boolean) => void;
onResolvedColumnsChange?: (meta: ColumnMetaType[]) => void;
/** Keep the current rows on screen while a refresh loads a new date range */
keepPreviousData?: boolean;
// Clicking outside the row side panel (and outside `keepOpenSelector`) closes
// it. Enabled by default; pass `false` to opt out.
closeOnClickOutside?: boolean;
Expand Down Expand Up @@ -82,6 +84,7 @@ export default function DBSqlRowTableWithSideBar({
selectionResetKey,
onSelectedRowsChange,
onResolvedColumnsChange,
keepPreviousData,
closeOnClickOutside = true,
keepOpenSelector = DEFAULT_KEEP_OPEN_SELECTOR,
}: Props) {
Expand Down Expand Up @@ -156,6 +159,7 @@ export default function DBSqlRowTableWithSideBar({
selectionResetKey={selectionResetKey}
onSelectedRowsChange={onSelectedRowsChange}
onResolvedColumnsChange={onResolvedColumnsChange}
keepPreviousData={keepPreviousData}
/>
</RowSidePanelContext>
);
Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/components/PatternTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function PatternTable({
onDraftPatternColumnChange: externalOnDraftPatternColumnChange,
onSubmit: externalOnSubmit,
source,
keepPreviousData,
}: {
config: BuilderChartConfigWithDateRange;
totalCountConfig: BuilderChartConfigWithDateRange;
Expand All @@ -41,6 +42,8 @@ export default function PatternTable({
onSubmit?: () => void;
totalCountQueryKeyPrefix: string;
source?: TSource;
/** Keep the current patterns on screen while a refresh loads a new date range */
keepPreviousData?: boolean;
}) {
const SAMPLES = 10_000;

Expand Down Expand Up @@ -69,6 +72,8 @@ export default function PatternTable({
const {
data: groupedResults,
isLoading: isGroupedPatternsLoading,
// Only true with `keepPreviousData`, while the previous patterns are shown
isPlaceholderData: isRefreshing,
error: groupedPatternsError,
patternQueryConfig,
} = useGroupedPatterns({
Expand All @@ -81,6 +86,7 @@ export default function PatternTable({
source.serviceNameExpression) ||
'',
totalCount,
keepPreviousData,
});

const isLoading =
Expand Down Expand Up @@ -142,7 +148,9 @@ export default function PatternTable({
<RawLogTable
isLive={false}
wrapLines={true}
isLoading={isLoading}
// The pulse is the only refresh indicator, not the loading footer
isLoading={isLoading && !isRefreshing}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Refresh shows final results When a pattern tile refreshes after a range with no patterns, setting isLoading to false makes the table say “No results found” while the new sample is still loading. If the prior range had rows, it says “End of Results” instead. Both messages make an unfinished refresh look complete.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional. The footer sits inside the pulsing table, so "End of Results" / "No results found" pulse along with the previous patterns they belong to, the same way the empty state pulses on the number, bar and pie tiles (#3211). The pattern table doesn't pass a date range to the footer, so it doesn't claim anything about the new range.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. Since the footer is rendered inside the same scroll container receiving effect-pulse, both “End of Results” and “No results found” remain visually associated with the previous patterns and are explicitly presented as part of the placeholder state—consistent with the empty-state behavior in the other dashboard tiles. The table does not expose the new date range in the footer, so it does not claim that the refreshed range is complete. I’m withdrawing this finding.

className={isRefreshing ? 'effect-pulse' : undefined}
rows={sortedGroupedResults ?? []}
displayedColumns={[
'__hdx_pattern_trend',
Expand Down
127 changes: 127 additions & 0 deletions packages/app/src/components/__tests__/DBSqlRowTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { screen } from '@testing-library/react';

import { DBSqlRowTable } from '@/components/DBRowTable';

const mockUseOffsetPaginatedQuery = jest.fn();
jest.mock('@/hooks/useOffsetPaginatedQuery', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseOffsetPaginatedQuery(...args),
}));

jest.mock('@/api', () => ({
__esModule: true,
default: { useMe: () => ({ data: { team: {} } }) },
}));

jest.mock('@/hooks/useMetadata', () => ({
...jest.requireActual('@/hooks/useMetadata'),
useColumns: () => ({ data: undefined }),
useTableMetadata: () => ({
data: { primary_key: 'Timestamp', partition_key: 'toDate(Timestamp)' },
}),
}));

jest.mock('@/hooks/usePatterns', () => ({
...jest.requireActual('@/hooks/usePatterns'),
useGroupedPatterns: () => ({ data: {}, isLoading: false, miner: null }),
}));

jest.mock('@/source', () => ({
...jest.requireActual('@/source'),
useSource: () => ({ data: undefined }),
}));

jest.mock('@/hooks/useChartConfig', () => ({
...jest.requireActual('@/hooks/useChartConfig'),
useAliasMapFromChartConfig: () => ({ data: {}, isLoading: false }),
useRenderedSqlChartConfig: () => ({ data: '', isLoading: false }),
}));

const config: React.ComponentProps<typeof DBSqlRowTable>['config'] = {
select: 'Body',
from: { databaseName: 'default', tableName: 'otel_logs' },
where: '',
timestampValueExpression: 'Timestamp',
connection: 'test-connection',
dateRange: [
new Date('2026-07-06T00:00:00Z'),
new Date('2026-07-06T01:00:00Z'),
],
};

function mockQueryResult({
isPlaceholderData,
}: {
isPlaceholderData: boolean;
}) {
mockUseOffsetPaginatedQuery.mockReturnValue({
data: {
data: [{ Body: 'previous row' }],
meta: [{ name: 'Body', type: 'String' }],
window: undefined,
},
fetchNextPage: jest.fn(),
hasNextPage: false,
isFetching: isPlaceholderData,
isError: false,
error: null,
isPlaceholderData,
});
}

function renderTable(
props: Partial<React.ComponentProps<typeof DBSqlRowTable>>,
) {
return renderWithMantine(
<QueryClientProvider client={new QueryClient()}>
<DBSqlRowTable config={config} sourceId="source-id" {...props} />
</QueryClientProvider>,
);
}

describe('DBSqlRowTable refresh', () => {
beforeEach(() => {
mockUseOffsetPaginatedQuery.mockReset();
});

it('asks the query to keep the previous rows when keepPreviousData is set', () => {
mockQueryResult({ isPlaceholderData: false });

renderTable({ keepPreviousData: true });

expect(mockUseOffsetPaginatedQuery.mock.calls[0][1]).toEqual(
expect.objectContaining({ keepPreviousData: true }),
);
});

it('pulses the previous rows while a refresh loads', () => {
mockQueryResult({ isPlaceholderData: true });

renderTable({ keepPreviousData: true });

expect(screen.getByTestId('search-results-table')).toHaveClass(
'effect-pulse',
);
});

it('does not pulse once fresh rows have loaded', () => {
mockQueryResult({ isPlaceholderData: false });

renderTable({ keepPreviousData: true });

expect(screen.getByTestId('search-results-table')).not.toHaveClass(
'effect-pulse',
);
});

it('does not pulse without keepPreviousData (e.g. live tail on the search page)', () => {
mockQueryResult({ isPlaceholderData: true });

renderTable({ isLive: true });

expect(screen.getByTestId('search-results-table')).not.toHaveClass(
'effect-pulse',
);
});
});
Loading
Loading