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();