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 82acb037d fix(ops): consolidate asset workflows (#2852)
82acb037d is described below

commit 82acb037d9c3924982985407d02912b581a9c872
Author: shown <[email protected]>
AuthorDate: Wed Sep 2 15:43:09 2026 +0800

    fix(ops): consolidate asset workflows (#2852)
    
    * [ISSUE #2728] fix(mock): isolate alert rules by domain
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2730] fix(grafana): pack generated dashboard panels by width
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    * [ISSUE #2732] fix(export): deduplicate asset download requests
    
    Signed-off-by: yuluo-yx <[email protected]>
    
    ---------
    
    Signed-off-by: yuluo-yx <[email protected]>
---
 server/scripts/gen_grafana_dashboards.py           | 66 +++++++++++++-------
 server/scripts/test_gen_grafana_dashboards.py      | 72 ++++++++++++++++++++++
 .../main/resources/grafana/rocketmq-broker.json    |  6 +-
 .../main/resources/grafana/rocketmq-consumer.json  |  6 +-
 .../src/main/resources/grafana/rocketmq-dlq.json   |  2 +-
 .../src/main/resources/grafana/rocketmq-jvm.json   |  6 +-
 .../main/resources/grafana/rocketmq-latency.json   |  2 +-
 .../main/resources/grafana/rocketmq-network.json   |  6 +-
 .../main/resources/grafana/rocketmq-overview.json  | 16 ++---
 .../main/resources/grafana/rocketmq-producer.json  |  6 +-
 .../main/resources/grafana/rocketmq-storage.json   |  9 ++-
 .../resources/grafana/rocketmq-threadpool.json     |  4 +-
 .../src/main/resources/grafana/rocketmq-topic.json |  4 +-
 .../src/main/resources/grafana/rocketmq-tps.json   |  4 +-
 web/src/components/AlertRuleAssetList.tsx          | 12 ++--
 web/src/components/GrafanaDashboardList.tsx        | 18 +++---
 .../__tests__/AlertRuleAssetList.test.tsx          | 18 ++++++
 .../__tests__/GrafanaDashboardList.test.tsx        | 38 ++++++++++++
 web/src/services/opsService.test.ts                | 70 +++++++++++++++++++++
 web/src/services/opsService.ts                     | 33 +++++-----
 20 files changed, 314 insertions(+), 84 deletions(-)

diff --git a/server/scripts/gen_grafana_dashboards.py 
b/server/scripts/gen_grafana_dashboards.py
index fe3a3c4e6..775918fd5 100644
--- a/server/scripts/gen_grafana_dashboards.py
+++ b/server/scripts/gen_grafana_dashboards.py
@@ -9,9 +9,9 @@ import json
 import os
 
 OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "src", "main", 
"resources", "grafana")
-os.makedirs(OUT_DIR, exist_ok=True)
 
 DS = "${DS_PROMETHEUS}"
+GRID_WIDTH = 24
 
 
 def ts_panel(panel_id, title, expr, grid, y, legend=None, unit="short"):
@@ -63,7 +63,8 @@ def gauge_panel(panel_id, title, expr, grid, y, max_=100):
             "defaults": {
                 "unit": "percent",
                 "max": max_,
-                "custom": {"min": 0},
+                "min": 0,
+                "custom": {},
             },
             "overrides": [],
         },
@@ -131,17 +132,33 @@ def dashboard(uid, title, description, panels, 
vars_=None):
     }
 
 
-def y_stack(panels):
-    """Assign gridPos y offsets sequentially (2 per row of w=12)."""
+def layout_panels(panels):
+    """Return panels packed into rows without mutating the input 
definitions."""
+    laid_out = []
+    x = 0
     y = 0
