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 6ec91975 feat: add Prometheus metrics explorer to dashboard (#723)
6ec91975 is described below

commit 6ec91975ef0c60a3045714e03291341889cb4c48
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:13:56 2026 +0800

    feat: add Prometheus metrics explorer to dashboard (#723)
---
 web/src/api/metrics.test.ts                        |  26 +-
 web/src/api/metrics.ts                             |  21 +
 web/src/components/MetricsExplorer.tsx             | 433 +++++++++++++++++++++
 .../components/__tests__/MetricsExplorer.test.tsx  | 226 +++++++++++
 web/src/pages/home/dashboard.tsx                   |   3 +
 5 files changed, 708 insertions(+), 1 deletion(-)

diff --git a/web/src/api/metrics.test.ts b/web/src/api/metrics.test.ts
index 092fea5c..7333aee7 100644
--- a/web/src/api/metrics.test.ts
+++ b/web/src/api/metrics.test.ts
@@ -18,7 +18,7 @@
 import MockAdapter from 'axios-mock-adapter';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import client from './client';
-import { getDashboard, queryMetrics } from './metrics';
+import { getDashboard, listMetricProfiles, queryMetrics } from './metrics';
 
 const mock = new MockAdapter(client);
 const dashboard = {
@@ -79,4 +79,28 @@ describe('metrics API', () => {
 
     await expect(queryMetrics(query)).resolves.toEqual(result);
   });
+
+  it('loads version-aware metric profiles', async () => {
+    const profiles = [
+      {
+        id: 'rocketmq5-native',
+        name: 'RocketMQ 5.x Native',
+        description: 'RocketMQ 5.x native Prometheus metrics',
+        metrics: [
+          {
+            semanticMetric: 'message_in_tps',
+            name: 'Message In TPS',
+            unit: 'messages/s',
+            prometheusMetric: 'rocketmq_messages_in_total',
+            promql: 'sum(rate(rocketmq_messages_in_total[1m])) by (cluster, 
node_id)',
+            labels: ['cluster', 'node_id'],
+          },
+        ],
+      },
+    ];
+
+    mock.onGet('/metrics/profiles').reply(200, { code: 200, data: profiles });
+
+    await expect(listMetricProfiles()).resolves.toEqual(profiles);
+  });
 });
diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts
index d0b71a92..b0665f08 100644
--- a/web/src/api/metrics.ts
+++ b/web/src/api/metrics.ts
@@ -75,6 +75,22 @@ export interface MetricQuery {
   step: string;
 }
 
