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 c7a434d4e fix(web): give the dashboard and its panels a usable 
information hierarchy (#4212)
c7a434d4e is described below

commit c7a434d4e5748c2d5ed42b3292df221c2abbc0b2
Author: lizhimins <[email protected]>
AuthorDate: Thu Sep 10 17:21:27 2026 +0800

    fix(web): give the dashboard and its panels a usable information hierarchy 
(#4212)
    
    The dashboard page rendered 53 `Statistic` blocks. Forty-five of them were 
per-panel query
    metadata — series, samples, scalar samples, histogram samples, warnings, 
plus a six-item
    `Descriptions` of the query window — laid out *above* each chart, so every 
chart sat below eleven
    metadata blocks competing with it for attention. The metadata is now one 
secondary line with the
    full breakdown behind an info popover, and the chart comes first. Eight 
blocks remain.
    
    The cluster health table carried fifteen columns totalling 1620px against a 
1053px container, so
    it always scrolled horizontally, and its seven traffic columns duplicated 
the traffic insight
    card directly above it. It now shows the eight identity and topology 
columns, with the traffic
    breakdown in the expandable row.
    
    Also in the traffic insight card: the four summary cards had unequal 
heights because only the
    top-share card rendered a caption line, and `align="stretch"` cannot fix 
that when the cards wrap
    onto two flex lines. All four now render the same structure with a caption 
slot. The findings
    alert used a `description` block holding one tag per finding, several times 
the height of the same
    text on one line; it is now a single `message`.
    
    Two unrelated layout defects on other pages:
    
    - The system alert filter row put twelve controls in a `Flex` with neither 
`wrap` nor `align`.
      Their declared widths total roughly 1496px in a 1292px container, and 
because the four `Input`s
      size with `width` rather than `minWidth` they were the ones that shrank — 
down to 16px, small
      enough to be unusable, while the `Select`s kept their size.
    - In the notification settings the test buttons and the submit button were 
two adjacent
      `Form.Item`s both with `marginBottom: 0`, stacked flush against each 
other. They now share one
      row.
    
    The refresh button and instance-filter placeholder on the dashboard were 
hard-coded English in an
    otherwise translated page and now go through i18n.
---
 web/src/components/MetricsExplorer.tsx             | 135 ++++++++++--------
 web/src/i18n/translations.ts                       |   2 +
 web/src/pages/home/DashboardTrafficInsights.tsx    | 127 +++++++++--------
 .../pages/home/__tests__/DashboardPage.test.tsx    |  35 +++--
 .../__tests__/DashboardTrafficInsights.test.tsx    |   3 +-
 web/src/pages/home/dashboard.tsx                   | 157 ++++++++++-----------
 web/src/pages/ops/systemAlerts.tsx                 |  16 ++-
 web/src/pages/settings/GeneralSettingsTab.tsx      |  42 +++---
 8 files changed, 275 insertions(+), 242 deletions(-)

diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index 93cf5dcd1..e1e7072f1 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -29,19 +29,25 @@ import {
   Input,
   List,
   Modal,
+  Popover,
   Segmented,
   Select,
   Skeleton,
   Space,
   Spin,
-  Statistic,
   Table,
   Tag,
   Tooltip,
   Typography,
 } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
-import { ArrowsClockwise, ClockCounterClockwise, DownloadSimple, Eye } from 
'@phosphor-icons/react';
+import {
+  ArrowsClockwise,
+  ClockCounterClockwise,
+  DownloadSimple,
+  Eye,
+  Info,
+} from '@phosphor-icons/react';
 
 import { listDataSources } from '../api/settings';
 import { listMetricProfiles, queryByDataSource, queryMetrics } from 
'../api/metrics';
@@ -1046,62 +1052,6 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
             style={{ marginBottom: 8 }}
           />
         ))}
