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 (