This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 4dbdb54cb fix(web): keep metric series identities distinct in the
chart legend (#4836)
4dbdb54cb is described below
commit 4dbdb54cb4710042f17d437aaea5175a4204204d
Author: Wang1rrr <[email protected]>
AuthorDate: Thu Sep 24 10:46:57 2026 +0800
fix(web): keep metric series identities distinct in the chart legend (#4836)
`metricSeriesLabel` truncates to the first three sorted labels so the
compact legend row fits the panel, and that truncated string was used as the
React key for both the polyline and the legend entry: two series differing only
in a later label shared one child key (duplicate-key warning) and rendered two
identical legend rows. Both children are now keyed by the complete per-series
identity (series index plus the full sorted label map, the convention
`buildMetricSeriesDetailRows` alrea [...]
---
web/src/components/MetricsExplorer.tsx | 35 ++++++++++++++-------
.../components/__tests__/MetricsExplorer.test.tsx | 30 ++++++++++++++++++
web/src/utils/metricsExplorerDiagnostics.test.ts | 36 ++++++++++++++++++++++
web/src/utils/metricsExplorerDiagnostics.ts | 25 +++++++++++++--
4 files changed, 112 insertions(+), 14 deletions(-)
diff --git a/web/src/components/MetricsExplorer.tsx
b/web/src/components/MetricsExplorer.tsx
index 4d9761987..6058d9dd5 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -65,6 +65,8 @@ import {
createMetricsQueryHistoryEntry,
loadMetricsQueryHistory,
mergeMetricsQueryHistory,
+ metricSeriesFullLabel,
+ metricSeriesIdentity,
metricSeriesLabel,
saveMetricsQueryHistory,
summarizeMetricData,
@@ -125,18 +127,25 @@ const MetricChart = ({
.flatMap((series, index) => {
const { samples } = toMetricSeriesSamples(series);
const baseLabel = metricSeriesLabel(series, metric.name);
+ const baseTooltip = metricSeriesFullLabel(series, metric.name);
+ // The display label drops every label past the third one, so it cannot
+ // identify a series: two series differing only in a later label share
it.
+ const identity = metricSeriesIdentity(series, index);
const isMixed =
samples.some((sample) => sample.kind === 'scalar') &&
samples.some((sample) => sample.kind === 'histogram');
// Keep raw floats and histogram-derived trends on separate lines.
- return (['scalar', 'histogram'] as const).map((kind, kindIndex) => ({
- color: SERIES_COLORS[(isMixed ? index * 2 + kindIndex : index) %
SERIES_COLORS.length],
- label: isMixed
- ? `${baseLabel} (${kind === 'histogram' ? histogramLabel :
'scalar'})`
- : baseLabel,
- samples: samples.filter((sample) => sample.kind === kind),
- fromHistogram: kind === 'histogram',
- }));
+ return (['scalar', 'histogram'] as const).map((kind, kindIndex) => {
+ const kindSuffix = kind === 'histogram' ? histogramLabel : 'scalar';
+ return {
+ color: SERIES_COLORS[(isMixed ? index * 2 + kindIndex : index) %
SERIES_COLORS.length],
+ key: `${identity}-${kind}`,
+ label: isMixed ? `${baseLabel} (${kindSuffix})` : baseLabel,
+ tooltip: isMixed ? `${baseTooltip} (${kindSuffix})` : baseTooltip,
+ samples: samples.filter((sample) => sample.kind === kind),
+ fromHistogram: kind === 'histogram',
+ };
+ });
})
.filter((series) => series.samples.length > 0);
@@ -217,7 +226,7 @@ const MetricChart = ({
})}
{chartSeries.map((series) => (
<polyline
- key={`${series.label}-${series.fromHistogram}`}
+ key={series.key}
fill="none"
stroke={series.color}
strokeWidth="2.5"
@@ -253,7 +262,7 @@ const MetricChart = ({
const latest = series.samples[series.samples.length - 1];
return (
<Flex
- key={`${series.label}-${series.fromHistogram}`}
+ key={series.key}
align="center"
gap={6}
style={{ flex: '0 1 auto', minWidth: 0, maxWidth: '100%' }}
@@ -261,7 +270,11 @@ const MetricChart = ({
<span
style={{ width: 14, height: 3, background: series.color,
display: 'inline-block' }}
/>
- <Text type="secondary" ellipsis={{ tooltip: series.label }}
style={{ maxWidth: 160 }}>
+ <Text
+ type="secondary"
+ ellipsis={{ tooltip: series.tooltip }}
+ style={{ maxWidth: 160 }}
+ >
{series.label}
</Text>
{series.fromHistogram ? (
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index acf5704f9..62dbccbed 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -372,6 +372,36 @@ describe('MetricsExplorer', () => {
expect(chart.querySelectorAll('polyline')).toHaveLength(10);
});
+ it('keeps series that differ only in a later label distinguishable', async
() => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() =>
{});
+ // Both series share their first three sorted labels, so the compact legend
+ // text is identical and only the dropped `pod` label tells them apart.
+ const collidingSeries = ['a', 'b'].map((pod, index) => ({
+ labels: { cluster: 'prod', job: 'rmq', namespace: 'ns', pod },
+ values: [
+ { timestamp: 1_799_996_400, value: String(40 + index * 10) },
+ { timestamp: 1_800_000_000, value: String(42 + index * 10) },
+ ],
+ histograms: [],
+ }));
+ vi.mocked(queryMetrics).mockResolvedValue({ ...metricData, series:
collidingSeries });
+
+ renderWithProviders(<MetricsExplorer />);
+
+ expect(await screen.findByText('52 messages/s')).toBeInTheDocument();
+
+ const chart = screen.getByRole('img', { name: 'Message In TPS time series'
});
+ expect(chart.querySelectorAll('polyline')).toHaveLength(2);
+ // The visible legend stays compact, so both entries still read the same.
+ expect(screen.getAllByText('cluster=prod / job=rmq /
namespace=ns')).toHaveLength(2);
+ // Their child identities are no longer shared.
+ expect(consoleError.mock.calls.filter((call) =>
String(call[0]).includes('same key'))).toEqual(
+ [],
+ );
+
+ consoleError.mockRestore();
+ });
+
it('runs a custom PromQL expression from the query box', async () => {
const user = userEvent.setup();
renderWithProviders(<MetricsExplorer />);
diff --git a/web/src/utils/metricsExplorerDiagnostics.test.ts
b/web/src/utils/metricsExplorerDiagnostics.test.ts
index 60fd11e0e..ee114df87 100644
--- a/web/src/utils/metricsExplorerDiagnostics.test.ts
+++ b/web/src/utils/metricsExplorerDiagnostics.test.ts
@@ -29,6 +29,8 @@ import {
createMetricsQueryHistoryEntry,
loadMetricsQueryHistory,
mergeMetricsQueryHistory,
+ metricSeriesFullLabel,
+ metricSeriesIdentity,
metricSeriesLabel,
saveMetricsQueryHistory,
stableLabelsText,
@@ -188,6 +190,40 @@ describe('metrics explorer diagnostics', () => {
]);
});
+ it('keeps the display label compact but the full label set and identity
unique', () => {
+ const colliding = ['a', 'b'].map((pod) => ({
+ labels: { cluster: 'prod', job: 'rmq', namespace: 'ns', pod },
+ values: [],
+ histograms: [],
+ }));
+
+ // The legend stays compact, so both series render the same visible text.
+ expect(colliding.map((series) => metricSeriesLabel(series,
metric.name))).toEqual([
+ 'cluster=prod / job=rmq / namespace=ns',
+ 'cluster=prod / job=rmq / namespace=ns',
+ ]);
+ // The tooltip keeps the labels the display dropped.
+ expect(colliding.map((series) => metricSeriesFullLabel(series,
metric.name))).toEqual([
+ 'cluster=prod / job=rmq / namespace=ns / pod=a',
+ 'cluster=prod / job=rmq / namespace=ns / pod=b',
+ ]);
+ // The rendering identity stays unique per original series.
+ const identities = colliding.map((series, index) =>
metricSeriesIdentity(series, index));
+ expect(new Set(identities).size).toBe(2);
+ });
+
+ it('falls back to the metric name or __name__ for the full label as well',
() => {
+ expect(
+ metricSeriesFullLabel(
+ { labels: { __name__: 'up' }, values: [], histograms: [] },
+ metric.name,
+ ),
+ ).toBe('up');
+ expect(metricSeriesFullLabel({ labels: {}, values: [], histograms: [] },
metric.name)).toBe(
+ metric.name,
+ );
+ });
+
it('exports scalar and histogram samples as formula-safe CSV rows', () => {
const dangerousMetric = { ...metric, name: '=Message In TPS' };
const rows = buildMetricCsvRows(metricData, dangerousMetric, {
diff --git a/web/src/utils/metricsExplorerDiagnostics.ts
b/web/src/utils/metricsExplorerDiagnostics.ts
index 8ae1980cf..e13235917 100644
--- a/web/src/utils/metricsExplorerDiagnostics.ts
+++ b/web/src/utils/metricsExplorerDiagnostics.ts
@@ -143,14 +143,33 @@ const sortedEntries = (labels: Record<string, string>) =>
export const stableLabelsText = (labels: Record<string, string>) =>
JSON.stringify(Object.fromEntries(sortedEntries(labels)));
-export const metricSeriesLabel = (series: MetricSeries, fallback: string) => {
+// Only the first few labels fit the compact legend row. The complete set stays
+// available through metricSeriesFullLabel and metricSeriesIdentity.
+const METRIC_SERIES_LABEL_LIMIT = 3;
+
+export const metricSeriesLabel = (
+ series: MetricSeries,
+ fallback: string,
+ limit: number = METRIC_SERIES_LABEL_LIMIT,
+) => {
const labels = sortedEntries(series.labels)
.filter(([key]) => key !== '__name__')
- .slice(0, 3)
+ .slice(0, limit)
.map(([key, value]) => `${key}=${value}`);
return labels.length > 0 ? labels.join(' / ') : series.labels.__name__ ||
fallback;
};
+// The whole label set in the same compact notation, for places where two
series
+// must stay tellable apart: the legend hover tooltip.
+export const metricSeriesFullLabel = (series: MetricSeries, fallback: string)
=>
+ metricSeriesLabel(series, fallback, Number.POSITIVE_INFINITY);
+
+// A rendering identity that is unique per original series. The display label
is
+// not: it drops every label past the limit, so two series differing only in a
+// later label share one display label.
+export const metricSeriesIdentity = (series: MetricSeries, seriesIndex:
number) =>
+ `${seriesIndex}-${stableLabelsText(series.labels)}`;
+
const sortMetricSamples = (samples: NumericMetricSample[]) =>
samples
.filter((sample) => Number.isFinite(sample.timestamp) &&
Number.isFinite(sample.value))
@@ -316,7 +335,7 @@ export const buildMetricSeriesDetailRows = (
const { samples, fromHistogram } = toMetricSeriesSamples(series);
const latest = samples[samples.length - 1];
return {
- key: `${seriesIndex}-${stableLabelsText(series.labels)}`,
+ key: metricSeriesIdentity(series, seriesIndex),
seriesIndex: seriesIndex + 1,
seriesLabel: metricSeriesLabel(series, metric.name),
labels: stableLabelsText(series.labels),