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 1f94ba031 fix(topic): stop misreporting load failures, stale selection
and sync scope (#5442)
1f94ba031 is described below
commit 1f94ba0311293b3cfbf7abc286b6c9c8cfa9d0c1
Author: 烤化の初雪 <[email protected]>
AuthorDate: Fri Oct 9 16:27:08 2026 +0800
fix(topic): stop misreporting load failures, stale selection and sync scope
(#5442)
Three defects on the topic page, all in `topic.tsx` and its test file.
- The detail dialog presented a failed load as "the broker has no route":
`openDetail` ran the
consumer-page and route queries in one try block, so either failure
skipped the other and the empty
state claimed a routing verdict. They are separate now, and a load
failure renders as a failure
with a retry. (#5442)
- Deleting a topic from its row never pruned `selectedRowKeys`, so a
deleted topic stayed selected
and the batch bar kept offering actions on it. (#5443)
- The sync dialog's empty state claimed every topic has a route while
quoting the number of topics
iterated. It now tracks how many lookups actually succeeded and how many
failed, and a failed
lookup renders a persistent error with a retry instead of the healthy
verdict - a transient toast
is long gone by the time the user reads the modal. (#5444)
`TopicPage.test.tsx` 41 tests green; prettier and eslint clean.
Folded in #5443 and #5444.
---
.../pages/instance/__tests__/TopicPage.test.tsx | 152 ++++++++++++++++++++-
web/src/pages/instance/topic.tsx | 109 +++++++++++++--
2 files changed, 245 insertions(+), 16 deletions(-)
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 30aded4d5..67645b453 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -113,6 +113,18 @@ const selectedInstance = {
gmtModified: '2026-01-01T00:00:00Z',
};
+const healthyBrokerRoute: BrokerRoute = {
+ brokerName: 'broker-a',
+ brokerAddr: '10.0.0.1:10911',
+ masterAddr: '10.0.0.1:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ readable: true,
+ writable: true,
+ replicaCount: 1,
+};
+
const renderWithProviders = (initialEntry = '/instance/topic') =>
render(
<App>
@@ -566,6 +578,57 @@ describe('TopicPage', () => {
expect(within(getTableBody()).queryByText('topic-01')).not.toBeInTheDocument();
});
+ it('still loads the routes when the consumer page fails, and does not claim
the topic has no route', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ expect(await screen.findByText('topic-01')).toBeInTheDocument();
+ topicServiceMocks.getTopicRoutes.mockResolvedValue([
+ {
+ brokerName: 'broker-a',
+ brokerAddr: '10.101.2.11:10911',
+ writeQueues: 8,
+ readQueues: 8,
+ perm: 'RW',
+ },
+ ]);
+ topicServiceMocks.getTopicConsumerPage.mockRejectedValueOnce(new
Error('blip'));
+
+ await user.click(screen.getAllByRole('button', { name: /详情/ })[0]);
+
+ // The route lookup is an independent request: a failed consumer page must
not skip it, or the
+ // modal renders the empty fallback as "the broker has no route" for a
topic that exists.
+ await waitFor(() =>
+
expect(topicServiceMocks.getTopicRoutes).toHaveBeenCalledWith('topic-01',
'instance-proxy-1'),
+ );
+ expect(screen.queryByRole('button', { name: '在 Broker 上重建'
})).not.toBeInTheDocument();
+ });
+
+ it('shows a retry instead of the rebuild action when the route request
itself failed', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ expect(await screen.findByText('topic-01')).toBeInTheDocument();
+ topicServiceMocks.getTopicRoutes.mockRejectedValueOnce(new Error('blip'));
+
+ await user.click(screen.getAllByRole('button', { name: /详情/ })[0]);
+
+ expect(await screen.findByText('路由诊断:加载失败')).toBeInTheDocument();
+ // Rebuilding an existing topic is not the answer to a failed request.
+ expect(screen.queryByRole('button', { name: '在 Broker 上重建'
})).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /重\s*试/ })).toBeInTheDocument();
+ });
+
+ it('labels an empty consumer table as a failed load instead of as no
consumers', async () => {
+ const user = userEvent.setup();
+ renderWithProviders();
+ expect(await screen.findByText('topic-01')).toBeInTheDocument();
+ topicServiceMocks.getTopicConsumerPage.mockRejectedValueOnce(new
Error('blip'));
+
+ await user.click(screen.getAllByRole('button', { name: /详情/ })[0]);
+
+ // Those rows never arrived, so an empty table must not read as "nobody
consumes this topic".
+ expect(await screen.findByText('消费者加载失败,请重试')).toBeInTheDocument();
+ });
+
it('clamps back to a valid page when the current page becomes empty after a
delete', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
instanceServiceMocks.listInstances.mockResolvedValue([
@@ -660,6 +723,39 @@ describe('TopicPage', () => {
expect(screen.getByText('共 0 个 Topic')).toBeInTheDocument();
});
+ it('drops a deleted topic from the selection so the batch delete cannot
re-submit it', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ const topic = buildTopics(1)[0];
+ let call = 0;
+ topicServiceMocks.listTopicsPage.mockImplementation(async () => {
+ call += 1;
+ return call === 1
+ ? { items: [topic], total: 1, page: 1, size: 20 }
+ : { items: [], total: 0, page: 1, size: 20 };
+ });
+ topicServiceMocks.deleteTopic.mockResolvedValue(undefined);
+ renderWithProviders();
+
+ const row = await screen.findByRole('row', { name: /topic-01/ });
+ await user.click(within(row).getByRole('checkbox'));
+ expect(screen.getByRole('button', { name: /删除 \(1\)$/
})).toBeInTheDocument();
+
+ await user.click(within(row).getByRole('button', { name: /删除/ }));
+ const dialog = (await screen.findByText(/确定要删除 Topic「topic-01」/)).closest(
+ '.ant-modal',
+ ) as HTMLElement;
+ await user.click(within(dialog).getByRole('button', { name: /删\s*除/ }));
+
+ await waitFor(() =>
+ expect(topicServiceMocks.deleteTopic).toHaveBeenCalledWith('topic-01',
'instance-proxy-1'),
+ );
+ // The row is gone, so a selection still holding its name can only fail on
the next batch
+ // delete - and that failure re-seeds the same unusable selection.
+ await waitFor(() =>
+ expect(screen.queryByRole('button', { name: /删除 \(1\)$/
})).not.toBeInTheDocument(),
+ );
+ });
+
it('keeps the selected instance when rebuilding a topic without a broker
route', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const topic = { ...buildTopics(1)[0], instanceId: 'instance-a' };
@@ -1297,7 +1393,11 @@ describe('TopicPage', () => {
resolveSecond([healthyRoute]);
await secondCheck;
});
- expect(screen.getByText(/所有 Topic 在 Broker 上均有路由/)).toBeInTheDocument();
+ // The check only looks at the rows of the current page and filter, so the
verdict must say so
+ // instead of declaring the whole inventory healthy.
+ expect(
+ screen.getByText(/当前列表中的 1 个 Topic 在 Broker 上均有路由.*只覆盖当前页与当前筛选/),
+ ).toBeInTheDocument();
await act(async () => {
resolveFirst([]);
@@ -1305,4 +1405,54 @@ describe('TopicPage', () => {
});
expect(screen.queryByText('缺失路由')).not.toBeInTheDocument();
});
+
+ it('reports a failed route check instead of claiming every topic has a
route', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ mockTopicsList(buildTopics(1));
+ topicServiceMocks.getTopicRoutes.mockRejectedValue(new Error('blip'));
+ renderWithProviders();
+
+ await screen.findByText('topic-01');
+ await user.click(await screen.findByRole('button', { name: /同步/ }));
+
+ // A lookup that threw proves nothing about that topic, so the empty state
must not fall back
+ // to the healthy verdict: nothing was verified, and the transient toast
is long gone by the
+ // time anyone reads the dialog.
+ expect(await screen.findByText('路由校验失败:1 个 Topic
未能校验')).toBeInTheDocument();
+ expect(screen.queryByText(/个 Topic 在 Broker
上均有路由/)).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /重\s*试/ })).toBeInTheDocument();
+ });
+
+ it('counts only the topics whose route check succeeded', async () => {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ mockTopicsList(buildTopics(2));
+ topicServiceMocks.getTopicRoutes.mockImplementation(async (name: string)
=> {
+ if (name === 'topic-02') throw new Error('blip');
+ return [healthyBrokerRoute];
+ });
+ renderWithProviders();
+
+ await screen.findByText('topic-01');
+ await user.click(await screen.findByRole('button', { name: /同步/ }));
+
+ expect(await screen.findByText('路由校验失败:1 个 Topic
未能校验')).toBeInTheDocument();
+ expect(screen.queryByText(/2 个 Topic 在 Broker
上均有路由/)).not.toBeInTheDocument();
+ });
+
+ it('scopes the sync verdict to the rows the list actually shows', async ()
=> {
+ const user = userEvent.setup({ pointerEventsCheck: 0 });
+ // Rows belonging to another instance are still in state while an instance
switch is in
+ // flight, and the table hides them; the verdict must not count topics
nobody can see.
+ mockTopicsList([buildTopics(1)[0], { ...buildTopics(2)[1], instanceId:
'instance-other' }]);
+ topicServiceMocks.getTopicRoutes.mockResolvedValue([healthyBrokerRoute]);
+ renderWithProviders();
+
+ await screen.findByText('topic-01');
+ expect(screen.queryByText('topic-02')).not.toBeInTheDocument();
+ await user.click(await screen.findByRole('button', { name: /同步/ }));
+
+ expect(
+ await screen.findByText(/当前列表中的 1 个 Topic 在 Broker 上均有路由/),
+ ).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index 4ff97afb9..cb62bbbe4 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -356,6 +356,15 @@ const TopicPageContent = ({
const [loading, setLoading] = useState(true);
const [routesByTopic, setRoutesByTopic] = useState<Record<string,
BrokerRoute[]>>({});
const [consumersByTopic, setConsumersByTopic] = useState<Record<string,
TopicConsumerPage>>({});
+ // A failed detail request leaves the per-topic maps empty, which is not the
same as "the broker
+ // has no route" or "nobody consumes this": without these flags the route
diagnosis renders the
+ // empty fallback as the critical "Broker 上没有 Topic 路由" verdict and offers
the rebuild action
+ // for a topic that exists. Kept per request kind so a route failure cannot
mislabel the consumer
+ // table, and vice versa.
+ const [routeLoadFailedTopics, setRouteLoadFailedTopics] =
useState<Record<string, boolean>>({});
+ const [consumerLoadFailedTopics, setConsumerLoadFailedTopics] =
useState<Record<string, boolean>>(
+ {},
+ );
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [searchText, setSearchText] = useState('');
const [typeFilter, setTypeFilter] = useState('');
@@ -367,6 +376,8 @@ const TopicPageContent = ({
const [syncModalOpen, setSyncModalOpen] = useState(false);
const [syncChecking, setSyncChecking] = useState(false);
const [syncMissing, setSyncMissing] = useState<Topic[]>([]);
+ const [syncCheckedCount, setSyncCheckedCount] = useState(0);
+ const [syncFailedCount, setSyncFailedCount] = useState(0);
const [syncedTopics, setSyncedTopics] = useState<Set<string>>(() => new
Set());
const [syncingKeys, setSyncingKeys] = useState<Set<string>>(() => new Set());
const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null);
@@ -527,20 +538,37 @@ const TopicPageContent = ({
setSelectedTopic(topic);
setDetailModalOpen(true);
setDetailLoading(true);
+ setRouteLoadFailedTopics((previous) => ({ ...previous, [topic.name]:
false }));
+ setConsumerLoadFailedTopics((previous) => ({ ...previous, [topic.name]:
false }));
+ // Load the two independently: a failing consumer page must not skip the
route lookup, or the
+ // modal renders "no route" for a topic whose routes were never
requested.
+ let detailFailed = false;
try {
await loadTopicConsumers(topic);
- if (requestId !== detailRequestIdRef.current) return;
- if (!isCloudInstance) {
+ } catch {
+ detailFailed = true;
+ if (requestId === detailRequestIdRef.current) {
+ setConsumerLoadFailedTopics((previous) => ({ ...previous,
[topic.name]: true }));
+ }
+ }
+ if (requestId !== detailRequestIdRef.current) return;
+ if (!isCloudInstance) {
+ try {
const routes = await getTopicRoutes(topic.name, selectedInstanceId
|| undefined);
if (requestId !== detailRequestIdRef.current) return;
setRoutesByTopic((previous) => ({ ...previous, [topic.name]: routes
}));
+ } catch {
+ detailFailed = true;
+ if (requestId === detailRequestIdRef.current) {
+ setRouteLoadFailedTopics((previous) => ({ ...previous,
[topic.name]: true }));
+ }
}
- } catch {
- if (requestId === detailRequestIdRef.current)
- message.error('Topic 详情加载失败,请稍后重试');
- } finally {
- if (requestId === detailRequestIdRef.current) setDetailLoading(false);
}
+ if (requestId !== detailRequestIdRef.current) return;
+ if (detailFailed) {
+ message.error('Topic 详情加载失败,请稍后重试');
+ }
+ setDetailLoading(false);
},
[loadTopicConsumers, isCloudInstance, selectedInstanceId],
);
@@ -588,10 +616,14 @@ const TopicPageContent = ({
setSyncModalOpen(true);
setSyncChecking(true);
setSyncMissing([]);
+ setSyncCheckedCount(0);
+ setSyncFailedCount(0);
setSyncedTopics(new Set());
try {
+ // Scan the rendered list rather than the raw page: the verdict below
quotes a count, and the
+ // table hides rows the client-side filter dropped, so both must cover
the same set.
const results = await Promise.all(
- topics.map(async (topic) => {
+ filteredTopics.map(async (topic) => {
const instanceId = topic.instanceId || selectedInstanceId ||
undefined;
try {
return { topic, routes: await getTopicRoutes(topic.name,
instanceId) };
@@ -602,6 +634,10 @@ const TopicPageContent = ({
);
if (syncRequestIdRef.current !== requestId) return;
const checked = results.filter((r) => r.routes !== null);
+ // A failed lookup proves nothing about that topic, so the counts are
what the empty state has
+ // to quote - iterating the list would report topics as verified that
were never resolved.
+ setSyncCheckedCount(checked.length);
+ setSyncFailedCount(results.length - checked.length);
if (checked.length < results.length) {
message.error('部分 Topic 路由校验失败,请稍后重试');
}
@@ -620,6 +656,22 @@ const TopicPageContent = ({
}
};
+ const renderSyncFailureAlert = () =>
+ syncFailedCount === 0 ? null : (
+ <Alert
+ type="error"
+ showIcon
+ style={{ marginBottom: 12 }}
+ message={`路由校验失败:${syncFailedCount} 个 Topic 未能校验`}
+ description="这些 Topic 无法判断是否缺失路由,请重试后再确认是否需要同步。"
+ action={
+ <Button size="small" onClick={() => void openSyncModal()}>
+ 重试
+ </Button>
+ }
+ />
+ );
+
const syncTopicToBroker = async (topic: Topic) => {
const instanceId = topic.instanceId || selectedInstanceId || undefined;
setSyncingKeys((previous) => new Set(previous).add(topic.name));
@@ -668,6 +720,10 @@ const TopicPageContent = ({
onOk: async () => {
try {
await deleteTopic(topic.name, selectedInstanceId || undefined);
+ // Drop the deleted row from the selection: a checked row that
disappears would
+ // otherwise keep the batch delete armed with a name that no
longer exists, and its
+ // failure path re-seeds that same selection - leaving nothing to
uncheck.
+ setSelectedRowKeys((previous) => previous.filter((key) => key !==
topic.name));
await reloadTopicPage();
message.success(`Topic「${topic.name}」已删除`);
} catch {
@@ -1060,6 +1116,7 @@ const TopicPageContent = ({
const routes = getRoutes(topic.name);
const diagnostics = analyzeTopicRoutes(routes);
const summary = diagnostics.summary;
+ const routeLoadFailed = routeLoadFailedTopics[topic.name] === true;
return (
<>
@@ -1069,16 +1126,22 @@ const TopicPageContent = ({
{!detailLoading && (
<Space direction="vertical" size={12} style={{ width: '100%',
marginBottom: 12 }}>
<Alert
- type={diagnostics.statusColor}
+ type={routeLoadFailed ? 'error' : diagnostics.statusColor}
showIcon
- message={`路由诊断:${diagnostics.statusText}`}
+ message={`路由诊断:${routeLoadFailed ? '加载失败' :
diagnostics.statusText}`}
description={
- diagnostics.status === 'healthy'
- ? `共 ${summary.brokerCount} 个 Broker,写队列
${summary.totalWriteQueues} 个,读队列 ${summary.totalReadQueues} 个。`
- : `发现 ${diagnostics.issues.length} 个诊断项,优先处理异常标记的 Broker。`
+ routeLoadFailed
+ ? '路由信息获取失败,下面的结论与重建操作暂不可用,请重试。'
+ : diagnostics.status === 'healthy'
+ ? `共 ${summary.brokerCount} 个 Broker,写队列
${summary.totalWriteQueues} 个,读队列 ${summary.totalReadQueues} 个。`
+ : `发现 ${diagnostics.issues.length} 个诊断项,优先处理异常标记的 Broker。`
}
action={
- routes.length === 0 ? (
+ routeLoadFailed ? (
+ <Button size="small" onClick={() => void openDetail(topic)}>
+ 重试
+ </Button>
+ ) : routes.length === 0 ? (
<Button
size="small"
type="primary"
@@ -1737,6 +1800,13 @@ const TopicPageContent = ({
columns={consumerColumns}
dataSource={getConsumerPage(selectedTopic.name).items}
rowKey="group"
+ // An empty table is only "nobody consumes this" after a load
that succeeded.
+ locale={{
+ emptyText:
+ consumerLoadFailedTopics[selectedTopic.name] === true
+ ? '消费者加载失败,请重试'
+ : undefined,
+ }}
pagination={{
current: getConsumerPage(selectedTopic.name).page,
pageSize: getConsumerPage(selectedTopic.name).pageSize,
@@ -2065,10 +2135,19 @@ const TopicPageContent = ({
</Flex>
) : syncMissing.length === 0 ? (
<div style={{ padding: '16px 0' }}>
- <Text type="secondary">所有 Topic 在 Broker 上均有路由,无需同步。</Text>
+ {syncFailedCount > 0 ? (
+ renderSyncFailureAlert()
+ ) : (
+ <Text type="secondary">
+ 当前列表中的 {syncCheckedCount} 个 Topic 在 Broker
+ 上均有路由,无需同步(本次校验只覆盖当前页与当前筛选,翻页或清除筛选可校验其他
+ Topic)。
+ </Text>
+ )}
</div>
) : (
<>
+ {renderSyncFailureAlert()}
<Text type="secondary" style={{ display: 'block', marginBottom: 12
}}>
以下 {syncMissing.length} 个 Topic 在 Broker 上找不到路由,可同步写入对应集群的
Broker(按元数据记录的队列数重建)。