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
5 changes: 5 additions & 0 deletions .changeset/silver-parents-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

fix: sort on the client side in KubernetedDashboardPage
93 changes: 58 additions & 35 deletions packages/app/src/KubernetesDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'last_value',
where,
groupBy,
...(sortState.column === 'phase' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
Expand All @@ -178,9 +175,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'last_value',
where,
groupBy,
...(sortState.column === 'restarts' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
Expand All @@ -189,9 +183,6 @@ export const InfraPodsStatusTable = ({
aggFn: undefined,
where,
groupBy,
...(sortState.column === 'uptime' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
Expand All @@ -208,9 +199,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'avg',
where,
groupBy,
...(sortState.column === 'cpuLimit' && {
sortOrder: sortState.order,
}),
},
{
table: 'metrics',
Expand All @@ -227,9 +215,6 @@ export const InfraPodsStatusTable = ({
aggFn: 'avg',
where,
groupBy,
...(sortState.column === 'memLimit' && {
sortOrder: sortState.order,
}),
},
],
dateRange,
Expand All @@ -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;
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we prefer to sort client-side instead of CH here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

in this case, it's much faster than another server call with potentially hundreds of thousands of records.

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;
Expand Down Expand Up @@ -563,21 +582,23 @@ const NodesTable = ({
return window.location.pathname + '?' + searchParams.toString();
}, []);

const resourceAttr = metricSource.resourceAttributesExpression;

const nodesList = React.useMemo(() => {
if (!data) {
return [];
}

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,
Expand Down Expand Up @@ -755,20 +776,22 @@ const NamespacesTable = ({
limit: { limit: TABLE_FETCH_LIMIT, offset: 0 },
});

const resourceAttr = metricSource.resourceAttributesExpression;

const namespacesList = React.useMemo(() => {
if (!data) {
return [];
}

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,
Expand Down
121 changes: 121 additions & 0 deletions packages/app/tests/e2e/features/kubernetes.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Locator, Page } from '@playwright/test';

import { expect, test } from '../utils/base-test';

test.describe('Kubernetes Dashboard', { tag: ['@kubernetes'] }, () => {
Expand Down Expand Up @@ -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();

Expand All @@ -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();
});
});
});
Loading