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>