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 9e0e2d1b feat: export all Grafana dashboards as an archive (#1320)
9e0e2d1b is described below

commit 9e0e2d1b7fd273676bdad544adea55fa9da51cd8
Author: yx9o <[email protected]>
AuthorDate: Mon Aug 10 20:30:43 2026 +0800

    feat: export all Grafana dashboards as an archive (#1320)
---
 docs/api-spec.md                                   | 106 +++++++++++++++++++++
 .../grafana/GrafanaDashboardController.java        |  13 +++
 .../metrics/grafana/GrafanaDashboardService.java   |  27 ++++++
 .../grafana/GrafanaDashboardControllerTest.java    |  13 +++
 .../grafana/GrafanaDashboardServiceTest.java       |  30 ++++++
 web/src/api/metrics.test.ts                        |  11 +++
 web/src/api/metrics.ts                             |   7 ++
 web/src/components/GrafanaDashboardList.tsx        |  32 ++++++-
 .../__tests__/GrafanaDashboardList.test.tsx        |  39 +++++++-
 web/src/i18n/translations.ts                       |   3 +
 web/src/services/grafanaService.test.ts            |  19 ++++
 web/src/services/grafanaService.ts                 |  11 +++
 12 files changed, 307 insertions(+), 4 deletions(-)

diff --git a/docs/api-spec.md b/docs/api-spec.md
index b75f4f0d..46062bec 100644
--- a/docs/api-spec.md
+++ b/docs/api-spec.md
@@ -114,6 +114,10 @@
 | 71 | GET | `/api/ai/tools` | 可用工具列表 |
 | 72 | POST | `/api/ai/tools/:name/execute` | 执行只读 AI 工具 |
 | 73 | POST | `/api/metrics/query` | 查询监控指标数据 |
+| 74 | GET | `/api/metrics/grafana/dashboards` | Grafana 看板列表 |
+| 75 | GET | `/api/metrics/grafana/dashboards/:uid` | Grafana 看板 JSON 模型 |
+| 76 | GET | `/api/metrics/grafana/dashboards/:uid/export` | 导出单个 Grafana 看板 
JSON |
+| 77 | GET | `/api/metrics/grafana/dashboards/export` | 打包导出全部 Grafana 看板 |
 
 ## 通用响应格式
 
@@ -1907,6 +1911,108 @@ studio:
 | `503` | Prometheus 未配置或暂时不可用 |
 | `504` | Prometheus 查询超时 |
 
+### 16.2 Grafana 看板列表
+
+```
+GET /api/metrics/grafana/dashboards
+```
+
+返回内置的 RocketMQ Grafana 看板资产元数据。服务端只返回可以解析为 JSON
+对象且包含有效 `uid` 的看板;无效资产会被跳过。
+
+**Response `data`:** `GrafanaDashboardInfo[]`
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `uid` | `string` | Grafana 看板 UID,同时用于详情和导出接口 |
+| `title` | `string` | 看板标题 |
+| `description` | `string` | 看板说明 |
+| `tags` | `string[]` | 看板标签 |
+
+**Response 示例:**
+
+```json
+[
+  {
+    "uid": "rocketmq-overview",
+    "title": "RocketMQ Cluster Overview",
+    "description": "Overview dashboard for RocketMQ cluster metrics",
+    "tags": ["rocketmq"]
+  }
+]
+```
+
+### 16.3 获取 Grafana 看板 JSON 模型
+
+```
+GET /api/metrics/grafana/dashboards/:uid
+```
+
+**Path Parameters:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `uid` | `string` | 是 | Grafana 看板 UID |
+
+**Response `data`:** `Record<string, any>`
+
+响应体是完整 Grafana dashboard JSON 模型,前端可用于预览或复制配置。
+
+**错误响应:**
+
+| HTTP 状态 | 场景 |
+|-----------|------|
+| `404` | 指定 UID 的内置看板不存在 |
+| `500` | 看板 JSON 读取失败 |
+
+### 16.4 导出单个 Grafana 看板
+
+```
+GET /api/metrics/grafana/dashboards/:uid/export
+```
+
+**Path Parameters:**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `uid` | `string` | 是 | Grafana 看板 UID |
+
+**Response:**
+
+- `Content-Type: application/json`
+- `Content-Disposition: attachment; filename="{uid}.json"`
+- Body 为原始 Grafana dashboard JSON 文件内容。
+
+**错误响应:**
+
+| HTTP 状态 | 场景 |
+|-----------|------|
+| `404` | 指定 UID 的内置看板不存在 |
+| `500` | 看板 JSON 读取失败 |
+
+### 16.5 打包导出全部 Grafana 看板
+
+```
+GET /api/metrics/grafana/dashboards/export
+```
+
+返回全部有效内置 Grafana 看板的 zip 压缩包。压缩包内每个条目按
+`{uid}.json` 命名,条目集合与 `GET /api/metrics/grafana/dashboards`
+返回的可见看板列表保持一致。
+
+**Response:**
+
+- `Content-Type: application/zip`
+- `Content-Disposition: attachment; filename="rocketmq-grafana-dashboards.zip"`
+- Body 为 zip 文件二进制内容。
+
+**错误响应:**
+
+| HTTP 状态 | 场景 |
+|-----------|------|
+| `404` | 没有可导出的有效内置看板 |
+| `500` | 看板 JSON 读取或 zip 打包失败 |
+
 ---
 
 ## 附录 A:枚举值速查
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardController.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardController.java
index c288e86b..9f7f4d13 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardController.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardController.java
@@ -58,6 +58,19 @@ public class GrafanaDashboardController {
         return Result.ok(grafanaDashboardService.getDashboard(uid));
     }
 
+    @Operation(summary = "Export all Grafana dashboards",
+            description = "Returns all bundled RocketMQ Grafana dashboards as 
a downloadable zip archive")
+    @ApiResponse(responseCode = "200", description = "Dashboard archive 
returned")
+    @GetMapping("/dashboards/export")
+    public ResponseEntity<byte[]> exportDashboards() {
+        byte[] archive = grafanaDashboardService.getDashboardsArchive();
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.valueOf("application/zip"));
+        headers.setContentDisposition(
+                
ContentDisposition.attachment().filename("rocketmq-grafana-dashboards.zip").build());
+        return new ResponseEntity<>(archive, headers, HttpStatus.OK);
+    }
+
     @Operation(summary = "Export a Grafana dashboard JSON",
             description = "Returns the raw Grafana dashboard JSON as a 
downloadable attachment")
     @ApiResponse(responseCode = "200", description = "Dashboard JSON returned")
diff --git 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
index 180292f9..b37a6867 100644
--- 
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
+++ 
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
@@ -26,12 +26,15 @@ import 
org.springframework.core.io.support.PathMatchingResourcePatternResolver;
 import org.springframework.core.io.support.ResourcePatternResolver;
 import org.springframework.stereotype.Service;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 
 /**
  * Loads the Grafana dashboard JSON assets bundled under {@code 
classpath*:grafana/*.json}
@@ -111,6 +114,30 @@ public class GrafanaDashboardService {
         }
     }
 
+    /**
+     * Returns all valid bundled dashboards as a zip archive. Invalid 
dashboard assets are skipped
+     * the same way as {@link #listDashboards()} so the archive matches the 
visible dashboard list.
+     */
+    public byte[] getDashboardsArchive() {
+        List<GrafanaDashboardInfo> dashboards = listDashboards();
+        if (dashboards.isEmpty()) {
+            throw new BusinessException(404, "No Grafana dashboards are 
available");
+        }
+        try (ByteArrayOutputStream output = new ByteArrayOutputStream();
+             ZipOutputStream zip = new ZipOutputStream(output, 
StandardCharsets.UTF_8)) {
+            for (GrafanaDashboardInfo dashboard : dashboards) {
+                ZipEntry entry = new ZipEntry(dashboard.uid() + ".json");
+                zip.putNextEntry(entry);
+                
zip.write(getDashboardJson(dashboard.uid()).getBytes(StandardCharsets.UTF_8));
+                zip.closeEntry();
+            }
+            zip.finish();
+            return output.toByteArray();
+        } catch (IOException e) {
+            throw new BusinessException(500, "Failed to export Grafana 
dashboards");
+        }
+    }
+
     private Resource findResource(String uid) {
         for (Resource resource : resolveResources()) {
             if (uid.equals(uidOf(resource))) {
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardControllerTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardControllerTest.java
index e97c4781..905cf2ca 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardControllerTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardControllerTest.java
@@ -84,4 +84,17 @@ class GrafanaDashboardControllerTest {
                         "attachment; filename=\"rocketmq-overview.json\""))
                 .andExpect(content().json("{\"uid\":\"rocketmq-overview\"}"));
     }
+
+    @Test
+    void exportDashboardsShouldReturnZipAttachment() throws Exception {
+        byte[] archive = "zip-content".getBytes();
+        
when(grafanaDashboardService.getDashboardsArchive()).thenReturn(archive);
+
+        mockMvc.perform(get("/api/metrics/grafana/dashboards/export"))
+                .andExpect(status().isOk())
+                .andExpect(header().string("Content-Type", "application/zip"))
+                .andExpect(header().string("Content-Disposition",
+                        "attachment; 
filename=\"rocketmq-grafana-dashboards.zip\""))
+                .andExpect(content().bytes(archive));
+    }
 }
diff --git 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardServiceTest.java
 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardServiceTest.java
index 5513de9e..3bc0667a 100644
--- 
a/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardServiceTest.java
+++ 
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardServiceTest.java
@@ -22,13 +22,16 @@ import org.junit.jupiter.api.Test;
 import org.springframework.core.io.ByteArrayResource;
 import org.springframework.core.io.Resource;
 
+import java.io.ByteArrayInputStream;
 import java.io.InputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.List;
 import java.util.Map;
+import java.util.zip.ZipInputStream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -93,6 +96,33 @@ class GrafanaDashboardServiceTest {
         assertEquals(404, exception.getCode());
     }
 
+    @Test
+    void getDashboardsArchiveShouldIncludeAllVisibleDashboards() throws 
Exception {
+        GrafanaDashboardService service = serviceWithResources(
+                resource("b.json", 
"{\"uid\":\"b\",\"title\":\"B\",\"tags\":[\"rocketmq\"]}"),
+                resource("invalid.json", "[]"),
+                resource("a.json", 
"{\"uid\":\"a\",\"title\":\"A\",\"tags\":[\"rocketmq\"]}"));
+
+        byte[] archive = service.getDashboardsArchive();
+
+        try (ZipInputStream zip = new ZipInputStream(new 
ByteArrayInputStream(archive), StandardCharsets.UTF_8)) {
+            assertEquals("a.json", zip.getNextEntry().getName());
+            assertTrue(new String(zip.readAllBytes(), 
StandardCharsets.UTF_8).contains("\"uid\":\"a\""));
+            assertEquals("b.json", zip.getNextEntry().getName());
+            assertTrue(new String(zip.readAllBytes(), 
StandardCharsets.UTF_8).contains("\"uid\":\"b\""));
+            assertNull(zip.getNextEntry());
+        }
+    }
+
+    @Test
+    void getDashboardsArchiveShouldRejectEmptyDashboardSet() {
+        GrafanaDashboardService service = 
serviceWithResources(resource("invalid.json", "[]"));
+
+        BusinessException exception = assertThrows(BusinessException.class, 
service::getDashboardsArchive);
+
+        assertEquals(404, exception.getCode());
+    }
+
     @Test
     void listDashboardsShouldSkipEmptyAndNonObjectAssets() {
         GrafanaDashboardService service = serviceWithResources(
diff --git a/web/src/api/metrics.test.ts b/web/src/api/metrics.test.ts
index 919bcf5f..adb098bc 100644
--- a/web/src/api/metrics.test.ts
+++ b/web/src/api/metrics.test.ts
@@ -23,6 +23,7 @@ import {
   listGrafanaDashboards,
   getGrafanaDashboard,
   exportGrafanaDashboard,
+  exportGrafanaDashboards,
   listMetricProfiles,
   queryByDataSource,
   queryMetrics,
@@ -191,4 +192,14 @@ describe('metrics API', () => {
     expect(result).toBeInstanceOf(Blob);
     await expect(result.text()).resolves.toContain('rocketmq-overview');
   });
+
+  it('exports all Grafana dashboards as a blob', async () => {
+    const blob = new Blob(['zip-content'], { type: 'application/zip' });
+
+    mock.onGet('/metrics/grafana/dashboards/export').reply(200, blob);
+
+    const result = await exportGrafanaDashboards();
+    expect(result).toBeInstanceOf(Blob);
+    await expect(result.text()).resolves.toContain('zip-content');
+  });
 });
diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts
index 8179154b..0e48c65c 100644
--- a/web/src/api/metrics.ts
+++ b/web/src/api/metrics.ts
@@ -157,3 +157,10 @@ export async function exportGrafanaDashboard(uid: string): 
Promise<Blob> {
   );
   return res.data;
 }
+
+export async function exportGrafanaDashboards(): Promise<Blob> {
+  const res = await client.get<Blob>('/metrics/grafana/dashboards/export', {
+    responseType: 'blob',
+  });
+  return res.data;
+}
diff --git a/web/src/components/GrafanaDashboardList.tsx 
b/web/src/components/GrafanaDashboardList.tsx
index 251ae6d3..2a08d542 100644
--- a/web/src/components/GrafanaDashboardList.tsx
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -23,6 +23,7 @@ import { useLang } from '../i18n/LangContext';
 import {
   getGrafanaDashboard,
   exportGrafanaDashboard,
+  exportGrafanaDashboards,
   listGrafanaDashboards,
 } from '../services/grafanaService';
 import type { GrafanaDashboardInfo } from '../api/metrics';
@@ -39,6 +40,7 @@ export const GrafanaDashboardList: React.FC = () => {
   const [viewLoading, setViewLoading] = useState(false);
   const viewRequestId = useRef(0);
   const [exportingUids, setExportingUids] = useState<Set<string>>(() => new 
Set());
+  const [exportingAll, setExportingAll] = useState(false);
 
   useEffect(() => {
     let cancelled = false;
@@ -87,13 +89,13 @@ export const GrafanaDashboardList: React.FC = () => {
     setViewLoading(false);
   };
 
-  const triggerDownload = (uid: string, content: Blob | string) => {
+  const triggerDownload = (filename: string, content: Blob | string) => {
     const blob =
       typeof content === 'string' ? new Blob([content], { type: 
'application/json' }) : content;
     const url = URL.createObjectURL(blob);
     const a = document.createElement('a');
     a.href = url;
-    a.download = `${uid}.json`;
+    a.download = filename;
     a.click();
     URL.revokeObjectURL(url);
   };
@@ -102,7 +104,7 @@ export const GrafanaDashboardList: React.FC = () => {
     setExportingUids((current) => new Set(current).add(info.uid));
     try {
       const blob = await exportGrafanaDashboard(info.uid);
-      triggerDownload(info.uid, blob);
+      triggerDownload(`${info.uid}.json`, blob);
       message.success(t('grafana.exported'));
     } catch {
       message.error(t('grafana.exportFailed'));
@@ -115,6 +117,19 @@ export const GrafanaDashboardList: React.FC = () => {
     }
   };
 
+  const handleExportAll = async () => {
+    setExportingAll(true);
+    try {
+      const blob = await exportGrafanaDashboards();
+      triggerDownload('rocketmq-grafana-dashboards.zip', blob);
+      message.success(t('grafana.exportAllDone'));
+    } catch {
+      message.error(t('grafana.exportAllFailed'));
+    } finally {
+      setExportingAll(false);
+    }
+  };
+
   const columns: ColumnsType<GrafanaDashboardInfo> = [
     {
       title: t('grafana.title'),
@@ -166,6 +181,17 @@ export const GrafanaDashboardList: React.FC = () => {
 
   return (
     <div>
+      <Space style={{ marginBottom: 12 }}>
+        <Button
+          icon={<DownloadSimple size={16} />}
+          loading={exportingAll}
+          disabled={loading || dashboards.length === 0}
+          onClick={handleExportAll}
+        >
+          {t('grafana.exportAll')}
+        </Button>
+      </Space>
+
       <Table
         columns={columns}
         dataSource={dashboards}
diff --git a/web/src/components/__tests__/GrafanaDashboardList.test.tsx 
b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
index 618ecb4c..fd049ad7 100644
--- a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
+++ b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
@@ -23,6 +23,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, 
it, vi } from 'vite
 import { LangProvider } from '../../i18n/LangContext';
 import {
   exportGrafanaDashboard,
+  exportGrafanaDashboards,
   getGrafanaDashboard,
   listGrafanaDashboards,
 } from '../../services/grafanaService';
@@ -32,6 +33,7 @@ vi.mock('../../services/grafanaService', () => ({
   listGrafanaDashboards: vi.fn(),
   getGrafanaDashboard: vi.fn(),
   exportGrafanaDashboard: vi.fn(),
+  exportGrafanaDashboards: vi.fn(),
 }));
 
 const dashboards = [
@@ -73,6 +75,9 @@ beforeEach(() => {
   vi.mocked(exportGrafanaDashboard).mockResolvedValue(
     new Blob([JSON.stringify(dashboardModel, null, 2)], { type: 
'application/json' }),
   );
+  vi.mocked(exportGrafanaDashboards).mockResolvedValue(
+    new Blob(['zip-content'], { type: 'application/zip' }),
+  );
 });
 
 afterEach(() => {
@@ -189,7 +194,7 @@ describe('GrafanaDashboardList', () => {
 
     await screen.findByText('RocketMQ Cluster Overview');
     const exportButtons = screen.getAllByRole('button', { name: /Export|导出/ });
-    await user.click(exportButtons[0]);
+    await user.click(exportButtons[1]);
 
     await waitFor(() => 
expect(exportGrafanaDashboard).toHaveBeenCalledWith('rocketmq-overview'));
     expect(createObjectURL).toHaveBeenCalledTimes(1);
@@ -197,4 +202,36 @@ describe('GrafanaDashboardList', () => {
 
     clickSpy.mockRestore();
   });
+
+  it('exports all dashboards and downloads the archive', async () => {
+    const user = userEvent.setup();
+    const createObjectURL = vi.fn().mockReturnValue('blob:grafana-all');
+    const revokeObjectURL = vi.fn();
+    let downloadedFilename = '';
+    const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 
'click').mockImplementation(function (
+      this: HTMLAnchorElement,
+    ) {
+      downloadedFilename = this.download;
+    });
+    Object.defineProperty(URL, 'createObjectURL', { writable: true, value: 
createObjectURL });
+    Object.defineProperty(URL, 'revokeObjectURL', { writable: true, value: 
revokeObjectURL });
+
+    render(
+      <App>
+        <LangProvider>
+          <GrafanaDashboardList />
+        </LangProvider>
+      </App>,
+    );
+
+    await screen.findByText('RocketMQ Cluster Overview');
+    await user.click(screen.getByRole('button', { name: /Export all|导出全部/ }));
+
+    await waitFor(() => 
expect(exportGrafanaDashboards).toHaveBeenCalledTimes(1));
+    expect(downloadedFilename).toBe('rocketmq-grafana-dashboards.zip');
+    expect(createObjectURL).toHaveBeenCalledTimes(1);
+    expect(clickSpy).toHaveBeenCalled();
+
+    clickSpy.mockRestore();
+  });
 });
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index ba45a2f7..2199fe09 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -815,6 +815,9 @@ const translations: Record<string, Record<Lang, string>> = {
   'grafana.loadFailed': { zh: '加载看板失败', en: 'Failed to load dashboards' },
   'grafana.exported': { zh: '看板已导出', en: 'Dashboard exported' },
   'grafana.exportFailed': { zh: '导出看板失败', en: 'Failed to export dashboard' },
+  'grafana.exportAll': { zh: '导出全部', en: 'Export all' },
+  'grafana.exportAllDone': { zh: '全部看板已导出', en: 'All dashboards exported' },
+  'grafana.exportAllFailed': { zh: '导出全部看板失败', en: 'Failed to export 
dashboards' },
   // ─── Alert rule templates ───
   'alertAssets.title': { zh: '告警规则模板', en: 'Alert Rule Templates' },
   'alertAssets.name': { zh: '名称', en: 'Name' },
diff --git a/web/src/services/grafanaService.test.ts 
b/web/src/services/grafanaService.test.ts
index b91bb4c2..40e835c2 100644
--- a/web/src/services/grafanaService.test.ts
+++ b/web/src/services/grafanaService.test.ts
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 
'vitest';
 import * as grafanaApi from '../api/metrics';
 import {
   exportGrafanaDashboard,
+  exportGrafanaDashboards,
   getGrafanaDashboard,
   listGrafanaDashboards,
 } from './grafanaService';
@@ -44,6 +45,13 @@ describe('grafanaService', () => {
     await expect(blob.text()).resolves.toContain('rocketmq-overview');
   });
 
+  it('exports all dashboards as a blob in mock mode', async () => {
+    const blob = await exportGrafanaDashboards();
+    expect(blob).toBeInstanceOf(Blob);
+    expect(blob.type).toBe('application/zip');
+    await expect(blob.text()).resolves.toContain('rocketmq-overview.json');
+  });
+
   it('delegates to the api in real mode', async () => {
     isMockModeMock.isMockMode = () => false;
     const listSpy = vi
@@ -56,4 +64,15 @@ describe('grafanaService', () => {
     expect(listSpy).toHaveBeenCalledTimes(1);
     expect(result[0].uid).toBe('rocketmq-overview');
   });
+
+  it('delegates bulk export to the api in real mode', async () => {
+    isMockModeMock.isMockMode = () => false;
+    const exportSpy = vi
+      .spyOn(grafanaApi, 'exportGrafanaDashboards')
+      .mockResolvedValue(new Blob(['zip-content'], { type: 'application/zip' 
}));
+
+    const result = await exportGrafanaDashboards();
+    expect(exportSpy).toHaveBeenCalledTimes(1);
+    expect(result.type).toBe('application/zip');
+  });
 });
diff --git a/web/src/services/grafanaService.ts 
b/web/src/services/grafanaService.ts
index 1b8bb84b..1568df8a 100644
--- a/web/src/services/grafanaService.ts
+++ b/web/src/services/grafanaService.ts
@@ -37,3 +37,14 @@ export async function exportGrafanaDashboard(uid: string): 
Promise<Blob> {
   }
   return metricsApi.exportGrafanaDashboard(uid);
 }
+
+export async function exportGrafanaDashboards(): Promise<Blob> {
+  if (isMockMode()) {
+    const bundle = mockGrafanaDashboards.map(({ uid, model }) => ({
+      filename: `${uid}.json`,
+      model,
+    }));
+    return new Blob([JSON.stringify(bundle, null, 2)], { type: 
'application/zip' });
+  }
+  return metricsApi.exportGrafanaDashboards();
+}

Reply via email to