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 38fb3c28 feat: support exporting selected alert rules as YAML (#762)
38fb3c28 is described below
commit 38fb3c28f5208b31bde0460b941a25aa52a664c7
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 3 11:37:51 2026 +0800
feat: support exporting selected alert rules as YAML (#762)
---
web/src/pages/studio/AlertManagement.tsx | 39 +++++++-
.../studio/__tests__/AlertManagement.test.tsx | 108 ++++++++++++++++++++-
2 files changed, 142 insertions(+), 5 deletions(-)
diff --git a/web/src/pages/studio/AlertManagement.tsx
b/web/src/pages/studio/AlertManagement.tsx
index a166cd83..631add35 100644
--- a/web/src/pages/studio/AlertManagement.tsx
+++ b/web/src/pages/studio/AlertManagement.tsx
@@ -155,6 +155,7 @@ const AlertManagementPage: React.FC = () => {
const [filterGroup, setFilterGroup] = useState('all');
const [filterSeverity, setFilterSeverity] = useState('all');
const [filterStatus, setFilterStatus] = useState('all');
+ const [selectedRuleKeys, setSelectedRuleKeys] = useState<React.Key[]>([]);
const [disabledRules, setDisabledRules] = useState<Record<string,
boolean>>(() => {
try {
const saved = localStorage.getItem('alertDisabledRules');
@@ -180,7 +181,14 @@ const AlertManagementPage: React.FC = () => {
const data = await queryAlertRules();
const yamlStr = data.rules || '';
if (!cancelled) {
- setAlertRules(parseYamlRules(yamlStr, disabledRulesRef.current));
+ const loadedRules = parseYamlRules(yamlStr,
disabledRulesRef.current);
+ const enabledRuleKeys = new Set<React.Key>(
+ loadedRules.filter((rule) => rule.enabled).map((rule) => rule.key),
+ );
+ setAlertRules(loadedRules);
+ setSelectedRuleKeys((currentKeys) =>
+ currentKeys.filter((key) => enabledRuleKeys.has(key)),
+ );
}
} catch {
if (!cancelled) {
@@ -205,7 +213,12 @@ const AlertManagementPage: React.FC = () => {
try {
const data = await queryAlertRules();
const yamlStr = data.rules || '';
- setAlertRules(parseYamlRules(yamlStr, disabledRulesRef.current));
+ const loadedRules = parseYamlRules(yamlStr, disabledRulesRef.current);
+ const enabledRuleKeys = new Set<React.Key>(
+ loadedRules.filter((rule) => rule.enabled).map((rule) => rule.key),
+ );
+ setAlertRules(loadedRules);
+ setSelectedRuleKeys((currentKeys) => currentKeys.filter((key) =>
enabledRuleKeys.has(key)));
} catch {
message.error(t('alertMgmt.fetchFailed'));
} finally {
@@ -220,6 +233,7 @@ const AlertManagementPage: React.FC = () => {
setAlertRules((prev) =>
prev.map((rule) => (rule.key === ruleKey ? { ...rule, enabled:
!rule.enabled } : rule)),
);
+ setSelectedRuleKeys((currentKeys) => currentKeys.filter((key) => key !==
ruleKey));
};
const handleAddRule = () => {
@@ -247,6 +261,7 @@ const AlertManagementPage: React.FC = () => {
const handleDeleteRule = (ruleKey: string) => {
setAlertRules((prev) => prev.filter((rule) => rule.key !== ruleKey));
+ setSelectedRuleKeys((currentKeys) => currentKeys.filter((key) => key !==
ruleKey));
const updated = { ...disabledRules };
delete updated[ruleKey];
setDisabledRules(updated);
@@ -258,6 +273,11 @@ const AlertManagementPage: React.FC = () => {
try {
const values = await form.validateFields();
if (editingRule) {
+ if (values.alert !== editingRule.key || values.enabled === false) {
+ setSelectedRuleKeys((currentKeys) =>
+ currentKeys.filter((key) => key !== editingRule.key),
+ );
+ }
setAlertRules((prev) =>
prev.map((rule) =>
rule.key === editingRule.key
@@ -301,8 +321,14 @@ const AlertManagementPage: React.FC = () => {
}
};
+ const selectedRules = useMemo(
+ () => alertRules.filter((rule) => rule.enabled &&
selectedRuleKeys.includes(rule.key)),
+ [alertRules, selectedRuleKeys],
+ );
+
const handleExportYaml = () => {
- const enabledRules = alertRules.filter((r) => r.enabled);
+ const enabledRules =
+ selectedRules.length > 0 ? selectedRules : alertRules.filter((rule) =>
rule.enabled);
const groups: Record<string, AlertRule[]> = {};
for (const rule of enabledRules) {
if (!groups[rule.group]) groups[rule.group] = [];
@@ -533,6 +559,7 @@ const AlertManagementPage: React.FC = () => {
</Button>
<Button icon={<DownloadSimple size={16} />}
onClick={handleExportYaml} size="small">
{t('alertMgmt.exportYaml')}
+ {selectedRules.length > 0 ? ` (${selectedRules.length})` : ''}
</Button>
</Space>
}
@@ -587,6 +614,12 @@ const AlertManagementPage: React.FC = () => {
dataSource={filteredRules}
loading={loading}
rowKey="key"
+ rowSelection={{
+ selectedRowKeys: selectedRules.map((rule) => rule.key),
+ onChange: setSelectedRuleKeys,
+ preserveSelectedRowKeys: true,
+ getCheckboxProps: (record) => ({ disabled: !record.enabled }),
+ }}
size="small"
pagination={{
pageSize: 10,
diff --git a/web/src/pages/studio/__tests__/AlertManagement.test.tsx
b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
index a62aaa84..75abb03e 100644
--- a/web/src/pages/studio/__tests__/AlertManagement.test.tsx
+++ b/web/src/pages/studio/__tests__/AlertManagement.test.tsx
@@ -16,8 +16,9 @@
*/
import type { ReactElement } from 'react';
-import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { afterEach, 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 { LangProvider } from '../../../i18n/LangContext';
import AlertManagementPage from '../AlertManagement';
@@ -41,6 +42,16 @@ groups:
annotations:
summary: "Broker unavailable"
description: "Broker has been unavailable for five minutes"
+ # Rule 2:
+ - alert: ConsumerLagHigh
+ expr: rocketmq_consumer_lag_messages > 100000
+ for: 10m
+ labels:
+ severity: warning
+ team: consumer
+ annotations:
+ summary: "Consumer lag is high"
+ description: "Consumer lag has exceeded the threshold"
`;
beforeAll(() => {
@@ -68,12 +79,31 @@ const renderWithProviders = (ui: ReactElement) => {
};
describe('AlertManagementPage', () => {
+ let createObjectURL: ReturnType<typeof vi.fn>;
+ let revokeObjectURL: ReturnType<typeof vi.fn>;
+ let clickSpy: ReturnType<typeof vi.spyOn>;
+
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
+ createObjectURL = vi.fn().mockReturnValue('blob:alert-rules');
+ revokeObjectURL = vi.fn();
+ Object.defineProperty(URL, 'createObjectURL', {
+ configurable: true,
+ value: createObjectURL,
+ });
+ Object.defineProperty(URL, 'revokeObjectURL', {
+ configurable: true,
+ value: revokeObjectURL,
+ });
+ clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(() => {});
vi.mocked(queryAlertRules).mockResolvedValue({ rules: rulesYaml });
});
+ afterEach(() => {
+ clickSpy.mockRestore();
+ });
+
it('loads alert rules after mount', async () => {
renderWithProviders(<AlertManagementPage />);
@@ -83,4 +113,78 @@ describe('AlertManagementPage', () => {
expect(await screen.findByText('BrokerDown')).toBeInTheDocument();
});
+
+ it('exports only the selected enabled alert rules when rows are selected',
async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AlertManagementPage />);
+
+ const brokerRule = await screen.findByText('BrokerDown');
+ expect(screen.getByText('ConsumerLagHigh')).toBeInTheDocument();
+
+ const brokerRow = brokerRule.closest('tr');
+ expect(brokerRow).not.toBeNull();
+ await user.click(within(brokerRow!).getByRole('checkbox'));
+ await user.click(screen.getByRole('button', { name: '导出 YAML (1)' }));
+
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ const yaml = await blob.text();
+ expect(yaml).toContain('alert: BrokerDown');
+ expect(yaml).not.toContain('alert: ConsumerLagHigh');
+ expect(clickSpy).toHaveBeenCalledTimes(1);
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:alert-rules');
+ });
+
+ it('exports all enabled alert rules when no rows are selected', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AlertManagementPage />);
+
+ await screen.findByText('BrokerDown');
+ await user.click(screen.getByRole('button', { name: '导出 YAML' }));
+
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ const yaml = await blob.text();
+ expect(yaml).toContain('alert: BrokerDown');
+ expect(yaml).toContain('alert: ConsumerLagHigh');
+ });
+
+ it('removes a selected rule when it is disabled', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AlertManagementPage />);
+
+ const brokerRule = await screen.findByText('BrokerDown');
+ const brokerRow = brokerRule.closest('tr');
+ expect(brokerRow).not.toBeNull();
+
+ await user.click(within(brokerRow!).getByRole('checkbox'));
+ expect(screen.getByRole('button', { name: '导出 YAML (1)'
})).toBeInTheDocument();
+
+ await user.click(within(brokerRow!).getByRole('switch'));
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: '导出 YAML'
})).toBeInTheDocument();
+ });
+ expect(within(brokerRow!).getByRole('checkbox')).toBeDisabled();
+ });
+
+ it('preserves selected rules while filtering the table', async () => {
+ const user = userEvent.setup();
+ renderWithProviders(<AlertManagementPage />);
+
+ const brokerRule = await screen.findByText('BrokerDown');
+ const brokerRow = brokerRule.closest('tr');
+ expect(brokerRow).not.toBeNull();
+ await user.click(within(brokerRow!).getByRole('checkbox'));
+
+ await user.type(screen.getByRole('textbox'), 'ConsumerLagHigh');
+
+ expect(screen.queryByText('BrokerDown')).not.toBeInTheDocument();
+ expect(screen.getByText('ConsumerLagHigh')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: '导出 YAML (1)' }));
+
+ const blob = createObjectURL.mock.calls[0][0] as Blob;
+ const yaml = await blob.text();
+ expect(yaml).toContain('alert: BrokerDown');
+ expect(yaml).not.toContain('alert: ConsumerLagHigh');
+ });
});