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: [] };