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 6334f9238 fix(web): unify request guards and clear stale view state 
across pages (#4331)
6334f9238 is described below

commit 6334f9238f8a985c85020a7c1293e8052ff8a196
Author: 烤化の初雪 <[email protected]>
AuthorDate: Wed Sep 16 15:51:43 2026 +0800

    fix(web): unify request guards and clear stale view state across pages 
(#4331)
    
    Incorporates ten related front-end fixes from the same author:
    
    - #4175 render the rule last-triggered time as UTC
    - #4189 keep the selected range when the metrics instance changes
    - #4245 reload the paginated group list after creating groups
    - #4249 edit topic configuration from the topic page
    - #4254 reset the silence form when the maintenance dialog is dismissed
    - #4263 export users against the committed search term
    - #4265 re-run the custom query when the dashboard instance changes
    - #4283 reset the queue browser pull indicators when the queue list reloads
    - #4285 surface the validation reason when a silence cannot be built
    - #4322 invalidate the in-flight connection query when the topic changes
---
 web/src/components/MetricsExplorer.tsx             |  37 ++++-
 web/src/components/QueueBrowser.tsx                |   4 +
 .../components/__tests__/MetricsExplorer.test.tsx  |  63 +++++++++
 web/src/components/__tests__/QueueBrowser.test.tsx |  26 ++++
 .../pages/cluster/__tests__/ClusterPage.test.tsx   |  73 ++++++++++
 web/src/pages/cluster/index.tsx                    | 156 ++++++++++++++-------
 .../pages/instance/__tests__/ConsumerPage.test.tsx |  32 +++++
 .../pages/instance/__tests__/TopicPage.test.tsx    |  93 ++++++++++++
 web/src/pages/instance/consumer.tsx                |  21 ++-
 web/src/pages/instance/topic.tsx                   |  72 ++++++++--
 web/src/pages/ops/__tests__/AlertsPage.test.tsx    |  15 +-
 .../pages/ops/__tests__/SystemAlertsPage.test.tsx  |  66 +++++++++
 web/src/pages/ops/alerts.tsx                       |   6 +-
 web/src/pages/ops/systemAlerts.tsx                 |  20 ++-
 web/src/pages/studio/Producer.tsx                  |  16 ++-
 web/src/pages/studio/UserManagement.tsx            |   6 +-
 web/src/pages/studio/__tests__/Producer.test.tsx   |  66 +++++++++
 .../pages/studio/__tests__/UserManagement.test.tsx |  36 +++++
 18 files changed, 718 insertions(+), 90 deletions(-)

diff --git a/web/src/components/MetricsExplorer.tsx 
b/web/src/components/MetricsExplorer.tsx
index e1e7072f1..fc205f7f9 100644
--- a/web/src/components/MetricsExplorer.tsx
+++ b/web/src/components/MetricsExplorer.tsx
@@ -511,6 +511,12 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
   const dataSourceCredentialsRef = useRef<DataSourceCredentials | null>(null);
   const dataSourceNamesRef = useRef<Map<string, string>>(new Map());
   const pendingAuthReplayRef = useRef<PendingAuthReplay | null>(null);
+  // Keeps the latest range readable from the mount effect so an 
instance-switch
+  // reload uses the user's picked range instead of a stale initial value.
+  const rangeIdRef = useRef(rangeId);
+  useEffect(() => {
+    rangeIdRef.current = rangeId;
+  }, [rangeId]);
 
   const selectedProfile = useMemo(
     () => profiles.find((profile) => profile.id === profileId),
@@ -655,7 +661,12 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
         const initialProfile =
           nextProfiles.find((profile) => profile.id === storedProfileId) ?? 
nextProfiles[0];
         setProfileId(initialProfile?.id ?? '');
-        void loadAll(initialProfile, RANGE_OPTIONS[0]);
+        // The effect re-runs when the instance prop changes (via loadAll's 
runQuery
+        // dependency). Reuse the selected range there; resetting to 
RANGE_OPTIONS[0]
+        // would query 1h while the Segmented control still shows the picked 
range.
+        const selectedRange =
+          RANGE_OPTIONS.find((range) => range.value === rangeIdRef.current) ?? 
RANGE_OPTIONS[0];
+        void loadAll(initialProfile, selectedRange);
       })
       .catch(() => {
         if (!cancelled) setProfileError(true);
@@ -844,6 +855,30 @@ const MetricsExplorer = ({ instanceId }: 
MetricsExplorerProps) => {
     appliedCustomPromql,
   ]);
 
+  // The dashboard keeps this explorer mounted and only swaps the instanceId 
prop. The
+  // reload effect re-runs the profile panels via loadAll, but nothing re-runs 
the custom
+  // query panel, so it would keep showing the previous instance's chart (or 
error) under
+  // the new instance. Re-run the committed custom query on the instance 
transition; when
+  // the selected data source drops out of the new instance's scope, the 
fallback effect
+  // above already re-runs both flows, so skip to avoid duplicating its 
request.
+  const lastInstanceIdRef = useRef(instanceId);
+  useEffect(() => {
+    if (lastInstanceIdRef.current === instanceId) return;
+    lastInstanceIdRef.current = instanceId;
+    if (!appliedCustomPromql) return;
+    if (dataSourceKey && !availableDataSources.some((source) => source.key === 
dataSourceKey)) {
+      return;
+    }
+    void runCustomQuery(appliedCustomPromql, selectedRange);
+  }, [
+    instanceId,
+    appliedCustomPromql,
+    runCustomQuery,
+    selectedRange,
+    dataSourceKey,
+    availableDataSources,
+  ]);
+
   const pendingAuthMode = pendingDataSource
     ? getDataSourceAuthMode(pendingDataSource.auth)
     : 'none';
diff --git a/web/src/components/QueueBrowser.tsx 
b/web/src/components/QueueBrowser.tsx
index 4057fae22..4ebcb2ba7 100644
--- a/web/src/components/QueueBrowser.tsx
+++ b/web/src/components/QueueBrowser.tsx
@@ -87,6 +87,10 @@ export const useQueueBrowser = (instanceId?: string) => {
     setQueues([]);
     setOffsets({});
     setEntries([]);
+    // A reload invalidates every in-flight pull (their stale requestIds make 
the
+    // finally-block skip the state sync), so the pulling indicators must be 
reset here.
+    pullingRef.current.clear();
+    setPulling(new Set());
     try {
       const result = await getQueueOffsets({ instanceId, topic });
       if (requestId !== requestSeqRef.current) return;
diff --git a/web/src/components/__tests__/MetricsExplorer.test.tsx 
b/web/src/components/__tests__/MetricsExplorer.test.tsx
index dfddb4f36..2965b096d 100644
--- a/web/src/components/__tests__/MetricsExplorer.test.tsx
+++ b/web/src/components/__tests__/MetricsExplorer.test.tsx
@@ -254,6 +254,34 @@ describe('MetricsExplorer', () => {
     );
   });
 
+  it('reuses the selected range when the instance prop changes', async () => {
+    const user = userEvent.setup();
+    const view = renderWithProviders(<MetricsExplorer instanceId="instance-1" 
/>);
+
+    await screen.findByRole('img', { name: 'Message In TPS time series' });
+    await user.click(screen.getByText('6h'));
+    await waitFor(() => expect(queryMetrics).toHaveBeenCalledTimes(2));
+    expect(queryMetrics).toHaveBeenLastCalledWith(
+      expect.objectContaining({ start: 1_799_978_400, step: '2m' }),
+    );
+
+    view.rerender(
+      <App>
+        <LangProvider>
+          <MetricsExplorer instanceId="instance-2" />
+        </LangProvider>
+      </App>,
+    );
+
+    // The instance switch re-runs the profiles effect; wait for the reload 
call
+    // to actually land, then assert it reused the picked 6h window instead of
+    // silently falling back to the default 1h window.
+    await waitFor(() => expect(queryMetrics).toHaveBeenCalledTimes(3));
+    expect(queryMetrics).toHaveBeenLastCalledWith(
+      expect.objectContaining({ start: 1_799_978_400, step: '2m' }),
+    );
+  });
+
   it('renders one panel per metric in the selected profile', async () => {
     vi.mocked(listMetricProfiles).mockResolvedValue([
       {
@@ -331,6 +359,41 @@ describe('MetricsExplorer', () => {
     expect(await screen.findAllByText('cluster=prod / 
node_id=broker-a')).not.toHaveLength(0);
   });
 
+  it('re-runs the committed custom query when the dashboard instance changes', 
async () => {
+    const user = userEvent.setup();
+    const view = renderWithProviders(<MetricsExplorer instanceId="instance-11" 
/>);
+    await screen.findByRole('img', { name: 'Message In TPS time series' });
+
+    await user.type(screen.getByLabelText('自定义查询'), 
'sum(rocketmq_topic_number)');
+    await user.click(screen.getByRole('button', { name: '查询' }));
+    await waitFor(() =>
+      expect(
+        vi
+          .mocked(queryMetrics)
+          .mock.calls.filter((call) => call[0].metric === 
'sum(rocketmq_topic_number)'),
+      ).toHaveLength(1),
+    );
+
+    // The dashboard keeps the explorer mounted and only swaps the instanceId 
prop;
+    // the profile panels re-run via loadAll, so the custom panel must re-run 
too
+    // instead of keeping the previous instance's chart on screen.
+    view.rerender(
+      <App>
+        <LangProvider>
+          <MetricsExplorer instanceId="instance-22" />
+        </LangProvider>
+      </App>,
+    );
+
+    await waitFor(() =>
+      expect(
+        vi
+          .mocked(queryMetrics)
+          .mock.calls.filter((call) => call[0].metric === 
'sum(rocketmq_topic_number)'),
+      ).toHaveLength(2),
+    );
+  });
+
   it('refreshes profile panels and the custom query independently', async () 
=> {
     const user = userEvent.setup();
     renderWithProviders(<MetricsExplorer />);
diff --git a/web/src/components/__tests__/QueueBrowser.test.tsx 
b/web/src/components/__tests__/QueueBrowser.test.tsx
index 008bff0e7..79958cb21 100644
--- a/web/src/components/__tests__/QueueBrowser.test.tsx
+++ b/web/src/components/__tests__/QueueBrowser.test.tsx
@@ -215,4 +215,30 @@ describe('QueueBrowser request ownership', () => {
     expect(getQueueOffsets).toHaveBeenCalledTimes(1);
     await act(async () => queues.resolve([queue('broker-a')]));
   });
+
+  it('clears the pulling indicator when the queue list reloads mid-pull', 
async () => {
+    const pull = createDeferred<MessageRecord | null>();
+    const reloadedQueues = createDeferred<QueueOffset[]>();
+    vi.mocked(getQueueOffsets)
+      .mockResolvedValueOnce([queue('broker-a')])
+      .mockReturnValueOnce(reloadedQueues.promise);
+    vi.mocked(pullMessageAtOffset).mockReturnValue(pull.promise);
+    const user = userEvent.setup();
+    render(<QueueBrowserProbe />);
+
+    await user.click(screen.getByRole('button', { name: 'topic-a' }));
+    await user.click(screen.getByRole('button', { name: 'load' }));
+    await waitFor(() => 
expect(screen.getByLabelText('queues')).toHaveTextContent('broker-a'));
+
+    await user.click(screen.getByRole('button', { name: 'pull' }));
+    await waitFor(() => 
expect(screen.getByLabelText('pulling')).toHaveTextContent('true'));
+
+    await user.click(screen.getByRole('button', { name: 'load' }));
+    await act(async () => {
+      pull.resolve(messageRecord('message-a'));
+      reloadedQueues.resolve([queue('broker-a')]);
+    });
+
+    expect(screen.getByLabelText('pulling')).toHaveTextContent('false');
+  });
 });
diff --git a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx 
b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
index 9b0c5f49c..b779b6476 100644
--- a/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
+++ b/web/src/pages/cluster/__tests__/ClusterPage.test.tsx
@@ -23,6 +23,7 @@ import { MemoryRouter } from 'react-router-dom';
 import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 
'vitest';
 import type {
   BrokerConfigDiffResult,
+  ClusterConfigPreviewResult,
   ClusterInfo,
   ClusterProbeResult,
   NameServerConfigDiffResult,
@@ -415,6 +416,78 @@ describe('Cluster page', () => {
     expect(within(dialog).getByRole('row', { name: /写队列数/ 
})).toHaveTextContent('16');
   });
 
+  it('keeps the latest broker config preview after a superseded response 
finishes last', async () => {
+    const user = userEvent.setup();
+    const stalePreview = deferred<ClusterConfigPreviewResult>();
+    const latestPreview = deferred<ClusterConfigPreviewResult>();
+    clusterServiceMocks.previewClusterConfig
+      .mockReturnValueOnce(stalePreview.promise)
+      .mockReturnValueOnce(latestPreview.promise);
+    renderWithProviders(<ClusterPage />);
+
+    const openConfigDialog = async () => {
+      const brokerRow = await screen.findByRole('row', { name: 
/10\.101\.2\.11:10911/ });
+      fireEvent.click(within(brokerRow).getByRole('button', { name: /^配\s*置$/ 
}));
+      return screen.findByRole('dialog', { name: /配置 - rocketmq-prod/ });
+    };
+    let dialog = await openConfigDialog();
+    fireEvent.click(within(dialog).getByRole('button', { name: /预\s*览/ }));
+    await waitFor(() => 
expect(clusterServiceMocks.previewClusterConfig).toHaveBeenCalledTimes(1));
+
+    // Cancel while the first preview is still in flight, reopen and preview 
again.
+    fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i }));
+    dialog = await openConfigDialog();
+    const writeQueuesInput = within(dialog).getByLabelText('写队列数');
+    await user.clear(writeQueuesInput);
+    await user.type(writeQueuesInput, '24');
+    fireEvent.click(within(dialog).getByRole('button', { name: /预\s*览/ }));
+    await waitFor(() => 
expect(clusterServiceMocks.previewClusterConfig).toHaveBeenCalledTimes(2));
+
+    await act(async () => {
+      latestPreview.resolve({
+        cluster: buildCluster(),
+        currentConfig: buildCluster().config!,
+        proposedConfig: { ...buildCluster().config!, writeQueueNums: 24 },
+        targetBrokers: [{ name: 'rocketmq-prod-0', address: 
'10.101.2.11:10911' }],
+        brokerProperties: { defaultTopicQueueNums: '24' },
+        changes: [
+          {
+            field: 'writeQueueNums',
+            currentValue: '8',
+            proposedValue: '24',
+            brokerProperty: 'defaultTopicQueueNums',
+          },
+        ],
+        changed: true,
+      });
+      await latestPreview.promise;
+    });
+    
expect(within(dialog).getByText('defaultTopicQueueNums=24')).toBeInTheDocument();
+
+    // The stale first response must not overwrite the newer preview.
+    await act(async () => {
+      stalePreview.resolve({
+        cluster: buildCluster(),
+        currentConfig: buildCluster().config!,
+        proposedConfig: { ...buildCluster().config!, writeQueueNums: 16 },
+        targetBrokers: [{ name: 'rocketmq-prod-0', address: 
'10.101.2.11:10911' }],
+        brokerProperties: { defaultTopicQueueNums: '16' },
+        changes: [
+          {
+            field: 'writeQueueNums',
+            currentValue: '8',
+            proposedValue: '16',
+            brokerProperty: 'defaultTopicQueueNums',
+          },
+        ],
+        changed: true,
+      });
+      await stalePreview.promise;
+    });
+    
expect(within(dialog).getByText('defaultTopicQueueNums=24')).toBeInTheDocument();
+    
expect(within(dialog).queryByText('defaultTopicQueueNums=16')).not.toBeInTheDocument();
+  });
+
   it('renders per-broker daily message counters in the broker tab', async () 
=> {
     renderWithProviders(<ClusterPage />);
 
diff --git a/web/src/pages/cluster/index.tsx b/web/src/pages/cluster/index.tsx
index 5c2ba31ad..df3cba85b 100644
--- a/web/src/pages/cluster/index.tsx
+++ b/web/src/pages/cluster/index.tsx
@@ -116,6 +116,34 @@ const CONFIG_FIELD_LABEL_KEYS: Record<string, string> = {
   msgTraceTopicName: 'cluster.msgTraceTopicName',
 };
 
+type RequestGeneration = {
+  begin: () => number;
+  isCurrent: (requestId: number) => boolean;
+  invalidate: () => void;
+};
+
+const createRequestGeneration = (): RequestGeneration => {
+  let current = 0;
+  return {
+    begin: () => {
+      current += 1;
+      return current;
+    },
+    isCurrent: (requestId) => current === requestId,
+    invalidate: () => {
+      current += 1;
+    },
+  };
+};
+
+// One latest-request guard per async flow on this page: begin() when issuing a
+// request, isCurrent() before committing its response, invalidate() when the
+// owning dialog closes or the page unmounts so late responses are dropped.
+const useRequestGeneration = (): RequestGeneration => {
+  const [generation] = useState(createRequestGeneration);
+  return generation;
+};
+
 // ─── Page 
─────────────────────────────────────────────────────────────────────
 
 const ClusterPage = () => {
@@ -166,75 +194,85 @@ const ClusterPage = () => {
 
   const [registryClusters, setRegistryClusters] = useState<ClusterInfo[]>([]);
   const [registryLoading, setRegistryLoading] = useState(true);
-  const nsRegistryRequestRef = useRef(0);
-  const registryClustersRequestRef = useRef(0);
-  const k8sCertsRequestRef = useRef(0);
-  const nsConfigDiffRequestRef = useRef(0);
-  const brokerConfigDiffRequestRef = useRef(0);
-  const connectionTestRequestRef = useRef(0);
+  const nsRegistryRequest = useRequestGeneration();
+  const registryClustersRequest = useRequestGeneration();
+  const k8sCertsRequest = useRequestGeneration();
+  const nsConfigDiffRequest = useRequestGeneration();
+  const brokerConfigDiffRequest = useRequestGeneration();
+  const connectionTestRequest = useRequestGeneration();
+  const configPreviewRequest = useRequestGeneration();
 
   const loadRegistryClusters = useCallback(async () => {
-    const requestId = ++registryClustersRequestRef.current;
+    const requestId = registryClustersRequest.begin();
     void Promise.resolve().then(() => {
-      if (registryClustersRequestRef.current === requestId) 
setRegistryLoading(true);
+      if (registryClustersRequest.isCurrent(requestId)) 
setRegistryLoading(true);
     });
     try {
       const nextClusters = await listRegistryClusters();
-      if (registryClustersRequestRef.current === requestId) {
+      if (registryClustersRequest.isCurrent(requestId)) {
         setRegistryClusters(nextClusters);
       }
     } catch {
-      if (registryClustersRequestRef.current === requestId) {
+      if (registryClustersRequest.isCurrent(requestId)) {
         setRegistryClusters([]);
       }
     } finally {
-      if (registryClustersRequestRef.current === requestId) {
+      if (registryClustersRequest.isCurrent(requestId)) {
         setRegistryLoading(false);
       }
     }
-  }, []);
+  }, [registryClustersRequest]);
 
   useEffect(() => {
     void Promise.resolve().then(loadRegistryClusters);
   }, [loadRegistryClusters]);
 
   const loadNsRegistry = useCallback(async () => {
-    const requestId = ++nsRegistryRequestRef.current;
+    const requestId = nsRegistryRequest.begin();
     try {
       const entries = await listNameserverRegistry();
-      if (nsRegistryRequestRef.current === requestId) setNsRegistry(entries);
+      if (nsRegistryRequest.isCurrent(requestId)) setNsRegistry(entries);
     } catch {
-      if (nsRegistryRequestRef.current === requestId) setNsRegistry([]);
+      if (nsRegistryRequest.isCurrent(requestId)) setNsRegistry([]);
     }
-  }, []);
+  }, [nsRegistryRequest]);
 
   useEffect(() => {
     void Promise.resolve().then(loadNsRegistry);
   }, [loadNsRegistry]);
 
   useEffect(() => {
-    const requestId = ++k8sCertsRequestRef.current;
+    const requestId = k8sCertsRequest.begin();
     listK8sCerts()
       .then((certs) => {
-        if (k8sCertsRequestRef.current === requestId) {
+        if (k8sCertsRequest.isCurrent(requestId)) {
           setK8sIdOptions([...new Set(certs.map((cert) => 
cert.k8sId).filter(Boolean))]);
         }
       })
       .catch(() => {
-        if (k8sCertsRequestRef.current === requestId) setK8sIdOptions([]);
+        if (k8sCertsRequest.isCurrent(requestId)) setK8sIdOptions([]);
       });
-  }, []);
+  }, [k8sCertsRequest]);
 
   useEffect(
     () => () => {
-      nsRegistryRequestRef.current += 1;
-      registryClustersRequestRef.current += 1;
-      k8sCertsRequestRef.current += 1;
-      nsConfigDiffRequestRef.current += 1;
-      brokerConfigDiffRequestRef.current += 1;
-      connectionTestRequestRef.current += 1;
+      nsRegistryRequest.invalidate();
+      registryClustersRequest.invalidate();
+      k8sCertsRequest.invalidate();
+      nsConfigDiffRequest.invalidate();
+      brokerConfigDiffRequest.invalidate();
+      connectionTestRequest.invalidate();
+      configPreviewRequest.invalidate();
     },
-    [],
+    [
+      brokerConfigDiffRequest,
+      configPreviewRequest,
+      connectionTestRequest,
+      k8sCertsRequest,
+      nsConfigDiffRequest,
+      nsRegistryRequest,
+      registryClustersRequest,
+    ],
   );
 
   const [nsCreateModalOpen, setNsCreateModalOpen] = useState(false);
@@ -334,7 +372,7 @@ const ClusterPage = () => {
 
   const openNameServerConfigDiff = useCallback(
     async (cluster: ClusterInfo) => {
-      const requestId = ++nsConfigDiffRequestRef.current;
+      const requestId = nsConfigDiffRequest.begin();
       setNsConfigDiffState({
         open: true,
         loading: true,
@@ -343,7 +381,7 @@ const ClusterPage = () => {
       });
       try {
         const result = await getNameServerConfigDiff(cluster.id, 
selectedInstanceIdRef.current);
-        if (requestId !== nsConfigDiffRequestRef.current) return;
+        if (!nsConfigDiffRequest.isCurrent(requestId)) return;
         setNsConfigDiffState({
           open: true,
           loading: false,
@@ -351,17 +389,17 @@ const ClusterPage = () => {
           result,
         });
       } catch {
-        if (requestId !== nsConfigDiffRequestRef.current) return;
+        if (!nsConfigDiffRequest.isCurrent(requestId)) return;
         setNsConfigDiffState((current) => ({ ...current, loading: false }));
         message.error(t('cluster.nsConfigDiffFailed'));
       }
     },
-    [t],
+    [nsConfigDiffRequest, t],
   );
 
   const openBrokerConfigDiff = useCallback(
     async (cluster: ClusterInfo) => {
-      const requestId = ++brokerConfigDiffRequestRef.current;
+      const requestId = brokerConfigDiffRequest.begin();
       setBrokerConfigDiffState({
         open: true,
         loading: true,
@@ -370,7 +408,7 @@ const ClusterPage = () => {
       });
       try {
         const result = await getBrokerConfigDiff(cluster.id, 
selectedInstanceIdRef.current);
-        if (requestId !== brokerConfigDiffRequestRef.current) return;
+        if (!brokerConfigDiffRequest.isCurrent(requestId)) return;
         setBrokerConfigDiffState({
           open: true,
           loading: false,
@@ -378,17 +416,17 @@ const ClusterPage = () => {
           result,
         });
       } catch {
-        if (requestId !== brokerConfigDiffRequestRef.current) return;
+        if (!brokerConfigDiffRequest.isCurrent(requestId)) return;
         setBrokerConfigDiffState((current) => ({ ...current, loading: false 
}));
         message.error(t('cluster.brokerConfigDiffFailed'));
       }
     },
-    [t],
+    [brokerConfigDiffRequest, t],
   );
   const closeNameServerConfigDiff = useCallback(() => {
-    nsConfigDiffRequestRef.current += 1;
+    nsConfigDiffRequest.invalidate();
     setNsConfigDiffState({ open: false, loading: false, cluster: null, result: 
null });
-  }, []);
+  }, [nsConfigDiffRequest]);
 
   // ─── Connection test ──────────────────────────────────────────────────────
   const [connectModalOpen, setConnectModalOpen] = useState(false);
@@ -397,43 +435,43 @@ const ClusterPage = () => {
   const [connectForm] = Form.useForm();
 
   const openConnectModal = useCallback(() => {
-    connectionTestRequestRef.current += 1;
+    connectionTestRequest.invalidate();
     setProbeResult(null);
     setConnectTesting(false);
     setConnectModalOpen(true);
-  }, []);
+  }, [connectionTestRequest]);
 
   const closeConnectModal = useCallback(() => {
-    connectionTestRequestRef.current += 1;
+    connectionTestRequest.invalidate();
     setConnectModalOpen(false);
     setConnectTesting(false);
     setProbeResult(null);
     connectForm.resetFields();
-  }, [connectForm]);
+  }, [connectForm, connectionTestRequest]);
 
   const handleTestConnection = useCallback(async () => {
-    const requestId = ++connectionTestRequestRef.current;
+    const requestId = connectionTestRequest.begin();
     let namesrvAddr: string;
     try {
       ({ namesrvAddr } = await connectForm.validateFields());
     } catch {
       return;
     }
-    if (requestId !== connectionTestRequestRef.current) return;
+    if (!connectionTestRequest.isCurrent(requestId)) return;
     setConnectTesting(true);
     setProbeResult(null);
     try {
       const result = await testClusterConnection(namesrvAddr);
-      if (requestId !== connectionTestRequestRef.current) return;
+      if (!connectionTestRequest.isCurrent(requestId)) return;
       setProbeResult(result);
       message.success(t('cluster.testConnectionSuccess'));
     } catch {
-      if (requestId !== connectionTestRequestRef.current) return;
+      if (!connectionTestRequest.isCurrent(requestId)) return;
       message.error(t('cluster.testConnectionFailed'));
     } finally {
-      if (requestId === connectionTestRequestRef.current) 
setConnectTesting(false);
+      if (connectionTestRequest.isCurrent(requestId)) setConnectTesting(false);
     }
-  }, [connectForm, t]);
+  }, [connectForm, connectionTestRequest, t]);
 
   // ─── Cluster refresh coordinator ──────────────────────────────────────────
   const [autoRefresh, setAutoRefresh] = useState(true);
@@ -612,6 +650,7 @@ const ClusterPage = () => {
   // Broker config handler
   const handleConfigOpen = (cluster: ClusterInfo) => {
     const cfg: ClusterConfig = cluster.config ?? ({} as ClusterConfig);
+    configPreviewRequest.invalidate();
     setSelectedCluster(cluster);
     setConfigPreview(null);
     setConfigPreviewLoading(false);
@@ -644,25 +683,31 @@ const ClusterPage = () => {
   };
 
   const handleConfigPreview = async () => {
+    const requestId = configPreviewRequest.begin();
     let values: ClusterConfigFormValues;
     try {
       values = await configForm.validateFields();
     } catch {
       return;
     }
+    if (!configPreviewRequest.isCurrent(requestId)) return;
     const request = buildConfigUpdateRequest(values);
     if (!request) return;
 
     setConfigPreviewLoading(true);
     try {
       const preview = await previewClusterConfig(request);
+      if (!configPreviewRequest.isCurrent(requestId)) return;
       setConfigPreview(preview);
       message.success(t('cluster.configPreviewGenerated'));
     } catch {
+      if (!configPreviewRequest.isCurrent(requestId)) return;
       setConfigPreview(null);
       message.error(t('cluster.configPreviewFailed'));
     } finally {
-      setConfigPreviewLoading(false);
+      if (configPreviewRequest.isCurrent(requestId)) {
+        setConfigPreviewLoading(false);
+      }
     }
   };
 
@@ -1000,7 +1045,7 @@ const ClusterPage = () => {
         title={t('cluster.brokerConfigDiffTitle', { name: titleName })}
         open={open}
         onCancel={() => {
-          brokerConfigDiffRequestRef.current += 1;
+          brokerConfigDiffRequest.invalidate();
           setBrokerConfigDiffState({
             open: false,
             loading: false,
@@ -1011,7 +1056,7 @@ const ClusterPage = () => {
         footer={
           <Button
             onClick={() => {
-              brokerConfigDiffRequestRef.current += 1;
+              brokerConfigDiffRequest.invalidate();
               setBrokerConfigDiffState({
                 open: false,
                 loading: false,
@@ -1309,6 +1354,7 @@ const ClusterPage = () => {
             title={t('cluster.configTitle', { name: selectedCluster.name })}
             open={configModalOpen}
             onCancel={() => {
+              configPreviewRequest.invalidate();
               setConfigModalOpen(false);
               setConfigPreview(null);
             }}
@@ -1325,7 +1371,15 @@ const ClusterPage = () => {
                 {t('cluster.configPreview')}
               </Button>
             </Space>
-            <Form form={configForm} layout="vertical" onValuesChange={() => 
setConfigPreview(null)}>
+            <Form
+              form={configForm}
+              layout="vertical"
+              onValuesChange={() => {
+                configPreviewRequest.invalidate();
+                setConfigPreview(null);
+                setConfigPreviewLoading(false);
+              }}
+            >
               <Form.Item label={t('cluster.flushDiskType')} 
name="flushDiskType">
                 <Radio.Group>
                   <Radio value="SYNC_FLUSH">{t('cluster.syncFlush')}</Radio>
diff --git a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx 
b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
index f4b4e92fc..c05895135 100644
--- a/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
+++ b/web/src/pages/instance/__tests__/ConsumerPage.test.tsx
@@ -506,6 +506,38 @@ describe('Consumer page', () => {
     confirmSpy.mockRestore();
   });
 
+  it('reloads the paginated group list after creating a group', async () => {
+    const created = { ...group, name: 'cg-created' };
+    vi.mocked(consumerService.listConsumerGroupPage)
+      .mockResolvedValueOnce(groupPage([group]))
+      .mockResolvedValue(groupPage([created, group]));
+    const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) 
=> {
+      void config.onOk?.();
+      return { destroy: vi.fn(), update: vi.fn() } as unknown as 
ReturnType<typeof Modal.confirm>;
+    });
+    const user = userEvent.setup();
+    renderWithProviders(<ConsumerPage />);
+
+    await screen.findByText('remote-cg');
+    expect(consumerService.listConsumerGroupPage).toHaveBeenCalledTimes(1);
+
+    const createButton = screen.getByRole('button', { name: '创建 Group' });
+    await waitFor(() => expect(createButton).toBeEnabled());
+    await user.click(createButton);
+    const dialog = await screen.findByRole('dialog');
+    await user.type(within(dialog).getByLabelText('Group 名称'), 'cg-created');
+    await user.click(within(dialog).getByRole('button', { name: /创\s*建/ }));
+
+    await waitFor(() => 
expect(consumerService.createConsumerGroup).toHaveBeenCalledTimes(1));
+    await waitFor(() => 
expect(consumerService.listConsumerGroupPage).toHaveBeenCalledTimes(2));
+    expect(consumerService.listConsumerGroupPage).toHaveBeenLastCalledWith(
+      expect.objectContaining({ page: 1, pageSize: 20 }),
+    );
+    expect(await screen.findByText('cg-created')).toBeInTheDocument();
+    expect(screen.getByText('共 2 个 Group')).toBeInTheDocument();
+    confirmSpy.mockRestore();
+  });
+
   it('prefills the group search from the ?group= query parameter', async () => 
{
     renderWithProviders(<ConsumerPage />, 
'/instance/consumer?group=remote-cg');
 
diff --git a/web/src/pages/instance/__tests__/TopicPage.test.tsx 
b/web/src/pages/instance/__tests__/TopicPage.test.tsx
index 4a15c614a..5a7ebf4d5 100644
--- a/web/src/pages/instance/__tests__/TopicPage.test.tsx
+++ b/web/src/pages/instance/__tests__/TopicPage.test.tsx
@@ -38,6 +38,7 @@ const topicServiceMocks = vi.hoisted(() => ({
   listTopics: vi.fn(),
   listTopicsPage: vi.fn(),
   sendTopicMessage: vi.fn(),
+  updateTopic: vi.fn(),
 }));
 
 const instanceServiceMocks = vi.hoisted(() => ({
@@ -264,6 +265,98 @@ describe('TopicPage', () => {
     expect(within(getTableBody()).queryByText('create 
response')).not.toBeInTheDocument();
   });
 
+  it('updates topic config through the update service and reloads the page', 
async () => {
+    topicServiceMocks.updateTopic.mockResolvedValue({
+      ...buildTopics(1)[0],
+      writeQueues: 16,
+      perm: 'RO',
+      remark: 'updated remark',
+    });
+    const user = userEvent.setup();
+    renderWithProviders();
+
+    expect(await screen.findByText('topic-01')).toBeInTheDocument();
+    const row = within(getTableBody()).getByText('topic-01').closest('tr') as 
HTMLElement;
+    await user.click(within(row).getByRole('button', { name: /配\s*置/ }));
+
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getByText('编辑 Topic')).toBeInTheDocument();
+    const nameInput = within(dialog).getByLabelText('Topic 名称') as 
HTMLInputElement;
+    expect(nameInput).toBeDisabled();
+    expect(nameInput.value).toBe('topic-01');
+
+    const writeQueues = within(dialog).getByLabelText('写队列数') as 
HTMLInputElement;
+    expect(writeQueues.value).toBe('8');
+    await user.clear(writeQueues);
+    await user.type(writeQueues, '16');
+    await user.click(within(dialog).getByText('只读'));
+    await user.click(within(dialog).getByRole('button', { name: /保\s*存/ }));
+
+    await waitFor(() => 
expect(topicServiceMocks.updateTopic).toHaveBeenCalledTimes(1));
+    expect(topicServiceMocks.updateTopic).toHaveBeenCalledWith(
+      expect.objectContaining({
+        name: 'topic-01',
+        writeQueues: 16,
+        perm: 'RO',
+        instanceId: 'instance-proxy-1',
+      }),
+    );
+    await waitFor(() => 
expect(topicServiceMocks.listTopicsPage).toHaveBeenCalledTimes(2));
+    expect(await screen.findByText(/更新成功/)).toBeInTheDocument();
+  });
+
+  it('edits cloud topics without the broker-only fields', async () => {
+    instanceServiceMocks.listInstances.mockResolvedValue([
+      { ...selectedInstance, vendor: 'ALIYUN' },
+    ]);
+    topicServiceMocks.updateTopic.mockResolvedValue(buildTopics(1)[0]);
+    const user = userEvent.setup();
+    renderWithProviders();
+
+    expect(await screen.findByText('topic-01')).toBeInTheDocument();
+    const row = within(getTableBody()).getByText('topic-01').closest('tr') as 
HTMLElement;
+    await user.click(within(row).getByRole('button', { name: /配\s*置/ }));
+
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getByText('编辑 Topic')).toBeInTheDocument();
+    expect(within(dialog).queryByLabelText('写队列数')).not.toBeInTheDocument();
+    expect(within(dialog).queryByText('只读')).not.toBeInTheDocument();
+
+    const remarkBox = within(dialog).getByLabelText('备注') as HTMLInputElement;
+    await user.clear(remarkBox);
+    await user.type(remarkBox, 'aliyun remark');
+    await user.click(within(dialog).getByRole('button', { name: /保\s*存/ }));
+
+    await waitFor(() => 
expect(topicServiceMocks.updateTopic).toHaveBeenCalledTimes(1));
+    expect(topicServiceMocks.updateTopic).toHaveBeenCalledWith(
+      expect.objectContaining({
+        name: 'topic-01',
+        remark: 'aliyun remark',
+        instanceId: 'instance-proxy-1',
+      }),
+    );
+  });
+
+  it('opens a clean create dialog after a cancelled edit', async () => {
+    const user = userEvent.setup();
+    renderWithProviders();
+
+    expect(await screen.findByText('topic-01')).toBeInTheDocument();
+    const row = within(getTableBody()).getByText('topic-01').closest('tr') as 
HTMLElement;
+    await user.click(within(row).getByRole('button', { name: /配\s*置/ }));
+    const editDialog = await screen.findByRole('dialog');
+    await user.click(within(editDialog).getByRole('button', { name: /取\s*消/ 
}));
+
+    await user.click(screen.getByRole('button', { name: /创建 Topic/ }));
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getByText('创建 Topic')).toBeInTheDocument();
+    await user.type(within(dialog).getByLabelText('Topic 名称'), 'topic-zz');
+    await user.click(within(dialog).getByRole('button', { name: /创\s*建/ }));
+
+    await waitFor(() => 
expect(topicServiceMocks.createTopic).toHaveBeenCalledTimes(1));
+    expect(topicServiceMocks.updateTopic).not.toHaveBeenCalled();
+  });
+
   it('ignores duplicate Topic creates while the first request is pending', 
async () => {
     topicServiceMocks.createTopic.mockImplementation(() => new Promise(() => 
{}));
     const user = userEvent.setup();
diff --git a/web/src/pages/instance/consumer.tsx 
b/web/src/pages/instance/consumer.tsx
index 7f2a8661a..f34471d1f 100644
--- a/web/src/pages/instance/consumer.tsx
+++ b/web/src/pages/instance/consumer.tsx
@@ -370,7 +370,7 @@ const ConsumerPageContent = ({
     [t, selectedInstanceId, search],
   );
 
-  const reloadConsumerGroupPageAfterDelete = useCallback(async () => {
+  const reloadConsumerGroupPage = useCallback(async () => {
     await loadConsumerGroupPage(page, pageSize);
   }, [loadConsumerGroupPage, page, pageSize]);
 
@@ -822,10 +822,7 @@ const ConsumerPageContent = ({
     setImportRows([...nextRows]);
 
     if (createdGroups.length > 0) {
-      setGroups((previous) => {
-        const createdNames = new Set(createdGroups.map((group) => group.name));
-        return [...createdGroups, ...previous.filter((group) => 
!createdNames.has(group.name))];
-      });
+      await reloadConsumerGroupPage();
     }
 
     const failedCount = nextRows.filter((row) => row.status === 
'failed').length;
@@ -1040,7 +1037,7 @@ const ConsumerPageContent = ({
                 cancelText: '取消',
                 onOk: async () => {
                   await deleteConsumerGroup(record.name, selectedInstanceId || 
undefined);
-                  await reloadConsumerGroupPageAfterDelete();
+                  await reloadConsumerGroupPage();
                   setSelectedRowKeys((prev) => prev.filter((key) => key !== 
record.name));
                   message.success(`消费组 ${record.name} 已删除`);
                 },
@@ -1471,7 +1468,7 @@ const ConsumerPageContent = ({
                       names,
                       selectedInstanceId || undefined,
                     );
-                    if (deleted.length > 0) await 
reloadConsumerGroupPageAfterDelete();
+                    if (deleted.length > 0) await reloadConsumerGroupPage();
                     if (failed.length > 0) {
                       message.warning(
                         `已删除 ${deleted.length} 个,失败 ${failed.length} 
个:${failed.join(', ')}`,
@@ -2275,7 +2272,7 @@ const ConsumerPageContent = ({
                 onOk: async () => {
                   setSubmitting(true);
                   try {
-                    const created = await createConsumerGroup({
+                    await createConsumerGroup({
                       name: values.name,
                       subscriptionMode: values.subscriptionMode,
                       consumeType: values.consumeType,
@@ -2285,10 +2282,10 @@ const ConsumerPageContent = ({
                       subscribedTopics: [],
                       instanceId: selectedInstanceId,
                     });
-                    setGroups((prev) => [
-                      created,
-                      ...prev.filter((group) => group.name !== created.name),
-                    ]);
+                    // The list is server-paginated: refetch the current page 
so the new
+                    // group lands where the server sorts it and the total 
stays truthful,
+                    // mirroring the topic inventory behavior after create.
+                    await reloadConsumerGroupPage();
                     message.success(`消费组 ${values.name} 创建成功`);
                     setCreateModalOpen(false);
                     form.resetFields();
diff --git a/web/src/pages/instance/topic.tsx b/web/src/pages/instance/topic.tsx
index e47d7af91..d379831d7 100644
--- a/web/src/pages/instance/topic.tsx
+++ b/web/src/pages/instance/topic.tsx
@@ -48,6 +48,7 @@ import {
   SendOutlined,
   DeleteOutlined,
   EyeOutlined,
+  EditOutlined,
   ImportOutlined,
   ExportOutlined,
   SyncOutlined,
@@ -75,6 +76,7 @@ import {
   importTopics,
   listTopicsPage,
   sendTopicMessage,
+  updateTopic,
 } from '../../services/topicService';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
 import type { Instance } from '../../api/instance';
@@ -371,6 +373,7 @@ const TopicPage = () => {
   const [selectedTopic, setSelectedTopic] = useState<Topic | null>(null);
   const [modalOpen, setModalOpen] = useState(false);
   const [creating, setCreating] = useState(false);
+  const [editingTopic, setEditingTopic] = useState<Topic | null>(null);
   const [form] = Form.useForm();
   const createTopicType = Form.useWatch('type', form);
   const [sendModalOpen, setSendModalOpen] = useState(false);
@@ -629,6 +632,8 @@ const TopicPage = () => {
       void openDetail(topic);
     } else if (key === 'route') {
       void openDetail(topic);
+    } else if (key === 'config') {
+      openEditConfig(topic);
     } else if (key === 'send') {
       setSendTopic(topic);
       setPropsMode('form');
@@ -741,7 +746,7 @@ const TopicPage = () => {
     {
       title: '操作',
       key: 'action',
-      width: 200,
+      width: 280,
       render: (_: unknown, record: Topic) => (
         <Flex gap={6} onClick={(e) => e.stopPropagation()}>
           <Button
@@ -752,6 +757,14 @@ const TopicPage = () => {
           >
             详情
           </Button>
+          <Button
+            size="small"
+            icon={<EditOutlined />}
+            style={{ borderColor: '#1677ff', color: '#1677ff' }}
+            onClick={() => handleAction('config', record)}
+          >
+            配置
+          </Button>
           {!isCloudInstance && (
             <Button
               size="small"
@@ -1123,7 +1136,7 @@ const TopicPage = () => {
     );
   };
 
-  // ─── Create modal submit ──────────────────────────────────────
+  // ─── Create / edit modal submit ───────────────────────────────
   const handleCreate = async () => {
     if (createInFlightRef.current) return;
     if (!selectedInstanceId) {
@@ -1134,17 +1147,27 @@ const TopicPage = () => {
     setCreating(true);
     try {
       const values = await form.validateFields();
-      const created = await createTopic({
-        ...values,
-        instanceId: selectedInstanceId,
-      });
-      await reloadTopicPage();
-      message.success(`Topic「${created.name}」创建成功`);
+      if (editingTopic) {
+        const updated = await updateTopic({
+          ...values,
+          instanceId: selectedInstanceId,
+        });
+        await reloadTopicPage();
+        message.success(`Topic「${updated.name}」更新成功`);
+      } else {
+        const created = await createTopic({
+          ...values,
+          instanceId: selectedInstanceId,
+        });
+        await reloadTopicPage();
+        message.success(`Topic「${created.name}」创建成功`);
+      }
       setModalOpen(false);
+      setEditingTopic(null);
       form.resetFields();
     } catch (error) {
       if (!(error && typeof error === 'object' && 'errorFields' in error)) {
-        message.error('创建 Topic 失败,请稍后重试');
+        message.error(editingTopic ? '更新 Topic 失败,请稍后重试' : '创建 Topic 
失败,请稍后重试');
       }
     } finally {
       createInFlightRef.current = false;
@@ -1152,6 +1175,21 @@ const TopicPage = () => {
     }
   };
 
+  // Classic dashboard parity: the topic row's CONFIG action reuses the create
+  // dialog in update mode — name/type stay fixed, queue counts and perm 
change.
+  const openEditConfig = (topic: Topic) => {
+    setEditingTopic(topic);
+    form.setFieldsValue({
+      name: topic.name,
+      type: topic.type,
+      writeQueues: topic.writeQueues,
+      readQueues: topic.readQueues,
+      perm: topic.perm,
+      remark: topic.remark,
+    });
+    setModalOpen(true);
+  };
+
   const handleImportFile = async (file: File) => {
     if (!selectedInstanceId) {
       message.error('请先选择实例');
@@ -1571,7 +1609,11 @@ const TopicPage = () => {
             type="primary"
             icon={<PlusOutlined />}
             disabled={!hasSelectedInstance}
-            onClick={() => setModalOpen(true)}
+            onClick={() => {
+              setEditingTopic(null);
+              form.resetFields();
+              setModalOpen(true);
+            }}
           >
             创建 Topic
           </Button>
@@ -1671,17 +1713,18 @@ const TopicPage = () => {
         )}
       </Modal>
 
-      {/* ── Create Topic Modal ────────────────────────────────── */}
+      {/* ── Create / Edit Topic Modal ─────────────────────────── */}
       <Modal
-        title="创建 Topic"
+        title={editingTopic ? '编辑 Topic' : '创建 Topic'}
         open={modalOpen}
         onCancel={() => {
           setModalOpen(false);
+          setEditingTopic(null);
           form.resetFields();
         }}
         onOk={handleCreate}
         confirmLoading={creating}
-        okText="创建"
+        okText={editingTopic ? '保存' : '创建'}
         cancelText="取消"
         width={560}
         destroyOnHidden
@@ -1712,7 +1755,7 @@ const TopicPage = () => {
               },
             ]}
           >
-            <Input placeholder="请输入 Topic 名称" />
+            <Input placeholder="请输入 Topic 名称" disabled={!!editingTopic} />
           </Form.Item>
 
           <Form.Item
@@ -1722,6 +1765,7 @@ const TopicPage = () => {
             extra={TOPIC_TYPE_CARDS.find((c) => c.value === 
createTopicType)?.desc}
           >
             <Segmented
+              disabled={!!editingTopic}
               options={TOPIC_TYPE_CARDS.filter((c) => !isCloudInstance || 
c.value !== 'LITE').map(
                 ({ value, label }) => ({ value, label }),
               )}
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index afe7360ce..7307a6eea 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -22,7 +22,7 @@ import { App } from 'antd';
 import type { AlertRule, NativeAlertMetricInfo, PageResult } from 
'../../../api/ops';
 import { LangProvider } from '../../../i18n/LangContext';
 import { LANGUAGE_STORAGE_KEY } from '../../../i18n/languagePreference';
-import { formatDateTime } from '../../../utils/format';
+import { formatUtcDateTime } from '../../../utils/format';
 import AlertsPage, { formatThresholdCondition, supportsUnavailableOperator } 
from '../alerts';
 import { listInstances } from '../../../services/instanceService';
 import {
@@ -229,10 +229,21 @@ describe('AlertsPage', () => {
 
     renderPage();
 
-    expect(await 
screen.findByText(formatDateTime(lastTriggered))).toBeInTheDocument();
+    expect(await 
screen.findByText(formatUtcDateTime(lastTriggered))).toBeInTheDocument();
     expect(screen.queryByText(lastTriggered)).not.toBeInTheDocument();
   });
 
+  it('interprets the last triggered timestamp as UTC, not the browser zone', 
async () => {
+    const lastTriggered = '2026-08-23T23:30:00';
+    vi.mocked(listAlertRulesPage).mockResolvedValue(
+      pageResult([{ ...cloneRule(alertRules[0]), lastTriggered }]),
+    );
+
+    renderPage();
+
+    expect(await 
screen.findByText(formatUtcDateTime(lastTriggered))).toBeInTheDocument();
+  });
+
   it('allows the unavailable operator only for availability metrics', () => {
     expect(supportsUnavailableOperator('nameserver.availability')).toBe(true);
     expect(supportsUnavailableOperator('broker.availability')).toBe(true);
diff --git a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
index 51f3f4eef..0bab9a680 100644
--- a/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/SystemAlertsPage.test.tsx
@@ -547,6 +547,72 @@ describe('SystemAlertsPage', () => {
     });
   });
 
+  it('resets the silence form after the dialog is dismissed without creating', 
async () => {
+    vi.mocked(listAlertSilencesPage).mockResolvedValue({
+      items: [],
+      total: 0,
+      page: 1,
+      size: 10,
+    });
+    vi.mocked(createAlertSilence).mockResolvedValue({
+      id: 11,
+      domain: 'BUSINESS',
+      startsAt: '2026-08-12T01:00',
+      endsAt: '2026-08-12T02:00',
+      createdBy: 'admin',
+    });
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.click(await screen.findByRole('button', { name: '维护窗口' }));
+    await user.type(screen.getByLabelText('规则 ID'), '42');
+    await user.type(screen.getByLabelText('标签范围'), 'brokerName=broker-a');
+    fireEvent.change(screen.getByLabelText('开始时间'), { target: { value: 
'2026-08-11T01:00' } });
+    fireEvent.change(screen.getByLabelText('结束时间'), { target: { value: 
'2026-08-11T02:00' } });
+
+    await user.click(await screen.findByRole('button', { name: 'Cancel' }));
+    await user.click(await screen.findByRole('button', { name: '维护窗口' }));
+
+    expect(await screen.findByLabelText('规则 ID')).toHaveValue('');
+    expect(screen.getByLabelText('标签范围')).toHaveValue('');
+
+    fireEvent.change(screen.getByLabelText('开始时间'), { target: { value: 
'2026-08-12T01:00' } });
+    fireEvent.change(screen.getByLabelText('结束时间'), { target: { value: 
'2026-08-12T02:00' } });
+    await user.click(screen.getByRole('button', { name: /创\s*建/ }));
+
+    await waitFor(() => {
+      expect(createAlertSilence).toHaveBeenLastCalledWith(
+        expect.not.objectContaining({ ruleId: 42 }),
+      );
+    });
+    const payload = vi.mocked(createAlertSilence).mock.lastCall?.[0] as 
Record<string, unknown>;
+    expect(payload.ruleId).toBeUndefined();
+    expect(payload.labels).toBeUndefined();
+  });
+
+  it('surfaces the label validation message when the silence labels are 
invalid', async () => {
+    vi.mocked(listAlertSilencesPage).mockResolvedValue({
+      items: [],
+      total: 0,
+      page: 1,
+      size: 10,
+    });
+    const user = userEvent.setup();
+    renderPage();
+
+    await user.click(await screen.findByRole('button', { name: '维护窗口' }));
+
+    // a trailing comma leaves an empty pair and must fail validation with the
+    // dedicated message, not the generic "creation failed" toast
+    await user.type(screen.getByLabelText('标签范围'), 'brokerName=broker-a,');
+    fireEvent.change(screen.getByLabelText('开始时间'), { target: { value: 
'2026-08-11T01:00' } });
+    fireEvent.change(screen.getByLabelText('结束时间'), { target: { value: 
'2026-08-11T02:00' } });
+    await user.click(screen.getByRole('button', { name: /创\s*建/ }));
+
+    expect(await screen.findByText('标签格式应为 
key=value,并以逗号分隔')).toBeInTheDocument();
+    expect(createAlertSilence).not.toHaveBeenCalled();
+  });
+
   it('loads maintenance windows by page and backs up after deleting the last 
page item', async () => {
     vi.mocked(listAlertSilencesPage)
       .mockResolvedValueOnce({
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index c675badf0..1efb33804 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -65,7 +65,7 @@ import {
 } from '../../services/opsService';
 import { attachThresholdUnit, normalizeDuration, normalizeMetric } from 
'./alertRulePayload';
 import { tableScrollX } from '../../utils/table';
-import { formatDateTime } from '../../utils/format';
+import { formatUtcDateTime } from '../../utils/format';
 import { listInstances } from '../../services/instanceService';
 import type { Instance } from '../../api/instance';
 import { downloadBlob } from '../../utils/download';
@@ -696,7 +696,9 @@ const AlertsPage = ({ domain = 'CLUSTER' }: 
AlertsPageProps) => {
       sorter: (a, b) => (a.lastTriggered ?? '').localeCompare(b.lastTriggered 
?? ''),
       render: (_, record) =>
         record.lastTriggered ? (
-          formatDateTime(record.lastTriggered)
+          // The backend stamps lastTriggered with ZoneOffset.UTC without an 
offset suffix;
+          // parse it as UTC so the column matches the system alerts page in 
any browser TZ.
+          formatUtcDateTime(record.lastTriggered)
         ) : (
           <span style={{ color: '#999' }}>{t('alerts.neverTriggered')}</span>
         ),
diff --git a/web/src/pages/ops/systemAlerts.tsx 
b/web/src/pages/ops/systemAlerts.tsx
index 7266f2214..95b64eb81 100644
--- a/web/src/pages/ops/systemAlerts.tsx
+++ b/web/src/pages/ops/systemAlerts.tsx
@@ -399,13 +399,14 @@ const SystemAlertsPage = () => {
       return;
     }
     setSavingSilence(true);
+    let request: CreateAlertSilence;
     try {
       const recurrence = values.recurrence ?? 'ONCE';
       const convertTime = (value: string) =>
         recurrence === 'ONCE'
           ? localDateTimeToUtc(value)
           : zonedLocalDateTimeToUtc(value, values.timeZone!);
-      const request: CreateAlertSilence = {
+      request = {
         instanceId: values.instanceId,
         startsAt: convertTime(values.startsAt),
         endsAt: convertTime(values.endsAt),
@@ -421,6 +422,18 @@ const SystemAlertsPage = () => {
             ? convertTime(values.recurrenceUntil)
             : undefined,
       };
+    } catch (error) {
+      // client-side validation throws carry field-specific messages; no 
request was
+      // made, so the axios interceptor never surfaces them
+      message.error(
+        error instanceof Error && error.message
+          ? error.message
+          : t('sysAlerts.silenceCreateFailed'),
+      );
+      setSavingSilence(false);
+      return;
+    }
+    try {
       await createAlertSilence(request);
       silenceForm.resetFields();
       setSilencePage(1);
@@ -828,7 +841,10 @@ const SystemAlertsPage = () => {
       <Modal
         title={t('sysAlerts.maintenanceWindows')}
         open={silencesVisible}
-        onCancel={() => setSilencesVisible(false)}
+        onCancel={() => {
+          setSilencesVisible(false);
+          silenceForm.resetFields();
+        }}
         onOk={() => void createSilence()}
         okText={t('sysAlerts.create')}
         okButtonProps={{ style: { display: canManageSilences ? undefined : 
'none' } }}
diff --git a/web/src/pages/studio/Producer.tsx 
b/web/src/pages/studio/Producer.tsx
index edd7a0e76..0fb20a820 100644
--- a/web/src/pages/studio/Producer.tsx
+++ b/web/src/pages/studio/Producer.tsx
@@ -118,15 +118,22 @@ const ProducerPage = () => {
     };
   }, []);
 
-  const handleInstanceChange = (instanceId: string) => {
+  // Invalidates any in-flight connection query and drops its visible results,
+  // shared by every scope change (instance / topic) so a slow response can 
never
+  // repopulate results that belong to a different scope.
+  const resetConnectionQuery = () => {
     queryRequestIdRef.current += 1;
     queryInFlightRef.current = null;
-    setSelectedInstanceId(instanceId);
-    setTopicList([]);
-    setProducerGroups([]);
     setConnectionList([]);
     setConnectionSummary(null);
     setLoading(false);
+  };
+
+  const handleInstanceChange = (instanceId: string) => {
+    resetConnectionQuery();
+    setSelectedInstanceId(instanceId);
+    setTopicList([]);
+    setProducerGroups([]);
     form.setFieldsValue({ selectedTopic: undefined, producerGroup: undefined 
});
   };
 
@@ -160,6 +167,7 @@ const ProducerPage = () => {
   }, [fetchTopicFailedMessage, form, message, selectedInstanceId]);
 
   const handleTopicChange = () => {
+    resetConnectionQuery();
     producerGroupRequestIdRef.current += 1;
     setProducerGroups([]);
     form.setFieldValue('producerGroup', undefined);
diff --git a/web/src/pages/studio/UserManagement.tsx 
b/web/src/pages/studio/UserManagement.tsx
index 7201415a7..633be58f2 100644
--- a/web/src/pages/studio/UserManagement.tsx
+++ b/web/src/pages/studio/UserManagement.tsx
@@ -265,7 +265,9 @@ const UserManagementPage = () => {
     setUserExporting(true);
     try {
       const exportedUsers = await exportStudioUsers({
-        search: search.trim() || undefined,
+        // The table only ever shows the debounced (committed) search; 
exporting the live
+        // input would produce a CSV for a query the user never saw displayed.
+        search: debouncedSearch || undefined,
         admin: roleFilter === undefined ? undefined : roleFilter === 'admin',
         enabled: statusFilter === undefined ? undefined : statusFilter === 
'enabled',
       });
@@ -279,7 +281,7 @@ const UserManagementPage = () => {
       message.error('导出用户列表失败,请稍后重试');
     }
     setUserExporting(false);
-  }, [admin, roleFilter, search, statusFilter]);
+  }, [admin, debouncedSearch, roleFilter, statusFilter]);
   // Declared widths total 1116px, which stays inside the usable content width 
of a normal
   // 1440px viewport (220px Sider plus page and Card padding), so the table 
does not show a
   // horizontal scrollbar by default. Columns whose text can be longer than 
that truncate with
diff --git a/web/src/pages/studio/__tests__/Producer.test.tsx 
b/web/src/pages/studio/__tests__/Producer.test.tsx
index 31f6b7348..b15e05305 100644
--- a/web/src/pages/studio/__tests__/Producer.test.tsx
+++ b/web/src/pages/studio/__tests__/Producer.test.tsx
@@ -516,6 +516,72 @@ describe('ProducerPage', () => {
     await waitFor(() => expect(search).not.toHaveClass('ant-btn-loading'));
   });
 
+  it('discards a slow connection response after the topic changes', async () 
=> {
+    const user = userEvent.setup();
+    let resolveTopicAQuery: ((value: ProducerConnectionResult) => void) | 
undefined;
+    vi.mocked(queryProducerConnection)
+      .mockImplementationOnce(
+        () =>
+          new Promise((resolve) => {
+            resolveTopicAQuery = resolve;
+          }),
+      )
+      .mockResolvedValueOnce(
+        producerResult([
+          {
+            clientId: 'payment-producer-1',
+            clientAddr: '192.168.1.20',
+            topic: 'payment-events',
+            producerGroup: 'pg-payment',
+            language: 'JAVA',
+            versionDesc: '5.1.0',
+          },
+        ]),
+      );
+    const { container } = renderWithProviders(<ProducerPage />);
+
+    await waitFor(() => expect(fetchTopicList).toHaveBeenCalledTimes(1));
+    const [, topicSelect] = screen.getAllByRole('combobox');
+    fireEvent.mouseDown(topicSelect.parentElement!);
+    await user.click(
+      await screen.findByText('order-events', { selector: 
'.ant-select-item-option-content' }),
+    );
+    await user.click(screen.getByRole('button', { name: /搜索/ }));
+    await waitFor(() => 
expect(queryProducerConnection).toHaveBeenCalledTimes(1));
+
+    // While the order-events query is still pending, switch the topic.
+    fireEvent.mouseDown(topicSelect.parentElement!);
+    await user.click(
+      await screen.findByText('payment-events', { selector: 
'.ant-select-item-option-content' }),
+    );
+
+    resolveTopicAQuery?.(
+      producerResult([
+        {
+          clientId: 'stale-producer',
+          clientAddr: '192.168.1.10',
+          language: 'JAVA',
+          versionDesc: '5.1.0',
+        },
+      ]),
+    );
+    await waitFor(() => {
+      
expect(within(container).queryByText('stale-producer')).not.toBeInTheDocument();
+    });
+    expect(within(container).queryByText('生产者连接健康')).not.toBeInTheDocument();
+
+    // The topic switch must also release the in-flight slot so a fresh query 
runs.
+    await user.click(screen.getByRole('button', { name: /搜索/ }));
+    await waitFor(() => {
+      expect(queryProducerConnection).toHaveBeenLastCalledWith(
+        'instance-1',
+        'payment-events',
+        undefined,
+      );
+    });
+    expect(await screen.findByText('payment-producer-1')).toBeInTheDocument();
+  });
+
   it('does not discover producer groups before a topic is selected', async () 
=> {
     const user = userEvent.setup();
     renderWithProviders(<ProducerPage />);
diff --git a/web/src/pages/studio/__tests__/UserManagement.test.tsx 
b/web/src/pages/studio/__tests__/UserManagement.test.tsx
index d3743a01d..29aba00b2 100644
--- a/web/src/pages/studio/__tests__/UserManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/UserManagement.test.tsx
@@ -184,6 +184,42 @@ describe('UserManagementPage', () => {
     expect(exportedCsv).toContain('"2"');
   });
 
+  it('exports users matching the committed search, not live input', async () 
=> {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderPage();
+    await screen.findByText('operator');
+
+    await user.type(screen.getByPlaceholderText('搜索用户名'), 'alpha');
+    await waitFor(() =>
+      expect(listStudioUsers).toHaveBeenLastCalledWith(
+        expect.objectContaining({ search: 'alpha' }),
+      ),
+    );
+
+    // Refine the input and export within the same synchronous block so the 
300 ms
+    // debounce cannot commit the new term in between: the CSV must match the 
query
+    // the displayed table was loaded with, not the uncommitted live input.
+    const searchInput = screen.getByPlaceholderText('搜索用户名');
+    act(() => {
+      fireEvent.change(searchInput, { target: { value: 'alpha-beta' } });
+      fireEvent.click(screen.getByRole('button', { name: '导出' }));
+    });
+
+    await waitFor(() =>
+      expect(downloadStudioUsers).toHaveBeenCalledWith({
+        search: 'alpha',
+        admin: undefined,
+        enabled: undefined,
+      }),
+    );
+    expect(downloadStudioUsers).not.toHaveBeenCalledWith({
+      search: 'alpha-beta',
+      admin: undefined,
+      enabled: undefined,
+    });
+    expect(downloadCsv).toHaveBeenCalledTimes(1);
+  });
+
   it('renders active session metadata and revokes sessions after 
confirmation', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     renderPage();

Reply via email to