-    for p in panels:
-        w = p["gridPos"]["w"]
-        p["gridPos"]["x"] = 0 if (panels.index(p) % 2 == 0 or w == 24) else 12
-        if w == 24:
-            p["gridPos"]["x"] = 0
-        p["gridPos"]["y"] = y
-        y += p["gridPos"]["h"]
-    return panels
+    row_height = 0
+    for panel in panels:
+        grid = panel["gridPos"]
+        width = grid["w"]
+        height = grid["h"]
+        if width <= 0 or width > GRID_WIDTH:
+            raise ValueError(f"panel width must be between 1 and {GRID_WIDTH}: 
{width}")
+        if height <= 0:
+            raise ValueError(f"panel height must be positive: {height}")
+        if x + width > GRID_WIDTH:
+            y += row_height
+            x = 0
+            row_height = 0
+
+        laid_out.append({**panel, "gridPos": {**grid, "x": x, "y": y}})
+        x += width
+        row_height = max(row_height, height)
+        if x == GRID_WIDTH:
+            y += row_height
+            x = 0
+            row_height = 0
+    return laid_out
 
 
 specs = []
@@ -281,13 +298,20 @@ specs.append((
     ],
 ))
 
-for uid, title, desc, panels in specs:
-    panels = y_stack(panels)
-    doc = dashboard(uid, title, desc, panels)
-    path = os.path.join(OUT_DIR, f"{uid}.json")
-    with open(path, "w", encoding="utf-8") as f:
-        json.dump(doc, f, indent=2, ensure_ascii=False)
-        f.write("\n")
-    print(f"wrote {path} ({len(panels)} panels)")
+def generate_dashboards(out_dir=OUT_DIR):
+    """Write all dashboard assets to ``out_dir`` in a deterministic order."""
+    os.makedirs(out_dir, exist_ok=True)
+    for uid, title, desc, panels in specs:
+        laid_out = layout_panels(panels)
+        doc = dashboard(uid, title, desc, laid_out)
+        path = os.path.join(out_dir, f"{uid}.json")
+        with open(path, "w", encoding="utf-8") as f:
+            json.dump(doc, f, indent=2, ensure_ascii=False)
+            f.write("\n")
+        print(f"wrote {path} ({len(laid_out)} panels)")
+
+    print(f"TOTAL dashboards: {len(specs)}")
+
 
-print(f"TOTAL dashboards: {len(specs)}")
+if __name__ == "__main__":
+    generate_dashboards()
diff --git a/server/scripts/test_gen_grafana_dashboards.py 
b/server/scripts/test_gen_grafana_dashboards.py
new file mode 100644
index 000000000..17fcac5d3
--- /dev/null
+++ b/server/scripts/test_gen_grafana_dashboards.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+################################################################################
+# 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.
+################################################################################
+"""Regression tests for the Grafana dashboard asset generator."""
+
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from gen_grafana_dashboards import gauge_panel, layout_panels
+
+
+def panel(width, height):
+    return {"gridPos": {"w": width, "h": height, "x": 99, "y": 99}}
+
+
+class LayoutPanelsTest(unittest.TestCase):
+
+    def test_packs_panels_by_width_and_advances_by_tallest_panel(self):
+        panels = [panel(12, 8), panel(12, 8), panel(6, 6), panel(6, 8), 
panel(12, 6)]
+
+        laid_out = layout_panels(panels)
+
+        self.assertEqual(
+            [(item["gridPos"]["x"], item["gridPos"]["y"]) for item in 
laid_out],
+            [(0, 0), (12, 0), (0, 8), (6, 8), (12, 8)],
+        )
+        self.assertEqual(panels[0]["gridPos"]["x"], 99)
+        self.assertEqual(panels[0]["gridPos"]["y"], 99)
+
+    def test_starts_a_new_row_when_the_next_panel_does_not_fit(self):
+        laid_out = layout_panels([panel(8, 6), panel(8, 10), panel(12, 4)])
+
+        self.assertEqual(
+            [(item["gridPos"]["x"], item["gridPos"]["y"]) for item in 
laid_out],
+            [(0, 0), (8, 0), (0, 10)],
+        )
+
+    def test_rejects_invalid_dimensions(self):
+        with self.assertRaises(ValueError):
+            layout_panels([panel(25, 8)])
+        with self.assertRaises(ValueError):
+            layout_panels([panel(12, 0)])
+
+
+class GaugePanelTest(unittest.TestCase):
+
+    def test_places_minimum_in_field_defaults(self):
+        gauge = gauge_panel(1, "Disk", "metric", 12, 0)
+
+        defaults = gauge["fieldConfig"]["defaults"]
+        self.assertEqual(defaults["min"], 0)
+        self.assertNotIn("min", defaults["custom"])
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/server/src/main/resources/grafana/rocketmq-broker.json 
b/server/src/main/resources/grafana/rocketmq-broker.json
index 100dd31f9..08f7b7e2b 100644
--- a/server/src/main/resources/grafana/rocketmq-broker.json
+++ b/server/src/main/resources/grafana/rocketmq-broker.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -158,7 +158,7 @@
         "h": 8,
         "w": 12,
         "x": 0,
