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>