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(按元数据记录的队列数重建)。

Reply via email to