+export interface MetricMapping {
+  semanticMetric: string;
+  name: string;
+  unit: string;
+  prometheusMetric: string;
+  promql: string;
+  labels: string[];
+}
+
+export interface MetricProfile {
+  id: string;
+  name: string;
+  description: string;
+  metrics: MetricMapping[];
+}
+
 // ─── Dashboard ──────────────────────────────────────────────────
 export async function getDashboard() {
   const res = await client.get<{ data: DashboardData }>('/dashboard');
@@ -86,3 +102,8 @@ export async function queryMetrics(query: MetricQuery) {
   const res = await client.post<{ data: MetricData }>('/metrics/query', query);
   return res.data.data;
 }
+
+export async function listMetricProfiles() {
+  const res = await client.get<{ data: MetricProfile[] }>('/metrics/profiles');
+  return res.data.data;
+}
diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
new file mode 100644
index 00000000..5ad43444
--- /dev/null
+++ b/web/src/components/MetricsExplorer.tsx
@@ -0,0 +1,433 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+  Alert,
+  Button,
+  Empty,
+  Flex,
+  Segmented,
+  Select,
+  Skeleton,
+  Tag,
+  Tooltip,
+  Typography,
+} from 'antd';
+import { ArrowsClockwise } from '@phosphor-icons/react';
+
+import { listMetricProfiles, queryMetrics } from '../api/metrics';
+import type { MetricData, MetricMapping, MetricProfile, MetricSeries } from 
'../api/metrics';
+import { useLang } from '../i18n/LangContext';
+
+const { Text, Title } = Typography;
+
+const CHART_WIDTH = 840;
+const CHART_HEIGHT = 240;
+const CHART_PADDING = { top: 18, right: 18, bottom: 32, left: 64 };
+const SERIES_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#722ed1', '#13c2c2', 
'#eb2f96'];
+
+const RANGE_OPTIONS = [
+  { label: '1h', value: '1h', seconds: 60 * 60, step: '30s' },
+  { label: '6h', value: '6h', seconds: 6 * 60 * 60, step: '2m' },
+  { label: '24h', value: '24h', seconds: 24 * 60 * 60, step: '5m' },
+] as const;
+
+interface NumericSample {
+  timestamp: number;
+  value: number;
+}
+
+const toNumericSamples = (series: MetricSeries): NumericSample[] =>
+  series.values
+    .map((sample) => ({ timestamp: sample.timestamp, value: 
Number(sample.value) }))
+    .filter((sample) => Number.isFinite(sample.timestamp) && 
Number.isFinite(sample.value));
+
+const seriesLabel = (series: MetricSeries, fallback: string) => {
+  const labels = Object.entries(series.labels)
+    .filter(([key]) => key !== '__name__')
+    .slice(0, 3)
+    .map(([key, value]) => `${key}=${value}`);
+  return labels.length > 0 ? labels.join(' / ') : series.labels.__name__ || 
fallback;
+};
+
+const formatMetricValue = (value: number) =>
+  new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value);
+
+interface MetricChartProps {
+  data: MetricData;
+  metric: MetricMapping;
+  locale: string;
+  noSamples: string;
+}
+
+const MetricChart = ({ data, metric, locale, noSamples }: MetricChartProps) => 
{
+  const chartSeries = data.series
+    .map((series, index) => ({
+      color: SERIES_COLORS[index % SERIES_COLORS.length],
+      label: seriesLabel(series, metric.name),
+      samples: toNumericSamples(series),
+    }))
+    .filter((series) => series.samples.length > 0);
+
+  if (chartSeries.length === 0) {
+    return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={noSamples} 
/>;
+  }
+
+  const samples = chartSeries.flatMap((series) => series.samples);
+  const timestamps = samples.map((sample) => sample.timestamp);
+  const values = samples.map((sample) => sample.value);
+  let minTime = Math.min(...timestamps);
+  let maxTime = Math.max(...timestamps);
+  let minValue = Math.min(...values);
+  let maxValue = Math.max(...values);
+
+  if (minTime === maxTime) {
+    minTime -= 1;
+    maxTime += 1;
+  }
+  if (minValue === maxValue) {
+    const padding = Math.max(Math.abs(minValue) * 0.1, 1);
+    minValue -= padding;
+    maxValue += padding;
+  }
+
+  const plotWidth = CHART_WIDTH - CHART_PADDING.left - CHART_PADDING.right;
+  const plotHeight = CHART_HEIGHT - CHART_PADDING.top - CHART_PADDING.bottom;
+  const x = (timestamp: number) =>
+    CHART_PADDING.left + ((timestamp - minTime) / (maxTime - minTime)) * 
plotWidth;
+  const y = (value: number) =>
+    CHART_PADDING.top + (1 - (value - minValue) / (maxValue - minValue)) * 
plotHeight;
+  const formatTime = (timestamp: number) =>
+    new Date(timestamp * 1000).toLocaleTimeString(locale, { hour: '2-digit', 
minute: '2-digit' });
+
+  return (
+    <div>
+      <svg
+        role="img"
+        aria-label={`${metric.name} time series`}
+        viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
+        preserveAspectRatio="xMidYMid meet"
+        style={{ width: '100%', minHeight: 220, display: 'block' }}
+      >
+        {[0, 0.5, 1].map((ratio) => {
+          const gridY = CHART_PADDING.top + ratio * plotHeight;
+          const gridValue = maxValue - ratio * (maxValue - minValue);
+          return (
+            <g key={ratio}>
+              <line
+                x1={CHART_PADDING.left}
+                x2={CHART_WIDTH - CHART_PADDING.right}
+                y1={gridY}
+                y2={gridY}
+                stroke="#e8e8e8"
+                strokeWidth="1"
+              />
+              <text
+                x={CHART_PADDING.left - 8}
+                y={gridY + 4}
+                textAnchor="end"
+                fontSize="11"
+                fill="#8c8c8c"
+              >
+                {formatMetricValue(gridValue)}
+              </text>
+            </g>
+          );
+        })}
+        {chartSeries.map((series) => (
+          <polyline
+            key={series.label}
+            fill="none"
+            stroke={series.color}
+            strokeWidth="2"
+            strokeLinejoin="round"
+            strokeLinecap="round"
+            points={series.samples
+              .map((sample) => `${x(sample.timestamp)},${y(sample.value)}`)
+              .join(' ')}
+          />
+        ))}
+        <text
+          x={CHART_PADDING.left}
+          y={CHART_HEIGHT - 8}
+          textAnchor="start"
+          fontSize="11"
+          fill="#8c8c8c"
+        >
+          {formatTime(minTime)}
+        </text>
+        <text
+          x={CHART_WIDTH - CHART_PADDING.right}
+          y={CHART_HEIGHT - 8}
+          textAnchor="end"
+          fontSize="11"
+          fill="#8c8c8c"
+        >
+          {formatTime(maxTime)}
+        </text>
+      </svg>
+
+      <Flex gap={16} wrap="wrap" style={{ marginTop: 8 }}>
+        {chartSeries.map((series) => {
+          const latest = series.samples[series.samples.length - 1];
+          return (
+            <Flex
+              key={series.label}
+              align="center"
+              gap={6}
+              style={{ flex: '1 1 220px', minWidth: 0, maxWidth: '100%' }}
+            >
+              <span
+                style={{ width: 18, height: 3, background: series.color, 
display: 'inline-block' }}
+              />
+              <Text ellipsis={{ tooltip: series.label }} style={{ maxWidth: 
220 }}>
+                {series.label}
+              </Text>
+              <Text strong>
+                {formatMetricValue(latest.value)} {metric.unit}
+              </Text>
+            </Flex>
+          );
+        })}
+      </Flex>
+    </div>
+  );
+};
+
+const MetricsExplorer = () => {
+  const { lang } = useLang();
+  const copy =
+    lang === 'zh'
+      ? {
+          title: 'Prometheus 指标',
+          profile: '指标模板',
+          metric: '指标',
+          range: '时间范围',
+          refresh: '刷新指标',
+          profileError: '指标模板加载失败',
+          queryError: 'Prometheus 查询失败',
+          noProfiles: '暂无指标模板',
+          noSamples: '暂无标量数据',
+        }
+      : {
+          title: 'Prometheus Metrics',
+          profile: 'Metric profile',
+          metric: 'Metric',
+          range: 'Time range',
+          refresh: 'Refresh metrics',
+          profileError: 'Failed to load metric profiles',
+          queryError: 'Prometheus query failed',
+          noProfiles: 'No metric profiles',
+          noSamples: 'No scalar samples',
+        };
+  const [profiles, setProfiles] = useState<MetricProfile[]>([]);
+  const [profileId, setProfileId] = useState('');
+  const [metricId, setMetricId] = useState('');
+  const [rangeId, setRangeId] = useState<(typeof 
RANGE_OPTIONS)[number]['value']>('1h');
+  const [data, setData] = useState<MetricData | null>(null);
+  const [profilesLoading, setProfilesLoading] = useState(true);
+  const [queryLoading, setQueryLoading] = useState(false);
+  const [profileError, setProfileError] = useState(false);
+  const [queryError, setQueryError] = useState(false);
+  const requestId = useRef(0);
+
+  const selectedProfile = useMemo(
+    () => profiles.find((profile) => profile.id === profileId),
+    [profileId, profiles],
+  );
+  const selectedMetric = useMemo(
+    () => selectedProfile?.metrics.find((metric) => metric.semanticMetric === 
metricId),
+    [metricId, selectedProfile],
+  );
+  const selectedRange = RANGE_OPTIONS.find((range) => range.value === rangeId) 
?? RANGE_OPTIONS[0];
+
+  const loadMetrics = useCallback(
+    async (metric: MetricMapping | undefined, range: (typeof 
RANGE_OPTIONS)[number]) => {
+      if (!metric) return;
+      const currentRequest = ++requestId.current;
+      const end = Math.floor(Date.now() / 1000);
+      setQueryLoading(true);
+      setQueryError(false);
+      try {
+        const result = await queryMetrics({
+          metric: metric.promql,
+          start: end - range.seconds,
+          end,
+          step: range.step,
+        });
+        if (currentRequest === requestId.current) setData(result);
+      } catch {
+        if (currentRequest === requestId.current) {
+          setData(null);
+          setQueryError(true);
+        }
+      } finally {
+        if (currentRequest === requestId.current) setQueryLoading(false);
+      }
+    },
+    [],
+  );
+
+  useEffect(() => {
+    let cancelled = false;
+    void listMetricProfiles()
+      .then((nextProfiles) => {
+        if (cancelled) return;
+        setProfiles(nextProfiles);
+        const initialProfile = nextProfiles[0];
+        const initialMetric = initialProfile?.metrics[0];
+        setProfileId(initialProfile?.id ?? '');
+        setMetricId(initialMetric?.semanticMetric ?? '');
+        void loadMetrics(initialMetric, RANGE_OPTIONS[0]);
+      })
+      .catch(() => {
+        if (!cancelled) setProfileError(true);
+      })
+      .finally(() => {
+        if (!cancelled) setProfilesLoading(false);
+      });
+    return () => {
+      cancelled = true;
+      requestId.current += 1;
+    };
+  }, [loadMetrics]);
+
+  const handleProfileChange = (nextProfileId: string) => {
+    const nextProfile = profiles.find((profile) => profile.id === 
nextProfileId);
+    const nextMetric = nextProfile?.metrics[0];
+    setProfileId(nextProfileId);
+    setMetricId(nextMetric?.semanticMetric ?? '');
+    setData(null);
+    void loadMetrics(nextMetric, selectedRange);
+  };
+
+  const handleMetricChange = (nextMetricId: string) => {
+    const nextMetric = selectedProfile?.metrics.find(
+      (metric) => metric.semanticMetric === nextMetricId,
+    );
+    setMetricId(nextMetricId);
+    setData(null);
+    void loadMetrics(nextMetric, selectedRange);
+  };
+
+  const handleRangeChange = (nextRangeId: (typeof 
RANGE_OPTIONS)[number]['value']) => {
+    const nextRange =
+      RANGE_OPTIONS.find((range) => range.value === nextRangeId) ?? 
RANGE_OPTIONS[0];
+    setRangeId(nextRangeId);
+    void loadMetrics(selectedMetric, nextRange);
+  };
+
+  return (
+    <section aria-labelledby="metrics-explorer-title" style={{ marginTop: 24 
}}>
+      <Flex
+        justify="space-between"
+        align="center"
+        gap={16}
+        wrap="wrap"
+        style={{ marginBottom: 12 }}
+      >
+        <Flex gap={8} wrap="wrap" align="center">
+          <Title id="metrics-explorer-title" level={4} style={{ margin: 0, 
fontSize: 16 }}>
+            {copy.title}
+          </Title>
+          {selectedMetric && <Tag>{selectedMetric.unit}</Tag>}
+        </Flex>
+
+        <Flex gap={8} wrap="wrap" align="center" style={{ maxWidth: '100%' }}>
+          <Select
+            aria-label={copy.profile}
+            value={profileId || undefined}
+            loading={profilesLoading}
+            onChange={handleProfileChange}
+            options={profiles.map((profile) => ({ label: profile.name, value: 
profile.id }))}
+            style={{ width: 210, maxWidth: '100%' }}
+          />
+          <Select
+            aria-label={copy.metric}
+            value={metricId || undefined}
+            onChange={handleMetricChange}
+            options={(selectedProfile?.metrics ?? []).map((metric) => ({
+              label: metric.name,
+              value: metric.semanticMetric,
+            }))}
+            style={{ width: 190, maxWidth: '100%' }}
+          />
+          <Segmented
+            aria-label={copy.range}
+            size="small"
+            value={rangeId}
+            onChange={(value) =>
+              handleRangeChange(value as (typeof 
RANGE_OPTIONS)[number]['value'])
+            }
+            options={RANGE_OPTIONS.map(({ label, value }) => ({ label, value 
}))}
+          />
+          <Tooltip title={copy.refresh}>
+            <Button
+              aria-label={copy.refresh}
+              icon={<ArrowsClockwise size={16} />}
+              onClick={() => void loadMetrics(selectedMetric, selectedRange)}
+              loading={queryLoading}
+            />
+          </Tooltip>
+        </Flex>
+      </Flex>
+
+      {selectedMetric && (
+        <Text
+          code
+          copyable
+          style={{ display: 'block', marginBottom: 12, overflowWrap: 
'anywhere' }}
+        >
+          {selectedMetric.promql}
+        </Text>
+      )}
+
+      {profilesLoading ? (
+        <Skeleton active paragraph={{ rows: 5 }} />
+      ) : profileError ? (
+        <Alert type="error" showIcon message={copy.profileError} />
+      ) : profiles.length === 0 ? (
+        <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} 
description={copy.noProfiles} />
+      ) : queryError ? (
+        <Alert type="error" showIcon message={copy.queryError} />
+      ) : queryLoading && !data ? (
+        <Skeleton active paragraph={{ rows: 5 }} />
+      ) : data && selectedMetric ? (
+        <>
+          {data.warnings.map((warning) => (
+            <Alert
+              key={warning}
+              type="warning"
+              showIcon
+              message={warning}
+              style={{ marginBottom: 8 }}
+            />
+          ))}
+          <MetricChart
+            data={data}
+            metric={selectedMetric}
+            locale={lang === 'zh' ? 'zh-CN' : 'en-US'}
+            noSamples={copy.noSamples}
+          />
+        </>
+      ) : null}
+    </section>
+  );
+};
+
+export default MetricsExplorer;
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
new file mode 100644
index 00000000..8149f0db
--- /dev/null
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from 'antd';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type React from 'react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
+
+import { listMetricProfiles, queryMetrics } from '../../api/metrics';
+import { LangProvider } from '../../i18n/LangContext';
+import MetricsExplorer from '../MetricsExplorer';
+
+vi.mock('../../api/metrics', () => ({
+  listMetricProfiles: vi.fn(),
+  queryMetrics: vi.fn(),
+}));
+
+const profiles = [
+  {
+    id: 'rocketmq5-native',
+    name: 'RocketMQ 5.x Native',
+    description: 'RocketMQ 5.x native metrics',
+    metrics: [
+      {
+        semanticMetric: 'message_in_tps',
+        name: 'Message In TPS',
+        unit: 'messages/s',
+        prometheusMetric: 'rocketmq_messages_in_total',
+        promql: 'sum(rate(rocketmq_messages_in_total[1m])) by (cluster, 
node_id)',
+        labels: ['cluster', 'node_id'],
+      },
+    ],
+  },
+  {
+    id: 'rocketmq4-exporter',
+    name: 'RocketMQ 4.x Exporter',
+    description: 'RocketMQ 4.x exporter metrics',
+    metrics: [
+      {
+        semanticMetric: 'consumer_lag_messages',
+        name: 'Consumer Lag Messages',
+        unit: 'messages',
+        prometheusMetric: 'rocketmq_message_accumulation',
+        promql: 'sum(rocketmq_message_accumulation) by (cluster, group, 
topic)',
+        labels: ['cluster', 'group', 'topic'],
+      },
+    ],
+  },
+];
+
+const metricData = {
+  resultType: 'matrix',
+  series: [
+    {
+      labels: { cluster: 'prod', node_id: 'broker-a' },
+      values: [
+        { timestamp: 1_799_996_400, value: '40' },
+        { timestamp: 1_800_000_000, value: '42' },
+      ],
+      histograms: [],
+    },
+  ],
+  warnings: [],
+};
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+beforeEach(() => {
+  vi.spyOn(Date, 'now').mockReturnValue(1_800_000_000_000);
+  vi.mocked(listMetricProfiles).mockResolvedValue(profiles);
+  vi.mocked(queryMetrics).mockResolvedValue(metricData);
+});
+
+afterEach(() => {
+  vi.restoreAllMocks();
+  vi.clearAllMocks();
+});
+
+const renderWithProviders = (ui: React.ReactElement) =>
+  render(
+    <App>
+      <LangProvider>{ui}</LangProvider>
+    </App>,
+  );
+
+const createDeferred = <T,>() => {
+  let resolve!: (value: T) => void;
+  const promise = new Promise<T>((resolvePromise) => {
+    resolve = resolvePromise;
+  });
+  return { promise, resolve };
+};
+
+describe('MetricsExplorer', () => {
+  it('loads a metric profile and renders its Prometheus series', async () => {
+    renderWithProviders(<MetricsExplorer />);
+
+    expect(await screen.findByText('RocketMQ 5.x Native')).toBeInTheDocument();
+    await waitFor(() =>
+      expect(queryMetrics).toHaveBeenCalledWith({
+        metric: 'sum(rate(rocketmq_messages_in_total[1m])) by (cluster, 
node_id)',
+        start: 1_799_996_400,
+        end: 1_800_000_000,
+        step: '30s',
+      }),
+    );
+
+    expect(screen.getByRole('img', { name: 'Message In TPS time series' 
})).toBeInTheDocument();
+    expect(screen.getByText('cluster=prod / 
node_id=broker-a')).toBeInTheDocument();
+    expect(screen.getByText('42 messages/s')).toBeInTheDocument();
+  });
+
+  it('updates the query window when the range changes', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<MetricsExplorer />);
+
+    await screen.findByRole('img', { name: 'Message In TPS time series' });
+    await user.click(screen.getByText('6h'));
+
+    await waitFor(() =>
+      expect(queryMetrics).toHaveBeenLastCalledWith({
+        metric: 'sum(rate(rocketmq_messages_in_total[1m])) by (cluster, 
node_id)',
+        start: 1_799_978_400,
+        end: 1_800_000_000,
+        step: '2m',
+      }),
+    );
+  });
+
+  it('queries the first metric when the version profile changes', async () => {
+    const user = userEvent.setup();
+    renderWithProviders(<MetricsExplorer />);
+
+    await screen.findByRole('img', { name: 'Message In TPS time series' });
+    await user.click(screen.getAllByRole('combobox')[0]);
+    await user.click(
+      await screen.findByText('RocketMQ 4.x Exporter', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+
+    await waitFor(() =>
+      expect(queryMetrics).toHaveBeenLastCalledWith({
+        metric: 'sum(rocketmq_message_accumulation) by (cluster, group, 
topic)',
+        start: 1_799_996_400,
+        end: 1_800_000_000,
+        step: '30s',
+      }),
+    );
+    expect(screen.getByText('Consumer Lag Messages')).toBeInTheDocument();
+  });
+
+  it('keeps the newest range result when an older query finishes later', async 
() => {
+    const initialQuery = createDeferred<typeof metricData>();
+    const newerData = {
+      ...metricData,
+      series: [
+        {
+          ...metricData.series[0],
+          values: [{ timestamp: 1_800_000_000, value: '84' }],
+        },
+      ],
+    };
+    vi.mocked(queryMetrics)
+      .mockReturnValueOnce(initialQuery.promise)
+      .mockResolvedValueOnce(newerData);
+
+    const user = userEvent.setup();
+    renderWithProviders(<MetricsExplorer />);
+
+    await waitFor(() => expect(queryMetrics).toHaveBeenCalledTimes(1));
+    await user.click(screen.getByText('6h'));
+    expect(await screen.findByText('84 messages/s')).toBeInTheDocument();
+
+    initialQuery.resolve(metricData);
+    await waitFor(() => expect(screen.queryByText('42 
messages/s')).not.toBeInTheDocument());
+    expect(screen.getByText('84 messages/s')).toBeInTheDocument();
+  });
+
+  it('shows a query failure without replacing the metric controls', async () 
=> {
+    vi.mocked(queryMetrics).mockRejectedValue(new Error('Prometheus 
unavailable'));
+
+    renderWithProviders(<MetricsExplorer />);
+
+    expect(await screen.findByText('Prometheus 查询失败')).toBeInTheDocument();
+    expect(screen.getByRole('combobox', { name: '指标模板' })).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: '刷新指标' })).toBeInTheDocument();
+  });
+
+  it('shows an empty state when Prometheus returns no scalar samples', async 
() => {
+    vi.mocked(queryMetrics).mockResolvedValue({ ...metricData, series: [] });
+
+    renderWithProviders(<MetricsExplorer />);
+
+    expect(await screen.findByText('暂无标量数据')).toBeInTheDocument();
+  });
+});
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
index 1242fca6..fd0d8293 100644
--- a/web/src/pages/home/dashboard.tsx
+++ b/web/src/pages/home/dashboard.tsx
@@ -6,6 +6,7 @@ import { ListDashes, ArrowDown } from '@phosphor-icons/react';
 import PageHeader from '../../components/PageHeader';
 import StatusBadge from '../../components/StatusBadge';
 import MiniBar from '../../components/MiniBar';
+import MetricsExplorer from '../../components/MetricsExplorer';
 import { CLUSTER_TYPE_MAP } from '../../constants/theme';
 import { getDashboard } from '../../services/dashboardService';
 import type { DashboardData } from '../../api/metrics';
@@ -240,6 +241,8 @@ const DashboardPage = () => {
           })}
         />
       </Card>
+
+      <MetricsExplorer />
     </div>
   );
 };

Reply via email to