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');
+  });
 });

Reply via email to