-        <Flex gap={16} wrap="wrap" style={{ marginBottom: 12 }}>
-          <Statistic
-            title={copy.series}
-            value={`${summary.visibleSeriesCount}/${summary.seriesCount}`}
-            style={{ minWidth: 96 }}
-          />
-          <Statistic title={copy.samples} value={summary.sampleCount} style={{ 
minWidth: 96 }} />
-          <Statistic
-            title={copy.scalarSamples}
-            value={summary.scalarSampleCount}
-            style={{ minWidth: 110 }}
-          />
-          <Statistic
-            title={copy.histogramSamples}
-            value={summary.histogramSampleCount}
-            style={{ minWidth: 130 }}
-          />
-          <Statistic title={copy.warnings} value={summary.warningCount} 
style={{ minWidth: 96 }} />
-        </Flex>
-        <Descriptions
-          size="small"
-          column={{ xs: 1, sm: 2, md: 3 }}
-          style={{ marginBottom: 12 }}
-          items={[
-            {
-              key: 'source',
-              label: copy.source,
-              children: state.query?.dataSourceName || copy.defaultDataSource,
-            },
-            {
-              key: 'query-window',
-              label: copy.queryWindow,
-              children: `${formatSeconds(state.query?.start)} - 
${formatSeconds(state.query?.end)}`,
-            },
-            {
-              key: 'queried-at',
-              label: copy.queriedAt,
-              children: formatMillis(state.query?.queriedAt),
-            },
-            {
-              key: 'first-sample',
-              label: copy.firstSample,
-              children: formatSeconds(summary.earliestTimestamp),
-            },
-            {
-              key: 'last-sample',
-              label: copy.lastSample,
-              children: formatSeconds(summary.latestTimestamp),
-            },
-            {
-              key: 'result-type',
-              label: copy.resultType,
-              children: state.data.resultType,
-            },
-          ]}
-        />
         <MetricChart
           data={state.data}
           metric={metric}
@@ -1111,6 +1061,74 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
           histogramTooltip={copy.histogramTooltip}
           hiddenSeriesText={copy.hiddenSeries}
         />
+        {/* The chart is the point of the panel, so the query metadata is 
condensed into one
+            secondary line with the full breakdown behind an info popover 
instead of a row of
+            Statistic blocks above the chart, which buried every chart on the 
dashboard. */}
+        <Flex align="center" gap={8} wrap="wrap" style={{ marginTop: 8 }}>
+          <Text type="secondary" style={{ fontSize: 14 }}>
+            {`${summary.visibleSeriesCount}/${summary.seriesCount} 
${copy.series} · ${summary.sampleCount} ${copy.samples}`}
+            {summary.warningCount > 0 ? ` · ${summary.warningCount} 
${copy.warnings}` : ''}
+          </Text>
+          <Popover
+            placement="topLeft"
+            content={
+              <Descriptions
+                size="small"
+                column={1}
+                style={{ maxWidth: 320 }}
+                items={[
+                  {
+                    key: 'source',
+                    label: copy.source,
+                    children: state.query?.dataSourceName || 
copy.defaultDataSource,
+                  },
+                  {
+                    key: 'query-window',
+                    label: copy.queryWindow,
+                    children: `${formatSeconds(state.query?.start)} - 
${formatSeconds(state.query?.end)}`,
+                  },
+                  {
+                    key: 'queried-at',
+                    label: copy.queriedAt,
+                    children: formatMillis(state.query?.queriedAt),
+                  },
+                  {
+                    key: 'first-sample',
+                    label: copy.firstSample,
+                    children: formatSeconds(summary.earliestTimestamp),
+                  },
+                  {
+                    key: 'last-sample',
+                    label: copy.lastSample,
+                    children: formatSeconds(summary.latestTimestamp),
+                  },
+                  {
+                    key: 'scalar-samples',
+                    label: copy.scalarSamples,
+                    children: summary.scalarSampleCount,
+                  },
+                  {
+                    key: 'histogram-samples',
+                    label: copy.histogramSamples,
+                    children: summary.histogramSampleCount,
+                  },
+                  {
+                    key: 'result-type',
+                    label: copy.resultType,
+                    children: state.data.resultType,
+                  },
+                ]}
+              />
+            }
+          >
+            <Button
+              type="text"
+              size="small"
+              aria-label={`${metric.name} ${copy.queryWindow}`}
+              icon={<Info size={14} />}
+            />
+          </Popover>
+        </Flex>
       </>
     );
   };
@@ -1440,6 +1458,7 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
           dataSource={detailRows}
           columns={detailColumns}
           pagination={{ pageSize: 8, showSizeChanger: false }}
