diff --git a/.changeset/stable-series-colors.md b/.changeset/stable-series-colors.md new file mode 100644 index 000000000..cc21ce485 --- /dev/null +++ b/.changeset/stable-series-colors.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +--- + +fix: time chart series keep the same color across refreshes; series are now +ordered by name instead of by the order ClickHouse returned them diff --git a/packages/app/src/ChartUtils.tsx b/packages/app/src/ChartUtils.tsx index fbbd1c4fa..839c421da 100644 --- a/packages/app/src/ChartUtils.tsx +++ b/packages/app/src/ChartUtils.tsx @@ -869,10 +869,18 @@ export function formatResponseForTimeChart({ } const logLevelColorOrder = getLogLevelColorOrder(); + // Series are otherwise ordered by first appearance in the response, which + // ClickHouse does not keep stable between runs. Colors are assigned by + // position, so tie-break by key to keep each series' color stable. let sortedLineData = Object.values(lineDataMap).sort((a, b) => { return ( logLevelColorOrder.findIndex(color => color === a.color) - - logLevelColorOrder.findIndex(color => color === b.color) + logLevelColorOrder.findIndex(color => color === b.color) || + (a.currentPeriodKey < b.currentPeriodKey + ? -1 + : a.currentPeriodKey > b.currentPeriodKey + ? 1 + : 0) ); }); diff --git a/packages/app/src/__tests__/ChartUtils.test.ts b/packages/app/src/__tests__/ChartUtils.test.ts index 54fc44697..78c70e050 100644 --- a/packages/app/src/__tests__/ChartUtils.test.ts +++ b/packages/app/src/__tests__/ChartUtils.test.ts @@ -69,6 +69,32 @@ describe('ChartUtils', () => { ); }); + it('should assign the same color to a series regardless of row order', () => { + const meta = [ + { name: 'count()', type: 'UInt64' }, + { name: 'ServiceName', type: 'String' }, + { name: '__hdx_time_bucket', type: 'DateTime' }, + ]; + const row = (ServiceName: string) => ({ + 'count()': 1, + ServiceName, + __hdx_time_bucket: '2025-11-26T11:12:00Z', + }); + const colorsByService = (services: string[]) => + Object.fromEntries( + formatResponseForTimeChart({ + currentPeriodResponse: { data: services.map(row), meta }, + dateRange: [new Date(), new Date()], + granularity: '1 minute', + generateEmptyBuckets: false, + }).lineData.map(line => [line.dataKey, line.color]), + ); + + const colors = colorsByService(['cart', 'checkout', 'email']); + expect(new Set(Object.values(colors)).size).toBe(3); + expect(colorsByService(['email', 'cart', 'checkout'])).toEqual(colors); + }); + it('should return empty results for an empty response', () => { const res = { data: [], @@ -252,16 +278,6 @@ describe('ChartUtils', () => { }, { color: COLORS[1], - dataKey: 'max · checkout', - currentPeriodKey: 'max · checkout', - previousPeriodKey: 'max · checkout (previous)', - displayName: 'max · checkout', - valueColumnName: 'max', - groupValues: ['checkout'], - isDashed: false, - }, - { - color: COLORS[2], dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', @@ -272,6 +288,16 @@ describe('ChartUtils', () => { groupValues: ['shipping'], isDashed: false, }, + { + color: COLORS[2], + dataKey: 'max · checkout', + currentPeriodKey: 'max · checkout', + previousPeriodKey: 'max · checkout (previous)', + displayName: 'max · checkout', + valueColumnName: 'max', + groupValues: ['checkout'], + isDashed: false, + }, { color: COLORS[3], dataKey: 'max · shipping', @@ -334,16 +360,6 @@ describe('ChartUtils', () => { }); expect(actual.lineData).toEqual([ - { - color: SEMANTIC_INFO_HEX, - dataKey: 'info', - currentPeriodKey: 'info', - previousPeriodKey: 'info (previous)', - displayName: 'info', - valueColumnName: 'count()', - groupValues: ['info'], - isDashed: false, - }, { color: SEMANTIC_INFO_HEX, dataKey: 'debug', @@ -354,6 +370,16 @@ describe('ChartUtils', () => { groupValues: ['debug'], isDashed: false, }, + { + color: SEMANTIC_INFO_HEX, + dataKey: 'info', + currentPeriodKey: 'info', + previousPeriodKey: 'info (previous)', + displayName: 'info', + valueColumnName: 'count()', + groupValues: ['info'], + isDashed: false, + }, { color: SEMANTIC_ERROR_HEX, dataKey: 'error', @@ -457,16 +483,6 @@ describe('ChartUtils', () => { }, { color: COLORS[1], - dataKey: 'max · checkout', - currentPeriodKey: 'max · checkout', - previousPeriodKey: 'max · checkout (previous)', - displayName: 'max · checkout', - valueColumnName: 'max', - groupValues: ['checkout'], - isDashed: false, - }, - { - color: COLORS[2], dataKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', currentPeriodKey: 'AVG(toFloat64OrDefault(toString(Duration))) · shipping', @@ -477,6 +493,16 @@ describe('ChartUtils', () => { groupValues: ['shipping'], isDashed: false, }, + { + color: COLORS[2], + dataKey: 'max · checkout', + currentPeriodKey: 'max · checkout', + previousPeriodKey: 'max · checkout (previous)', + displayName: 'max · checkout', + valueColumnName: 'max', + groupValues: ['checkout'], + isDashed: false, + }, { color: COLORS[3], dataKey: 'max · shipping',