From c467b57d7334736e0450c95db566af3e8e0836c5 Mon Sep 17 00:00:00 2001 From: Sumit Kumar Date: Thu, 13 Aug 2026 01:07:47 +0530 Subject: [PATCH] Fix group timeline refetch on group change Signed-off-by: Sumit Kumar --- .../GroupCharts/GroupTimelineRefetch.spec.tsx | 251 ++++++++++++++++++ .../GroupCharts/StatusCountTimeline.tsx | 202 ++++++++------ .../GroupCharts/VersionCountTimeline.tsx | 166 ++++++------ 3 files changed, 465 insertions(+), 154 deletions(-) create mode 100644 frontend/src/components/Groups/GroupCharts/GroupTimelineRefetch.spec.tsx diff --git a/frontend/src/components/Groups/GroupCharts/GroupTimelineRefetch.spec.tsx b/frontend/src/components/Groups/GroupCharts/GroupTimelineRefetch.spec.tsx new file mode 100644 index 000000000..57d44c11c --- /dev/null +++ b/frontend/src/components/Groups/GroupCharts/GroupTimelineRefetch.spec.tsx @@ -0,0 +1,251 @@ +import '../../../i18n/config.ts'; + +import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { Group } from '../../../api/apiDataTypes'; +import themes from '../../../lib/themes'; +import { groupChartStoreContext } from '../../../stores/Stores'; +import StatusCountTimeline from './StatusCountTimeline'; +import { Duration } from './TimelineChart'; +import VersionCountTimeline from './VersionCountTimeline'; + +vi.mock('./TimelineChart', () => ({ + default: (props: { data: any[]; keys: string[] }) => ( +
+ {props.data.map(entry => ( +
+ {entry.timestamp} + {props.keys.map(key => `${key}:${entry[key]}`).join(',')} +
+ ))} +
+ ), +})); + +const duration: Duration = { + displayValue: '1 day', + queryValue: '1d', + disabled: false, +}; + +function deferredTimeline() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(res => { + resolve = res; + }); + + return { promise, resolve }; +} + +function makeGroup(id: string, name: string): Group { + return { + id, + name, + description: '', + created_ts: '2026-01-01T00:00:00Z', + rollout_in_progress: false, + application_id: 'app-1', + channel_id: null, + policy_updates_enabled: false, + policy_safe_mode: false, + policy_office_hours: false, + policy_timezone: null, + policy_period_interval: '', + policy_max_updates_per_period: 0, + policy_update_timeout: '', + channel: { + id: `channel-${id}`, + name: 'stable', + color: '#000000', + created_ts: '2026-01-01T00:00:00Z', + application_id: 'app-1', + package_id: null, + package: null, + arch: 1, + }, + track: 'stable', + }; +} + +function wrapWithStore(ui: ReactNode, store: object) { + const ChartStoreContext = groupChartStoreContext(); + + return ( + + + {ui} + + + ); +} + +function renderWithStore(ui: ReactNode, store: object) { + return render(wrapWithStore(ui, store)); +} + +describe('group timeline charts', () => { + it('refetches version timeline data when the group changes with the same duration', async () => { + const alpha = makeGroup('group-alpha', 'Alpha'); + const beta = makeGroup('group-beta', 'Beta'); + const store = { + getGroupVersionCountTimeline: vi.fn((_appID: string, groupID: string) => + Promise.resolve( + groupID === alpha.id + ? { '2026-01-01T00:00:00Z': { '1.0.0': 3 } } + : { '2026-01-01T00:00:00Z': { '2.0.0': 5 } } + ) + ), + }; + + const { rerender } = renderWithStore( + , + store + ); + + await waitFor(() => + expect(store.getGroupVersionCountTimeline).toHaveBeenCalledWith('app-1', alpha.id, '1d') + ); + await waitFor(() => expect(screen.getByText('1.0.0')).toBeTruthy()); + + rerender( + wrapWithStore( + , + store + ) + ); + + await waitFor(() => + expect(store.getGroupVersionCountTimeline).toHaveBeenCalledWith('app-1', beta.id, '1d') + ); + await waitFor(() => expect(screen.getByText('2.0.0')).toBeTruthy()); + expect(screen.queryByText('1.0.0')).toBeNull(); + }); + + it('ignores stale version timeline responses after the selected group changes', async () => { + const alpha = makeGroup('group-alpha', 'Alpha'); + const beta = makeGroup('group-beta', 'Beta'); + const alphaTimeline = deferredTimeline<{ [key: string]: { [key: string]: number } }>(); + const store = { + getGroupVersionCountTimeline: vi.fn((_appID: string, groupID: string) => { + if (groupID === alpha.id) { + return alphaTimeline.promise; + } + + return Promise.resolve({ '2026-01-01T00:00:00Z': { '2.0.0': 5 } }); + }), + }; + + const { rerender } = renderWithStore( + , + store + ); + + await waitFor(() => + expect(store.getGroupVersionCountTimeline).toHaveBeenCalledWith('app-1', alpha.id, '1d') + ); + + rerender( + wrapWithStore( + , + store + ) + ); + + await waitFor(() => + expect(store.getGroupVersionCountTimeline).toHaveBeenCalledWith('app-1', beta.id, '1d') + ); + await waitFor(() => expect(screen.getByText('2.0.0')).toBeTruthy()); + + await act(async () => { + alphaTimeline.resolve({ '2026-01-01T00:00:00Z': { '1.0.0': 3 } }); + await alphaTimeline.promise; + }); + + expect(screen.getByText('2.0.0')).toBeTruthy(); + expect(screen.queryByText('1.0.0')).toBeNull(); + }); + + it('refetches status timeline data when the group changes with the same duration', async () => { + const alpha = makeGroup('group-alpha', 'Alpha'); + const beta = makeGroup('group-beta', 'Beta'); + const store = { + getGroupStatusCountTimeline: vi.fn((_appID: string, groupID: string) => + Promise.resolve( + groupID === alpha.id + ? { '2026-01-01T00:00:00Z': { 4: { '1.0.0': 3 } } } + : { '2026-01-01T00:00:00Z': { 8: { '2.0.0': 5 } } } + ) + ), + }; + + const { rerender } = renderWithStore( + , + store + ); + + await waitFor(() => + expect(store.getGroupStatusCountTimeline).toHaveBeenCalledWith('app-1', alpha.id, '1d') + ); + await waitFor(() => expect(screen.getByText('1.0.0')).toBeTruthy()); + + rerender( + wrapWithStore( + , + store + ) + ); + + await waitFor(() => + expect(store.getGroupStatusCountTimeline).toHaveBeenCalledWith('app-1', beta.id, '1d') + ); + await waitFor(() => expect(screen.getByText('2.0.0')).toBeTruthy()); + expect(screen.queryByText('1.0.0')).toBeNull(); + }); + + it('ignores stale status timeline responses after the selected group changes', async () => { + const alpha = makeGroup('group-alpha', 'Alpha'); + const beta = makeGroup('group-beta', 'Beta'); + const alphaTimeline = deferredTimeline<{ [key: string]: { [key: number]: object } }>(); + const store = { + getGroupStatusCountTimeline: vi.fn((_appID: string, groupID: string) => { + if (groupID === alpha.id) { + return alphaTimeline.promise; + } + + return Promise.resolve({ '2026-01-01T00:00:00Z': { 8: { '2.0.0': 5 } } }); + }), + }; + + const { rerender } = renderWithStore( + , + store + ); + + await waitFor(() => + expect(store.getGroupStatusCountTimeline).toHaveBeenCalledWith('app-1', alpha.id, '1d') + ); + + rerender( + wrapWithStore( + , + store + ) + ); + + await waitFor(() => + expect(store.getGroupStatusCountTimeline).toHaveBeenCalledWith('app-1', beta.id, '1d') + ); + await waitFor(() => expect(screen.getByText('2.0.0')).toBeTruthy()); + + await act(async () => { + alphaTimeline.resolve({ '2026-01-01T00:00:00Z': { 4: { '1.0.0': 3 } } }); + await alphaTimeline.promise; + }); + + expect(screen.getByText('2.0.0')).toBeTruthy(); + expect(screen.queryByText('1.0.0')).toBeNull(); + }); +}); diff --git a/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.tsx b/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.tsx index eda93b4e7..057d25dbe 100644 --- a/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.tsx +++ b/frontend/src/components/Groups/GroupCharts/StatusCountTimeline.tsx @@ -24,9 +24,86 @@ export interface StatusCountTimelineProps { isAnimationActive?: boolean; } +function makeEmptyTimelineChartData() { + return { data: [], keys: [], colors: {} }; +} + +function getStatusFromTimeline(timeline: { [key: number]: number }) { + if (Object.keys(timeline).length === 0) { + return []; + } + + return Object.keys(Object.values(timeline)[0]).filter(status => parseInt(status) !== 0); +} + +function makeStatusesColors( + statuses: { [key: string]: any }, + statusDefs: { + [key: string]: { + label: string; + color: string; + icon: IconifyIcon; + queryValue: string; + }; + } +) { + const colors: { + [key: string]: string; + } = {}; + + Object.values(statuses).forEach(status => { + const statusInfo = getInstanceStatus(status, ''); + colors[status] = statusDefs[statusInfo.type].color; + }); + + return colors; +} + +function makeTimelineChartData( + groupTimeline: { [key: string]: any }, + statusDefs: { + [key: string]: { + label: string; + color: string; + icon: IconifyIcon; + queryValue: string; + }; + } +) { + const data = Object.keys(groupTimeline).map((timestamp, i) => { + const status = groupTimeline[timestamp]; + const statusCount: { + [key: string]: any; + } = {}; + Object.keys(status).forEach((st: string) => { + const values = status[st]; + const count = Object.values(values).reduce((a: any, b: any) => a + b, 0); + statusCount[st] = count; + }); + + return { + index: i, + timestamp: timestamp, + ...statusCount, + }; + }); + + const statuses = getStatusFromTimeline(groupTimeline); + const colors = makeStatusesColors(statuses, statusDefs); + + return { + data: data, + keys: statuses, + colors: colors, + }; +} + export default function StatusCountTimeline(props: StatusCountTimelineProps) { const [selectedEntry, setSelectedEntry] = React.useState(-1); - const { duration } = props; + const { duration, group } = props; + const applicationID = group?.application_id; + const groupID = group?.id; + const durationQueryValue = duration.queryValue; const [timelineChartData, setTimelineChartData] = React.useState<{ data: { index: number; @@ -36,11 +113,7 @@ export default function StatusCountTimeline(props: StatusCountTimelineProps) { colors: { [key: string]: string; }; - }>({ - data: [], - keys: [], - colors: {}, - }); + }>(makeEmptyTimelineChartData); const [timeline, setTimeline] = React.useState<{ timeline: { @@ -65,56 +138,11 @@ export default function StatusCountTimeline(props: StatusCountTimelineProps) { queryValue: string; }; } = makeStatusDefs(theme as Theme); + const statusDefsRef = React.useRef(statusDefs); - function makeChartData(groupTimeline: { [key: string]: any }) { - const data = Object.keys(groupTimeline).map((timestamp, i) => { - const status = groupTimeline[timestamp]; - const statusCount: { - [key: string]: any; - } = {}; - Object.keys(status).forEach((st: string) => { - const values = status[st]; - const count = Object.values(values).reduce((a: any, b: any) => a + b, 0); - statusCount[st] = count; - }); - - return { - index: i, - timestamp: timestamp, - ...statusCount, - }; - }); - - const statuses = getStatusFromTimeline(groupTimeline); - const colors = makeStatusesColors(statuses); - - setTimelineChartData({ - data: data, - keys: statuses, - colors: colors, - }); - } - - function makeStatusesColors(statuses: { [key: string]: any }) { - const colors: { - [key: string]: string; - } = {}; - - Object.values(statuses).forEach(status => { - const statusInfo = getInstanceStatus(status, ''); - colors[status] = statusDefs[statusInfo.type].color; - }); - - return colors; - } - - function getStatusFromTimeline(timeline: { [key: number]: number }) { - if (Object.keys(timeline).length === 0) { - return []; - } - - return Object.keys(Object.values(timeline)[0]).filter(status => parseInt(status) !== 0); - } + React.useEffect(() => { + statusDefsRef.current = statusDefs; + }); function getInstanceCount(selectedEntry: number) { const status_breakdown: { @@ -179,31 +207,55 @@ export default function StatusCountTimeline(props: StatusCountTimelineProps) { // Make the timeline data again when needed. React.useEffect(() => { - async function getStatusTimeline(group: Group | null) { - if (group) { - setTimelineChartData({ data: [], keys: [], colors: {} }); - try { - const statusCountTimeline = await groupChartStore.getGroupStatusCountTimeline( - group.application_id, - group.id, - duration.queryValue - ); - setTimeline({ - timeline: statusCountTimeline, - lastUpdate: new Date().toUTCString(), - }); + let canceled = false; + + setSelectedEntry(-1); + setTimelineChartData(makeEmptyTimelineChartData()); + + if (!applicationID || !groupID) { + setTimeline({ + timeline: {}, + lastUpdate: new Date().toUTCString(), + }); + return () => { + canceled = true; + }; + } - makeChartData(statusCountTimeline || []); - setSelectedEntry(-1); - } catch (error) { + const selectedApplicationID = applicationID; + const selectedGroupID = groupID; + + async function getStatusTimeline() { + try { + const statusCountTimeline = await groupChartStore.getGroupStatusCountTimeline( + selectedApplicationID, + selectedGroupID, + durationQueryValue + ); + if (canceled) { + return; + } + + const safeTimeline = statusCountTimeline || {}; + setTimeline({ + timeline: safeTimeline, + lastUpdate: new Date().toUTCString(), + }); + + setTimelineChartData(makeTimelineChartData(safeTimeline, statusDefsRef.current)); + } catch (error) { + if (!canceled) { console.error(error); } } } - setSelectedEntry(-1); - getStatusTimeline(props.group); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.duration]); + + getStatusTimeline(); + + return () => { + canceled = true; + }; + }, [applicationID, durationQueryValue, groupChartStore, groupID]); return ( diff --git a/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.tsx b/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.tsx index cf5f38208..c36f9e217 100644 --- a/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.tsx +++ b/frontend/src/components/Groups/GroupCharts/VersionCountTimeline.tsx @@ -22,74 +22,72 @@ export interface VersionCountTimelineProps { isAnimationActive?: boolean; } +function makeEmptyTimelineChartData() { + return { data: [], keys: [], colors: [] }; +} + +function getVersionsFromTimeline(timeline: { [key: string]: any }) { + if (Object.keys(timeline).length === 0) { + return []; + } + + const versions: string[] = []; + + Object.keys(Object.values(timeline)[0]).forEach(version => { + const cleanedVersion = cleanSemverVersion(version); + // Discard any invalid versions (empty strings, etc.) + if (semver.valid(cleanedVersion)) { + versions.push(cleanedVersion); + } + }); + + // Sort versions (earliest first) + versions.sort((version1, version2) => { + return semver.compare(version1, version2); + }); + + return versions; +} + +function makeTimelineChartData(theme: Theme, group: Group, groupTimeline: { [key: string]: any }) { + const data = Object.keys(groupTimeline).map((timestamp, i) => { + const versions = groupTimeline[timestamp]; + return { + index: i, + timestamp: timestamp, + ...versions, + }; + }); + + const versions = getVersionsFromTimeline(groupTimeline); + const versionColors: { + [key: string]: string; + } = makeColorsForVersions(theme, versions, group.channel); + + return { + data: data, + keys: versions, + colors: versionColors, + }; +} + export default function VersionCountTimeline(props: VersionCountTimelineProps) { const [selectedEntry, setSelectedEntry] = React.useState(-1); - const { duration } = props; + const { duration, group } = props; + const applicationID = group?.application_id; + const groupID = group?.id; + const durationQueryValue = duration.queryValue; const [timelineChartData, setTimelineChartData] = React.useState<{ data: any[]; keys: any[]; colors: any; - }>({ - data: [], - keys: [], - colors: [], - }); - const [timeline, setTimeline] = React.useState({ - timeline: {}, - // A long time ago, to force the first update... - lastUpdate: new Date(2000, 1, 1).toUTCString(), - }); + }>(makeEmptyTimelineChartData); const theme = useTheme(); const ChartStoreContext = groupChartStoreContext(); const groupChartStore = React.useContext(ChartStoreContext); - function makeChartData(group: Group, groupTimeline: { [key: string]: any }) { - const data = Object.keys(groupTimeline).map((timestamp, i) => { - const versions = groupTimeline[timestamp]; - return { - index: i, - timestamp: timestamp, - ...versions, - }; - }); - - const versions = getVersionsFromTimeline(groupTimeline); - const versionColors: { - [key: string]: string; - } = makeColorsForVersions(theme as Theme, versions, group.channel); - - setTimelineChartData({ - data: data, - keys: versions, - colors: versionColors, - }); - } - - function getVersionsFromTimeline(timeline: { [key: string]: any }) { - if (Object.keys(timeline).length === 0) { - return []; - } - - const versions: string[] = []; - - Object.keys(Object.values(timeline)[0]).forEach(version => { - const cleanedVersion = cleanSemverVersion(version); - // Discard any invalid versions (empty strings, etc.) - if (semver.valid(cleanedVersion)) { - versions.push(cleanedVersion); - } - }); - - // Sort versions (earliest first) - versions.sort((version1, version2) => { - return semver.compare(version1, version2); - }); - - return versions; - } - function getInstanceCount(selectedEntry: number) { const version_breakdown = []; let selectedEntryPoint = selectedEntry; @@ -152,36 +150,46 @@ export default function VersionCountTimeline(props: VersionCountTimelineProps) { // Make the timeline data again when needed. React.useEffect(() => { let canceled = false; - async function getVersionTimeline(group: Group | null) { - if (group) { - // Check if we should update the timeline or it's too early. - const lastUpdate = new Date(timeline.lastUpdate); - setTimelineChartData({ data: [], keys: [], colors: [] }); - try { - const versionCountTimeline = await groupChartStore.getGroupVersionCountTimeline( - group.application_id, - group.id, - duration.queryValue - ); - if (!canceled) { - setTimeline({ - timeline: versionCountTimeline, - lastUpdate: lastUpdate.toUTCString(), - }); - } - makeChartData(group, versionCountTimeline || []); - setSelectedEntry(-1); - } catch (error) { + + setSelectedEntry(-1); + setTimelineChartData(makeEmptyTimelineChartData()); + + if (!group || !applicationID || !groupID) { + return () => { + canceled = true; + }; + } + + const selectedGroup = group; + const selectedApplicationID = applicationID; + const selectedGroupID = groupID; + + async function getVersionTimeline() { + try { + const versionCountTimeline = await groupChartStore.getGroupVersionCountTimeline( + selectedApplicationID, + selectedGroupID, + durationQueryValue + ); + if (canceled) { + return; + } + + const safeTimeline = versionCountTimeline || {}; + setTimelineChartData(makeTimelineChartData(theme as Theme, selectedGroup, safeTimeline)); + } catch (error) { + if (!canceled) { console.error(error); } } } - getVersionTimeline(props.group); + + getVersionTimeline(); + return () => { canceled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [duration]); + }, [applicationID, durationQueryValue, group, groupChartStore, groupID, theme]); return (