+          tableLayout="fixed"
           scroll={{ x: tableScrollX(detailColumns) }}
         />
       </Drawer>
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 079a5fad0..b215b8913 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -98,6 +98,8 @@ const translations: Record<string, Record<Lang, string>> = {
   'dashboard.todayMessages': { zh: '今日消息', en: "Today's Messages" },
   'dashboard.clusterHealth': { zh: '集群健康概览', en: 'Cluster Health' },
   'dashboard.clusterName': { zh: '集群名称', en: 'Cluster Name' },
+  'dashboard.allInstances': { zh: '全部已配置实例', en: 'All configured instances' },
+  'dashboard.instanceFilter': { zh: '实例筛选', en: 'Instance filter' },
   'dashboard.broker': { zh: 'Broker', en: 'Broker' },
   'dashboard.proxy': { zh: 'Proxy', en: 'Proxy' },
   'dashboard.topic': { zh: 'Topic', en: 'Topic' },
diff --git a/web/src/pages/home/DashboardTrafficInsights.tsx 
b/web/src/pages/home/DashboardTrafficInsights.tsx
index 6bfe372cc..b2d36ba20 100644
--- a/web/src/pages/home/DashboardTrafficInsights.tsx
+++ b/web/src/pages/home/DashboardTrafficInsights.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { Alert, Card, Col, Empty, Flex, Row, Statistic, Tag, Typography } from 
'antd';
+import { Alert, Card, Col, Empty, Row, Statistic, Tag, Typography } from 
'antd';
 import { useLang } from '../../i18n/LangContext';
 import {
   formatTrafficPercent,
@@ -67,20 +67,52 @@ const DashboardTrafficInsights = ({ insights }: Props) => {
   const { t } = useLang();
   const visibleIssues = insights.issues.slice(0, 4);
 
-  const renderIssue = (issue: DashboardTrafficIssue) => (
-    <Tag key={`${issue.code}-${issue.clusterId ?? 'global'}`} 
color={levelColor[issue.level]}>
-      {t(issueTextKey(issue), {
-        cluster: issue.clusterName ?? t('dashboardTraffic.allClusters'),
-        value:
-          issue.value == null
-            ? '-'
-            : formatTrafficPercent(
-                issue.code === 'RECENT_TRAFFIC_DROP' ? Math.abs(issue.value) : 
issue.value,
-              ),
-        threshold: issue.threshold == null ? '-' : 
formatTrafficPercent(issue.threshold),
-      })}
-    </Tag>
-  );
+  const issueText = (issue: DashboardTrafficIssue) =>
+    t(issueTextKey(issue), {
+      cluster: issue.clusterName ?? t('dashboardTraffic.allClusters'),
+      value:
+        issue.value == null
+          ? '-'
+          : formatTrafficPercent(
+              issue.code === 'RECENT_TRAFFIC_DROP' ? Math.abs(issue.value) : 
issue.value,
+            ),
+      threshold: issue.threshold == null ? '-' : 
formatTrafficPercent(issue.threshold),
+    });
+
+  // All four cards render the same structure, caption slot included. Only the 
top-share card has
+  // something to put there, and letting the others omit it made them shorter 
than their
+  // neighbours — `align="stretch"` cannot fix that because the cards wrap 
onto two flex lines.
+  const summaryCards = [
+    {
+      key: 'activeClusters',
+      title: t('dashboardTraffic.activeClusters'),
+      value: `${insights.activeClusterCount}/${insights.totalClusterCount}`,
+      caption: '',
+    },
+    {
+      key: 'topClusterShare',
+      title: t('dashboardTraffic.topClusterShare'),
+      value: insights.topClusterSharePercent,
+      suffix: '%',
+      precision: 1,
+      caption: insights.topCluster?.name ?? '-',
+    },
+    {
+      key: 'balanceScore',
+      title: t('dashboardTraffic.balanceScore'),
+      value: insights.balanceScore,
+      suffix: '/100',
+      caption: '',
+    },
+    {
+      key: 'unhealthyTraffic',
+      title: t('dashboardTraffic.unhealthyTraffic'),
+      value: formatTrafficTps(insights.unhealthyTrafficTps),
+      suffix: '/s',
+      valueStyle: { color: insights.unhealthyTrafficTps > 0 ? '#cf1322' : 
undefined },
+      caption: '',
+    },
+  ];
 
   return (
     <Card
@@ -93,48 +125,28 @@ const DashboardTrafficInsights = ({ insights }: Props) => {
       style={{ marginBottom: 24 }}
       styles={{ body: { padding: 20 } }}
     >
-      <Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
-        <Col xs={12} lg={6}>
-          <Card size="small">
-            <Statistic
-              title={t('dashboardTraffic.activeClusters')}
-              
value={`${insights.activeClusterCount}/${insights.totalClusterCount}`}
-            />
-          </Card>
-        </Col>
-        <Col xs={12} lg={6}>
-          <Card size="small">
-            <Statistic
-              title={t('dashboardTraffic.topClusterShare')}
-              value={insights.topClusterSharePercent}
-              suffix="%"
-              precision={1}
-            />
-            <Text type="secondary">{insights.topCluster?.name ?? '-'}</Text>
-          </Card>
-        </Col>
-        <Col xs={12} lg={6}>
-          <Card size="small">
-            <Statistic
-              title={t('dashboardTraffic.balanceScore')}
-              value={insights.balanceScore}
-              suffix="/100"
-            />
-          </Card>
-        </Col>
-        <Col xs={12} lg={6}>
-          <Card size="small">
-            <Statistic
-              title={t('dashboardTraffic.unhealthyTraffic')}
-              value={formatTrafficTps(insights.unhealthyTrafficTps)}
-              suffix="/s"
-              valueStyle={{ color: insights.unhealthyTrafficTps > 0 ? 
'#cf1322' : undefined }}
-            />
-          </Card>
-        </Col>
+      <Row gutter={[12, 12]} align="stretch" style={{ marginBottom: 16 }}>
+        {summaryCards.map((card) => (
+          <Col xs={12} lg={6} key={card.key}>
+            <Card size="small" style={{ height: '100%' }}>
+              <Statistic
+                title={card.title}
+                value={card.value}
+                suffix={card.suffix}
+                precision={card.precision}
+                valueStyle={card.valueStyle}
+              />
+              <Text type="secondary" ellipsis={{ tooltip: card.caption || 
undefined }}>
+                {card.caption || '\u00a0'}
+              </Text>
+            </Card>
+          </Col>
+        ))}
       </Row>
 
       {visibleIssues.length > 0 && (
+        /* One compact line rather than a `description` block of one Tag per 
finding, which took
+           several times the vertical space for the same text. */
         <Alert
           showIcon
           type={
@@ -144,12 +156,7 @@ const DashboardTrafficInsights = ({ insights }: Props) => {
                 ? 'warning'
                 : 'info'
           }
-          message={t('dashboardTraffic.findings')}
-          description={
-            <Flex wrap="wrap" gap={8}>
-              {visibleIssues.map(renderIssue)}
-            </Flex>
-          }
+          
message={`${t('dashboardTraffic.findings')}:${visibleIssues.map(issueText).join('、')}`}
           style={{ marginBottom: 16 }}
         />
       )}
diff --git a/web/src/pages/home/__tests__/DashboardPage.test.tsx 
b/web/src/pages/home/__tests__/DashboardPage.test.tsx
index baafc771c..2df36867a 100644
--- a/web/src/pages/home/__tests__/DashboardPage.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardPage.test.tsx
@@ -177,27 +177,34 @@ describe('DashboardPage', () => {
     expect(within(row as 
HTMLElement).getAllByText('N/A').length).toBeGreaterThanOrEqual(1);
   });
 
-  it('merges traffic insight metrics into the existing cluster health table', 
async () => {
+  it('exposes traffic insight metrics in the expanded cluster health row', 
async () => {
     
vi.mocked(dashboardService.getDashboard).mockResolvedValue(trafficDashboard());
+    const user = userEvent.setup();
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('traffic-a');
     const clusterHealthCard = screen.getByText('集群健康概览').closest('.ant-card');
     expect(clusterHealthCard).not.toBeNull();
     const clusterHealth = within(clusterHealthCard as HTMLElement);
+
+    // The traffic breakdown lives behind the row expander so the main table 
stays at eight
+    // identity/topology columns instead of scrolling horizontally.
+    expect(clusterHealth.queryByText('总 TPS')).toBeNull();
+    const row = clusterHealth.getByText('traffic-a').closest('tr');
+    expect(row).not.toBeNull();
+    await user.click(
+      within(row as HTMLElement).getByRole('button', { name: /展开行|Expand row/u 
}),
+    );
+
     expect(clusterHealth.getAllByText('总 TPS')).not.toHaveLength(0);
     expect(clusterHealth.getAllByText('占比')).not.toHaveLength(0);
     expect(clusterHealth.getAllByText('单 Broker TPS')).not.toHaveLength(0);
     expect(clusterHealth.getAllByText('出入比')).not.toHaveLength(0);
-
-    const row = clusterHealth.getByText('traffic-a').closest('tr');
-    expect(row).not.toBeNull();
-    const clusterRow = within(row as HTMLElement);
-    expect(clusterRow.getByText('150/s')).toBeInTheDocument();
-    expect(clusterRow.getByText('75%')).toBeInTheDocument();
-    expect(clusterRow.getByText('75/s')).toBeInTheDocument();
-    expect(clusterRow.getByText('0.5:1')).toBeInTheDocument();
-    expect(clusterRow.getByText(/上升/u)).toBeInTheDocument();
+    expect(clusterHealth.getByText('150/s')).toBeInTheDocument();
+    expect(clusterHealth.getByText('75%')).toBeInTheDocument();
+    expect(clusterHealth.getByText('75/s')).toBeInTheDocument();
+    expect(clusterHealth.getByText('0.5:1')).toBeInTheDocument();
+    expect(clusterHealth.getByText(/上升/u)).toBeInTheDocument();
   });
 
   it('does not show dashboard data from the previous instance while loading a 
new selection', async () => {
@@ -209,7 +216,7 @@ describe('DashboardPage', () => {
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('initial-cluster');
-    const selector = screen.getByRole('combobox', { name: 'Dashboard instance' 
});
+    const selector = screen.getByRole('combobox', { name: '实例筛选' });
     await user.click(selector);
     await user.click(
       await screen.findByText('instance-a', { selector: 
'.ant-select-item-option-content' }),
@@ -232,7 +239,7 @@ describe('DashboardPage', () => {
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('initial-cluster');
-    const selector = screen.getByRole('combobox', { name: 'Dashboard instance' 
});
+    const selector = screen.getByRole('combobox', { name: '实例筛选' });
     await user.click(selector);
     await user.click(
       await screen.findByText('instance-a', { selector: 
'.ant-select-item-option-content' }),
@@ -263,7 +270,7 @@ describe('DashboardPage', () => {
     );
 
     await screen.findAllByText('instance-a-cluster');
-    const selector = screen.getByRole('combobox', { name: 'Dashboard instance' 
});
+    const selector = screen.getByRole('combobox', { name: '实例筛选' });
     await user.click(selector);
     await user.click(
       await screen.findByText('instance-b', { selector: 
'.ant-select-item-option-content' }),
@@ -305,7 +312,7 @@ describe('DashboardPage', () => {
     renderWithProviders(<DashboardPage />);
 
     await screen.findAllByText('apache-cluster');
-    await user.click(screen.getByRole('combobox', { name: 'Dashboard instance' 
}));
+    await user.click(screen.getByRole('combobox', { name: '实例筛选' }));
 
     expect(
       await screen.findByText('apache-instance', {
diff --git a/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx 
b/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
index ea08d0176..d5d0198a8 100644
--- a/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
+++ b/web/src/pages/home/__tests__/DashboardTrafficInsights.test.tsx
@@ -150,6 +150,7 @@ describe('DashboardTrafficInsights', () => {
 
     expect(screen.getByText('流量洞察')).toBeInTheDocument();
     expect(screen.getByText('暂无集群流量数据')).toBeInTheDocument();
-    expect(screen.getByText('未检测到活跃流量')).toBeInTheDocument();
+    // Findings render as one joined line, so match on a substring rather than 
the exact node text.
+    expect(screen.getByText(/未检测到活跃流量/u)).toBeInTheDocument();
   });
 });
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
index 98cefbf57..396909dde 100644
--- a/web/src/pages/home/dashboard.tsx
+++ b/web/src/pages/home/dashboard.tsx
@@ -5,6 +5,7 @@ import {
   Button,
   Card,
   Col,
+  Descriptions,
   Flex,
   Progress,
   Row,
@@ -132,16 +133,16 @@ const DashboardPage = () => {
       extra={
         <Space>
           <Select
-            aria-label="Dashboard instance"
+            aria-label={t('dashboard.instanceFilter')}
             allowClear
-            placeholder="All configured instances"
+            placeholder={t('dashboard.allInstances')}
             value={selectedInstanceId}
             onChange={setSelectedInstanceId}
             options={instances.map((instance) => ({ value: instance.name, 
label: instance.name }))}
             style={{ width: 220 }}
           />
           <Button onClick={() => void loadDashboard()} loading={loading}>
-            Refresh
+            {t('common.refresh')}
           </Button>
         </Space>
       }
@@ -216,11 +217,18 @@ const DashboardPage = () => {
     },
   ];
 
+  // Identity + topology only. The seven traffic columns this table used to 
carry pushed it to
+  // 1620px — a permanent horizontal scrollbar — and duplicated the 流量洞察 card 
above, so they
+  // now live in the expandable row instead.
   const clusterColumns: ColumnsType<ClusterRow> = [
     {
       title: t('dashboard.clusterName'),
       dataIndex: 'name',
       key: 'name',
+      // The one flexible column: it absorbs the surplus on a wide window so 
the fixed columns
+      // below keep their declared widths instead of all inflating 
proportionally.
+      minWidth: 220,
+      ellipsis: true,
       render: (name: string) => <Text strong>{name}</Text>,
     },
     {
@@ -234,6 +242,7 @@ const DashboardPage = () => {
       title: t('common.type'),
       dataIndex: 'type',
       key: 'type',
+      width: 110,
       render: (type: string) => {
         const info = CLUSTER_TYPE_MAP[type];
         return info ? <Tag color={info.color}>{t(info.labelKey)}</Tag> : type;
@@ -243,13 +252,14 @@ const DashboardPage = () => {
       title: t('common.version'),
       dataIndex: 'version',
       key: 'version',
+      width: 110,
       render: (v: string) => <span style={{ fontSize: 14 }}>{v}</span>,
     },
     {
       title: t('dashboard.broker'),
       dataIndex: 'brokers',
       key: 'brokers',
-      width: 80,
+      width: 90,
       align: 'center' as const,
       render: renderTopologyCount,
     },
@@ -257,7 +267,7 @@ const DashboardPage = () => {
       title: t('dashboard.proxy'),
       dataIndex: 'proxies',
       key: 'proxies',
-      width: 80,
+      width: 90,
       align: 'center' as const,
       render: renderTopologyCount,
     },
@@ -265,89 +275,67 @@ const DashboardPage = () => {
       title: t('dashboard.topic'),
       dataIndex: 'topics',
       key: 'topics',
-      width: 80,
+      width: 90,
       align: 'center' as const,
     },
     {
       title: t('dashboard.group'),
       dataIndex: 'groups',
       key: 'groups',
-      width: 80,
-      align: 'center' as const,
-    },
-    {
-      title: t('dashboard.tpsIn'),
-      dataIndex: 'tpsIn',
-      key: 'tpsIn',
       width: 90,
-      align: 'right' as const,
-      render: (v: number) => v.toLocaleString(),
-    },
-    {
-      title: t('dashboard.tpsOut'),
-      dataIndex: 'tpsOut',
-      key: 'tpsOut',
-      width: 90,
-      align: 'right' as const,
-      render: (v: number) => v.toLocaleString(),
+      align: 'center' as const,
     },
-    {
-      title: t('dashboardTraffic.totalTps'),
-      key: 'trafficTotalTps',
-      width: 120,
-      align: 'right' as const,
-      render: (_, record) => {
-        const insight = trafficInsightByClusterId.get(record.id);
-        return insight ? `${formatTrafficTps(insight.totalTps)}/s` : '-';
+  ];
+
+  const renderClusterTraffic = (record: ClusterRow) => {
+    const insight = trafficInsightByClusterId.get(record.id);
+    const trendDirection = insight?.trendDirection ?? 'unknown';
+    const items = [
+      { key: 'tpsIn', label: t('dashboard.tpsIn'), children: 
`${record.tpsIn.toLocaleString()}/s` },
+      {
+        key: 'tpsOut',
+        label: t('dashboard.tpsOut'),
+        children: `${record.tpsOut.toLocaleString()}/s`,
       },
-    },
-    {
-      title: t('dashboardTraffic.share'),
-      key: 'trafficShare',
-      width: 150,
-      render: (_, record) => {
-        const insight = trafficInsightByClusterId.get(record.id);
-        if (!insight) return '-';
-        return (
-          <Flex vertical gap={4}>
-            <Text>{formatTrafficPercent(insight.sharePercent)}</Text>
-            <Progress percent={Math.min(100, insight.sharePercent)} 
showInfo={false} size="small" />
-          </Flex>
-        );
+      {
+        key: 'totalTps',
+        label: t('dashboardTraffic.totalTps'),
+        children: insight ? `${formatTrafficTps(insight.totalTps)}/s` : '-',
       },
-    },
-    {
-      title: t('dashboardTraffic.perBroker'),
-      key: 'trafficPerBroker',
-      width: 130,
-      align: 'right' as const,
-      render: (_, record) => {
-        const insight = trafficInsightByClusterId.get(record.id);
-        return insight ? `${formatTrafficTps(insight.perBrokerTps)}/s` : '-';
+      {
+        key: 'perBroker',
+        label: t('dashboardTraffic.perBroker'),
+        children: insight ? `${formatTrafficTps(insight.perBrokerTps)}/s` : 
'-',
       },
-    },
-    {
-      title: t('dashboardTraffic.inOutRatio'),
-      key: 'trafficInOutRatio',
-      width: 110,
-      align: 'right' as const,
-      render: (_, record) => {
-        const insight = trafficInsightByClusterId.get(record.id);
-        return insight?.inOutRatio == null ? 'N/A' : `${insight.inOutRatio}:1`;
+      {
+        key: 'inOutRatio',
+        label: t('dashboardTraffic.inOutRatio'),
+        children: insight?.inOutRatio == null ? t('common.na') : 
`${insight.inOutRatio}:1`,
       },
-    },
-    {
-      title: t('dashboard.trend'),
-      dataIndex: 'throughput',
-      key: 'throughput',
-      width: 150,
-      render: (data: number[], record) => {
-        const insight = trafficInsightByClusterId.get(record.id);
-        const trendDirection = insight?.trendDirection ?? 'unknown';
-        return (
-          <Space direction="vertical" size={2}>
+      {
+        key: 'share',
+        label: t('dashboardTraffic.share'),
+        children: insight ? (
+          <Flex align="center" gap={8}>
+            <Text>{formatTrafficPercent(insight.sharePercent)}</Text>
+            <Progress
+              percent={Math.min(100, insight.sharePercent)}
+              showInfo={false}
+              size="small"
+              style={{ width: 90, margin: 0 }}
+            />
+          </Flex>
+        ) : (
+          '-'
+        ),
+      },
+      {
+        key: 'trend',
+        label: t('dashboard.trend'),
+        children: (
+          <Flex align="center" gap={8}>
             <MiniBar
-              data={data}
+              data={record.throughput}
               color={
                 record.status === 'healthy'
                   ? '#52c41a'
@@ -358,15 +346,16 @@ const DashboardPage = () => {
               height={26}
               width={100}
             />
-            <Tag color={trafficTrendColor[trendDirection]}>
+            <Tag color={trafficTrendColor[trendDirection]} style={{ 
marginInlineEnd: 0 }}>
               {t(trafficTrendLabelKey[trendDirection])}
               {formatTrafficTrendDelta(insight?.trendDeltaPercent ?? null)}
             </Tag>
-          </Space>
-        );
+          </Flex>
+        ),
       },
-    },
-  ];
+    ];
+    return <Descriptions size="small" column={{ xs: 1, sm: 2, md: 3, lg: 4 }} 
items={items} />;
+  };
 
   return (
     <div style={{ padding: 24 }}>
@@ -404,10 +393,14 @@ const DashboardPage = () => {
           rowKey="id"
           size="small"
           pagination={false}
-          scroll={{ x: tableScrollX(clusterColumns) }}
+          tableLayout="fixed"
+          scroll={{ x: tableScrollX(clusterColumns, { expandable: true }) }}
+          expandable={{
+            expandedRowRender: renderClusterTraffic,
+            rowExpandable: () => true,
+          }}
           onRow={() => ({
             style: { cursor: 'pointer' },
-            onClick: () => navigate(clusterPagePath),
           })}
         />
       </Card>
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index dbf3ce179..7266f2214 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -475,7 +475,7 @@ const SystemAlertsPage = () => {
         }
       />
 
-      <Flex gap={8} style={{ marginBottom: 16 }}>
+      <Flex gap={8} wrap align="center" style={{ marginBottom: 16 }}>
         {['all', 'error', 'warning', 'info'].map((level) => (
           <Button
             key={level}
@@ -518,7 +518,7 @@ const SystemAlertsPage = () => {
           aria-label={t('sysAlerts.instanceFilter')}
           size="small"
           placeholder={t('sysAlerts.instanceId')}
-          style={{ width: 150 }}
+          style={{ width: 150, flex: 'none' }}
           value={instanceFilter}
           onChange={(event) => {
             setInstanceFilter(event.target.value);
@@ -529,7 +529,7 @@ const SystemAlertsPage = () => {
           aria-label={t('sysAlerts.labelsFilter')}
           size="small"
           placeholder={t('sysAlerts.labelsPlaceholder')}
-          style={{ width: 190 }}
+          style={{ width: 190, flex: 'none' }}
           value={labelFilter}
           onChange={(event) => {
             setLabelFilter(event.target.value);
@@ -540,7 +540,7 @@ const SystemAlertsPage = () => {
           aria-label={t('sysAlerts.startTimeFilter')}
           type="datetime-local"
           size="small"
-          style={{ width: 190 }}
+          style={{ width: 190, flex: 'none' }}
           value={fromFilter}
           onChange={(event) => {
             setFromFilter(event.target.value);
@@ -551,7 +551,7 @@ const SystemAlertsPage = () => {
           aria-label={t('sysAlerts.endTimeFilter')}
           type="datetime-local"
           size="small"
-          style={{ width: 190 }}
+          style={{ width: 190, flex: 'none' }}
           value={toFilter}
           onChange={(event) => {
             setToFilter(event.target.value);
@@ -587,7 +587,11 @@ const SystemAlertsPage = () => {
             { value: 'delivered', label: 
t('sysAlerts.notificationNotSuppressed') },
           ]}
         />
-        {collectorStatus && <Tag 
color="success">{t('sysAlerts.nativeCollectionEnabled')}</Tag>}
+        {collectorStatus && (
+          <Tag color="success" style={{ marginInlineEnd: 0 }}>
+            {t('sysAlerts.nativeCollectionEnabled')}
+          </Tag>
+        )}
       </Flex>
 
       <Flex vertical gap={12}>
diff --git a/web/src/pages/settings/GeneralSettingsTab.tsx 
b/web/src/pages/settings/GeneralSettingsTab.tsx
index e7ce47fc3..7e347f92f 100644
--- a/web/src/pages/settings/GeneralSettingsTab.tsx
+++ b/web/src/pages/settings/GeneralSettingsTab.tsx
@@ -346,30 +346,30 @@ export const GeneralSettingsTab = () => {
           </Form.Item>
 
           <Form.Item style={{ marginBottom: 0 }}>
-            <Space>
+            <Flex align="center" justify="space-between" gap={8} wrap>
+              <Space>
+                <Button
+                  onClick={() => void sendTest('dingtalk')}
+                  loading={testingChannel === 'dingtalk'}
+                >
+                  {t('settings.testDingtalk')}
+                </Button>
+                <Button onClick={() => void sendTest('email')} 
loading={testingChannel === 'email'}>
+                  {t('settings.testEmail')}
+                </Button>
+                <Button onClick={() => void sendTest('sms')} 
loading={testingChannel === 'sms'}>
+                  {t('settings.testSmsWebhook')}
+                </Button>
+              </Space>
               <Button
-                onClick={() => void sendTest('dingtalk')}
-                loading={testingChannel === 'dingtalk'}
+                type="primary"
+                htmlType="submit"
+                loading={savingNotification}
+                disabled={loading}
               >
-                {t('settings.testDingtalk')}
-              </Button>
-              <Button onClick={() => void sendTest('email')} 
loading={testingChannel === 'email'}>
-                {t('settings.testEmail')}
+                {t('settings.saveSettings')}
               </Button>
-              <Button onClick={() => void sendTest('sms')} 
loading={testingChannel === 'sms'}>
-                {t('settings.testSmsWebhook')}
-              </Button>
-            </Space>
-          </Form.Item>
-          <Form.Item style={{ marginBottom: 0 }}>
-            <Button
-              type="primary"
-              htmlType="submit"
-              loading={savingNotification}
-              disabled={loading}
-            >
-              {t('settings.saveSettings')}
-            </Button>
+            </Flex>
           </Form.Item>
         </Form>
       </Card>

Reply via email to