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 8002d6509 fix(web): stop tag columns wrapping and tidy six layout
mismatches (#6533)
8002d6509 is described below
commit 8002d650994bc3aacb3785f1587527bf66e4437e
Author: lizhimins <[email protected]>
AuthorDate: Wed Oct 14 10:10:36 2026 +0800
fix(web): stop tag columns wrapping and tidy six layout mismatches (#6533)
Thirteen table columns render an array of antd Tags inside a wrapping
Space or Flex. Two things made them wrap: `.ant-tag` carries its own
`margin-inline-end: 8px`, which stacks on top of the container's `gap: 4`
and renders as 12px of spacing, and the declared column widths were sized
without that margin. AuditRiskInsights had already hand-written
`marginInlineEnd: 0` for exactly this reason; the other twelve had not.
Add TagList and route all thirteen through it: the tag margin is
cancelled so the container gap is the only spacing, `wrap={false}` makes
wrapping impossible, `max` collapses the overflow into a `+N` tag that
lists the rest on hover, and `tagMaxWidth` clips one long label with a
native title. AuditRiskInsights previously did `slice(0, 3)`, dropping
the fourth token silently; it now shows `+1`.
The widths are re-derived from measured tag widths, not estimated. Two
measurement traps are worth recording because both produce plausible
numbers: injecting a bare `<span class="ant-tag">` misses antd 5's
component styles (they are scoped to `:where(.css-hash).ant-tag`), and
reading `getBoundingClientRect()` on a tag already inside a constrained
flex container returns the shrunk width — `scrollWidth` gives the natural
one. Clone a rendered tag, or read scrollWidth.
audit.tsx also needed `width` rather than `minWidth`. antd only emits a
`<col>` for `width`, so under `table-layout: fixed` a column carrying only
`minWidth` merely shares the leftover with the other unsized columns.
That table has two, so the detail column stayed at 290px whatever
`minWidth` said, and nowrap then clipped the third tag mid-word.
Six layout mismatches, each measured in a browser before and after:
| where | before | after |
|:--|:--|:--|
| group detail modal, health tab | 1080px wide for a table needing 384;
four `span={6}` Statistic cards | 800px, one `#fafafa` summary bar matching the
progress tab |
| group detail modal, settings tab | 480x548 vertical form, three switches
each on its own row | 752x204, three-column grid |
| message query, by-key input | 240px beside the by-message-id input's
400px | 400px |
| client diagnostics table | resource and type stacked in one cell, 2 lines
/ 61px | type in its own column, 1 line / 40px |
| system alerts filter bar | control heights 24 / 26 / 22px mixed; four
independent buttons for one mutually-exclusive filter | all 32px; Segmented,
the idiom the message page and settings already use |
| dashboard cluster health card | body padding `0 20px 16px`, table 1px
above the head border | `16px 20px`, 15px clear |
The datetime-local inputs need an explicit `height: 32`: dropping
`size="small"` brings every other control to 32px but leaves the native
picker at 34px. The Selects use `minWidth` rather than `width` so the
English labels ("All delivery statuses", ~204px) are not truncated.
Tag defaults to 12px, below this project's 14px floor, so TagList sets
14px; the columns it now covers were a mix of 12px and hand-set 14px.
Verified: `tsc -b` clean, eslint 0 errors on every touched file,
`vitest run` 160 files / 1612 tests green (8 new for TagList), and every
row of the table above re-measured against the deployed build.
One test needed updating: Segmented's radio input carries
`pointer-events: none`, so `user.click` refuses it — the test now clicks
the wrapping `.ant-segmented-item`.
---
web/src/components/AlertRuleAssetList.tsx | 18 +-
web/src/components/GrafanaDashboardList.tsx | 17 +-
web/src/components/TagList.tsx | 83 +++++++
web/src/components/__tests__/TagList.test.tsx | 84 ++++++++
web/src/pages/cluster/clients.tsx | 57 ++---
web/src/pages/cluster/index.tsx | 46 ++--
web/src/pages/home/dashboard.tsx | 2 +-
web/src/pages/instance/acl.tsx | 54 +++--
web/src/pages/instance/consumer.tsx | 238 ++++++++++++---------
web/src/pages/instance/message.tsx | 2 +-
web/src/pages/instance/topic.tsx | 25 ++-
web/src/pages/ops/AuditRiskInsights.tsx | 19 +-
.../pages/ops/__tests__/SystemAlertsPage.test.tsx | 7 +-
web/src/pages/ops/alerts.tsx | 18 +-
web/src/pages/ops/audit.tsx | 25 ++-
web/src/pages/ops/systemAlerts.tsx | 76 ++++---
16 files changed, 495 insertions(+), 276 deletions(-)
diff --git a/web/src/components/AlertRuleAssetList.tsx
b/web/src/components/AlertRuleAssetList.tsx
index 7338735f3..78fcd4de8 100644
--- a/web/src/components/AlertRuleAssetList.tsx
+++ b/web/src/components/AlertRuleAssetList.tsx
@@ -20,6 +20,7 @@ import { Alert, App, Button, Input, Modal, Select, Space,
Table, Tag, Typography
import type { ColumnsType } from 'antd/es/table';
import { ArrowClockwise, DownloadSimple, Eye } from '@phosphor-icons/react';
import { useLang } from '../i18n/LangContext';
+import TagList from './TagList';
import {
exportAlertRuleAsset,
getAlertRuleAsset,
@@ -177,15 +178,16 @@ export const AlertRuleAssetList: React.FC = () => {
title: t('alertAssets.severity'),
dataIndex: 'severities',
key: 'severities',
- width: 180,
+ // CRITICAL + WARNING + INFO 三个标签并排实测需 239px
+ width: 240,
render: (severities: string[]) => (
- <Space size={[0, 4]} wrap>
- {(severities || []).map((severity) => (
- <Tag key={severity} color={SEVERITY_COLORS[severity] || 'default'}>
- {severity.toUpperCase()}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={(severities || []).map((severity) => ({
+ key: severity,
+ label: severity.toUpperCase(),
+ color: SEVERITY_COLORS[severity] || 'default',
+ }))}
+ />
),
},
{
diff --git a/web/src/components/GrafanaDashboardList.tsx
b/web/src/components/GrafanaDashboardList.tsx
index 167da1438..bf35092d8 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -16,10 +16,11 @@
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Alert, App, Button, Input, Modal, Select, Space, Table, Tag,
Typography } from 'antd';
+import { Alert, App, Button, Input, Modal, Select, Space, Table, Typography }
from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ArrowClockwise, DownloadSimple, Eye } from '@phosphor-icons/react';
import { useLang } from '../i18n/LangContext';
+import TagList from './TagList';
import {
getGrafanaDashboard,
exportGrafanaDashboard,
@@ -184,15 +185,13 @@ export const GrafanaDashboardList: React.FC = () => {
title: t('grafana.tags'),
dataIndex: 'tags',
key: 'tags',
- width: 140,
+ width: 200,
render: (tags: string[]) => (
- <Space size={[0, 4]} wrap>
- {(tags || []).map((tag) => (
- <Tag key={tag} color="blue">
- {tag}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={(tags || []).map((tag) => ({ key: tag, label: tag, color:
'blue' }))}
+ max={2}
+ tagMaxWidth={70}
+ />
),
},
{
diff --git a/web/src/components/TagList.tsx b/web/src/components/TagList.tsx
new file mode 100644
index 000000000..b1f2ea182
--- /dev/null
+++ b/web/src/components/TagList.tsx
@@ -0,0 +1,83 @@
+/*
+ * 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 type { CSSProperties } from 'react';
+import { Flex, Tag, Tooltip } from 'antd';
+
+export interface TagListItem {
+ key: string;
+ label: string;
+ color?: string;
+}
+
+interface TagListProps {
+ items: TagListItem[];
+ /**
+ * How many tags to render before collapsing the rest into a `+N` tag that
lists them on hover.
+ * Leave unset only when the value set is bounded and the column is wide
enough for all of it.
+ */
+ max?: number;
+ /** Truncates each tag to this width; the full label stays reachable via the
native title. */
+ tagMaxWidth?: number;
+ gap?: number;
+ style?: CSSProperties;
+}
+
+// antd gives `.ant-tag` an 8px `margin-inline-end`, which stacks on top of
the container's own gap
+// and makes the real spacing 12px when 4px was asked for. The container is
the single source of
+// truth for spacing here, so the tag margin is cancelled. 14px is the
project-wide font floor;
+// antd's own default for Tag is 12px.
+const TAG_STYLE: CSSProperties = { marginInlineEnd: 0, fontSize: 14 };
+
+const TagList = ({ items, max, tagMaxWidth, gap = 4, style }: TagListProps) =>
{
+ const visible = max === undefined ? items : items.slice(0, max);
+ const hidden = max === undefined ? [] : items.slice(max);
+ const clip: CSSProperties | undefined = tagMaxWidth
+ ? {
+ maxWidth: tagMaxWidth,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ }
+ : undefined;
+
+ return (
+ <Flex gap={gap} wrap={false} align="center" style={{ minWidth: 0, ...style
}}>
+ {visible.map((item) => (
+ <Tag
+ key={item.key}
+ color={item.color}
+ style={{ ...TAG_STYLE, ...clip }}
+ title={tagMaxWidth ? item.label : undefined}
+ >
+ {item.label}
+ </Tag>
+ ))}
+ {hidden.length > 0 && (
+ <Tooltip
+ title={hidden.map((item) => (
+ <div key={item.key}>{item.label}</div>
+ ))}
+ >
+ <Tag style={TAG_STYLE}>+{hidden.length}</Tag>
+ </Tooltip>
+ )}
+ </Flex>
+ );
+};
+
+export default TagList;
diff --git a/web/src/components/__tests__/TagList.test.tsx
b/web/src/components/__tests__/TagList.test.tsx
new file mode 100644
index 000000000..8ef360681
--- /dev/null
+++ b/web/src/components/__tests__/TagList.test.tsx
@@ -0,0 +1,84 @@
+/*
+ * 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 { describe, it, expect } from 'vitest';
+import { render, screen, fireEvent } from '@testing-library/react';
+import TagList from '../TagList';
+
+const items = [
+ { key: 'dingtalk', label: 'DingTalk', color: 'blue' },
+ { key: 'email', label: 'Email', color: 'green' },
+ { key: 'sms', label: 'SMS', color: 'orange' },
+];
+
+describe('TagList', () => {
+ it('renders every item when no max is givenTest', () => {
+ render(<TagList items={items} />);
+ expect(screen.getByText('DingTalk')).toBeInTheDocument();
+ expect(screen.getByText('Email')).toBeInTheDocument();
+ expect(screen.getByText('SMS')).toBeInTheDocument();
+ expect(screen.queryByText('+1')).not.toBeInTheDocument();
+ });
+
+ it('renders nothing when there are no itemsTest', () => {
+ const { container } = render(<TagList items={[]} />);
+ expect(container.querySelectorAll('.ant-tag')).toHaveLength(0);
+ });
+
+ it('cancels the antd tag margin so the container gap is the only
spacingTest', () => {
+ render(<TagList items={items} />);
+ // antd's own `.ant-tag` carries margin-inline-end: 8px, which would stack
on top of gap={4}
+ // and render as 12px of spacing. Every tag has to cancel it.
+ screen
+ .getAllByText(/DingTalk|Email|SMS/)
+ .forEach((tag) => expect(tag.style.marginInlineEnd).toBe('0px'));
+ });
+
+ it('never wraps onto a second lineTest', () => {
+ const { container } = render(<TagList items={items} />);
+ expect(container.querySelector('.ant-flex-wrap-wrap')).toBeNull();
+ });
+
+ it('collapses the overflow into a +N tag that lists the hidden labelsTest',
async () => {
+ render(<TagList items={items} max={1} />);
+ expect(screen.getByText('DingTalk')).toBeInTheDocument();
+ expect(screen.queryByText('Email')).not.toBeInTheDocument();
+
+ fireEvent.mouseEnter(screen.getByText('+2'));
+ expect(await screen.findByText('Email')).toBeInTheDocument();
+ expect(screen.getByText('SMS')).toBeInTheDocument();
+ });
+
+ it('keeps every item when max is above the item countTest', () => {
+ render(<TagList items={items} max={5} />);
+ expect(screen.getByText('SMS')).toBeInTheDocument();
+ expect(screen.queryByText(/\+\d/)).not.toBeInTheDocument();
+ });
+
+ it('truncates long labels but keeps them reachable through the titleTest',
() => {
+ render(<TagList items={[{ key: 't', label: 'a-very-long-topic-name' }]}
tagMaxWidth={80} />);
+ const tag = screen.getByText('a-very-long-topic-name');
+ expect(tag.style.maxWidth).toBe('80px');
+ expect(tag.style.textOverflow).toBe('ellipsis');
+ expect(tag.getAttribute('title')).toBe('a-very-long-topic-name');
+ });
+
+ it('does not add a title when labels are not truncatedTest', () => {
+ render(<TagList items={[{ key: 't', label: 'SMS' }]} />);
+ expect(screen.getByText('SMS').getAttribute('title')).toBeNull();
+ });
+});
diff --git a/web/src/pages/cluster/clients.tsx
b/web/src/pages/cluster/clients.tsx
index 8c2e67b1f..780aac7b7 100644
--- a/web/src/pages/cluster/clients.tsx
+++ b/web/src/pages/cluster/clients.tsx
@@ -38,6 +38,7 @@ import type { ColumnsType } from 'antd/es/table';
import type { TableProps } from 'antd';
import PageHeader from '../../components/PageHeader';
+import TagList from '../../components/TagList';
import { useLang } from '../../i18n/LangContext';
import type { ClientConnection } from '../../api/connections';
import { listConnections } from '../../services/connectionsService';
@@ -553,18 +554,21 @@ const ClientsPage = () => {
width: 220,
// Unbounded resource names (_INNER_TRACE_PRODUCER-…-CONSUME-1) must
clip inside the
// cell: the project-wide `.ant-table-cell { white-space: nowrap }`
otherwise lets them
- // spill over the neighbouring column. Each line ellipsises on its own
with a native
- // title tooltip, the ConversationListModal title-column idiom.
+ // spill over the neighbouring column.
ellipsis: { showTitle: false },
render: (_: unknown, record) => (
- <div style={{ minWidth: 0 }}>
- <Text strong style={diagnosticCellLine} title={record.resource}>
- {record.resource}
- </Text>
- <Text type="secondary" style={diagnosticCellLine}>
- {record.type}
- </Text>
- </div>
+ <Text strong style={diagnosticCellLine} title={record.resource}>
+ {record.resource}
+ </Text>
+ ),
+ },
+ {
+ title: t('common.type'),
+ dataIndex: 'type',
+ key: 'resourceType',
+ width: 100,
+ render: (type: string) => (
+ <Text style={{ fontSize: 14 }}>{(typeConfig[type] ?? { label: type
}).label}</Text>
),
},
{
@@ -586,15 +590,16 @@ const ClientsPage = () => {
title: t('clients.protocol'),
dataIndex: 'protocols',
key: 'protocols',
+ // gRPC + Remoting 两个标签并排实测需 157px
width: 160,
render: (protocols: string[]) => (
- <Space size={4} wrap>
- {protocols.map((protocol) => (
- <Tag key={protocol} color={protocolConfig[protocol]?.color ??
'default'}>
- {protocol}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={protocols.map((protocol) => ({
+ key: protocol,
+ label: protocol,
+ color: protocolConfig[protocol]?.color ?? 'default',
+ }))}
+ />
),
},
{
@@ -602,18 +607,22 @@ const ClientsPage = () => {
key: 'versions',
width: 220,
render: (_: unknown, record) => (
- <Space size={4} wrap>
- {record.languages.map((language) => (
- <Tag key={language} color={languageConfig[language]?.color ??
'default'}>
- {languageConfig[language]?.label ?? language}
- </Tag>
- ))}
+ <Flex gap={4} wrap={false} align="center" style={{ minWidth: 0 }}>
+ <TagList
+ items={record.languages.map((language) => ({
+ key: language,
+ label: languageConfig[language]?.label ?? language,
+ color: languageConfig[language]?.color ?? 'default',
+ }))}
+ max={2}
+ tagMaxWidth={64}
+ />
{record.versions.map((version) => (
<Text key={version} code>
{version}
</Text>
))}
- </Space>
+ </Flex>
),
},
{
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index cf33221c9..2cbdae3c2 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -52,6 +52,7 @@ import {
} from '@ant-design/icons';
import { Cpu, HardDrives, Globe } from '@phosphor-icons/react';
import PageHeader from '../../components/PageHeader';
+import TagList from '../../components/TagList';
import InfoBanner from '../../components/InfoBanner';
import { useLang } from '../../i18n/LangContext';
import { countClusterComponents } from './clusterStats';
@@ -945,15 +946,17 @@ const ClusterPage = () => {
dataIndex: 'values',
key: 'values',
render: (values: NameServerConfigDifference['values']) => (
- <Space size={[0, 4]} wrap>
- {values.map((value) => (
- <Tag key={value.address} color={value.configured ? 'blue' :
'default'}>
- {`${value.address}: ${
- value.configured ? (value.value ?? '-') :
t('cluster.nsConfigDiffUnconfigured')
- }`}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={values.map((value) => ({
+ key: value.address,
+ label: `${value.address}: ${
+ value.configured ? (value.value ?? '-') :
t('cluster.nsConfigDiffUnconfigured')
+ }`,
+ color: value.configured ? 'blue' : 'default',
+ }))}
+ max={3}
+ tagMaxWidth={200}
+ />
),
},
];
@@ -1092,20 +1095,17 @@ const ClusterPage = () => {
dataIndex: 'values',
key: 'values',
render: (values: BrokerConfigDifference['values']) => (
- <Space size={[0, 4]} wrap>
- {values.map((value) => (
- <Tag
- key={`${value.address}-${value.value ?? 'missing'}`}
- color={value.configured ? 'blue' : 'default'}
- >
- {`${value.brokerName || value.address}: ${
- value.configured
- ? (value.value ?? '-')
- : t('cluster.brokerConfigDiffUnconfigured')
- }`}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={values.map((value) => ({
+ key: `${value.address}-${value.value ?? 'missing'}`,
+ label: `${value.brokerName || value.address}: ${
+ value.configured ? (value.value ?? '-') :
t('cluster.brokerConfigDiffUnconfigured')
+ }`,
+ color: value.configured ? 'blue' : 'default',
+ }))}
+ max={3}
+ tagMaxWidth={200}
+ />
),
},
];
diff --git a/web/src/pages/home/dashboard.tsx b/web/src/pages/home/dashboard.tsx
index 87561e9ae..1e4a8be31 100644
--- a/web/src/pages/home/dashboard.tsx
+++ b/web/src/pages/home/dashboard.tsx
@@ -385,7 +385,7 @@ const DashboardPage = () => {
<Card
title={t('dashboard.clusterHealth')}
extra={<a onClick={() =>
navigate(clusterPagePath)}>{t('common.viewAll')}</a>}
- styles={{ body: { padding: '0 20px 16px' } }}
+ styles={{ body: { padding: '16px 20px' } }}
>
<Table
dataSource={clusters}
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index d09dbff54..2cf13d667 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -50,6 +50,7 @@ import {
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import PageHeader from '../../components/PageHeader';
+import TagList from '../../components/TagList';
import InfoBanner from '../../components/InfoBanner';
import { InstanceSelect } from '../../components/InstanceSelect';
import { useLang } from '../../i18n/LangContext';
@@ -625,15 +626,16 @@ const AclPageContent = ({
title: t('acl.permissions'),
dataIndex: 'actions',
key: 'actions',
- width: 180,
+ // 规则表单里 PUB / SUB / ALL 是三个独立 Checkbox、可以同时勾上;三个标签并排实测需 252px
+ width: 260,
render: (actions: AclRule['actions']) => (
- <Space size={4} wrap>
- {actions.map((action) => (
- <Tag key={action} color={actionTagColor[action]} style={{
fontSize: 14 }}>
- {actionLabel[action] ?? action}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={actions.map((action) => ({
+ key: action,
+ label: actionLabel[action] ?? action,
+ color: actionTagColor[action],
+ }))}
+ />
),
},
{
@@ -810,13 +812,11 @@ const AclPageContent = ({
width: 280,
sorter: (a, b) => a.clusters.length - b.clusters.length,
render: (clusters: string[]) => (
- <Space size={4} wrap>
- {clusters.map((c) => (
- <Tag key={c} color="processing" style={{ fontSize: 14 }}>
- {c}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={clusters.map((c) => ({ key: c, label: c, color: 'processing'
}))}
+ max={2}
+ tagMaxWidth={110}
+ />
),
},
{
@@ -928,13 +928,11 @@ const AclPageContent = ({
key: 'topicPerms',
width: 220,
render: (perms: string[]) => (
- <Space size={4} wrap>
- {(perms ?? []).map((p) => (
- <Tag key={p} color="blue" style={{ fontSize: 14 }}>
- {p}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={(perms ?? []).map((p) => ({ key: p, label: p, color: 'blue'
}))}
+ max={2}
+ tagMaxWidth={80}
+ />
),
},
{
@@ -943,13 +941,11 @@ const AclPageContent = ({
key: 'groupPerms',
width: 200,
render: (perms: string[]) => (
- <Space size={4} wrap>
- {(perms ?? []).map((p) => (
- <Tag key={p} color="green" style={{ fontSize: 14 }}>
- {p}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={(perms ?? []).map((p) => ({ key: p, label: p, color: 'green'
}))}
+ max={2}
+ tagMaxWidth={70}
+ />
),
},
{
diff --git a/web/src/pages/instance/consumer.tsx
b/web/src/pages/instance/consumer.tsx
index 59294a7ca..ae78fe23a 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -178,6 +178,18 @@ const healthStatusTagColor = (status:
ConsumerGroupHealthStatus) => {
return 'green';
};
+const healthStatusColor = (status: ConsumerGroupHealthStatus) => {
+ if (status === 'critical') return '#ff4d4f';
+ if (status === 'warning') return '#faad14';
+ return '#52c41a';
+};
+
+const healthProgressStatus = (status: ConsumerGroupHealthStatus) => {
+ if (status === 'critical') return 'exception' as const;
+ if (status === 'warning') return 'active' as const;
+ return 'success' as const;
+};
+
const issueSeverityTagColor = (severity: ConsumerGroupHealthIssue['severity'])
=> {
if (severity === 'critical') return 'red';
if (severity === 'warning') return 'orange';
@@ -1682,7 +1694,7 @@ const ConsumerPageContent = ({
originalSettingsRef.current = null;
settingsForm.resetFields();
}}
- width={detailTab === 'progress' || detailTab === 'health' ? 1080 : 800}
+ width={detailTab === 'progress' ? 1080 : 800}
destroyOnHidden
footer={null}
>
@@ -1970,85 +1982,83 @@ const ConsumerPageContent = ({
/>
)}
- <Row gutter={16}>
- <Col span={6}>
- <Card size="small" style={{ borderRadius: 8 }}>
- <Statistic
- title="健康分"
- value={selectedGroupHealth.summary.healthScore}
- suffix="/ 100"
- valueStyle={{
- color:
- selectedGroupHealth.status === 'critical'
- ? '#ff4d4f'
- : selectedGroupHealth.status === 'warning'
- ? '#faad14'
- : '#52c41a',
- }}
- />
- <Progress
- percent={selectedGroupHealth.summary.healthScore}
- showInfo={false}
- status={
- selectedGroupHealth.status === 'critical'
- ? 'exception'
- : selectedGroupHealth.status === 'warning'
- ? 'active'
- : 'success'
- }
- />
- </Card>
- </Col>
- <Col span={6}>
- <Card size="small" style={{ borderRadius: 8 }}>
- <Statistic
- title="已知堆积"
- value={selectedGroupHealth.summary.totalKnownLag}
- valueStyle={{
- color:
lagColor(selectedGroupHealth.summary.totalKnownLag),
- }}
- />
- <Text type="secondary">
- 报告堆积:
+ <Card
+ size="small"
+ style={{
+ background: '#fafafa',
+ borderRadius: 8,
+ }}
+ styles={{ body: { padding: '8px 16px' } }}
+ >
+ <Space size={24} wrap>
+ <Space size={4}>
+ <Text type="secondary">健康分:</Text>
+ <Text
+ strong
+ style={{ color:
healthStatusColor(selectedGroupHealth.status) }}
+ >
+ {selectedGroupHealth.summary.healthScore} / 100
+ </Text>
+ </Space>
+ <Space size={4}>
+ <Text type="secondary">已知堆积:</Text>
+ <Text
+ strong
+ style={{ color:
lagColor(selectedGroupHealth.summary.totalKnownLag) }}
+ >
+
{selectedGroupHealth.summary.totalKnownLag.toLocaleString()}
+ </Text>
+ </Space>
+ <Space size={4}>
+ <Text type="secondary">报告堆积:</Text>
+ <Text strong>
{selectedGroupHealth.summary.reportedLag === null
? UNAVAILABLE_LAG_LABEL
:
selectedGroupHealth.summary.reportedLag.toLocaleString()}
</Text>
- </Card>
- </Col>
- <Col span={6}>
- <Card size="small" style={{ borderRadius: 8 }}>
- <Statistic
- title="Queue 覆盖"
- value={selectedGroupHealth.summary.queueCount}
-
suffix={`/${selectedGroupHealth.summary.subscribedTopicCount} Topic`}
- />
- <Text type="secondary">
+ </Space>
+ <Space size={4}>
+ <Text type="secondary">Queue 覆盖:</Text>
+ <Text strong>
+ {selectedGroupHealth.summary.queueCount} /{' '}
+ {selectedGroupHealth.summary.subscribedTopicCount}
Topic
+ </Text>
+ </Space>
+ <Space size={4}>
+ <Text type="secondary">Queue 堆积:</Text>
+ <Text strong>
{selectedGroupHealth.summary.unknownQueueCount > 0
- ?
`${selectedGroupHealth.summary.unknownQueueCount} 个 Queue 堆积不可用`
- : 'Queue 堆积均可计算'}
+ ?
`${selectedGroupHealth.summary.unknownQueueCount} 个不可用`
+ : '均可计算'}
</Text>
- </Card>
- </Col>
- <Col span={6}>
- <Card size="small" style={{ borderRadius: 8 }}>
- <Statistic
- title="客户端"
- value={selectedGroupHealth.summary.onlineInstances}
- formatter={(value) =>
- formatOnlineInstances(Number(value),
UNAVAILABLE_LAG_LABEL)
- }
- />
- <Text type="secondary">
+ </Space>
+ <Space size={4}>
+ <Text type="secondary">客户端:</Text>
+ <Text strong>
+ {formatOnlineInstances(
+ selectedGroupHealth.summary.onlineInstances,
+ UNAVAILABLE_LAG_LABEL,
+ )}
+ </Text>
+ </Space>
+ <Space size={4}>
+ <Text type="secondary">心跳:</Text>
+ <Text strong>
{selectedGroupHealth.summary.onlineInstances < 0
- ? '客户端连接信息不可用'
+ ? '连接信息不可用'
: selectedGroupHealth.summary.staleClientCount > 0
- ?
`${selectedGroupHealth.summary.staleClientCount} 个心跳过期`
- : '心跳状态正常'}
+ ?
`${selectedGroupHealth.summary.staleClientCount} 个过期`
+ : '正常'}
</Text>
- </Card>
- </Col>
- </Row>
+ </Space>
+ </Space>
+ <Progress
+ percent={selectedGroupHealth.summary.healthScore}
+ showInfo={false}
+ style={{ marginTop: 8, marginBottom: 0 }}
+
status={healthProgressStatus(selectedGroupHealth.status)}
+ />
+ </Card>
{selectedGroupHealth.issues.length > 0 ? (
<Table
@@ -2190,41 +2200,57 @@ const ConsumerPageContent = ({
disabled: isCloudInstance,
children: (
<Spin spinning={settingsLoading}>
- <Form form={settingsForm} layout="vertical" style={{
maxWidth: 480 }}>
- <Form.Item label="Group 名称">
- <Text strong>{selectedGroup.name}</Text>
- </Form.Item>
- <Form.Item
- label="重试队列数"
- name="retryQueueNums"
- rules={[{ required: true, message: '请输入重试队列数' }]}
- >
- <InputNumber min={1} max={128} style={{ width: '100%'
}} />
- </Form.Item>
- <Form.Item
- label="最大重试次数"
- name="retryMaxTimes"
- rules={[{ required: true, message: '请输入最大重试次数' }]}
- >
- <InputNumber min={1} max={128} style={{ width: '100%'
}} />
- </Form.Item>
- <Form.Item label="启用消费" name="consumeEnable"
valuePropName="checked">
- <Switch />
- </Form.Item>
- <Form.Item
- label="顺序消费"
- name="consumeMessageOrderly"
- valuePropName="checked"
- >
- <Switch />
- </Form.Item>
- <Form.Item
- label="广播消费"
- name="consumeBroadcastEnable"
- valuePropName="checked"
- >
- <Switch />
- </Form.Item>
+ <Form form={settingsForm} layout="vertical">
+ <Row gutter={16}>
+ <Col span={8}>
+ <Form.Item label="Group 名称">
+ <Text strong>{selectedGroup.name}</Text>
+ </Form.Item>
+ </Col>
+ <Col span={8}>
+ <Form.Item
+ label="重试队列数"
+ name="retryQueueNums"
+ rules={[{ required: true, message: '请输入重试队列数' }]}
+ >
+ <InputNumber min={1} max={128} style={{ width:
'100%' }} />
+ </Form.Item>
+ </Col>
+ <Col span={8}>
+ <Form.Item
+ label="最大重试次数"
+ name="retryMaxTimes"
+ rules={[{ required: true, message: '请输入最大重试次数' }]}
+ >
+ <InputNumber min={1} max={128} style={{ width:
'100%' }} />
+ </Form.Item>
+ </Col>
+ </Row>
+ <Row gutter={16}>
+ <Col span={8}>
+ <Form.Item label="启用消费" name="consumeEnable"
valuePropName="checked">
+ <Switch />
+ </Form.Item>
+ </Col>
+ <Col span={8}>
+ <Form.Item
+ label="顺序消费"
+ name="consumeMessageOrderly"
+ valuePropName="checked"
+ >
+ <Switch />
+ </Form.Item>
+ </Col>
+ <Col span={8}>
+ <Form.Item
+ label="广播消费"
+ name="consumeBroadcastEnable"
+ valuePropName="checked"
+ >
+ <Switch />
+ </Form.Item>
+ </Col>
+ </Row>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
diff --git a/web/src/pages/instance/message.tsx
b/web/src/pages/instance/message.tsx
index 475ab9ed9..1adb5b378 100644
--- a/web/src/pages/instance/message.tsx
+++ b/web/src/pages/instance/message.tsx
@@ -1190,7 +1190,7 @@ const MessagePageContent = ({
/>
<Input
placeholder={t('messagePage.inputKeyPlaceholder')}
- style={{ width: 240 }}
+ style={{ width: 400 }}
value={keyInput}
onChange={(e) => setKeyInput(e.target.value)}
/>
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 1be6ee7a4..538111051 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -63,6 +63,7 @@ import PageHeader from '../../components/PageHeader';
import InfoBanner from '../../components/InfoBanner';
import { InstanceSelect } from '../../components/InstanceSelect';
import TopicConfigComparisonDrawer from
'../../components/TopicConfigComparisonDrawer';
+import TagList from '../../components/TagList';
import { useLang } from '../../i18n/LangContext';
import { TOPIC_TYPE_MAP, CLUSTER_TYPE_MAP } from '../../constants/theme';
import type { Topic, BrokerRoute, ConsumerGroupInfo, TopicConsumerPage } from
'../../api/metadata';
@@ -924,17 +925,19 @@ const TopicPageContent = ({
Master {record.masterAddr}
</Text>
)}
- <Space size={4} wrap>
- {record.brokerIds.length > 0 ? (
- record.brokerIds.map((id) => (
- <Tag key={id} color={id === '0' ? 'blue' : undefined}>
- {id === '0' ? 'Master' : `Replica ${id}`}
- </Tag>
- ))
- ) : (
- <Tag color="warning">地址未知</Tag>
- )}
- </Space>
+ {record.brokerIds.length > 0 ? (
+ <TagList
+ items={record.brokerIds.map((id) => ({
+ key: id,
+ label: id === '0' ? 'Master' : `Replica ${id}`,
+ color: id === '0' ? 'blue' : undefined,
+ }))}
+ max={2}
+ tagMaxWidth={90}
+ />
+ ) : (
+ <Tag color="warning">地址未知</Tag>
+ )}
</Space>
),
},
diff --git a/web/src/pages/ops/AuditRiskInsights.tsx
b/web/src/pages/ops/AuditRiskInsights.tsx
index a26828098..45267f2cf 100644
--- a/web/src/pages/ops/AuditRiskInsights.tsx
+++ b/web/src/pages/ops/AuditRiskInsights.tsx
@@ -24,7 +24,6 @@ import {
Progress,
Row,
Skeleton,
- Space,
Statistic,
Table,
Tag,
@@ -36,6 +35,7 @@ import type { AuditRecord } from '../../api/ops';
import { useLang } from '../../i18n/LangContext';
import { formatDateTime, formatNumber } from '../../utils/format';
import { tableScrollX } from '../../utils/table';
+import TagList from '../../components/TagList';
import {
auditRiskOperationLabel,
buildAuditRiskInsights,
@@ -151,14 +151,17 @@ const AuditRiskInsights = ({ summary, records, loading }:
Props) => {
title: t('audit.opType'),
dataIndex: 'operationTypes',
key: 'operationTypes',
+ width: 240,
+ // 原来 slice(0, 3) 把第 4 个起静默丢掉,换成 TagList 的 +N 至少悬停能看到
render: (operationTypes: string[]) => (
- <Space size={[4, 4]} wrap>
- {operationTypes.slice(0, 3).map((operationType) => (
- <Tag key={operationType} style={{ marginInlineEnd: 0 }}>
- {formatOperationLabel(operationType, t)}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={operationTypes.map((operationType) => ({
+ key: operationType,
+ label: formatOperationLabel(operationType, t),
+ }))}
+ max={2}
+ tagMaxWidth={88}
+ />
),
},
{
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 684879f3b..44929e8b4 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -322,7 +322,12 @@ describe('SystemAlertsPage', () => {
renderPage();
await screen.findByText('Mixed-case error');
- await user.click(screen.getByRole('button', { name: /严重/ }));
+ // 级别筛选是 Segmented:里面的 radio input 带 pointer-events: none,user.click 点不动,
+ // 要点它外层的 .ant-segmented-item label。不能用 getByText('严重')——告警卡片里也渲染同一个词。
+ const severeFilter = screen
+ .getByRole('radio', { name: /严重/ })
+ .closest('.ant-segmented-item') as HTMLElement;
+ await user.click(severeFilter);
await waitFor(() => {
expect(screen.getByText('Mixed-case error')).toBeInTheDocument();
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index cd40b629b..373e88a46 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -40,6 +40,7 @@ import {
} from 'antd';
import type { ColumnsType, TableRowSelection } from 'antd/es/table/interface';
import PageHeader from '../../components/PageHeader';
+import TagList from '../../components/TagList';
import { useLang } from '../../i18n/LangContext';
import type {
AlertRule,
@@ -675,15 +676,16 @@ const AlertsPageContent = ({ domain = 'CLUSTER' }:
AlertsPageProps) => {
},
{
title: t('alerts.channels'),
- width: 130,
+ // DingTalk + Email + SMS 三个标签并排实测需 201px(TagList 已把标签自带的 8px margin 归零)
+ width: 210,
render: (_, record) => (
- <Flex gap={4} wrap="wrap">
- {(record.channels ?? []).map((ch) => (
- <Tag key={ch} color={channelColors[ch]}>
- {channelLabels[ch]}
- </Tag>
- ))}
- </Flex>
+ <TagList
+ items={(record.channels ?? []).map((ch) => ({
+ key: ch,
+ label: channelLabels[ch],
+ color: channelColors[ch],
+ }))}
+ />
),
},
{
diff --git a/web/src/pages/ops/audit.tsx b/web/src/pages/ops/audit.tsx
index 498e70402..01b027618 100644
--- a/web/src/pages/ops/audit.tsx
+++ b/web/src/pages/ops/audit.tsx
@@ -29,7 +29,6 @@ import {
InputNumber,
Typography,
message,
- Space,
Tooltip,
} from 'antd';
import { Trash } from '@phosphor-icons/react';
@@ -38,6 +37,7 @@ import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import PageHeader from '../../components/PageHeader';
+import TagList from '../../components/TagList';
import { useLang } from '../../i18n/LangContext';
import type { AuditFilter, AuditFilterOptions, AuditSummary } from
'../../api/audit';
import type { AuditRecord } from '../../api/ops';
@@ -280,13 +280,14 @@ const AuditPage: React.FC = () => {
);
}
return (
- <Space size={[4, 4]} wrap>
- {tokens.map((token) => (
- <Tag key={`${token.label}:${token.value}`} style={{ marginInlineEnd:
0 }}>
- {token.label}: {token.value}
- </Tag>
- ))}
- </Space>
+ <TagList
+ items={tokens.map((token) => ({
+ key: `${token.label}:${token.value}`,
+ label: `${token.label}: ${token.value}`,
+ }))}
+ max={3}
+ tagMaxWidth={190}
+ />
);
};
@@ -401,6 +402,14 @@ const AuditPage: React.FC = () => {
{
title: t('audit.detail'),
dataIndex: 'detail',
+ // 三个 token 的自然宽实测 209 + 123 + 104(量 scrollWidth,不能量
getBoundingClientRect:
+ // 后者在已被 flex-shrink 压窄的单元格里量到的是收缩后的值,拿它算列宽会循环论证)。
+ // 必须用 `width` 不能用 `minWidth`:antd 只把 `width` 落成 <col>,`minWidth` 仅参与
+ // tableScrollX 求和;table-layout:fixed 下没有 width 的列只会和「失败原因」平分余量
+ // (实测各 290),配合 TagList 的 nowrap 会把标签压窄到逐个带省略号。
+ // 450 = 190(tagMaxWidth 上限) + 123 + 104 + gap 8 + 单元格内边距 16,留 9px 余量;
+ // 全表合计 1640,1920 视口下容器 1650,不会引出横向滚动条。
+ width: 450,
ellipsis: true,
render: renderDetail,
},
diff --git a/web/src/pages/ops/systemAlerts.tsx
b/web/src/pages/ops/systemAlerts.tsx
index 8a01dd885..2347178d3 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -31,6 +31,7 @@ import {
Form,
Input,
Popconfirm,
+ Segmented,
} from 'antd';
import { CheckCircle, DownloadSimple, Trash } from '@phosphor-icons/react';
import PageHeader from '../../components/PageHeader';
@@ -509,35 +510,38 @@ const SystemAlertsPage = () => {
}
/>
+ {/* 筛选条:全部控件统一默认尺寸(32px 高),宽度按内容分三档,不再混用 size="small"
+ —— 原来 4 个级别按钮 + 3 个 Select + 4 个 Input 高矮不一(datetime-local 在 small
+ 下压不到 24px、实测 26px),宽度也是 132/150/190/124 各写各的。 */}
<Flex gap={8} wrap align="center" style={{ marginBottom: 16 }}>
- {['all', 'error', 'warning', 'info'].map((level) => (
- <Button
- key={level}
- type={levelFilter === level ? 'primary' : 'default'}
- size="small"
- onClick={() => {
- setLevelFilter(level);
- setPage(1);
- }}
- >
- {level === 'all' ? t('common.all') :
alertLevelConfig[level]?.label}
- {level !== 'all' && (
- <Badge
- count={alerts.filter((a) => normalizeAlertLevel(a.level) ===
level).length}
- style={{
- marginLeft: 4,
- backgroundColor:
- level === 'error' ? '#ff4d4f' : level === 'warning' ?
'#fa8c16' : '#1677ff',
- }}
- size="small"
- />
- )}
- </Button>
- ))}
+ <Segmented
+ value={levelFilter}
+ onChange={(value) => {
+ setLevelFilter(String(value));
+ setPage(1);
+ }}
+ options={['all', 'error', 'warning', 'info'].map((level) => ({
+ value: level,
+ label: (
+ <span style={{ display: 'inline-flex', alignItems: 'center',
gap: 4 }}>
+ {level === 'all' ? t('common.all') :
alertLevelConfig[level]?.label}
+ {level !== 'all' && (
+ <Badge
+ count={alerts.filter((a) => normalizeAlertLevel(a.level)
=== level).length}
+ style={{
+ backgroundColor:
+ level === 'error' ? '#ff4d4f' : level === 'warning' ?
'#fa8c16' : '#1677ff',
+ }}
+ size="small"
+ />
+ )}
+ </span>
+ ),
+ }))}
+ />
<Select
value={domainFilter}
- size="small"
- style={{ minWidth: 132 }}
+ style={{ minWidth: 140 }}
onChange={(value) => {
setDomainFilter(value);
setPage(1);
@@ -550,9 +554,8 @@ const SystemAlertsPage = () => {
/>
<Input
aria-label={t('sysAlerts.instanceFilter')}
- size="small"
placeholder={t('sysAlerts.instanceId')}
- style={{ width: 150, flex: 'none' }}
+ style={{ width: 180, flex: 'none' }}
value={instanceFilter}
onChange={(event) => {
setInstanceFilter(event.target.value);
@@ -561,9 +564,8 @@ const SystemAlertsPage = () => {
/>
<Input
aria-label={t('sysAlerts.labelsFilter')}
- size="small"
placeholder={t('sysAlerts.labelsPlaceholder')}
- style={{ width: 190, flex: 'none' }}
+ style={{ width: 180, flex: 'none' }}
value={labelFilter}
onChange={(event) => {
setLabelFilter(event.target.value);
@@ -573,8 +575,7 @@ const SystemAlertsPage = () => {
<Input
aria-label={t('sysAlerts.startTimeFilter')}
type="datetime-local"
- size="small"
- style={{ width: 190, flex: 'none' }}
+ style={{ width: 210, height: 32, flex: 'none' }}
value={fromFilter}
onChange={(event) => {
setFromFilter(event.target.value);
@@ -584,8 +585,7 @@ const SystemAlertsPage = () => {
<Input
aria-label={t('sysAlerts.endTimeFilter')}
type="datetime-local"
- size="small"
- style={{ width: 190, flex: 'none' }}
+ style={{ width: 210, height: 32, flex: 'none' }}
value={toFilter}
onChange={(event) => {
setToFilter(event.target.value);
@@ -594,8 +594,7 @@ const SystemAlertsPage = () => {
/>
<Select
value={transitionFilter}
- size="small"
- style={{ minWidth: 124 }}
+ style={{ minWidth: 140 }}
onChange={(value) => {
setTransitionFilter(value);
setPage(1);
@@ -609,8 +608,7 @@ const SystemAlertsPage = () => {
<Select
aria-label={t('sysAlerts.suppressionFilter')}
value={suppressionFilter}
- size="small"
- style={{ minWidth: 132 }}
+ style={{ minWidth: 160 }}
onChange={(value) => {
setSuppressionFilter(value);
setPage(1);
@@ -622,7 +620,7 @@ const SystemAlertsPage = () => {
]}
/>
{collectorStatus && (
- <Tag color="success" style={{ marginInlineEnd: 0 }}>
+ <Tag color="success" style={{ marginInlineEnd: 0, fontSize: 14 }}>
{t('sysAlerts.nativeCollectionEnabled')}
</Tag>
)}