diff --git a/.changeset/kubernetes-tables-refresh-pulse.md b/.changeset/kubernetes-tables-refresh-pulse.md new file mode 100644 index 0000000000..24ec7b7179 --- /dev/null +++ b/.changeset/kubernetes-tables-refresh-pulse.md @@ -0,0 +1,10 @@ +--- +'@hyperdx/app': patch +--- + +fix: keep Kubernetes dashboard tables on screen while the page refreshes + +On a refresh, the Pods, Nodes and Namespaces tables dropped their rows and +showed a loading skeleton until the new range loaded. They now keep the +current rows on screen and pulse while the refetch runs, like the charts +beside them. diff --git a/packages/app/src/KubernetesDashboardPage.tsx b/packages/app/src/KubernetesDashboardPage.tsx index 7897d8a548..98ba05d70d 100644 --- a/packages/app/src/KubernetesDashboardPage.tsx +++ b/packages/app/src/KubernetesDashboardPage.tsx @@ -5,6 +5,7 @@ import Head from 'next/head'; import Link from 'next/link'; import cx from 'classnames'; import sub from 'date-fns/sub'; +import { isEqual, omit } from 'lodash'; import { useQueryState } from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; @@ -113,6 +114,22 @@ const Th = React.memo<{ const TABLE_FETCH_LIMIT = 10000; +const withoutDateRange = (part: unknown) => + typeof part === 'object' && part !== null ? omit(part, ['dateRange']) : part; + +// Whether the previous query ran this config over another time range. Rows +// from another source or filter would be read with the wrong resource +// attributes, so only a time-range change keeps them on screen. +function isSameQueryIgnoringDateRange( + prevKey: readonly unknown[] | undefined, + config: object, +) { + const target = withoutDateRange(config); + return ( + prevKey?.some(part => isEqual(withoutDateRange(part), target)) ?? false + ); +} + type InfraPodsStatusTableColumn = | 'restarts' | 'uptime' @@ -175,7 +192,7 @@ export const InfraPodsStatusTable = ({ }); const groupBy = ['k8s.pod.name', 'k8s.namespace.name', 'k8s.node.name']; - const { data, isError, isLoading } = useQueriedChartConfig({ + const queryConfig = { ...convertV1ChartConfigToV2( { series: [ @@ -244,7 +261,16 @@ export const InfraPodsStatusTable = ({ }, ), limit: { limit: TABLE_FETCH_LIMIT, offset: 0 }, - }); + }; + const { data, isError, isLoading, isPlaceholderData } = useQueriedChartConfig( + queryConfig, + { + placeholderData: (prev, prevQuery) => + isSameQueryIgnoringDateRange(prevQuery?.queryKey, queryConfig) + ? prev + : undefined, + }, + ); const resourceAttr = metricSource.resourceAttributesExpression; @@ -376,7 +402,7 @@ export const InfraPodsStatusTable = ({ /> - {isAtFetchLimit && !isLoading && !isError && podsList.length > 0 && ( + {isAtFetchLimit && !isError && podsList.length > 0 && ( Showing first {TABLE_FETCH_LIMIT.toLocaleString()} pods. Use the @@ -387,12 +413,13 @@ export const InfraPodsStatusTable = ({
- {isLoading ? ( + {isLoading && !data ? ( ) : isError ? (
@@ -539,7 +566,7 @@ export const InfraPodsStatusTable = ({ ); }; -const NodesTable = ({ +export const NodesTable = ({ metricSource, where, dateRange, @@ -550,7 +577,7 @@ const NodesTable = ({ }) => { const groupBy = ['k8s.node.name']; - const { data, isError, isLoading } = useQueriedChartConfig({ + const queryConfig = { ...convertV1ChartConfigToV2( { series: [ @@ -595,7 +622,16 @@ const NodesTable = ({ }, ), limit: { limit: TABLE_FETCH_LIMIT, offset: 0 }, - }); + }; + const { data, isError, isLoading, isPlaceholderData } = useQueriedChartConfig( + queryConfig, + { + placeholderData: (prev, prevQuery) => + isSameQueryIgnoringDateRange(prevQuery?.queryKey, queryConfig) + ? prev + : undefined, + }, + ); const getLink = React.useCallback((nodeName: string) => { const searchParams = new URLSearchParams(window.location.search); @@ -637,12 +673,13 @@ const NodesTable = ({
- {isLoading ? ( + {isLoading && !data ? ( ) : isError ? (
@@ -742,7 +779,7 @@ const NodesTable = ({ ); }; -const NamespacesTable = ({ +export const NamespacesTable = ({ dateRange, metricSource, where, @@ -753,7 +790,7 @@ const NamespacesTable = ({ }) => { const groupBy = ['k8s.namespace.name']; - const { data, isError, isLoading } = useQueriedChartConfig({ + const queryConfig = { ...convertV1ChartConfigToV2( { series: [ @@ -795,7 +832,16 @@ const NamespacesTable = ({ }, ), limit: { limit: TABLE_FETCH_LIMIT, offset: 0 }, - }); + }; + const { data, isError, isLoading, isPlaceholderData } = useQueriedChartConfig( + queryConfig, + { + placeholderData: (prev, prevQuery) => + isSameQueryIgnoringDateRange(prevQuery?.queryKey, queryConfig) + ? prev + : undefined, + }, + ); const resourceAttr = metricSource.resourceAttributesExpression; @@ -836,12 +882,13 @@ const NamespacesTable = ({
- {isLoading ? ( + {isLoading && !data ? ( ) : isError ? (
diff --git a/packages/app/src/__tests__/KubernetesDashboardPage.tables.test.tsx b/packages/app/src/__tests__/KubernetesDashboardPage.tables.test.tsx new file mode 100644 index 0000000000..3ff0cedb74 --- /dev/null +++ b/packages/app/src/__tests__/KubernetesDashboardPage.tables.test.tsx @@ -0,0 +1,207 @@ +import { SourceKind, TMetricSource } from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; + +import { + InfraPodsStatusTable, + NamespacesTable, + NodesTable, +} from '@/KubernetesDashboardPage'; +import { KubePhase } from '@/types'; + +const mockUseQueriedChartConfig = jest.fn(); +jest.mock('@/hooks/useChartConfig', () => ({ + ...jest.requireActual('@/hooks/useChartConfig'), + useQueriedChartConfig: (...args: unknown[]) => + mockUseQueriedChartConfig(...args), +})); + +// jsdom has no layout, so render the first rows instead of the visible window. +jest.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: Math.min(count, 20) }, (_, index) => ({ + index, + key: index, + start: index * 40, + end: (index + 1) * 40, + size: 40, + })), + getTotalSize: () => count * 40, + measureElement: () => {}, + options: { scrollMargin: 0 }, + }), +})); + +const metricSource: TMetricSource = { + id: 'metrics-1', + name: 'Metrics', + kind: SourceKind.Metric, + connection: 'conn-1', + from: { databaseName: 'default', tableName: '' }, + metricTables: { + gauge: 'otel_metrics_gauge', + sum: 'otel_metrics_sum', + histogram: 'otel_metrics_histogram', + summary: 'otel_metrics_summary', + 'exponential histogram': 'otel_metrics_exp_histogram', + }, + resourceAttributesExpression: 'ResourceAttributes', + timestampValueExpression: 'TimeUnix', +}; + +const dateRange: [Date, Date] = [ + new Date('2026-07-06T00:00:00Z'), + new Date('2026-07-06T01:00:00Z'), +]; + +function mockQuery(state: { + data?: { data: Record[] }; + isLoading?: boolean; + isPlaceholderData?: boolean; +}) { + mockUseQueriedChartConfig.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + isPlaceholderData: false, + ...state, + }); +} + +const podRow = { + "arrayElement(ResourceAttributes, 'k8s.pod.name')": 'api-7d9f', + 'last_value(k8s.pod.phase)': KubePhase.Running, +}; + +describe.each([ + { + table: 'pods', + Table: InfraPodsStatusTable, + row: podRow, + name: 'api-7d9f', + empty: 'No pods found', + }, + { + table: 'nodes', + Table: NodesTable, + row: { "arrayElement(ResourceAttributes, 'k8s.node.name')": 'node-a' }, + name: 'node-a', + empty: 'No nodes found', + }, + { + table: 'namespaces', + Table: NamespacesTable, + row: { + "arrayElement(ResourceAttributes, 'k8s.namespace.name')": 'payments', + }, + name: 'payments', + empty: 'No namespaces found', + }, +])('Kubernetes $table table refresh', ({ Table, row, name, empty }) => { + const renderTable = () => + renderWithMantine( + , + ); + + beforeEach(() => { + mockUseQueriedChartConfig.mockReset(); + }); + + it('keeps the previous rows only while the same query loads a new time range', () => { + mockQuery({ data: { data: [row] } }); + + renderTable(); + + const [config, options] = mockUseQueriedChartConfig.mock.calls[0]; + const previous = { data: [row] }; + const previousQuery = (prevConfig: unknown) => ({ + queryKey: [prevConfig, false, false, 60], + }); + const earlierRange = [ + new Date('2026-07-05T23:00:00Z'), + new Date('2026-07-06T00:00:00Z'), + ]; + expect( + options.placeholderData( + previous, + previousQuery({ ...config, dateRange: earlierRange }), + ), + ).toBe(previous); + // Rows from another source would be read with the wrong attributes. + expect( + options.placeholderData( + previous, + previousQuery({ ...config, connection: 'conn-2' }), + ), + ).toBeUndefined(); + expect(options.placeholderData(previous, undefined)).toBeUndefined(); + }); + + it('keeps the previous rows on screen and pulses them while a refresh loads', () => { + // The new range's query is still loading behind the previous rows. + mockQuery({ + data: { data: [row] }, + isLoading: true, + isPlaceholderData: true, + }); + + renderTable(); + + expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByRole('table').parentElement).toHaveClass('effect-pulse'); + }); + + it('pulses the empty state while a refresh loads', () => { + mockQuery({ data: { data: [] }, isPlaceholderData: true }); + + renderTable(); + + expect(screen.getByText(empty).parentElement).toHaveClass('effect-pulse'); + }); + + it('stops pulsing once fresh rows load', () => { + mockQuery({ data: { data: [row] } }); + + renderTable(); + + expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByRole('table').parentElement).not.toHaveClass( + 'effect-pulse', + ); + }); + + it('shows the loading skeleton before the first rows arrive', () => { + mockQuery({ isLoading: true }); + + renderTable(); + + // The skeleton is a table without any rows or empty-state message. + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.queryByText(name)).not.toBeInTheDocument(); + expect(screen.queryByText(empty)).not.toBeInTheDocument(); + }); +}); + +describe('Kubernetes pods table fetch limit', () => { + beforeEach(() => { + mockUseQueriedChartConfig.mockReset(); + }); + + it('keeps the fetch-limit notice above the previous rows while a refresh loads', () => { + mockQuery({ + data: { data: Array.from({ length: 10_000 }, () => podRow) }, + isLoading: true, + isPlaceholderData: true, + }); + + renderWithMantine( + , + ); + + expect(screen.getByText(/Showing first 10.?000 pods/)).toBeInTheDocument(); + }); +});