mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
fix: Improve Kubernetes dashboard performance (#1333)
These are the minimal set of changes needed to improve the kubernetes dashboard with 100k+ pods. **Changes:** * Fixed a performance issue in ChartUtils that caused computation to be O(n^2). This caused charts to slow down rendering to a crawl and freeze the page. It's not as noticeable with a smaller data set. This was the main issue. * Limited the number of items returned in the nodes, namespaces, and pods tables to 10k. This was the second biggest issue. * Introduced a virtualized table to each of the tables to speed up rendering. This was the third biggest issue. * Increased the amount of unique items returned from the metadata query so that users can filter for the items they need (UX improvement) **Future changes that will improve the experience even more:** 1) Fetch 10k, but add pagination (UX) 2) Improve query for fetching tabular data. It's timeseries, but realistically, we can make a smarter more performant query 3) To fill out the data in the tables (cpu, memory, uptime, etc...) we make separate queries and combine them on the server side. We could make this one large query (we have an existing ticket in the backlog for it). 4) Chart rendering is very computational intensive. It would be a better user experience to load these after the table loads. **Outstanding (existing) bugs that exist that I will fix in follow-up tickets:** 1) The namespaces query uses the wrong time window. It does not respect the global time picker date range. 2) Sorting of the table columns is broken. Ref: HDX-2370 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
2743d85b37
commit
892e43f889
5 changed files with 453 additions and 220 deletions
5
.changeset/flat-countries-teach.md
Normal file
5
.changeset/flat-countries-teach.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@hyperdx/app": patch
|
||||
---
|
||||
|
||||
fix: Improve loading of kubernetes dashboard
|
||||
|
|
@ -600,7 +600,11 @@ export function formatResponseForTimeChart({
|
|||
const ts = date.getTime() / 1000;
|
||||
|
||||
for (const valueColumn of valueColumns) {
|
||||
const tsBucket = tsBucketMap.get(ts) ?? {};
|
||||
let tsBucket = tsBucketMap.get(ts);
|
||||
if (tsBucket == null) {
|
||||
tsBucket = { [timestampColumn.name]: ts };
|
||||
tsBucketMap.set(ts, tsBucket);
|
||||
}
|
||||
|
||||
const keyName = [
|
||||
// Simplify the display name if there's only one series and a group by
|
||||
|
|
@ -614,11 +618,8 @@ export function formatResponseForTimeChart({
|
|||
const value =
|
||||
typeof rawValue === 'number' ? rawValue : Number.parseFloat(rawValue);
|
||||
|
||||
tsBucketMap.set(ts, {
|
||||
...tsBucket,
|
||||
[timestampColumn.name]: ts,
|
||||
[keyName]: value,
|
||||
});
|
||||
// Mutate the existing bucket object to avoid repeated large object copies
|
||||
tsBucket[keyName] = value;
|
||||
|
||||
let color: string | undefined = undefined;
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import * as React from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Link from 'next/link';
|
||||
import cx from 'classnames';
|
||||
|
|
@ -8,6 +8,7 @@ import { useQueryState } from 'nuqs';
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -15,7 +16,6 @@ import {
|
|||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
Table,
|
||||
|
|
@ -24,8 +24,10 @@ import {
|
|||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import { TimePicker } from '@/components/TimePicker';
|
||||
import { useVirtualList } from '@/hooks/useVirtualList';
|
||||
|
||||
import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar';
|
||||
import { DBTimeChart } from './components/DBTimeChart';
|
||||
|
|
@ -51,8 +53,6 @@ import { parseTimeQuery, useTimeQuery } from './timeQuery';
|
|||
import { KubePhase } from './types';
|
||||
import { formatNumber, formatUptime } from './utils';
|
||||
|
||||
const makeId = () => Math.floor(100000000 * Math.random()).toString(36);
|
||||
|
||||
const getKubePhaseNumber = (phase: string) => {
|
||||
switch (phase) {
|
||||
case 'running':
|
||||
|
|
@ -92,6 +92,8 @@ const Th = React.memo<{
|
|||
);
|
||||
});
|
||||
|
||||
const TABLE_FETCH_LIMIT = 10000;
|
||||
|
||||
type InfraPodsStatusTableColumn =
|
||||
| 'restarts'
|
||||
| 'uptime'
|
||||
|
|
@ -147,8 +149,8 @@ export const InfraPodsStatusTable = ({
|
|||
});
|
||||
|
||||
const groupBy = ['k8s.pod.name', 'k8s.namespace.name', 'k8s.node.name'];
|
||||
const { data, isError, isLoading } = useQueriedChartConfig(
|
||||
convertV1ChartConfigToV2(
|
||||
const { data, isError, isLoading } = useQueriedChartConfig({
|
||||
...convertV1ChartConfigToV2(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
|
|
@ -230,7 +232,8 @@ export const InfraPodsStatusTable = ({
|
|||
metric: metricSource,
|
||||
},
|
||||
),
|
||||
);
|
||||
limit: { limit: TABLE_FETCH_LIMIT, offset: 0 },
|
||||
});
|
||||
|
||||
// TODO: Use useTable
|
||||
const podsList = React.useMemo(() => {
|
||||
|
|
@ -238,31 +241,35 @@ export const InfraPodsStatusTable = ({
|
|||
return [];
|
||||
}
|
||||
|
||||
return data.data
|
||||
.map((row: any) => {
|
||||
return {
|
||||
id: makeId(),
|
||||
name: row["arrayElement(ResourceAttributes, 'k8s.pod.name')"],
|
||||
namespace:
|
||||
row["arrayElement(ResourceAttributes, 'k8s.namespace.name')"],
|
||||
node: row["arrayElement(ResourceAttributes, 'k8s.node.name')"],
|
||||
restarts: row['last_value(k8s.container.restarts)'],
|
||||
uptime: row['undefined(k8s.pod.uptime)'],
|
||||
cpuAvg: row['avg(k8s.pod.cpu.utilization)'],
|
||||
cpuLimitUtilization: row['avg(k8s.pod.cpu_limit_utilization)'],
|
||||
memAvg: row['avg(k8s.pod.memory.usage)'],
|
||||
memLimitUtilization: row['avg(k8s.pod.memory_limit_utilization)'],
|
||||
phase: row['last_value(k8s.pod.phase)'],
|
||||
};
|
||||
})
|
||||
.filter(pod => {
|
||||
if (phaseFilter === 'all') {
|
||||
return true;
|
||||
}
|
||||
return pod.phase === getKubePhaseNumber(phaseFilter);
|
||||
});
|
||||
// Filter first to reduce the number of objects we create
|
||||
const phaseFilteredData = data.data.filter((row: any) => {
|
||||
if (phaseFilter === 'all') {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
row['last_value(k8s.pod.phase)'] === getKubePhaseNumber(phaseFilter)
|
||||
);
|
||||
});
|
||||
|
||||
// Transform only the filtered data
|
||||
return phaseFilteredData.map((row: any, index: number) => ({
|
||||
id: `pod-${index}`, // Use index-based ID instead of random makeId()
|
||||
name: row["arrayElement(ResourceAttributes, 'k8s.pod.name')"],
|
||||
namespace: row["arrayElement(ResourceAttributes, 'k8s.namespace.name')"],
|
||||
node: row["arrayElement(ResourceAttributes, 'k8s.node.name')"],
|
||||
restarts: row['last_value(k8s.container.restarts)'],
|
||||
uptime: row['undefined(k8s.pod.uptime)'],
|
||||
cpuAvg: row['avg(k8s.pod.cpu.utilization)'],
|
||||
cpuLimitUtilization: row['avg(k8s.pod.cpu_limit_utilization)'],
|
||||
memAvg: row['avg(k8s.pod.memory.usage)'],
|
||||
memLimitUtilization: row['avg(k8s.pod.memory_limit_utilization)'],
|
||||
phase: row['last_value(k8s.pod.phase)'],
|
||||
}));
|
||||
}, [data, phaseFilter]);
|
||||
|
||||
// Check if we're hitting the fetch limit (indicating there might be more data)
|
||||
const isAtFetchLimit = data?.data && data.data.length >= TABLE_FETCH_LIMIT;
|
||||
|
||||
const getLink = React.useCallback((podName: string) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set('podName', `${podName}`);
|
||||
|
|
@ -279,6 +286,32 @@ export const InfraPodsStatusTable = ({
|
|||
sort: sortState.column === column ? sortState.order : null,
|
||||
});
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: podsList.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 40, []),
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
const [paddingTop, paddingBottom] = useMemo(
|
||||
() =>
|
||||
virtualItems.length > 0
|
||||
? [
|
||||
Math.max(
|
||||
0,
|
||||
virtualItems[0].start - rowVirtualizer.options.scrollMargin,
|
||||
),
|
||||
Math.max(0, totalSize - virtualItems[virtualItems.length - 1].end),
|
||||
]
|
||||
: [0, 0],
|
||||
[virtualItems, rowVirtualizer.options.scrollMargin, totalSize],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card p="md" data-testid="k8s-pods-table">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
|
|
@ -298,10 +331,20 @@ export const InfraPodsStatusTable = ({
|
|||
/>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
{isAtFetchLimit && !isLoading && !isError && podsList.length > 0 && (
|
||||
<Card.Section px="md" py="xs">
|
||||
<Alert variant="light" color="blue">
|
||||
Showing first {TABLE_FETCH_LIMIT.toLocaleString()} pods. Use the
|
||||
filters above to narrow your search.
|
||||
</Alert>
|
||||
</Card.Section>
|
||||
)}
|
||||
<Card.Section>
|
||||
<ScrollArea
|
||||
viewportProps={{
|
||||
style: { maxHeight: 300 },
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
style={{
|
||||
height: '300px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
|
@ -339,88 +382,111 @@ export const InfraPodsStatusTable = ({
|
|||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{podsList.map(pod => (
|
||||
<Link key={pod.id} href={getLink(pod.name)} legacyBehavior>
|
||||
<Table.Tr className="cursor-pointer">
|
||||
<Table.Td>{pod.name}</Table.Td>
|
||||
<Table.Td
|
||||
data-testid={`k8s-pods-table-namespace-${pod.id}`}
|
||||
{paddingTop > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
colSpan={8}
|
||||
style={{ height: `${paddingTop}px` }}
|
||||
/>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{virtualItems.map(virtualRow => {
|
||||
const pod = podsList[virtualRow.index];
|
||||
return (
|
||||
<Link key={pod.id} href={getLink(pod.name)} legacyBehavior>
|
||||
<Table.Tr
|
||||
className="cursor-pointer"
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
>
|
||||
{pod.namespace}
|
||||
</Table.Td>
|
||||
<Table.Td>{pod.node}</Table.Td>
|
||||
<Table.Td>
|
||||
<FormatPodStatus status={pod.phase} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
color="gray"
|
||||
label={
|
||||
formatNumber(
|
||||
pod.cpuAvg,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
) + ' avg'
|
||||
}
|
||||
<Table.Td>{pod.name}</Table.Td>
|
||||
<Table.Td
|
||||
data-testid={`k8s-pods-table-namespace-${pod.id}`}
|
||||
>
|
||||
<Text
|
||||
span
|
||||
c={pod.cpuLimitUtilization ? undefined : 'gray.7'}
|
||||
{pod.namespace}
|
||||
</Table.Td>
|
||||
<Table.Td>{pod.node}</Table.Td>
|
||||
<Table.Td>
|
||||
<FormatPodStatus status={pod.phase} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
color="gray"
|
||||
label={
|
||||
formatNumber(
|
||||
pod.cpuAvg,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
) + ' avg'
|
||||
}
|
||||
>
|
||||
{pod.cpuLimitUtilization
|
||||
? formatNumber(
|
||||
pod.cpuLimitUtilization,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)
|
||||
: '-'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
color="gray"
|
||||
label={
|
||||
formatNumber(pod.memAvg, K8S_MEM_NUMBER_FORMAT) +
|
||||
' avg'
|
||||
}
|
||||
>
|
||||
<Text
|
||||
span
|
||||
c={pod.memLimitUtilization ? undefined : 'gray.7'}
|
||||
<Text
|
||||
span
|
||||
c={pod.cpuLimitUtilization ? undefined : 'gray.7'}
|
||||
>
|
||||
{pod.cpuLimitUtilization
|
||||
? formatNumber(
|
||||
pod.cpuLimitUtilization,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)
|
||||
: '-'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
color="gray"
|
||||
label={
|
||||
formatNumber(pod.memAvg, K8S_MEM_NUMBER_FORMAT) +
|
||||
' avg'
|
||||
}
|
||||
>
|
||||
{pod.memLimitUtilization
|
||||
? formatNumber(
|
||||
pod.memLimitUtilization,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)
|
||||
: '-'}
|
||||
<Text
|
||||
span
|
||||
c={pod.memLimitUtilization ? undefined : 'gray.7'}
|
||||
>
|
||||
{pod.memLimitUtilization
|
||||
? formatNumber(
|
||||
pod.memLimitUtilization,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)
|
||||
: '-'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text span c={pod.uptime ? undefined : 'gray.7'}>
|
||||
{pod.uptime ? formatUptime(pod.uptime) : '–'}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text span c={pod.uptime ? undefined : 'gray.7'}>
|
||||
{pod.uptime ? formatUptime(pod.uptime) : '–'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
color={
|
||||
pod.restarts >= 10
|
||||
? 'red.6'
|
||||
: pod.restarts >= 5
|
||||
? 'yellow.3'
|
||||
: 'grey.7'
|
||||
}
|
||||
>
|
||||
{pod.restarts}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Link>
|
||||
))}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text
|
||||
color={
|
||||
pod.restarts >= 10
|
||||
? 'red.6'
|
||||
: pod.restarts >= 5
|
||||
? 'yellow.3'
|
||||
: 'grey.7'
|
||||
}
|
||||
>
|
||||
{pod.restarts}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
colSpan={8}
|
||||
style={{ height: `${paddingBottom}px` }}
|
||||
/>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -437,8 +503,8 @@ const NodesTable = ({
|
|||
}) => {
|
||||
const groupBy = ['k8s.node.name'];
|
||||
|
||||
const { data, isError, isLoading } = useQueriedChartConfig(
|
||||
convertV1ChartConfigToV2(
|
||||
const { data, isError, isLoading } = useQueriedChartConfig({
|
||||
...convertV1ChartConfigToV2(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
|
|
@ -481,7 +547,8 @@ const NodesTable = ({
|
|||
metric: metricSource,
|
||||
},
|
||||
),
|
||||
);
|
||||
limit: { limit: TABLE_FETCH_LIMIT, offset: 0 },
|
||||
});
|
||||
|
||||
const getLink = React.useCallback((nodeName: string) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
|
@ -505,15 +572,25 @@ const NodesTable = ({
|
|||
});
|
||||
}, [data]);
|
||||
|
||||
const {
|
||||
containerRef: nodesContainerRef,
|
||||
rowVirtualizer: nodesRowVirtualizer,
|
||||
virtualItems: nodeVirtualItems,
|
||||
paddingTop: nodesPaddingTop,
|
||||
paddingBottom: nodesPaddingBottom,
|
||||
} = useVirtualList(nodesList.length, 40, 10);
|
||||
|
||||
return (
|
||||
<Card p="md" data-testid="k8s-nodes-table">
|
||||
<Card.Section p="md" py="xs" withBorder>
|
||||
Nodes
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<ScrollArea
|
||||
viewportProps={{
|
||||
style: { maxHeight: 300 },
|
||||
<div
|
||||
ref={nodesContainerRef}
|
||||
style={{
|
||||
height: '300px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
|
@ -537,58 +614,80 @@ const NodesTable = ({
|
|||
<Table.Th style={{ width: 130 }}>Uptime</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{nodesList.map(node => (
|
||||
<Link
|
||||
key={node.name}
|
||||
href={getLink(node.name)}
|
||||
legacyBehavior
|
||||
>
|
||||
<Table.Tr className="cursor-pointer">
|
||||
<Table.Td>{node.name || 'N/A'}</Table.Td>
|
||||
<Table.Td>
|
||||
{node.ready === 1 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="green"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Ready
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Not Ready
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(
|
||||
node.cpuAvg,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(node.memAvg, K8S_MEM_NUMBER_FORMAT)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{node.uptime ? formatUptime(node.uptime) : '–'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Link>
|
||||
))}
|
||||
{nodesPaddingTop > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
colSpan={5}
|
||||
style={{ height: `${nodesPaddingTop}px` }}
|
||||
/>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{nodeVirtualItems.map(virtualRow => {
|
||||
const node = nodesList[virtualRow.index];
|
||||
return (
|
||||
<Link
|
||||
key={node.name}
|
||||
href={getLink(node.name)}
|
||||
legacyBehavior
|
||||
>
|
||||
<Table.Tr
|
||||
className="cursor-pointer"
|
||||
ref={nodesRowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
>
|
||||
<Table.Td>{node.name || 'N/A'}</Table.Td>
|
||||
<Table.Td>
|
||||
{node.ready === 1 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="green"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Ready
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Not Ready
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(
|
||||
node.cpuAvg,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(node.memAvg, K8S_MEM_NUMBER_FORMAT)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{node.uptime ? formatUptime(node.uptime) : '–'}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{nodesPaddingBottom > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
colSpan={5}
|
||||
style={{ height: `${nodesPaddingBottom}px` }}
|
||||
/>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -605,8 +704,8 @@ const NamespacesTable = ({
|
|||
}) => {
|
||||
const groupBy = ['k8s.namespace.name'];
|
||||
|
||||
const { data, isError, isLoading } = useQueriedChartConfig(
|
||||
convertV1ChartConfigToV2(
|
||||
const { data, isError, isLoading } = useQueriedChartConfig({
|
||||
...convertV1ChartConfigToV2(
|
||||
{
|
||||
series: [
|
||||
{
|
||||
|
|
@ -646,7 +745,8 @@ const NamespacesTable = ({
|
|||
metric: metricSource,
|
||||
},
|
||||
),
|
||||
);
|
||||
limit: { limit: TABLE_FETCH_LIMIT, offset: 0 },
|
||||
});
|
||||
|
||||
const namespacesList = React.useMemo(() => {
|
||||
if (!data) {
|
||||
|
|
@ -663,6 +763,14 @@ const NamespacesTable = ({
|
|||
});
|
||||
}, [data]);
|
||||
|
||||
const {
|
||||
containerRef: namespacesContainerRef,
|
||||
rowVirtualizer: namespacesRowVirtualizer,
|
||||
virtualItems: namespaceVirtualItems,
|
||||
paddingTop: namespacesPaddingTop,
|
||||
paddingBottom: namespacesPaddingBottom,
|
||||
} = useVirtualList(namespacesList.length, 40, 10);
|
||||
|
||||
const getLink = React.useCallback((namespaceName: string) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set('namespaceName', `${namespaceName}`);
|
||||
|
|
@ -675,9 +783,11 @@ const NamespacesTable = ({
|
|||
Namespaces
|
||||
</Card.Section>
|
||||
<Card.Section>
|
||||
<ScrollArea
|
||||
viewportProps={{
|
||||
style: { maxHeight: 300 },
|
||||
<div
|
||||
ref={namespacesContainerRef}
|
||||
style={{
|
||||
height: '300px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
|
|
@ -701,53 +811,79 @@ const NamespacesTable = ({
|
|||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{namespacesList.map(namespace => (
|
||||
<Link
|
||||
key={namespace.name}
|
||||
href={getLink(namespace.name)}
|
||||
legacyBehavior
|
||||
>
|
||||
<Table.Tr className="cursor-pointer">
|
||||
<Table.Td>{namespace.name || 'N/A'}</Table.Td>
|
||||
<Table.Td>
|
||||
{namespace.phase === 1 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="green"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Ready
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Terminating
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(
|
||||
namespace.cpuAvg,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(namespace.memAvg, K8S_MEM_NUMBER_FORMAT)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Link>
|
||||
))}
|
||||
{namespacesPaddingTop > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
colSpan={4}
|
||||
style={{ height: `${namespacesPaddingTop}px` }}
|
||||
/>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{namespaceVirtualItems.map(virtualRow => {
|
||||
const namespace = namespacesList[virtualRow.index];
|
||||
return (
|
||||
<Link
|
||||
key={namespace.name}
|
||||
href={getLink(namespace.name)}
|
||||
legacyBehavior
|
||||
>
|
||||
<Table.Tr
|
||||
className="cursor-pointer"
|
||||
ref={namespacesRowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
>
|
||||
<Table.Td>{namespace.name || 'N/A'}</Table.Td>
|
||||
<Table.Td>
|
||||
{namespace.phase === 1 ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="green"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Ready
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="red"
|
||||
fw="normal"
|
||||
tt="none"
|
||||
size="md"
|
||||
>
|
||||
Terminating
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(
|
||||
namespace.cpuAvg,
|
||||
K8S_CPU_PERCENTAGE_NUMBER_FORMAT,
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{formatNumber(
|
||||
namespace.memAvg,
|
||||
K8S_MEM_NUMBER_FORMAT,
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{namespacesPaddingBottom > 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td
|
||||
colSpan={4}
|
||||
style={{ height: `${namespacesPaddingBottom}px` }}
|
||||
/>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata';
|
||||
import {
|
||||
|
|
@ -39,12 +39,22 @@ const FilterSelect: React.FC<FilterSelectProps> = ({
|
|||
const { data, isLoading } = useGetKeyValues({
|
||||
chartConfig,
|
||||
keys: [`${metricSource.resourceAttributesExpression}['${fieldName}']`],
|
||||
disableRowLimit: true,
|
||||
limit: 1000000,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
data?.[0]?.value
|
||||
.map(value => ({ value, label: value }))
|
||||
.sort((a, b) => a.value.localeCompare(b.value)) || [], // Sort alphabetically for better search results
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={placeholder + (isLoading ? ' (loading...)' : '')}
|
||||
data={data?.[0]?.value.map(value => ({ value, label: value })) || []}
|
||||
data={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
searchable
|
||||
|
|
@ -55,7 +65,7 @@ const FilterSelect: React.FC<FilterSelectProps> = ({
|
|||
disabled={isLoading}
|
||||
variant="filled"
|
||||
w={200}
|
||||
limit={20}
|
||||
limit={100} // Show up to 100 search results
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
81
packages/app/src/hooks/useVirtualList.tsx
Normal file
81
packages/app/src/hooks/useVirtualList.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
/**
|
||||
* A custom hook for virtualizing large lists to improve rendering performance.
|
||||
* Uses @tanstack/react-virtual under the hood to only render visible items.
|
||||
*
|
||||
* @param count - Total number of items in the list
|
||||
* @param estimate - Estimated height of each row in pixels
|
||||
* @param overscan - Number of items to render outside the visible area (default: 10)
|
||||
*
|
||||
* @returns An object containing:
|
||||
* - containerRef: Ref to attach to the scrollable container element
|
||||
* - rowVirtualizer: The virtualizer instance for advanced usage
|
||||
* - virtualItems: Array of currently visible items with their indices and sizes
|
||||
* - paddingTop: Top padding value to maintain scroll position
|
||||
* - paddingBottom: Bottom padding value to maintain scroll position
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const MyList = ({ items }) => {
|
||||
* const { containerRef, virtualItems, paddingTop, paddingBottom } = useVirtualList(
|
||||
* items.length,
|
||||
* 40, // 40px estimated row height
|
||||
* 10 // render 10 items outside viewport
|
||||
* );
|
||||
*
|
||||
* return (
|
||||
* <div ref={containerRef} style={{ height: '400px', overflow: 'auto' }}>
|
||||
* {paddingTop > 0 && <div style={{ height: paddingTop }} />}
|
||||
* {virtualItems.map(virtualRow => (
|
||||
* <div key={virtualRow.index}>
|
||||
* {items[virtualRow.index].name}
|
||||
* </div>
|
||||
* ))}
|
||||
* {paddingBottom > 0 && <div style={{ height: paddingBottom }} />}
|
||||
* </div>
|
||||
* );
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export const useVirtualList = (
|
||||
count: number,
|
||||
estimate: number,
|
||||
overscan: number = 10,
|
||||
) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count,
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: useCallback(() => estimate, [estimate]),
|
||||
overscan,
|
||||
});
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
|
||||
const [paddingTop, paddingBottom] = useMemo(
|
||||
() =>
|
||||
virtualItems.length > 0
|
||||
? [
|
||||
Math.max(
|
||||
0,
|
||||
virtualItems[0].start - rowVirtualizer.options.scrollMargin,
|
||||
),
|
||||
Math.max(0, totalSize - virtualItems[virtualItems.length - 1].end),
|
||||
]
|
||||
: [0, 0],
|
||||
[virtualItems, rowVirtualizer.options.scrollMargin, totalSize],
|
||||
);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
rowVirtualizer,
|
||||
virtualItems,
|
||||
paddingTop,
|
||||
paddingBottom,
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue