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

Reply via email to