-        "y": 16
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
@@ -200,7 +200,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 24
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-consumer.json 
b/server/src/main/resources/grafana/rocketmq-consumer.json
index 417052ada..80f6e9f62 100644
--- a/server/src/main/resources/grafana/rocketmq-consumer.json
+++ b/server/src/main/resources/grafana/rocketmq-consumer.json
@@ -116,8 +116,8 @@
       "gridPos": {
         "h": 8,
         "w": 12,
-        "x": 12,
-        "y": 6
+        "x": 6,
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -159,7 +159,7 @@
         "h": 8,
         "w": 12,
         "x": 0,
-        "y": 14
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-dlq.json 
b/server/src/main/resources/grafana/rocketmq-dlq.json
index 6fe5a12f4..c6bddd963 100644
--- a/server/src/main/resources/grafana/rocketmq-dlq.json
+++ b/server/src/main/resources/grafana/rocketmq-dlq.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-jvm.json 
b/server/src/main/resources/grafana/rocketmq-jvm.json
index 9d9a69f71..c19c9cee1 100644
--- a/server/src/main/resources/grafana/rocketmq-jvm.json
+++ b/server/src/main/resources/grafana/rocketmq-jvm.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -158,7 +158,7 @@
         "h": 8,
         "w": 12,
         "x": 0,
-        "y": 16
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
@@ -200,7 +200,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 24
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-latency.json 
b/server/src/main/resources/grafana/rocketmq-latency.json
index 028de8b51..fa7e42fc4 100644
--- a/server/src/main/resources/grafana/rocketmq-latency.json
+++ b/server/src/main/resources/grafana/rocketmq-latency.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-network.json 
b/server/src/main/resources/grafana/rocketmq-network.json
index ddec7e09d..b6085a90e 100644
--- a/server/src/main/resources/grafana/rocketmq-network.json
+++ b/server/src/main/resources/grafana/rocketmq-network.json
@@ -116,8 +116,8 @@
       "gridPos": {
         "h": 8,
         "w": 12,
-        "x": 12,
-        "y": 6
+        "x": 6,
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -159,7 +159,7 @@
         "h": 8,
         "w": 12,
         "x": 0,
-        "y": 14
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-overview.json 
b/server/src/main/resources/grafana/rocketmq-overview.json
index d0c676941..2f7da454a 100644
--- a/server/src/main/resources/grafana/rocketmq-overview.json
+++ b/server/src/main/resources/grafana/rocketmq-overview.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -158,7 +158,7 @@
         "h": 6,
         "w": 6,
         "x": 0,
-        "y": 16
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
@@ -200,8 +200,8 @@
       "gridPos": {
         "h": 6,
         "w": 6,
-        "x": 12,
-        "y": 22
+        "x": 6,
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
@@ -243,8 +243,8 @@
       "gridPos": {
         "h": 6,
         "w": 6,
-        "x": 0,
-        "y": 28
+        "x": 12,
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
@@ -286,8 +286,8 @@
       "gridPos": {
         "h": 6,
         "w": 6,
-        "x": 12,
-        "y": 34
+        "x": 18,
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-producer.json 
b/server/src/main/resources/grafana/rocketmq-producer.json
index 2d99ad971..c9f06274a 100644
--- a/server/src/main/resources/grafana/rocketmq-producer.json
+++ b/server/src/main/resources/grafana/rocketmq-producer.json
@@ -116,8 +116,8 @@
       "gridPos": {
         "h": 8,
         "w": 12,
-        "x": 12,
-        "y": 6
+        "x": 6,
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -159,7 +159,7 @@
         "h": 8,
         "w": 24,
         "x": 0,
-        "y": 14
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-storage.json 
b/server/src/main/resources/grafana/rocketmq-storage.json
index ca3a98e30..4952427ca 100644
--- a/server/src/main/resources/grafana/rocketmq-storage.json
+++ b/server/src/main/resources/grafana/rocketmq-storage.json
@@ -80,9 +80,8 @@
         "defaults": {
           "unit": "percent",
           "max": 100,
-          "custom": {
-            "min": 0
-          }
+          "min": 0,
+          "custom": {}
         },
         "overrides": []
       },
@@ -116,7 +115,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -158,7 +157,7 @@
         "h": 8,
         "w": 24,
         "x": 0,
-        "y": 16
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-threadpool.json 
b/server/src/main/resources/grafana/rocketmq-threadpool.json
index a5ea4784d..c40b0044f 100644
--- a/server/src/main/resources/grafana/rocketmq-threadpool.json
+++ b/server/src/main/resources/grafana/rocketmq-threadpool.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -158,7 +158,7 @@
         "h": 8,
         "w": 24,
         "x": 0,
-        "y": 16
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-topic.json 
b/server/src/main/resources/grafana/rocketmq-topic.json
index 651879c86..94093ab7a 100644
--- a/server/src/main/resources/grafana/rocketmq-topic.json
+++ b/server/src/main/resources/grafana/rocketmq-topic.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -158,7 +158,7 @@
         "h": 8,
         "w": 24,
         "x": 0,
-        "y": 16
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/server/src/main/resources/grafana/rocketmq-tps.json 
b/server/src/main/resources/grafana/rocketmq-tps.json
index 14acc1585..062bd6bee 100644
--- a/server/src/main/resources/grafana/rocketmq-tps.json
+++ b/server/src/main/resources/grafana/rocketmq-tps.json
@@ -116,7 +116,7 @@
         "h": 8,
         "w": 12,
         "x": 12,
-        "y": 8
+        "y": 0
       },
       "fieldConfig": {
         "defaults": {
@@ -158,7 +158,7 @@
         "h": 8,
         "w": 24,
         "x": 0,
-        "y": 16
+        "y": 8
       },
       "fieldConfig": {
         "defaults": {
diff --git a/web/src/components/AlertRuleAssetList.tsx 
b/web/src/components/AlertRuleAssetList.tsx
index b27db3076..91b091392 100644
--- a/web/src/components/AlertRuleAssetList.tsx
+++ b/web/src/components/AlertRuleAssetList.tsx
@@ -50,6 +50,7 @@ export const AlertRuleAssetList: React.FC = () => {
   const mountedRef = useRef(true);
   const listRequestId = useRef(0);
   const viewRequestId = useRef(0);
+  const exportingNamesRef = useRef<Set<string>>(new Set());
   const [exportingNames, setExportingNames] = useState<Set<string>>(() => new 
Set());
 
   const severityOptions = useMemo(
@@ -135,7 +136,9 @@ export const AlertRuleAssetList: React.FC = () => {
   };
 
   const handleExport = async (info: AlertRuleAssetInfo) => {
-    setExportingNames((current) => new Set(current).add(info.name));
+    if (exportingNamesRef.current.has(info.name)) return;
+    exportingNamesRef.current.add(info.name);
+    setExportingNames(new Set(exportingNamesRef.current));
     try {
       const blob = await exportAlertRuleAsset(info.name);
       downloadBlob(blob, `${info.name}.yaml`);
@@ -143,11 +146,8 @@ export const AlertRuleAssetList: React.FC = () => {
     } catch {
       message.error(t('alertAssets.exportFailed'));
     } finally {
-      setExportingNames((current) => {
-        const next = new Set(current);
-        next.delete(info.name);
-        return next;
-      });
+      exportingNamesRef.current.delete(info.name);
+      if (mountedRef.current) setExportingNames(new 
Set(exportingNamesRef.current));
     }
   };
 
diff --git a/web/src/components/GrafanaDashboardList.tsx 
b/web/src/components/GrafanaDashboardList.tsx
index b76284e86..5551de567 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -45,6 +45,8 @@ export const GrafanaDashboardList: React.FC = () => {
   const mountedRef = useRef(true);
   const listRequestId = useRef(0);
   const viewRequestId = useRef(0);
+  const exportingUidsRef = useRef<Set<string>>(new Set());
+  const exportingAllRef = useRef(false);
   const [exportingUids, setExportingUids] = useState<Set<string>>(() => new 
Set());
   const [exportingAll, setExportingAll] = useState(false);
 
@@ -131,7 +133,9 @@ export const GrafanaDashboardList: React.FC = () => {
   };
 
   const handleExport = async (info: GrafanaDashboardInfo) => {
-    setExportingUids((current) => new Set(current).add(info.uid));
+    if (exportingUidsRef.current.has(info.uid)) return;
+    exportingUidsRef.current.add(info.uid);
+    setExportingUids(new Set(exportingUidsRef.current));
     try {
       const blob = await exportGrafanaDashboard(info.uid);
       downloadBlob(blob, `${info.uid}.json`);
@@ -139,15 +143,14 @@ export const GrafanaDashboardList: React.FC = () => {
     } catch {
       message.error(t('grafana.exportFailed'));
     } finally {
-      setExportingUids((current) => {
-        const next = new Set(current);
-        next.delete(info.uid);
-        return next;
-      });
+      exportingUidsRef.current.delete(info.uid);
+      if (mountedRef.current) setExportingUids(new 
Set(exportingUidsRef.current));
     }
   };
 
   const handleExportAll = async () => {
+    if (exportingAllRef.current) return;
+    exportingAllRef.current = true;
     setExportingAll(true);
     try {
       const download = await exportGrafanaDashboards();
@@ -156,7 +159,8 @@ export const GrafanaDashboardList: React.FC = () => {
     } catch {
       message.error(t('grafana.exportAllFailed'));
     } finally {
-      setExportingAll(false);
+      exportingAllRef.current = false;
+      if (mountedRef.current) setExportingAll(false);
     }
   };
 
diff --git a/web/src/components/__tests__/AlertRuleAssetList.test.tsx 
b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
index 8e9e742b7..4588e90b1 100644
--- a/web/src/components/__tests__/AlertRuleAssetList.test.tsx
+++ b/web/src/components/__tests__/AlertRuleAssetList.test.tsx
@@ -186,6 +186,24 @@ describe('AlertRuleAssetList', () => {
     });
   });
 
+  it('deduplicates an asset export before loading state renders', async () => {
+    
vi.mocked(alertRuleAssetService.listAlertRuleAssets).mockResolvedValue(sampleAssets);
+    let resolveExport!: (value: Blob) => void;
+    vi.mocked(alertRuleAssetService.exportAlertRuleAsset).mockImplementation(
+      () => new Promise((resolve) => (resolveExport = resolve)),
+    );
+    renderWithProviders(<AlertRuleAssetList />);
+
+    const exportButtons = await screen.findAllByRole('button', { name: 
/导出|Export/ });
+    act(() => {
+      exportButtons[0].click();
+      exportButtons[0].click();
+    });
+
+    
expect(alertRuleAssetService.exportAlertRuleAsset).toHaveBeenCalledTimes(1);
+    await act(async () => resolveExport(new Blob(['rules'])));
+  });
+
   it('downloads the yaml when Export is clicked', async () => {
     const createObjectURLSpy = vi.spyOn(URL, 
'createObjectURL').mockReturnValue('blob:url');
     const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() 
=> {});
diff --git a/web/src/components/__tests__/GrafanaDashboardList.test.tsx 
b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
index 400bc842e..b74026a65 100644
--- a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
+++ b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
@@ -231,6 +231,44 @@ describe('GrafanaDashboardList', () => {
     });
   });
 
+  it('deduplicates a dashboard export before loading state renders', async () 
=> {
+    let resolveExport!: (value: Blob) => void;
+    vi.mocked(exportGrafanaDashboard).mockImplementation(
+      () => new Promise((resolve) => (resolveExport = resolve)),
+    );
+    renderDashboardList();
+
+    await screen.findByText('RocketMQ Cluster Overview');
+    const exportButtons = screen.getAllByRole('button', { name: 
/^(Export|导出)$/ });
+    act(() => {
+      exportButtons[0].click();
+      exportButtons[0].click();
+    });
+
+    expect(exportGrafanaDashboard).toHaveBeenCalledTimes(1);
+    await act(async () => resolveExport(new Blob(['dashboard'])));
+  });
+
+  it('deduplicates bulk export before loading state renders', async () => {
+    let resolveExport!: (value: Awaited<ReturnType<typeof 
exportGrafanaDashboards>>) => void;
+    vi.mocked(exportGrafanaDashboards).mockImplementation(
+      () => new Promise((resolve) => (resolveExport = resolve)),
+    );
+    renderDashboardList();
+
+    await screen.findByText('RocketMQ Cluster Overview');
+    const exportAll = screen.getByRole('button', { name: /Export all|导出全部/ });
+    act(() => {
+      exportAll.click();
+      exportAll.click();
+    });
+
+    expect(exportGrafanaDashboards).toHaveBeenCalledTimes(1);
+    await act(async () =>
+      resolveExport({ blob: new Blob(['dashboards']), filename: 
'dashboards.zip' }),
+    );
+  });
+
   it('exports a dashboard and triggers a download', async () => {
     const user = userEvent.setup();
     const createObjectURL = vi.fn().mockReturnValue('blob:grafana');
diff --git a/web/src/services/opsService.test.ts 
b/web/src/services/opsService.test.ts
index 68c22186d..a4a18b878 100644
--- a/web/src/services/opsService.test.ts
+++ b/web/src/services/opsService.test.ts
@@ -20,8 +20,11 @@ import type { AuditRecord } from '../api/ops';
 import { mockAuditRecords } from '../mock/audit';
 import {
   createAlertRule,
+  deleteAlertRule,
+  exportAlertRulesTransfer,
   exportAuditLogs,
   getAuditFilterOptions,
+  importAlertRulesTransfer,
   listAlertRules,
   listAlertRulesPage,
   listAuditRecords,
@@ -111,6 +114,73 @@ describe('ops service mock data', () => {
     expect(after.find((rule) => rule.id === 999999)).toBeUndefined();
   });
 
+  it('keeps alert rule CRUD isolated between cluster and business domains', 
async () => {
+    const clusterRule = await createAlertRule(
+      { name: 'cluster-domain-rule', channels: ['email'] },
+      'CLUSTER',
+    );
+    const businessRule = await createAlertRule(
+      { name: 'business-domain-rule', channels: ['sms'] },
+      'BUSINESS',
+    );
+
+    expect((await listAlertRules('CLUSTER')).map((rule) => 
rule.name)).toContain(
+      'cluster-domain-rule',
+    );
+    expect((await listAlertRules('CLUSTER')).map((rule) => 
rule.name)).not.toContain(
+      'business-domain-rule',
+    );
+    expect((await listAlertRules('BUSINESS')).map((rule) => 
rule.name)).toContain(
+      'business-domain-rule',
+    );
+    expect((await listAlertRules('BUSINESS')).map((rule) => 
rule.name)).not.toContain(
+      'cluster-domain-rule',
+    );
+
+    await updateAlertRule({ ...businessRule, name: 
'updated-business-domain-rule' }, 'BUSINESS');
+    expect((await listAlertRules('BUSINESS')).map((rule) => 
rule.name)).toContain(
+      'updated-business-domain-rule',
+    );
+    expect((await listAlertRules('CLUSTER')).map((rule) => 
rule.name)).not.toContain(
+      'updated-business-domain-rule',
+    );
+
+    await deleteAlertRule(clusterRule.id, 'CLUSTER');
+    expect((await listAlertRules('CLUSTER')).map((rule) => 
rule.id)).not.toContain(clusterRule.id);
+    expect((await listAlertRules('BUSINESS')).map((rule) => 
rule.id)).toContain(businessRule.id);
+    await deleteAlertRule(businessRule.id, 'BUSINESS');
+  });
+
+  it('imports and exports alert rules within the selected domain only', async 
() => {
+    const imported = await importAlertRulesTransfer(
+      {
+        version: 1,
+        domain: 'BUSINESS',
+        rules: [
+          {
+            name: 'business-transfer-rule',
+            metric: 'consumer.lag.total',
+            operator: '>',
+            threshold: 100,
+            duration: '5m',
+            channels: ['email'],
+            enabled: true,
+            description: 'business-only transfer',
+          },
+        ],
+      },
+      'BUSINESS',
+    );
+
+    expect((await exportAlertRulesTransfer('BUSINESS')).rules.map((rule) => 
rule.name)).toContain(
+      'business-transfer-rule',
+    );
+    expect(
+      (await exportAlertRulesTransfer('CLUSTER')).rules.map((rule) => 
rule.name),
+    ).not.toContain('business-transfer-rule');
+    await deleteAlertRule(imported[0].id, 'BUSINESS');
+  });
+
   it('returns copied system alert rows', async () => {
     const first = await listSystemAlerts();
     const originalTitle = first[0].title;
diff --git a/web/src/services/opsService.ts b/web/src/services/opsService.ts
index da6ef9f6d..d0a7f4f91 100644
--- a/web/src/services/opsService.ts
+++ b/web/src/services/opsService.ts
@@ -30,7 +30,11 @@ import { mockAuditRecords } from '../mock/audit';
 import { systemAlerts as mockSystemAlerts } from '../mock/dashboard';
 
 let auditRecordsState = mockAuditRecords as unknown as AuditRecord[];
-const alertRulesState = mockAlertRules as unknown as AlertRule[];
+const initialAlertRules = mockAlertRules as unknown as AlertRule[];
+const alertRulesState: Record<AlertRuleDomain, AlertRule[]> = {
+  CLUSTER: initialAlertRules.map(copyAlertRule),
+  BUSINESS: initialAlertRules.map(copyAlertRule),
+};
 let alertSilencesState: AlertSilence[] = [];
 
 function copyAlertRule(rule: AlertRule): AlertRule {
@@ -113,7 +117,7 @@ function formatAuditCsv(records: AuditRecord[]): string {
 }
 
 export async function listAlertRules(domain: AlertRuleDomain = 'CLUSTER'): 
Promise<AlertRule[]> {
-  if (isMockMode()) return alertRulesState.map(copyAlertRule);
+  if (isMockMode()) return alertRulesState[domain].map(copyAlertRule);
   return opsApi.listAlertRules(domain);
 }
 export async function listAlertRulesPage(
@@ -125,7 +129,7 @@ export async function listAlertRulesPage(
   const search = query.search?.trim().toLowerCase();
   const page = Math.max(1, query.page ?? 1);
   const pageSize = Math.min(100, Math.max(1, query.pageSize ?? 20));
-  const filtered = alertRulesState
+  const filtered = alertRulesState[domain]
     .filter((rule) => query.enabled == null || rule.enabled === query.enabled)
     .filter(
       (rule) =>
@@ -150,7 +154,8 @@ export async function listAlertRuleRuntime(
 export async function exportAlertRulesTransfer(
   domain: AlertRuleDomain = 'CLUSTER',
 ): Promise<AlertRuleTransfer> {
-  if (isMockMode()) return { version: 1, domain, rules: 
alertRulesState.map(copyAlertRule) };
+  if (isMockMode())
+    return { version: 1, domain, rules: 
alertRulesState[domain].map(copyAlertRule) };
   return opsApi.exportAlertRulesTransfer(domain);
 }
 
@@ -164,7 +169,7 @@ export async function importAlertRulesTransfer(
     ...copyAlertRule(rule as AlertRule),
     id: startId + index,
   }));
-  alertRulesState.push(...imported);
+  alertRulesState[domain].push(...imported);
   return imported.map(copyAlertRule);
 }
 
@@ -237,7 +242,7 @@ export async function createAlertRule(
       ...data,
       channels: [...(data.channels ?? [])],
     };
-    alertRulesState.push(rule);
+    alertRulesState[domain].push(rule);
     return copyAlertRule(rule);
   }
   return opsApi.createAlertRule(data, domain);
@@ -248,10 +253,10 @@ export async function updateAlertRule(
   domain: AlertRuleDomain = 'CLUSTER',
 ): Promise<AlertRule> {
   if (isMockMode()) {
-    const index = alertRulesState.findIndex((rule) => rule.id === data.id);
+    const index = alertRulesState[domain].findIndex((rule) => rule.id === 
data.id);
     if (index < 0) throw new Error(`Alert rule not found: ${data.id}`);
     const rule = copyAlertRule(data);
-    alertRulesState[index] = rule;
+    alertRulesState[domain][index] = rule;
     return copyAlertRule(rule);
   }
   return opsApi.updateAlertRule(data, domain);
@@ -263,7 +268,7 @@ export async function toggleAlertRule(
   domain: AlertRuleDomain = 'CLUSTER',
 ): Promise<AlertRule> {
   if (isMockMode()) {
-    const rule = alertRulesState.find((item) => item.id === id);
+    const rule = alertRulesState[domain].find((item) => item.id === id);
     if (!rule) throw new Error(`Alert rule not found: ${id}`);
     rule.enabled = enabled;
     return copyAlertRule(rule);
@@ -276,8 +281,8 @@ export async function deleteAlertRule(
   domain: AlertRuleDomain = 'CLUSTER',
 ): Promise<void> {
   if (isMockMode()) {
-    const idx = alertRulesState.findIndex((rule) => rule.id === id);
-    if (idx >= 0) alertRulesState.splice(idx, 1);
+    const idx = alertRulesState[domain].findIndex((rule) => rule.id === id);
+    if (idx >= 0) alertRulesState[domain].splice(idx, 1);
     return;
   }
   return opsApi.deleteAlertRule(id, domain);
@@ -293,7 +298,7 @@ export async function bulkToggleAlertRules(
   const failures: Record<string, string> = {};
   const updatedRules: AlertRule[] = [];
   for (const id of [...new Set(ids)]) {
-    const rule = alertRulesState.find((item) => item.id === id);
+    const rule = alertRulesState[domain].find((item) => item.id === id);
     if (!rule) {
       failures[String(id)] = 'Alert rule not found';
       continue;
@@ -313,12 +318,12 @@ export async function bulkDeleteAlertRules(
   const succeededIds: number[] = [];
   const failures: Record<string, string> = {};
   for (const id of [...new Set(ids)]) {
-    const index = alertRulesState.findIndex((item) => item.id === id);
+    const index = alertRulesState[domain].findIndex((item) => item.id === id);
     if (index < 0) {
       failures[String(id)] = 'Alert rule not found';
       continue;
     }
-    alertRulesState.splice(index, 1);
+    alertRulesState[domain].splice(index, 1);
     succeededIds.push(id);
   }
   return { succeededIds, failures, updatedRules: [] };

Reply via email to