From f9c7be4f6c7f9213f073128285bd22b5660dd3b8 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Tue, 11 Nov 2025 10:49:51 -0500 Subject: [PATCH 1/2] fix: sort on the client side in KubernetedDashboardPage Also fixes hardcoded ResourceAttributes Fixes: HDX-2790, HDX-2792 --- packages/app/src/KubernetesDashboardPage.tsx | 93 +++++++++----- .../app/tests/e2e/features/kubernetes.spec.ts | 121 ++++++++++++++++++ 2 files changed, 179 insertions(+), 35 deletions(-) diff --git a/packages/app/src/KubernetesDashboardPage.tsx b/packages/app/src/KubernetesDashboardPage.tsx index b22ac0bc7..f7b14a4f0 100644 --- a/packages/app/src/KubernetesDashboardPage.tsx +++ b/packages/app/src/KubernetesDashboardPage.tsx @@ -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; @@ -563,6 +582,8 @@ const NodesTable = ({ return window.location.pathname + '?' + searchParams.toString(); }, []); + const resourceAttr = metricSource.resourceAttributesExpression; + const nodesList = React.useMemo(() => { if (!data) { return []; @@ -570,14 +591,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, @@ -755,6 +776,8 @@ const NamespacesTable = ({ limit: { limit: TABLE_FETCH_LIMIT, offset: 0 }, }); + const resourceAttr = metricSource.resourceAttributesExpression; + const namespacesList = React.useMemo(() => { if (!data) { return []; @@ -762,13 +785,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, diff --git a/packages/app/tests/e2e/features/kubernetes.spec.ts b/packages/app/tests/e2e/features/kubernetes.spec.ts index 932ced54d..9768e11d6 100644 --- a/packages/app/tests/e2e/features/kubernetes.spec.ts +++ b/packages/app/tests/e2e/features/kubernetes.spec.ts @@ -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 { + 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(); + }); + }); }); From 4612e13e5efb3ea5a32b89205d8fa24f2a5978b6 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Tue, 11 Nov 2025 17:15:44 +0100 Subject: [PATCH 2/2] changeset Implement client-side sorting in KubernetedDashboardPage. --- .changeset/silver-parents-develop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/silver-parents-develop.md diff --git a/.changeset/silver-parents-develop.md b/.changeset/silver-parents-develop.md new file mode 100644 index 000000000..4ccb44b96 --- /dev/null +++ b/.changeset/silver-parents-develop.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +fix: sort on the client side in KubernetedDashboardPage