Skip to content
Merged
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
112 changes: 111 additions & 1 deletion frontend/src/features/nodes/NodesTablePage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
Expand All @@ -15,13 +15,16 @@ import { nodesSearchSchema } from '$/routes/_app/env/$env/nodes';
import type { NodesPagedResponse } from '$/api/types';
import type { SettingValue } from '$/api/settings';
import type { Features } from '$/api/features';
import type { NodeTileSeries, StatsResponse } from '$/api/stats';

// ---------------------------------------------------------------------------
// Mock the nodes API module
// ---------------------------------------------------------------------------
const mockListNodes = vi.fn<() => Promise<NodesPagedResponse>>();
const mockListServiceSettings = vi.fn<() => Promise<SettingValue[]>>();
const mockGetFeatures = vi.fn<() => Promise<Features>>();
const mockGetStats = vi.fn<() => Promise<StatsResponse>>();
const mockGetNodeActivityTilesBatch = vi.fn<() => Promise<Record<string, NodeTileSeries>>>();

vi.mock('$/api/nodes', () => ({
listNodes: (...args: unknown[]) => mockListNodes(...(args as [])),
Expand All @@ -35,6 +38,11 @@ vi.mock('$/api/features', () => ({
getFeatures: (...args: unknown[]) => mockGetFeatures(...(args as [])),
}));

vi.mock('$/api/stats', () => ({
getStats: (...args: unknown[]) => mockGetStats(...(args as [])),
getNodeActivityTilesBatch: (...args: unknown[]) => mockGetNodeActivityTilesBatch(...(args as [])),
}));

vi.mock('$/api/client', () => ({
isAuthenticated: () => true,
getCsrfToken: () => 'test-csrf',
Expand Down Expand Up @@ -96,6 +104,56 @@ function makeResponse(overrides: Partial<NodesPagedResponse> = {}): NodesPagedRe
};
}

function makeStatsResponse(overrides: Partial<StatsResponse> = {}): StatsResponse {
return {
total_nodes: 1,
active_nodes: 1,
inactive_nodes: 0,
inactive_hours: 72,
total_active_queries: 0,
total_active_carves: 0,
platform_counts: {
linux: 1,
darwin: 0,
windows: 0,
other: 0,
},
environments: [
{
uuid: 'test-env',
name: 'test-env',
active: 1,
inactive: 0,
total: 1,
active_queries: 0,
active_carves: 0,
platform_counts: {
linux: 1,
darwin: 0,
windows: 0,
other: 0,
},
},
],
...overrides,
};
}

function makeTileSeries(overrides: Partial<NodeTileSeries> = {}): NodeTileSeries {
return {
start: '2026-07-31T00:00:00Z',
bucket_seconds: 3600,
enroll: new Array(48).fill(0),
config: new Array(48).fill(0),
status: new Array(48).fill(0),
result: new Array(48).fill(0),
query_read: new Array(48).fill(0),
query_write: new Array(48).fill(0),
total: new Array(48).fill(0),
...overrides,
};
}

// ---------------------------------------------------------------------------
// Router factory — paths mirror the production app structure exactly.
// The `from` string in useParams/useSearch is derived from the full route
Expand Down Expand Up @@ -156,10 +214,19 @@ function renderWithProviders(router: ReturnType<typeof makeTestRouter>) {
// ---------------------------------------------------------------------------

describe('NodesTablePage', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

beforeEach(() => {
vi.clearAllMocks();
mockListServiceSettings.mockResolvedValue([]);
mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false });
mockGetStats.mockResolvedValue(makeStatsResponse());
mockGetNodeActivityTilesBatch.mockResolvedValue({
'ABC12345-0000-0000-0000-000000000001': makeTileSeries(),
});
});

it('renders node rows after loading', async () => {
Expand All @@ -175,6 +242,49 @@ describe('NodesTablePage', () => {
expect(screen.getByText('linux')).toBeInTheDocument();
});

it('loads two Redis day blobs for the 24h activity column', async () => {
mockListNodes.mockResolvedValue(makeResponse());

renderWithProviders(makeTestRouter());

await waitFor(() => {
expect(mockGetNodeActivityTilesBatch).toHaveBeenCalled();
});

expect(mockGetNodeActivityTilesBatch).toHaveBeenCalledWith(
'test-env',
['abc12345-0000-0000-0000-000000000001'],
2,
);
});

it('renders exactly the trailing 24 hourly buckets from two-day activity data', async () => {
vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-07-31T10:30:00Z').getTime());

const status = new Array(48).fill(0);
const total = new Array(48).fill(0);
status[11] = 2;
status[34] = 3;
total[11] = 2;
total[34] = 3;
mockListNodes.mockResolvedValue(makeResponse());
mockGetNodeActivityTilesBatch.mockResolvedValue({
'abc12345-0000-0000-0000-000000000001': makeTileSeries({
start: '2026-07-30T00:00:00Z',
status,
total,
}),
});

renderWithProviders(makeTestRouter());

const heatmap = await screen.findByRole('img', {
name: /Node activity over the last 24 hours, 5 events total/i,
});

expect(heatmap.querySelectorAll('span')).toHaveLength(96);
});

it('shows nothing except skeleton rows while loading', () => {
// Never resolve
mockListNodes.mockReturnValue(new Promise(() => {}));
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/features/nodes/NodesTablePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,18 +303,18 @@ interface HeatmapCellProps {
* Per-cell tooltip shows the hour + 5-category breakdown.
*/
function HeatmapCell({ tiles, globalMax, lastSeen }: HeatmapCellProps) {
// The Redis tile series has 24 hourly buckets for a 1-day window.
// Trim future hours (the day blob is UTC-midnight aligned) so "now" is
// the rightmost column.
// Redis tile series are UTC-day aligned. The table requests two day blobs
// so the visual can always slice the trailing 24 hourly buckets across the
// UTC midnight boundary.
const trimToNow = (arr: number[] | undefined): number[] => {
if (!arr || arr.length === 0) return new Array<number>(24).fill(0);
const startMs = tiles ? Date.parse(tiles.start) : NaN;
if (Number.isNaN(startMs)) return arr;
const currentHourIdx = Math.floor((Date.now() - startMs) / 3_600_000);
const cut = Math.max(1, Math.min(currentHourIdx + 1, arr.length));
const trimmed = arr.slice(0, cut);
// Right-pad to 24 so the grid doesn't shrink.
while (trimmed.length < 24) trimmed.push(0);
const trimmed = arr.slice(Math.max(0, cut - 24), cut);
// Left-pad to 24 so the rightmost column remains the current hour.
while (trimmed.length < 24) trimmed.unshift(0);
return trimmed;
};

Expand Down Expand Up @@ -548,7 +548,7 @@ export function NodesTablePage() {
const visibleUuids = (data?.items ?? []).map((n) => n.uuid);
const { data: tilesByUuid } = useQuery({
queryKey: ['node-tiles-batch', env, visibleUuids] as const,
queryFn: () => getNodeActivityTilesBatch(env, visibleUuids, 1),
queryFn: () => getNodeActivityTilesBatch(env, visibleUuids, 2),
staleTime: 30_000,
refetchInterval: 30_000,
enabled: visibleUuids.length > 0,
Expand Down
Loading