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 3f018462 feat(alerts): add server-side bulk rule actions (#1920)
3f018462 is described below
commit 3f0184621b6df2565c276a698357bb8508eb14fc
Author: btlqql <[email protected]>
AuthorDate: Thu Aug 13 19:09:50 2026 +0800
feat(alerts): add server-side bulk rule actions (#1920)
Co-authored-by: Yue Wang <[email protected]>
---
.../studio/ops/alert/AlertRuleBulkResultVO.java | 31 +++++++++
.../studio/ops/alert/AlertRuleController.java | 12 ++++
.../rocketmq/studio/ops/alert/AlertService.java | 65 ++++++++++++++++++
.../studio/ops/alert/BulkDeleteAlertRulesDTO.java | 31 +++++++++
.../studio/ops/alert/BulkToggleAlertRulesDTO.java | 34 ++++++++++
.../studio/ops/alert/AlertRuleControllerTest.java | 29 ++++++++
.../studio/ops/alert/AlertServiceTest.java | 34 ++++++++++
web/src/api/ops.test.ts | 14 ++++
web/src/api/ops.ts | 19 ++++++
web/src/i18n/translations.ts | 10 +++
web/src/pages/ops/__tests__/AlertsPage.test.tsx | 79 +++++++++++++++++-----
web/src/pages/ops/alerts.tsx | 69 +++++++++++++------
web/src/services/opsService.ts | 46 ++++++++++++-
13 files changed, 437 insertions(+), 36 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleBulkResultVO.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleBulkResultVO.java
new file mode 100644
index 00000000..715e640d
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleBulkResultVO.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+@Data
+@Builder
+public class AlertRuleBulkResultVO {
+ private List<String> succeededIds;
+ private Map<String, String> failures;
+ private List<AlertRuleVO> updatedRules;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
index 1d4c9df0..028077e5 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertRuleController.java
@@ -70,6 +70,18 @@ public class AlertRuleController {
return Result.ok();
}
+ @PostMapping("/bulk-toggle")
+ public Result<AlertRuleBulkResultVO> bulkToggle(
+ @Valid @RequestBody BulkToggleAlertRulesDTO request) {
+ return Result.ok(alertService.bulkToggleRules(request.getIds(),
request.getEnabled()));
+ }
+
+ @PostMapping("/bulk-delete")
+ public Result<AlertRuleBulkResultVO> bulkDelete(
+ @Valid @RequestBody BulkDeleteAlertRulesDTO request) {
+ return Result.ok(alertService.bulkDeleteRules(request.getIds()));
+ }
+
private AlertRuleRequestDTO requireAlertRule(AlertRuleRequestDTO rule) {
if (rule == null) {
throw new BusinessException(400, "Alert rule request is required");
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
index 175dd72c..833531ca 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/AlertService.java
@@ -143,6 +143,71 @@ public class AlertService {
recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", id, null, null);
}
+ public AlertRuleBulkResultVO bulkToggleRules(List<String> ids, boolean
enabled) {
+ List<String> normalizedIds = normalizeBulkIds(ids);
+ Map<String, AlertRuleVO> rulesById = new LinkedHashMap<>();
+ for (AlertRuleVO rule : alertRepository.findAllRules()) {
+ rulesById.put(rule.getId(), rule);
+ }
+ List<String> succeeded = new ArrayList<>();
+ Map<String, String> failures = new LinkedHashMap<>();
+ List<AlertRuleVO> updated = new ArrayList<>();
+ for (String id : normalizedIds) {
+ AlertRuleVO rule = rulesById.get(id);
+ if (rule == null) {
+ failures.put(id, "Alert rule not found");
+ continue;
+ }
+ try {
+ rule.setEnabled(enabled);
+ AlertRuleVO saved = alertRepository.saveRule(rule);
+ auditRule("TOGGLE_ALERT_RULE", saved, "enabled=" + enabled +
", bulk=true");
+ succeeded.add(id);
+ updated.add(saved);
+ } catch (RuntimeException failure) {
+ failures.put(id, failure.getMessage() == null ? "Update
failed" : failure.getMessage());
+ }
+ }
+ return AlertRuleBulkResultVO.builder()
+
.succeededIds(succeeded).failures(failures).updatedRules(updated).build();
+ }
+
+ public AlertRuleBulkResultVO bulkDeleteRules(List<String> ids) {
+ List<String> normalizedIds = normalizeBulkIds(ids);
+ List<String> succeeded = new ArrayList<>();
+ Map<String, String> failures = new LinkedHashMap<>();
+ for (String id : normalizedIds) {
+ try {
+ if (!alertRepository.deleteRule(id)) {
+ failures.put(id, "Alert rule not found");
+ continue;
+ }
+ recordAudit("DELETE_ALERT_RULE", "ALERT_RULE", id, null,
"bulk=true");
+ succeeded.add(id);
+ } catch (RuntimeException failure) {
+ failures.put(id, failure.getMessage() == null ? "Delete
failed" : failure.getMessage());
+ }
+ }
+ return AlertRuleBulkResultVO.builder()
+
.succeededIds(succeeded).failures(failures).updatedRules(List.of()).build();
+ }
+
+ private List<String> normalizeBulkIds(List<String> ids) {
+ if (ids == null || ids.isEmpty()) {
+ throw new BusinessException(400, "ids are required");
+ }
+ Set<String> seen = new HashSet<>();
+ List<String> normalized = new ArrayList<>();
+ for (String id : ids) {
+ validateRuleId(id);
+ String trimmed = id.trim();
+ if (seen.add(trimmed)) {
+ normalized.add(trimmed);
+ }
+ }
+ return normalized;
+ }
+
public List<SystemAlertVO> listAlerts(String level) {
log.info("Listing system alerts, level={}", level);
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/BulkDeleteAlertRulesDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/BulkDeleteAlertRulesDTO.java
new file mode 100644
index 00000000..1f98141f
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/BulkDeleteAlertRulesDTO.java
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class BulkDeleteAlertRulesDTO {
+ @NotEmpty(message = "ids are required")
+ @Size(max = 100, message = "at most 100 rule ids are allowed")
+ private List<@NotBlank(message = "rule id must not be blank") String> ids;
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/alert/BulkToggleAlertRulesDTO.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/BulkToggleAlertRulesDTO.java
new file mode 100644
index 00000000..f88c8ee1
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/alert/BulkToggleAlertRulesDTO.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.rocketmq.studio.ops.alert;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class BulkToggleAlertRulesDTO {
+ @NotEmpty(message = "ids are required")
+ @Size(max = 100, message = "at most 100 rule ids are allowed")
+ private List<@NotBlank(message = "rule id must not be blank") String> ids;
+ @NotNull(message = "enabled is required")
+ private Boolean enabled;
+}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
index dc5e130a..c79bcf5e 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertRuleControllerTest.java
@@ -252,4 +252,33 @@ class AlertRuleControllerTest {
verifyNoInteractions(alertService);
}
+
+ @Test
+ void bulkToggleShouldReturnPartialResults() throws Exception {
+ AlertRuleVO updated =
AlertRuleVO.builder().id("rule-1").enabled(false).build();
+ when(alertService.bulkToggleRules(List.of("rule-1", "missing"), false))
+ .thenReturn(AlertRuleBulkResultVO.builder()
+ .succeededIds(List.of("rule-1"))
+ .failures(Map.of("missing", "Alert rule not found"))
+ .updatedRules(List.of(updated)).build());
+
+ mockMvc.perform(post("/api/alert-rules/bulk-toggle")
+ .contentType(MediaType.APPLICATION_JSON)
+
.content("{\"ids\":[\"rule-1\",\"missing\"],\"enabled\":false}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.succeededIds[0]").value("rule-1"))
+ .andExpect(jsonPath("$.data.failures.missing").value("Alert
rule not found"))
+
.andExpect(jsonPath("$.data.updatedRules[0].enabled").value(false));
+ }
+
+ @Test
+ void bulkDeleteShouldRejectEmptyIds() throws Exception {
+ mockMvc.perform(post("/api/alert-rules/bulk-delete")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"ids\":[]}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.message").value("ids are required"));
+
+ verifyNoInteractions(alertService);
+ }
}
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
index 8c43b4fd..d46c8979 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/alert/AlertServiceTest.java
@@ -597,6 +597,40 @@ class AlertServiceTest {
.satisfies(ex -> assertThat(((BusinessException)
ex).getCode()).isEqualTo(404));
}
+ @Test
+ void bulkToggleShouldDeduplicateIdsAndReportMissingRules() {
+ AlertRuleVO rule = AlertRuleVO.builder().id("rule-1").name("High
CPU").enabled(false).build();
+ when(alertRepository.findAllRules()).thenReturn(List.of(rule));
+ when(alertRepository.saveRule(any(AlertRuleVO.class)))
+ .thenAnswer(invocation -> invocation.getArgument(0));
+
+ AlertRuleBulkResultVO result = alertService.bulkToggleRules(
+ List.of("rule-1", "missing", "rule-1"), true);
+
+ assertThat(result.getSucceededIds()).containsExactly("rule-1");
+ assertThat(result.getFailures()).containsEntry("missing", "Alert rule
not found");
+ assertThat(result.getUpdatedRules()).singleElement()
+ .extracting(AlertRuleVO::isEnabled).isEqualTo(true);
+ verify(alertRepository).saveRule(rule);
+ }
+
+ @Test
+ void bulkDeleteShouldPreservePartialFailureDetails() {
+ when(alertRepository.deleteRule("rule-1")).thenReturn(true);
+ when(alertRepository.deleteRule("missing")).thenReturn(false);
+ when(alertRepository.deleteRule("rule-2"))
+ .thenThrow(new IllegalStateException("database unavailable"));
+
+ AlertRuleBulkResultVO result = alertService.bulkDeleteRules(
+ List.of("rule-1", "missing", "rule-2"));
+
+ assertThat(result.getSucceededIds()).containsExactly("rule-1");
+ assertThat(result.getFailures())
+ .containsEntry("missing", "Alert rule not found")
+ .containsEntry("rule-2", "database unavailable");
+ assertThat(result.getUpdatedRules()).isEmpty();
+ }
+
@Test
void listAlertsShouldReturnAlertsForLevel() {
SystemAlertVO alert1 =
SystemAlertVO.builder().id("a1").level(AlertLevel.error)
diff --git a/web/src/api/ops.test.ts b/web/src/api/ops.test.ts
index 1491c8a6..a732e7b5 100644
--- a/web/src/api/ops.test.ts
+++ b/web/src/api/ops.test.ts
@@ -30,6 +30,8 @@ import {
updateAlertRule,
toggleAlertRule,
deleteAlertRule,
+ bulkDeleteAlertRules,
+ bulkToggleAlertRules,
listSystemAlerts,
acknowledgeAlert,
clearAcknowledgedAlerts,
@@ -182,6 +184,18 @@ describe('Ops API - Alert Rules', () => {
});
await deleteAlertRule('1');
});
+
+ it('submits bulk alert rule operations in one request', async () => {
+ const result = { succeededIds: ['1'], failures: { missing: 'not found' },
updatedRules: [] };
+ mock.onPost('/alert-rules/bulk-toggle').reply((config) => {
+ expect(JSON.parse(config.data)).toEqual({ ids: ['1', 'missing'],
enabled: false });
+ return [200, { code: 200, data: result }];
+ });
+ mock.onPost('/alert-rules/bulk-delete').reply(200, { code: 200, data:
result });
+
+ await expect(bulkToggleAlertRules(['1', 'missing'],
false)).resolves.toEqual(result);
+ await expect(bulkDeleteAlertRules(['1',
'missing'])).resolves.toEqual(result);
+ });
});
describe('Ops API - System Alerts & Audit', () => {
diff --git a/web/src/api/ops.ts b/web/src/api/ops.ts
index 8a0e4a57..811953f3 100644
--- a/web/src/api/ops.ts
+++ b/web/src/api/ops.ts
@@ -15,6 +15,12 @@ export interface AlertRule {
description: string;
}
+export interface AlertRuleBulkResult {
+ succeededIds: string[];
+ failures: Record<string, string>;
+ updatedRules: AlertRule[];
+}
+
// Matches mock/dashboard.ts systemAlerts
export interface SystemAlert {
id: string;
@@ -83,6 +89,19 @@ export async function deleteAlertRule(id: string) {
await client.post('/alert-rules/delete', { id });
}
+export async function bulkToggleAlertRules(ids: string[], enabled: boolean) {
+ const res = await client.post<{ data: AlertRuleBulkResult
}>('/alert-rules/bulk-toggle', {
+ ids,
+ enabled,
+ });
+ return res.data.data;
+}
+
+export async function bulkDeleteAlertRules(ids: string[]) {
+ const res = await client.post<{ data: AlertRuleBulkResult
}>('/alert-rules/bulk-delete', { ids });
+ return res.data.data;
+}
+
// ─── System Alerts ──────────────────────────────────────────────
export async function listSystemAlerts() {
const res = await client.get<{ data: SystemAlert[] }>('/system-alerts');
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 46104837..4427ab61 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -215,6 +215,16 @@ const translations: Record<string, Record<Lang, string>> =
{
'alerts.selectedRules': { zh: '已选择 {count} 条告警规则', en: 'Selected alert
rules: {count}' },
'alerts.bulkEnable': { zh: '批量启用', en: 'Enable Selected' },
'alerts.bulkDisable': { zh: '批量禁用', en: 'Disable Selected' },
+ 'alerts.bulkDelete': { zh: '批量删除', en: 'Delete Selected' },
+ 'alerts.bulkDeleteConfirm': {
+ zh: '确认删除选中的 {count} 条规则?',
+ en: 'Delete {count} selected rules?',
+ },
+ 'alerts.bulkDeleteSuccess': { zh: '所选告警规则已删除', en: 'Selected alert rules
deleted' },
+ 'alerts.bulkDeletePartial': {
+ zh: '删除完成:成功 {success},失败 {failed}',
+ en: 'Delete completed: {success} succeeded, {failed} failed',
+ },
'alerts.bulkEnableSuccess': {
zh: '已启用 {count} 条告警规则',
en: 'Enabled {count} alert rules',
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
index c463f38f..bf5d7fd7 100644
--- a/web/src/pages/ops/__tests__/AlertsPage.test.tsx
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -22,13 +22,20 @@ import { App } from 'antd';
import type { AlertRule } from '../../../api/ops';
import { LangProvider } from '../../../i18n/LangContext';
import AlertsPage from '../alerts';
-import { listAlertRules, toggleAlertRule } from '../../../services/opsService';
+import {
+ bulkDeleteAlertRules,
+ bulkToggleAlertRules,
+ listAlertRules,
+ toggleAlertRule,
+} from '../../../services/opsService';
vi.mock('../../../services/opsService', () => ({
createAlertRule: vi.fn(),
deleteAlertRule: vi.fn(),
listAlertRules: vi.fn(),
toggleAlertRule: vi.fn(),
+ bulkToggleAlertRules: vi.fn(),
+ bulkDeleteAlertRules: vi.fn(),
updateAlertRule: vi.fn(),
}));
@@ -109,6 +116,19 @@ describe('AlertsPage', () => {
if (!rule) throw new Error(`Rule not found: ${id}`);
return { ...cloneRule(rule), enabled };
});
+ vi.mocked(bulkToggleAlertRules).mockImplementation(async (ids, enabled) =>
({
+ succeededIds: ids,
+ failures: {},
+ updatedRules: ids.map((id) => ({
+ ...cloneRule(alertRules.find((item) => item.id === id)!),
+ enabled,
+ })),
+ }));
+ vi.mocked(bulkDeleteAlertRules).mockResolvedValue({
+ succeededIds: [],
+ failures: {},
+ updatedRules: [],
+ });
});
it('bulk enables selected alert rules and clears the selection after
success', async () => {
@@ -122,10 +142,9 @@ describe('AlertsPage', () => {
await user.click(screen.getByRole('button', { name: '批量启用' }));
await waitFor(() => {
- expect(toggleAlertRule).toHaveBeenCalledTimes(2);
+ expect(bulkToggleAlertRules).toHaveBeenCalledTimes(1);
});
- expect(toggleAlertRule).toHaveBeenCalledWith('alert-a', true);
- expect(toggleAlertRule).toHaveBeenCalledWith('alert-b', true);
+ expect(bulkToggleAlertRules).toHaveBeenCalledWith(['alert-a', 'alert-b'],
true);
expect(await screen.findByText('已启用 2 条告警规则')).toBeInTheDocument();
expect(within(getRuleRow('Broker disk
usage')).getByRole('switch')).toHaveAttribute(
'aria-checked',
@@ -143,11 +162,10 @@ describe('AlertsPage', () => {
vi.mocked(listAlertRules).mockResolvedValue(
alertRules.map((rule) => ({ ...cloneRule(rule), enabled: true })),
);
- vi.mocked(toggleAlertRule).mockImplementation(async (id, enabled) => {
- if (id === 'alert-b') throw new Error('network error');
- const rule = alertRules.find((item) => item.id === id);
- if (!rule) throw new Error(`Rule not found: ${id}`);
- return { ...cloneRule(rule), enabled };
+ vi.mocked(bulkToggleAlertRules).mockResolvedValue({
+ succeededIds: ['alert-a'],
+ failures: { 'alert-b': 'network error' },
+ updatedRules: [{ ...cloneRule(alertRules[0]), enabled: false }],
});
const user = userEvent.setup();
@@ -160,7 +178,7 @@ describe('AlertsPage', () => {
await user.click(screen.getByRole('button', { name: '批量禁用' }));
await waitFor(() => {
- expect(toggleAlertRule).toHaveBeenCalledTimes(2);
+ expect(bulkToggleAlertRules).toHaveBeenCalledTimes(1);
});
expect(await screen.findByText('已禁用 1 条告警规则,1 条失败')).toBeInTheDocument();
expect(within(getRuleRow('Broker disk
usage')).getByRole('switch')).toHaveAttribute(
@@ -176,7 +194,7 @@ describe('AlertsPage', () => {
});
it('keeps all selected alert rules selected when the bulk action fails',
async () => {
- vi.mocked(toggleAlertRule).mockRejectedValue(new Error('network error'));
+ vi.mocked(bulkToggleAlertRules).mockRejectedValue(new Error('network
error'));
const user = userEvent.setup();
renderPage();
@@ -201,9 +219,15 @@ describe('AlertsPage', () => {
});
it('disables other alert rule mutations while a bulk action is running',
async () => {
- let resolveToggle: ((rule: AlertRule) => void) | undefined;
- vi.mocked(toggleAlertRule).mockReturnValue(
- new Promise<AlertRule>((resolve) => {
+ let resolveToggle:
+ | ((result: {
+ succeededIds: string[];
+ failures: Record<string, string>;
+ updatedRules: AlertRule[];
+ }) => void)
+ | undefined;
+ vi.mocked(bulkToggleAlertRules).mockReturnValue(
+ new Promise((resolve) => {
resolveToggle = resolve;
}),
);
@@ -216,7 +240,7 @@ describe('AlertsPage', () => {
await user.click(screen.getByRole('button', { name: '批量启用' }));
await waitFor(() => {
- expect(toggleAlertRule).toHaveBeenCalledWith('alert-a', true);
+ expect(bulkToggleAlertRules).toHaveBeenCalledWith(['alert-a'], true);
});
expect(screen.getByRole('button', { name: '新建规则' })).toBeDisabled();
expect(within(getRuleRow('Broker disk
usage')).getByRole('switch')).toBeDisabled();
@@ -227,7 +251,30 @@ describe('AlertsPage', () => {
within(getRuleRow('Broker disk usage')).getByRole('button', { name: '删除'
}),
).toBeDisabled();
- resolveToggle?.({ ...cloneRule(alertRules[0]), enabled: true });
+ resolveToggle?.({
+ succeededIds: ['alert-a'],
+ failures: {},
+ updatedRules: [{ ...cloneRule(alertRules[0]), enabled: true }],
+ });
expect(await screen.findByText('已启用 1 条告警规则')).toBeInTheDocument();
});
+
+ it('bulk deletes selected rules after confirmation', async () => {
+ vi.mocked(bulkDeleteAlertRules).mockResolvedValue({
+ succeededIds: ['alert-a', 'alert-b'],
+ failures: {},
+ updatedRules: [],
+ });
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByText('Broker disk usage');
+ await user.click(within(getRuleRow('Broker disk
usage')).getByRole('checkbox'));
+ await user.click(within(getRuleRow('Consumer lag')).getByRole('checkbox'));
+ await user.click(screen.getByRole('button', { name: '批量删除' }));
+ await user.click(await screen.findByRole('button', { name: 'OK' }));
+
+ await waitFor(() =>
expect(bulkDeleteAlertRules).toHaveBeenCalledWith(['alert-a', 'alert-b']));
+ expect(screen.queryByText('Broker disk usage')).not.toBeInTheDocument();
+ expect(await screen.findByText('所选告警规则已删除')).toBeInTheDocument();
+ });
});
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index b47a99ff..d43d3a50 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -40,6 +40,8 @@ import { useLang } from '../../i18n/LangContext';
import type { AlertRule } from '../../api/ops';
import {
createAlertRule,
+ bulkDeleteAlertRules,
+ bulkToggleAlertRules,
deleteAlertRule,
listAlertRules,
toggleAlertRule,
@@ -69,7 +71,7 @@ const AlertsPage = () => {
const [submitting, setSubmitting] = useState(false);
const [actionId, setActionId] = useState<string | null>(null);
const [selectedRuleIds, setSelectedRuleIds] = useState<Key[]>([]);
- const [bulkAction, setBulkAction] = useState<'enable' | 'disable' |
null>(null);
+ const [bulkAction, setBulkAction] = useState<'enable' | 'disable' | 'delete'
| null>(null);
const [form] = Form.useForm();
const channelLabels: Record<string, string> = {
@@ -155,24 +157,9 @@ const AlertsPage = () => {
setBulkAction(enabled ? 'enable' : 'disable');
try {
- const results = await Promise.allSettled(
- targetIds.map(async (id) => ({
- id,
- rule: await toggleAlertRule(id, enabled),
- })),
- );
-
- const updatedRules = new Map<string, AlertRule>();
- const failedIds: string[] = [];
-
- results.forEach((result, index) => {
- const id = targetIds[index];
- if (result.status === 'fulfilled') {
- updatedRules.set(result.value.id, result.value.rule);
- } else {
- failedIds.push(id);
- }
- });
+ const result = await bulkToggleAlertRules(targetIds, enabled);
+ const updatedRules = new Map(result.updatedRules.map((rule) => [rule.id,
rule]));
+ const failedIds = Object.keys(result.failures);
if (updatedRules.size > 0) {
setRules((previous) => previous.map((rule) =>
updatedRules.get(rule.id) ?? rule));
@@ -200,11 +187,46 @@ const AlertsPage = () => {
}),
);
}
+ } catch {
+ message.error(
+ t(enabled ? 'alerts.bulkEnableFailed' : 'alerts.bulkDisableFailed', {
+ count: targetIds.length,
+ }),
+ );
} finally {
setBulkAction(null);
}
};
+ const handleBulkDelete = () => {
+ const targetIds = selectedRuleIds.map(String);
+ if (targetIds.length === 0 || isActionRunning) return;
+ Modal.confirm({
+ title: t('alerts.bulkDeleteConfirm', { count: targetIds.length }),
+ okButtonProps: { danger: true },
+ onOk: async () => {
+ setBulkAction('delete');
+ try {
+ const result = await bulkDeleteAlertRules(targetIds);
+ const succeeded = new Set(result.succeededIds);
+ const failedIds = Object.keys(result.failures);
+ setRules((previous) => previous.filter((rule) =>
!succeeded.has(rule.id)));
+ setSelectedRuleIds(failedIds);
+ if (failedIds.length === 0)
message.success(t('alerts.bulkDeleteSuccess'));
+ else
+ message.warning(
+ t('alerts.bulkDeletePartial', {
+ success: result.succeededIds.length,
+ failed: failedIds.length,
+ }),
+ );
+ } finally {
+ setBulkAction(null);
+ }
+ },
+ });
+ };
+
const rowSelection: TableRowSelection<AlertRule> = {
selectedRowKeys: selectedRuleIds,
onChange: (keys) => setSelectedRuleIds(keys),
@@ -393,6 +415,15 @@ const AlertsPage = () => {
>
{t('alerts.bulkDisable')}
</Button>
+ <Button
+ danger
+ size="small"
+ disabled={!hasSelectedRules || isActionRunning}
+ loading={bulkAction === 'delete'}
+ onClick={handleBulkDelete}
+ >
+ {t('alerts.bulkDelete')}
+ </Button>
</Flex>
</Flex>
<Table<AlertRule>
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index bdd9ad6d..fbfc326d 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -2,7 +2,14 @@ import { exportAuditLogs as exportAuditLogsApi,
fetchAuditFilterOptions } from '
import type { AuditFilter, AuditFilterOptions } from '../api/audit';
import { isMockMode } from './dataMode';
import * as opsApi from '../api/ops';
-import type { AlertRule, SystemAlert, AuditQuery, AuditRecord, PageResult }
from '../api/ops';
+import type {
+ AlertRule,
+ AlertRuleBulkResult,
+ SystemAlert,
+ AuditQuery,
+ AuditRecord,
+ PageResult,
+} from '../api/ops';
import { mockAlertRules } from '../mock/alerts';
import { mockAuditRecords } from '../mock/audit';
import { systemAlerts as mockSystemAlerts } from '../mock/dashboard';
@@ -146,6 +153,43 @@ export async function deleteAlertRule(id: string):
Promise<void> {
return opsApi.deleteAlertRule(id);
}
+export async function bulkToggleAlertRules(
+ ids: string[],
+ enabled: boolean,
+): Promise<AlertRuleBulkResult> {
+ if (!isMockMode()) return opsApi.bulkToggleAlertRules(ids, enabled);
+ const succeededIds: string[] = [];
+ const failures: Record<string, string> = {};
+ const updatedRules: AlertRule[] = [];
+ for (const id of [...new Set(ids)]) {
+ const rule = alertRulesState.find((item) => item.id === id);
+ if (!rule) {
+ failures[id] = 'Alert rule not found';
+ continue;
+ }
+ rule.enabled = enabled;
+ succeededIds.push(id);
+ updatedRules.push(copyAlertRule(rule));
+ }
+ return { succeededIds, failures, updatedRules };
+}
+
+export async function bulkDeleteAlertRules(ids: string[]):
Promise<AlertRuleBulkResult> {
+ if (!isMockMode()) return opsApi.bulkDeleteAlertRules(ids);
+ const succeededIds: string[] = [];
+ const failures: Record<string, string> = {};
+ for (const id of [...new Set(ids)]) {
+ const index = alertRulesState.findIndex((item) => item.id === id);
+ if (index < 0) {
+ failures[id] = 'Alert rule not found';
+ continue;
+ }
+ alertRulesState.splice(index, 1);
+ succeededIds.push(id);
+ }
+ return { succeededIds, failures, updatedRules: [] };
+}
+
export async function listSystemAlerts(): Promise<SystemAlert[]> {
if (isMockMode()) return (mockSystemAlerts as unknown as
SystemAlert[]).map(copySystemAlert);
return opsApi.listSystemAlerts();