fix: sort on the client side in KubernetedDashboardPage (#1350)

Also fixes hardcoded ResourceAttributes

Fixes: HDX-2790, HDX-2792
This commit is contained in:
Tom Alexander 2025-11-26 13:31:56 -05:00 committed by GitHub
parent 211460273d
commit 3b2a8633dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 184 additions and 35 deletions

View file

@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---
fix: sort on the client side in KubernetedDashboardPage

View file

@ -167,9 +167,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'last_value',
where,
groupBy,
...(sortState.column === 'phase' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
@ -178,9 +175,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'last_value',
where,
groupBy,
...(sortState.column === 'restarts' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
@ -189,9 +183,6 @@ export const InfraPodsStatusTable = ({
aggFn: undefined,
where,
groupBy,
...(sortState.column === 'uptime' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
@ -208,9 +199,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'avg',
where,
groupBy,
...(sortState.column === 'cpuLimit' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
@ -227,9 +215,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'avg',
where,
groupBy,
...(sortState.column === 'memLimit' && {
sortOrder: sortState.order,
}),
},
],
dateRange,
@ -242,13 +227,15 @@ export const InfraPodsStatusTable = ({
limit: { limit: TABLE_FETCH_LIMIT, offset: 0 },
});
const resourceAttr = metricSource.resourceAttributesExpression;
// TODO: Use useTable
const podsList = React.useMemo(() => {
if (!data) {
return [];
}
// Filter first to reduce the number of objects we create
// Filter by phase
const phaseFilteredData = data.data.filter((row: any) => {
if (phaseFilter === 'all') {
return true;
@ -258,21 +245,53 @@ export const InfraPodsStatusTable = ({
);
});
// 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]);
// Transform the filtered data
const transformedData = phaseFilteredData.map(
(row: any, index: number) => ({
id: `pod-${index}`, // Use index-based ID instead of random makeId()
name: row[`arrayElement(${resourceAttr}, 'k8s.pod.name')`],
namespace: row[`arrayElement(${resourceAttr}, 'k8s.namespace.name')`],
node: row[`arrayElement(${resourceAttr}, '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)'],
}),
);
// Sort the data client-side
return transformedData.sort((a, b) => {
const getValue = (pod: (typeof transformedData)[0]) => {
switch (sortState.column) {
case 'phase':
return pod.phase;
case 'restarts':
return pod.restarts;
case 'uptime':
return pod.uptime;
case 'cpuLimit':
return pod.cpuLimitUtilization;
case 'memLimit':
return pod.memLimitUtilization;
}
};
const aValue = getValue(a);
const bValue = getValue(b);
// Handle null/undefined - push to end
if (aValue == null && bValue == null) return 0;
if (aValue == null) return 1;
if (bValue == null) return -1;
// Compare and apply sort order
const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
return sortState.order === 'asc' ? comparison : -comparison;
});
}, [data, phaseFilter, sortState, resourceAttr]);
// Check if we're hitting the fetch limit (indicating there might be more data)
const isAtFetchLimit = data?.data && data.data.length >= TABLE_FETCH_LIMIT;
@ -561,6 +580,8 @@ const NodesTable = ({
return window.location.pathname + '?' + searchParams.toString();
}, []);
const resourceAttr = metricSource.resourceAttributesExpression;
const nodesList = React.useMemo(() => {
if (!data) {
return [];
@ -568,14 +589,14 @@ const NodesTable = ({
return data.data.map((row: any) => {
return {
name: row["arrayElement(ResourceAttributes, 'k8s.node.name')"],
name: row[`arrayElement(${resourceAttr}, 'k8s.node.name')`],
cpuAvg: row['avg(k8s.node.cpu.utilization)'],
memAvg: row['avg(k8s.node.memory.usage)'],
ready: row['avg(k8s.node.condition_ready)'],
uptime: row['undefined(k8s.node.uptime)'],
};
});
}, [data]);
}, [data, resourceAttr]);
const {
containerRef: nodesContainerRef,
@ -753,6 +774,8 @@ const NamespacesTable = ({
limit: { limit: TABLE_FETCH_LIMIT, offset: 0 },
});
const resourceAttr = metricSource.resourceAttributesExpression;
const namespacesList = React.useMemo(() => {
if (!data) {
return [];
@ -760,13 +783,13 @@ const NamespacesTable = ({
return data.data.map((row: any) => {
return {
name: row["arrayElement(ResourceAttributes, 'k8s.namespace.name')"],
name: row[`arrayElement(${resourceAttr}, 'k8s.namespace.name')`],
cpuAvg: row['sum(k8s.pod.cpu.utilization)'],
memAvg: row['sum(k8s.pod.memory.usage)'],
phase: row['last_value(k8s.namespace.phase)'],
};
});
}, [data]);
}, [data, resourceAttr]);
const {
containerRef: namespacesContainerRef,

View file

@ -1,3 +1,5 @@
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '../utils/base-test';
test.describe('Kubernetes Dashboard', { tag: ['@kubernetes'] }, () => {
@ -154,6 +156,10 @@ test.describe('Kubernetes Dashboard', { tag: ['@kubernetes'] }, () => {
}) => {
// Verify initial state is "Running"
const podsTable = page.getByTestId('k8s-pods-table');
// Wait for table to load
await expect(podsTable.locator('tbody tr').first()).toBeVisible();
const runningTab = podsTable.getByRole('radio', { name: 'Running' });
await expect(runningTab).toBeChecked();
@ -168,4 +174,119 @@ test.describe('Kubernetes Dashboard', { tag: ['@kubernetes'] }, () => {
const allTab = podsTable.getByRole('radio', { name: 'All' });
await expect(allTab).toBeChecked();
});
test.describe('Pods Table Sorting', () => {
const SORT_ICON_SELECTOR = 'i.bi-caret-down-fill, i.bi-caret-up-fill';
async function waitForTableLoad(page: Page): Promise<Locator> {
const podsTable = page.getByTestId('k8s-pods-table');
await expect(podsTable.locator('tbody tr').first()).toBeVisible();
return podsTable;
}
function getColumnHeader(podsTable: Locator, columnName: string): Locator {
return podsTable.locator('thead th').filter({ hasText: columnName });
}
function getSortIcon(header: Locator): Locator {
return header.locator(SORT_ICON_SELECTOR);
}
test('should sort by restarts column', async ({ page }) => {
const podsTable = await waitForTableLoad(page);
const restartsHeader = getColumnHeader(podsTable, 'Restarts');
await expect(restartsHeader.locator('i.bi-caret-down-fill')).toBeVisible({
timeout: 10000,
});
const firstRestartsBefore = await podsTable
.locator('tbody tr')
.first()
.locator('td')
.last()
.textContent();
await restartsHeader.click();
await page.waitForTimeout(500);
await expect(restartsHeader.locator('i.bi-caret-up-fill')).toBeVisible();
const firstRestartsAfter = await podsTable
.locator('tbody tr')
.first()
.locator('td')
.last()
.textContent();
expect(firstRestartsBefore).not.toBe(firstRestartsAfter);
});
test('should sort by status column', async ({ page }) => {
const podsTable = await waitForTableLoad(page);
const statusHeader = getColumnHeader(podsTable, 'Status');
const sortIcon = getSortIcon(statusHeader);
await expect(sortIcon).toHaveCount(0);
await statusHeader.click();
await page.waitForTimeout(500);
await expect(sortIcon).toBeVisible();
});
test('should sort by CPU/Limit column', async ({ page }) => {
const podsTable = await waitForTableLoad(page);
const cpuLimitHeader = getColumnHeader(podsTable, 'CPU/Limit');
const sortIcon = getSortIcon(cpuLimitHeader);
await cpuLimitHeader.click();
await page.waitForTimeout(500);
await expect(sortIcon).toBeVisible();
await cpuLimitHeader.click();
await page.waitForTimeout(500);
await expect(sortIcon).toBeVisible();
});
test('should sort by Memory/Limit column', async ({ page }) => {
const podsTable = await waitForTableLoad(page);
const memLimitHeader = getColumnHeader(podsTable, 'Mem/Limit');
await memLimitHeader.click();
await page.waitForTimeout(500);
await expect(getSortIcon(memLimitHeader)).toBeVisible();
});
test('should sort by Age column', async ({ page }) => {
const podsTable = await waitForTableLoad(page);
const ageHeader = getColumnHeader(podsTable, 'Age');
await ageHeader.click();
await page.waitForTimeout(500);
await expect(getSortIcon(ageHeader)).toBeVisible();
});
test('should maintain sort when switching phase filters', async ({
page,
}) => {
const podsTable = await waitForTableLoad(page);
const ageHeader = getColumnHeader(podsTable, 'Age');
const sortIcon = getSortIcon(ageHeader);
await ageHeader.click();
await page.waitForTimeout(500);
await expect(sortIcon).toBeVisible();
await podsTable.getByText('All', { exact: true }).click();
await page.waitForTimeout(500);
await expect(sortIcon).toBeVisible();
});
});
});