From e2d4b68964f729f0e0f1561f78fc5cb481843c64 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:18:17 +0200 Subject: [PATCH] Fixed bug in the 24h activity tile for all nodes table --- .../features/nodes/NodesTablePage.test.tsx | 112 +++++++++++++++++- .../src/features/nodes/NodesTablePage.tsx | 14 +-- 2 files changed, 118 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/nodes/NodesTablePage.test.tsx b/frontend/src/features/nodes/NodesTablePage.test.tsx index 093ee3e8..33835833 100644 --- a/frontend/src/features/nodes/NodesTablePage.test.tsx +++ b/frontend/src/features/nodes/NodesTablePage.test.tsx @@ -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'; @@ -15,6 +15,7 @@ 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 @@ -22,6 +23,8 @@ import type { Features } from '$/api/features'; const mockListNodes = vi.fn<() => Promise>(); const mockListServiceSettings = vi.fn<() => Promise>(); const mockGetFeatures = vi.fn<() => Promise>(); +const mockGetStats = vi.fn<() => Promise>(); +const mockGetNodeActivityTilesBatch = vi.fn<() => Promise>>(); vi.mock('$/api/nodes', () => ({ listNodes: (...args: unknown[]) => mockListNodes(...(args as [])), @@ -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', @@ -96,6 +104,56 @@ function makeResponse(overrides: Partial = {}): NodesPagedRe }; } +function makeStatsResponse(overrides: Partial = {}): 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 { + 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 @@ -156,10 +214,19 @@ function renderWithProviders(router: ReturnType) { // --------------------------------------------------------------------------- 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 () => { @@ -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(() => {})); diff --git a/frontend/src/features/nodes/NodesTablePage.tsx b/frontend/src/features/nodes/NodesTablePage.tsx index 2f001b58..347c5a73 100644 --- a/frontend/src/features/nodes/NodesTablePage.tsx +++ b/frontend/src/features/nodes/NodesTablePage.tsx @@ -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(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; }; @@ -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,