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 d8b0254c feat: support bulk enabling and disabling alert rules (#747)
d8b0254c is described below

commit d8b0254c18b4d6b6f4e561089ab2e26406a89c72
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:18:15 2026 +0800

    feat: support bulk enabling and disabling alert rules (#747)
---
 web/src/i18n/translations.ts                    |  27 +++
 web/src/pages/ops/__tests__/AlertsPage.test.tsx | 233 ++++++++++++++++++++++++
 web/src/pages/ops/alerts.tsx                    | 120 +++++++++++-
 3 files changed, 377 insertions(+), 3 deletions(-)

diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 0963de9d..45161c42 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -208,6 +208,33 @@ const translations: Record<string, Record<Lang, string>> = 
{
   'alerts.lastTriggered': { zh: '最近触发', en: 'Last Triggered' },
   'alerts.neverTriggered': { zh: '从未触发', en: 'Never' },
   'alerts.ruleCreated': { zh: '规则创建成功', en: 'Rule created successfully' },
+  'alerts.selectedRules': { zh: '已选择 {count} 条告警规则', en: 'Selected alert 
rules: {count}' },
+  'alerts.bulkEnable': { zh: '批量启用', en: 'Enable Selected' },
+  'alerts.bulkDisable': { zh: '批量禁用', en: 'Disable Selected' },
+  'alerts.bulkEnableSuccess': {
+    zh: '已启用 {count} 条告警规则',
+    en: 'Enabled {count} alert rules',
+  },
+  'alerts.bulkDisableSuccess': {
+    zh: '已禁用 {count} 条告警规则',
+    en: 'Disabled {count} alert rules',
+  },
+  'alerts.bulkEnableFailed': {
+    zh: '{count} 条告警规则启用失败',
+    en: '{count} alert rules failed to enable',
+  },
+  'alerts.bulkDisableFailed': {
+    zh: '{count} 条告警规则禁用失败',
+    en: '{count} alert rules failed to disable',
+  },
+  'alerts.bulkEnablePartial': {
+    zh: '已启用 {success} 条告警规则,{failed} 条失败',
+    en: 'Enabled {success} alert rules, {failed} failed',
+  },
+  'alerts.bulkDisablePartial': {
+    zh: '已禁用 {success} 条告警规则,{failed} 条失败',
+    en: 'Disabled {success} alert rules, {failed} failed',
+  },
 
   // ─── System Alerts ───
   'sysAlerts.title': { zh: '系统告警', en: 'System Alerts' },
diff --git a/web/src/pages/ops/__tests__/AlertsPage.test.tsx 
b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
new file mode 100644
index 00000000..c463f38f
--- /dev/null
+++ b/web/src/pages/ops/__tests__/AlertsPage.test.tsx
@@ -0,0 +1,233 @@
+/*
+ * 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.
+ */
+
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+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';
+
+vi.mock('../../../services/opsService', () => ({
+  createAlertRule: vi.fn(),
+  deleteAlertRule: vi.fn(),
+  listAlertRules: vi.fn(),
+  toggleAlertRule: vi.fn(),
+  updateAlertRule: vi.fn(),
+}));
+
+const alertRules: AlertRule[] = [
+  {
+    id: 'alert-a',
+    name: 'Broker disk usage',
+    metric: '磁盘使用率',
+    operator: '>',
+    threshold: 85,
+    thresholdUnit: '%',
+    duration: '5分钟',
+    channels: ['email'],
+    enabled: false,
+    lastTriggered: null,
+    description: 'disk usage',
+  },
+  {
+    id: 'alert-b',
+    name: 'Consumer lag',
+    metric: '消费堆积量',
+    operator: '>',
+    threshold: 1000,
+    thresholdUnit: '条',
+    duration: '15分钟',
+    channels: ['dingtalk'],
+    enabled: false,
+    lastTriggered: null,
+    description: 'consumer lag',
+  },
+];
+
+beforeAll(() => {
+  Object.defineProperty(window, 'matchMedia', {
+    writable: true,
+    value: vi.fn().mockImplementation((query: string) => ({
+      matches: false,
+      media: query,
+      onchange: null,
+      addListener: vi.fn(),
+      removeListener: vi.fn(),
+      addEventListener: vi.fn(),
+      removeEventListener: vi.fn(),
+      dispatchEvent: vi.fn(),
+    })),
+  });
+});
+
+function cloneRule(rule: AlertRule): AlertRule {
+  return {
+    ...rule,
+    channels: [...rule.channels],
+  };
+}
+
+function renderPage() {
+  return render(
+    <App>
+      <LangProvider>
+        <AlertsPage />
+      </LangProvider>
+    </App>,
+  );
+}
+
+function getRuleRow(ruleName: string) {
+  const row = screen.getByText(ruleName).closest('tr');
+  if (!row) throw new Error(`Row not found: ${ruleName}`);
+  return row;
+}
+
+describe('AlertsPage', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(listAlertRules).mockResolvedValue(alertRules.map(cloneRule));
+    vi.mocked(toggleAlertRule).mockImplementation(async (id, enabled) => {
+      const rule = alertRules.find((item) => item.id === id);
+      if (!rule) throw new Error(`Rule not found: ${id}`);
+      return { ...cloneRule(rule), enabled };
+    });
+  });
+
+  it('bulk enables selected alert rules and clears the selection after 
success', async () => {
+    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 waitFor(() => {
+      expect(toggleAlertRule).toHaveBeenCalledTimes(2);
+    });
+    expect(toggleAlertRule).toHaveBeenCalledWith('alert-a', true);
+    expect(toggleAlertRule).toHaveBeenCalledWith('alert-b', true);
+    expect(await screen.findByText('已启用 2 条告警规则')).toBeInTheDocument();
+    expect(within(getRuleRow('Broker disk 
usage')).getByRole('switch')).toHaveAttribute(
+      'aria-checked',
+      'true',
+    );
+    expect(within(getRuleRow('Consumer 
lag')).getByRole('switch')).toHaveAttribute(
+      'aria-checked',
+      'true',
+    );
+    expect(within(getRuleRow('Broker disk 
usage')).getByRole('checkbox')).not.toBeChecked();
+    expect(within(getRuleRow('Consumer 
lag')).getByRole('checkbox')).not.toBeChecked();
+  });
+
+  it('keeps only failed alert rules selected after a partial bulk failure', 
async () => {
+    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 };
+    });
+
+    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 waitFor(() => {
+      expect(toggleAlertRule).toHaveBeenCalledTimes(2);
+    });
+    expect(await screen.findByText('已禁用 1 条告警规则,1 条失败')).toBeInTheDocument();
+    expect(within(getRuleRow('Broker disk 
usage')).getByRole('switch')).toHaveAttribute(
+      'aria-checked',
+      'false',
+    );
+    expect(within(getRuleRow('Consumer 
lag')).getByRole('switch')).toHaveAttribute(
+      'aria-checked',
+      'true',
+    );
+    expect(within(getRuleRow('Broker disk 
usage')).getByRole('checkbox')).not.toBeChecked();
+    expect(within(getRuleRow('Consumer 
lag')).getByRole('checkbox')).toBeChecked();
+  });
+
+  it('keeps all selected alert rules selected when the bulk action fails', 
async () => {
+    vi.mocked(toggleAlertRule).mockRejectedValue(new Error('network error'));
+
+    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: '批量启用' }));
+
+    expect(await screen.findByText('2 条告警规则启用失败')).toBeInTheDocument();
+    expect(within(getRuleRow('Broker disk 
usage')).getByRole('checkbox')).toBeChecked();
+    expect(within(getRuleRow('Consumer 
lag')).getByRole('checkbox')).toBeChecked();
+    expect(within(getRuleRow('Broker disk 
usage')).getByRole('switch')).toHaveAttribute(
+      'aria-checked',
+      'false',
+    );
+    expect(within(getRuleRow('Consumer 
lag')).getByRole('switch')).toHaveAttribute(
+      'aria-checked',
+      'false',
+    );
+  });
+
+  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) => {
+        resolveToggle = resolve;
+      }),
+    );
+
+    const user = userEvent.setup();
+    renderPage();
+
+    await screen.findByText('Broker disk usage');
+    await user.click(within(getRuleRow('Broker disk 
usage')).getByRole('checkbox'));
+    await user.click(screen.getByRole('button', { name: '批量启用' }));
+
+    await waitFor(() => {
+      expect(toggleAlertRule).toHaveBeenCalledWith('alert-a', true);
+    });
+    expect(screen.getByRole('button', { name: '新建规则' })).toBeDisabled();
+    expect(within(getRuleRow('Broker disk 
usage')).getByRole('switch')).toBeDisabled();
+    expect(
+      within(getRuleRow('Broker disk usage')).getByRole('button', { name: '编辑' 
}),
+    ).toBeDisabled();
+    expect(
+      within(getRuleRow('Broker disk usage')).getByRole('button', { name: '删除' 
}),
+    ).toBeDisabled();
+
+    resolveToggle?.({ ...cloneRule(alertRules[0]), enabled: true });
+    expect(await screen.findByText('已启用 1 条告警规则')).toBeInTheDocument();
+  });
+});
diff --git a/web/src/pages/ops/alerts.tsx b/web/src/pages/ops/alerts.tsx
index 6bcbf8ba..7aee76bf 100644
--- a/web/src/pages/ops/alerts.tsx
+++ b/web/src/pages/ops/alerts.tsx
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { useEffect, useState } from 'react';
+import { useEffect, useState, type Key } from 'react';
 import { Plus, Pencil, Trash } from '@phosphor-icons/react';
 import {
   Button,
@@ -31,8 +31,9 @@ import {
   Checkbox,
   Flex,
   message,
+  theme,
 } from 'antd';
-import type { ColumnsType } from 'antd/es/table';
+import type { ColumnsType, TableRowSelection } from 'antd/es/table/interface';
 import PageHeader from '../../components/PageHeader';
 import { useLang } from '../../i18n/LangContext';
 import type { AlertRule } from '../../api/ops';
@@ -59,12 +60,15 @@ const durationOptions = ['1分钟', '5分钟', '15分钟', '30分钟'];
 
 const AlertsPage = () => {
   const { t } = useLang();
+  const { token } = theme.useToken();
   const [rules, setRules] = useState<AlertRule[]>([]);
   const [loading, setLoading] = useState(true);
   const [modalVisible, setModalVisible] = useState(false);
   const [editingRule, setEditingRule] = useState<AlertRule | null>(null);
   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 [form] = Form.useForm();
 
   const channelLabels: Record<string, string> = {
@@ -93,6 +97,10 @@ const AlertsPage = () => {
   }, []);
 
   const enabledCount = rules.filter((r) => r.enabled).length;
+  const selectedCount = selectedRuleIds.length;
+  const hasSelectedRules = selectedCount > 0;
+  const isBulkRunning = bulkAction !== null;
+  const isActionRunning = actionId !== null || isBulkRunning;
 
   // eslint-disable-next-line react-hooks/purity
   const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
@@ -113,6 +121,7 @@ const AlertsPage = () => {
   };
 
   const handleToggle = async (rule: AlertRule, enabled: boolean) => {
+    if (isActionRunning) return;
     setActionId(`toggle-${rule.id}`);
     try {
       const updated = await toggleAlertRule(rule.id, enabled);
@@ -125,10 +134,12 @@ const AlertsPage = () => {
   };
 
   const handleDelete = async (rule: AlertRule) => {
+    if (isActionRunning) return;
     setActionId(`delete-${rule.id}`);
     try {
       await deleteAlertRule(rule.id);
       setRules((previous) => previous.filter((item) => item.id !== rule.id));
+      setSelectedRuleIds((previous) => previous.filter((id) => id !== 
rule.id));
       message.success('告警规则已删除');
     } catch {
       message.error('删除告警规则失败,请稍后重试');
@@ -137,6 +148,70 @@ const AlertsPage = () => {
     }
   };
 
+  const handleBulkToggle = async (enabled: boolean) => {
+    const targetIds = selectedRuleIds.map(String);
+    if (targetIds.length === 0 || isActionRunning) return;
+
+    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);
+        }
+      });
+
+      if (updatedRules.size > 0) {
+        setRules((previous) => previous.map((rule) => 
updatedRules.get(rule.id) ?? rule));
+      }
+
+      setSelectedRuleIds(failedIds);
+
+      if (failedIds.length === 0) {
+        message.success(
+          t(enabled ? 'alerts.bulkEnableSuccess' : 
'alerts.bulkDisableSuccess', {
+            count: updatedRules.size,
+          }),
+        );
+      } else if (updatedRules.size === 0) {
+        message.error(
+          t(enabled ? 'alerts.bulkEnableFailed' : 'alerts.bulkDisableFailed', {
+            count: targetIds.length,
+          }),
+        );
+      } else {
+        message.warning(
+          t(enabled ? 'alerts.bulkEnablePartial' : 
'alerts.bulkDisablePartial', {
+            success: updatedRules.size,
+            failed: failedIds.length,
+          }),
+        );
+      }
+    } finally {
+      setBulkAction(null);
+    }
+  };
+
+  const rowSelection: TableRowSelection<AlertRule> = {
+    selectedRowKeys: selectedRuleIds,
+    onChange: (keys) => setSelectedRuleIds(keys),
+    getCheckboxProps: () => ({
+      disabled: isActionRunning,
+    }),
+  };
+
   const columns: ColumnsType<AlertRule> = [
     {
       title: t('alerts.ruleName'),
@@ -172,6 +247,7 @@ const AlertsPage = () => {
         <Switch
           checked={record.enabled}
           loading={actionId === `toggle-${record.id}`}
+          disabled={isActionRunning}
           onChange={(enabled) => void handleToggle(record, enabled)}
         />
       ),
@@ -192,6 +268,7 @@ const AlertsPage = () => {
           <Button
             size="small"
             icon={<Pencil size={14} />}
+            disabled={isActionRunning}
             style={{ borderColor: '#1890ff', color: '#1890ff' }}
             onClick={() => openEditModal(record)}
           >
@@ -202,6 +279,7 @@ const AlertsPage = () => {
             icon={<Trash size={14} />}
             danger
             loading={actionId === `delete-${record.id}`}
+            disabled={isActionRunning}
             style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
             onClick={() => void handleDelete(record)}
           >
@@ -263,7 +341,12 @@ const AlertsPage = () => {
                 {triggered24h}
               </span>
             </Flex>
-            <Button type="primary" icon={<Plus />} onClick={openCreateModal}>
+            <Button
+              type="primary"
+              icon={<Plus />}
+              disabled={isActionRunning}
+              onClick={openCreateModal}
+            >
               {t('alerts.newRule')}
             </Button>
           </Flex>
@@ -272,12 +355,43 @@ const AlertsPage = () => {
 
       {/* ─── Table ─── */}
       <Card bodyStyle={{ padding: 0 }}>
+        <Flex
+          align="center"
+          justify="space-between"
+          style={{
+            padding: '12px 16px',
+            borderBottom: `1px solid ${token.colorBorderSecondary}`,
+          }}
+        >
+          <span style={{ color: token.colorTextSecondary }}>
+            {t('alerts.selectedRules', { count: selectedCount })}
+          </span>
+          <Flex gap={8}>
+            <Button
+              size="small"
+              disabled={!hasSelectedRules || isActionRunning}
+              loading={bulkAction === 'enable'}
+              onClick={() => void handleBulkToggle(true)}
+            >
+              {t('alerts.bulkEnable')}
+            </Button>
+            <Button
+              size="small"
+              disabled={!hasSelectedRules || isActionRunning}
+              loading={bulkAction === 'disable'}
+              onClick={() => void handleBulkToggle(false)}
+            >
+              {t('alerts.bulkDisable')}
+            </Button>
+          </Flex>
+        </Flex>
         <Table<AlertRule>
           columns={columns}
           dataSource={rules}
           rowKey="id"
           size="small"
           loading={loading}
+          rowSelection={rowSelection}
           pagination={false}
         />
       </Card>

Reply via email to