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 bdb8fdea feat(metrics): bundle Grafana dashboards with view and export
(#926)
bdb8fdea is described below
commit bdb8fdea315fc225c44f3dbdab2da181dc8cab0e
Author: zhaohai <[email protected]>
AuthorDate: Tue Aug 4 18:03:46 2026 +0800
feat(metrics): bundle Grafana dashboards with view and export (#926)
* feat(metrics): bundle Grafana dashboards with view/export (METRICS-01)
Ship 12 ready-to-use Grafana dashboard JSON assets for RocketMQ (overview,
broker, producer, consumer, topic, tps, storage, jvm, thread-pool, dlq,
latency, network) and expose them through the dashboard.
Backend:
- GrafanaDashboardInfo record + GrafanaDashboardService (loads
classpath*:grafana/*.json)
- GrafanaDashboardController: list / get model / export (attachment)
endpoints
- backend unit tests (8 passing)
Frontend:
- GrafanaDashboardList component + GrafanaDashboards page + nav entry +
route
- metrics api (grafana) + grafanaService + mock + i18n keys
- frontend unit tests (14 passing)
* fix(ci): resolve tsc -b type errors in grafana dashboard frontend
- GrafanaDashboardList.tsx: drop unused antd `message` import (used only via
App.useApp()); remove unused default `React` import (automatic JSX
runtime).
- grafanaService.test.ts: widen the vi.hoisted isMockMode mock to `() =>
boolean`
so switching it to `() => false` in the real-mode case no longer fails
with
TS2322 under `tsc -b`.
Co-Authored-By: WorkBuddy <[email protected]>
---------
Co-authored-by: WorkBuddy <[email protected]>
---
server/scripts/gen_grafana_dashboards.py | 293 +++++++++++++++++++
.../grafana/GrafanaDashboardController.java | 72 +++++
.../metrics/grafana/GrafanaDashboardInfo.java | 27 ++
.../metrics/grafana/GrafanaDashboardService.java | 153 ++++++++++
.../main/resources/grafana/rocketmq-broker.json | 234 +++++++++++++++
.../main/resources/grafana/rocketmq-consumer.json | 193 ++++++++++++
.../src/main/resources/grafana/rocketmq-dlq.json | 150 ++++++++++
.../src/main/resources/grafana/rocketmq-jvm.json | 234 +++++++++++++++
.../main/resources/grafana/rocketmq-latency.json | 150 ++++++++++
.../main/resources/grafana/rocketmq-network.json | 193 ++++++++++++
.../main/resources/grafana/rocketmq-overview.json | 322 +++++++++++++++++++++
.../main/resources/grafana/rocketmq-producer.json | 193 ++++++++++++
.../main/resources/grafana/rocketmq-storage.json | 192 ++++++++++++
.../resources/grafana/rocketmq-threadpool.json | 192 ++++++++++++
.../src/main/resources/grafana/rocketmq-topic.json | 192 ++++++++++++
.../src/main/resources/grafana/rocketmq-tps.json | 192 ++++++++++++
.../grafana/GrafanaDashboardControllerTest.java | 87 ++++++
.../grafana/GrafanaDashboardServiceTest.java | 80 +++++
web/src/App.tsx | 2 +
web/src/api/metrics.test.ts | 49 +++-
web/src/api/metrics.ts | 28 ++
web/src/components/GrafanaDashboardList.tsx | 188 ++++++++++++
.../__tests__/GrafanaDashboardList.test.tsx | 141 +++++++++
web/src/i18n/translations.ts | 9 +
web/src/layouts/MainLayout.tsx | 3 +
web/src/mock/grafanaDashboards.ts | 184 ++++++++++++
web/src/pages/studio/GrafanaDashboards.tsx | 44 +++
web/src/services/grafanaService.test.ts | 59 ++++
web/src/services/grafanaService.ts | 39 +++
29 files changed, 3894 insertions(+), 1 deletion(-)
diff --git a/server/scripts/gen_grafana_dashboards.py
b/server/scripts/gen_grafana_dashboards.py
new file mode 100644
index 00000000..fe3a3c4e
--- /dev/null
+++ b/server/scripts/gen_grafana_dashboards.py
@@ -0,0 +1,293 @@
+#!/usr/bin/env python3
+"""Generate RocketMQ Grafana dashboard JSON assets for the dashboard project.
+
+Each dashboard is a standalone Grafana dashboard model (schemaVersion 39) that
+queries Prometheus-compatible RocketMQ metrics. The JSON files are written to
+server/src/main/resources/grafana/ and loaded at runtime by
GrafanaDashboardService.
+"""
+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}"
+
+
+def ts_panel(panel_id, title, expr, grid, y, legend=None, unit="short"):
+ target = {
+ "datasource": {"type": "prometheus", "uid": DS},
+ "expr": expr,
+ "legendFormat": legend or "",
+ "refId": "A",
+ }
+ return {
+ "id": panel_id,
+ "title": title,
+ "type": "timeseries",
+ "datasource": {"type": "prometheus", "uid": DS},
+ "gridPos": {"h": 8, "w": grid, "x": 0, "y": y},
+ "fieldConfig": {
+ "defaults": {"custom": {"drawStyle": "line", "fillOpacity": 10},
"unit": unit},
+ "overrides": [],
+ },
+ "options": {"legend": {"displayMode": "list", "placement": "bottom"}},
+ "targets": [target],
+ }
+
+
+def stat_panel(panel_id, title, expr, grid, y, unit="short"):
+ return {
+ "id": panel_id,
+ "title": title,
+ "type": "stat",
+ "datasource": {"type": "prometheus", "uid": DS},
+ "gridPos": {"h": 6, "w": grid, "x": 0, "y": y},
+ "fieldConfig": {
+ "defaults": {"unit": unit, "custom": {"thresholdsStyle": {"mode":
"none"}}},
+ "overrides": [],
+ },
+ "options": {"reduceOptions": {"calcs": ["lastNotNull"]}},
+ "targets": [{"datasource": {"type": "prometheus", "uid": DS}, "expr":
expr, "refId": "A"}],
+ }
+
+
+def gauge_panel(panel_id, title, expr, grid, y, max_=100):
+ return {
+ "id": panel_id,
+ "title": title,
+ "type": "gauge",
+ "datasource": {"type": "prometheus", "uid": DS},
+ "gridPos": {"h": 8, "w": grid, "x": 0, "y": y},
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percent",
+ "max": max_,
+ "custom": {"min": 0},
+ },
+ "overrides": [],
+ },
+ "options": {"reduceOptions": {"calcs": ["lastNotNull"]}},
+ "targets": [{"datasource": {"type": "prometheus", "uid": DS}, "expr":
expr, "refId": "A"}],
+ }
+
+
+def template_vars(extra=None):
+ base = [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0,
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {"type": "prometheus", "uid": DS},
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0,
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {"type": "prometheus", "uid": DS},
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0,
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {"type": "prometheus", "uid": DS},
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0,
+ },
+ ]
+ if extra:
+ base.extend(extra)
+ return {"list": base}
+
+
+def dashboard(uid, title, description, panels, vars_=None):
+ return {
+ "uid": uid,
+ "title": title,
+ "description": description,
+ "tags": ["rocketmq"],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": True,
+ "templating": vars_ or template_vars(),
+ "time": {"from": "now-6h", "to": "now"},
+ "refresh": "30s",
+ "panels": panels,
+ }
+
+
+def y_stack(panels):
+ """Assign gridPos y offsets sequentially (2 per row of w=12)."""
+ 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
+
+
+specs = []
+
+# 1. Overview
+specs.append((
+ "rocketmq-overview", "RocketMQ Cluster Overview",
+ "Cluster-wide throughput, topic/group counts and producer footprint.",
+ [
+ ts_panel(1, "Messages In TPS",
"sum(rate(rocketmq_messages_in_total{cluster=\"$cluster\"}[1m]))", 12, 0, "by
cluster", "ops"),
+ ts_panel(2, "Messages Out TPS",
"sum(rate(rocketmq_messages_out_total{cluster=\"$cluster\"}[1m]))", 12, 0, "by
cluster", "ops"),
+ stat_panel(3, "Total Topics", "count(count by (topic)
(rocketmq_messages_in_total{cluster=\"$cluster\"}))", 6, 8),
+ stat_panel(4, "Total Consumer Groups", "count(count by (group)
(rocketmq_messages_out_total{cluster=\"$cluster\"}))", 6, 8),
+ stat_panel(5, "Producer Count",
"max(rocketmq_producer_count{cluster=\"$cluster\"})", 6, 8),
+ stat_panel(6, "Broker Count",
"count(rocketmq_messages_in_total{cluster=\"$cluster\"})", 6, 8),
+ ],
+))
+
+# 2. Broker
+specs.append((
+ "rocketmq-broker", "RocketMQ Broker",
+ "Per-broker throughput, dispatch backlog and thread pool pressure.",
+ [
+ ts_panel(1, "Broker Messages In TPS", "sum by (broker)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\",broker=\"$broker\"}[1m]))",
12, 0, "{{broker}}", "ops"),
+ ts_panel(2, "Broker Messages Out TPS", "sum by (broker)
(rate(rocketmq_messages_out_total{cluster=\"$cluster\",broker=\"$broker\"}[1m]))",
12, 0, "{{broker}}", "ops"),
+ ts_panel(3, "Dispatch Behind Bytes",
"rocketmq_dispatch_behind_bytes{cluster=\"$cluster\",broker=\"$broker\"}", 12,
8, "{{broker}}", "bytes"),
+ ts_panel(4, "Thread Pool Queue Size",
"rocketmq_threadpool_queue_size{cluster=\"$cluster\",broker=\"$broker\"}", 12,
8, "{{broker}}"),
+ ],
+))
+
+# 3. Producer
+specs.append((
+ "rocketmq-producer", "RocketMQ Producer",
+ "Producer presence, send size and per-topic ingress.",
+ [
+ stat_panel(1, "Producer Count",
"max(rocketmq_producer_count{cluster=\"$cluster\"})", 6, 0),
+ ts_panel(2, "Producer Message Size",
"rocketmq_producer_message_size{cluster=\"$cluster\"}", 12, 0, "{{topic}}",
"bytes"),
+ ts_panel(3, "Messages In by Topic", "sum by (topic)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\"}[1m]))", 24, 8,
"{{topic}}", "ops"),
+ ],
+))
+
+# 4. Consumer
+specs.append((
+ "rocketmq-consumer", "RocketMQ Consumer",
+ "Consumer group egress, lag and client footprint.",
+ [
+ stat_panel(1, "Consumer Count",
"max(rocketmq_consumer_count{cluster=\"$cluster\"})", 6, 0),
+ ts_panel(2, "Messages Out TPS by Group", "sum by (group)
(rate(rocketmq_messages_out_total{cluster=\"$cluster\",group=\"$group\"}[1m]))",
12, 0, "{{group}}", "ops"),
+ ts_panel(3, "Consumer Message Size",
"rocketmq_consumer_message_size{cluster=\"$cluster\"}", 12, 8, "{{group}}",
"bytes"),
+ ],
+))
+
+# 5. Topic
+specs.append((
+ "rocketmq-topic", "RocketMQ Topic",
+ "Per-topic ingress/egress throughput and dispatch backlog.",
+ [
+ ts_panel(1, "Topic Messages In TPS", "sum by (topic)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\",topic=\"$topic\"}[1m]))",
12, 0, "{{topic}}", "ops"),
+ ts_panel(2, "Topic Messages Out TPS", "sum by (topic)
(rate(rocketmq_messages_out_total{cluster=\"$cluster\",topic=\"$topic\"}[1m]))",
12, 0, "{{topic}}", "ops"),
+ ts_panel(3, "Topic Dispatch Behind Bytes",
"rocketmq_dispatch_behind_bytes{cluster=\"$cluster\",topic=\"$topic\"}", 24, 8,
"{{topic}}", "bytes"),
+ ],
+))
+
+# 6. TPS
+specs.append((
+ "rocketmq-tps", "RocketMQ TPS",
+ "Cluster and per-broker message throughput trends.",
+ [
+ ts_panel(1, "Cluster TPS In",
"sum(rate(rocketmq_messages_in_total{cluster=\"$cluster\"}[1m]))", 12, 0, "",
"ops"),
+ ts_panel(2, "Cluster TPS Out",
"sum(rate(rocketmq_messages_out_total{cluster=\"$cluster\"}[1m]))", 12, 0, "",
"ops"),
+ ts_panel(3, "Per-Broker TPS In", "sum by (broker)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\",broker=\"$broker\"}[1m]))",
24, 8, "{{broker}}", "ops"),
+ ],
+))
+
+# 7. Storage
+specs.append((
+ "rocketmq-storage", "RocketMQ Storage",
+ "Broker disk usage and JVM heap footprint.",
+ [
+ gauge_panel(1, "Disk Use Ratio",
"rocketmq_disk_use_ratio{cluster=\"$cluster\",broker=\"$broker\"}", 12, 0),
+ ts_panel(2, "JVM Heap Used",
"jvm_memory_used_bytes{area=\"heap\",cluster=\"$cluster\",broker=\"$broker\"}",
12, 0, "{{broker}}", "bytes"),
+ ts_panel(3, "Dispatch Behind Bytes",
"rocketmq_dispatch_behind_bytes{cluster=\"$cluster\",broker=\"$broker\"}", 24,
8, "{{broker}}", "bytes"),
+ ],
+))
+
+# 8. JVM
+specs.append((
+ "rocketmq-jvm", "RocketMQ Broker JVM",
+ "JVM memory, threads and garbage collection for brokers.",
+ [
+ ts_panel(1, "JVM Heap",
"jvm_memory_used_bytes{area=\"heap\",cluster=\"$cluster\",broker=\"$broker\"}",
12, 0, "{{broker}}", "bytes"),
+ ts_panel(2, "JVM Non-Heap",
"jvm_memory_used_bytes{area=\"nonheap\",cluster=\"$cluster\",broker=\"$broker\"}",
12, 0, "{{broker}}", "bytes"),
+ ts_panel(3, "Live Threads",
"jvm_threads_live_threads{cluster=\"$cluster\",broker=\"$broker\"}", 12, 8,
"{{broker}}"),
+ ts_panel(4, "GC Pause (1m rate)",
"rate(jvm_gc_pause_seconds_sum{cluster=\"$cluster\",broker=\"$broker\"}[1m])",
12, 8, "{{broker}}", "s"),
+ ],
+))
+
+# 9. Thread pool
+specs.append((
+ "rocketmq-threadpool", "RocketMQ Thread Pool",
+ "Broker thread pool queue depth, capacity and rejections.",
+ [
+ ts_panel(1, "Queue Size",
"rocketmq_threadpool_queue_size{cluster=\"$cluster\",broker=\"$broker\"}", 12,
0, "{{broker}}"),
+ ts_panel(2, "Queue Capacity",
"rocketmq_threadpool_queue_capacity{cluster=\"$cluster\",broker=\"$broker\"}",
12, 0, "{{broker}}"),
+ ts_panel(3, "Reject Count (1m)",
"increase(rocketmq_threadpool_reject_count{cluster=\"$cluster\",broker=\"$broker\"}[1m])",
24, 8, "{{broker}}"),
+ ],
+))
+
+# 10. DLQ
+specs.append((
+ "rocketmq-dlq", "RocketMQ DLQ & Retry",
+ "Dead-letter queue resend volume and latency.",
+ [
+ ts_panel(1, "DLQ Resend Count (1m)",
"rate(rocketmq_dlq_resend_count{cluster=\"$cluster\"}[1m])", 12, 0,
"{{topic}}"),
+ ts_panel(2, "DLQ Resend Latency",
"rocketmq_dlq_resend_latency{cluster=\"$cluster\"}", 12, 0, "{{topic}}", "s"),
+ ],
+))
+
+# 11. Latency
+specs.append((
+ "rocketmq-latency", "RocketMQ Latency",
+ "Dispatch and client push latency percentiles.",
+ [
+ ts_panel(1, "Dispatch Latency p99", "histogram_quantile(0.99, sum by
(le) (rate(rocketmq_dispatch_latency_bucket{cluster=\"$cluster\"}[5m])))", 12,
0, "", "s"),
+ ts_panel(2, "Send To Client Latency p99", "histogram_quantile(0.99,
sum by (le)
(rate(rocketmq_send_to_client_latency_bucket{cluster=\"$cluster\"}[5m])))", 12,
0, "", "s"),
+ ],
+))
+
+# 12. Network
+specs.append((
+ "rocketmq-network", "RocketMQ Network & Connections",
+ "Client connections and produced/consumed connection counts.",
+ [
+ stat_panel(1, "Client Connections",
"rocketmq_producer_count{cluster=\"$cluster\"} +
rocketmq_consumer_count{cluster=\"$cluster\"}", 6, 0),
+ ts_panel(2, "Connections by Broker",
"rocketmq_connection_count{cluster=\"$cluster\",broker=\"$broker\"}", 12, 0,
"{{broker}}"),
+ ts_panel(3, "Producer Connections",
"rocketmq_producer_count{cluster=\"$cluster\"}", 12, 8, "{{broker}}"),
+ ],
+))
+
+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)")
+
+print(f"TOTAL dashboards: {len(specs)}")
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
new file mode 100644
index 00000000..fd8c9197
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardController.java
@@ -0,0 +1,72 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.grafana;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.apache.rocketmq.studio.common.domain.Result;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/metrics/grafana")
+@RequiredArgsConstructor
+public class GrafanaDashboardController {
+
+ private final GrafanaDashboardService grafanaDashboardService;
+
+ @Operation(summary = "List bundled Grafana dashboards",
+ description = "Returns metadata for every RocketMQ Grafana
dashboard shipped with the dashboard")
+ @ApiResponse(responseCode = "200", description = "Dashboards listed
successfully", useReturnTypeSchema = true)
+ @GetMapping("/dashboards")
+ public Result<List<GrafanaDashboardInfo>> listDashboards() {
+ return Result.ok(grafanaDashboardService.listDashboards());
+ }
+
+ @Operation(summary = "Get a Grafana dashboard model",
+ description = "Returns the parsed Grafana dashboard JSON for the
given uid")
+ @ApiResponse(responseCode = "200", description = "Dashboard returned
successfully", useReturnTypeSchema = true)
+ @ApiResponse(responseCode = "404", description = "Dashboard uid is
unknown")
+ @GetMapping("/dashboards/{uid}")
+ public Result<Map<String, Object>> getDashboard(@PathVariable("uid")
String uid) {
+ return Result.ok(grafanaDashboardService.getDashboard(uid));
+ }
+
+ @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")
+ @ApiResponse(responseCode = "404", description = "Dashboard uid is
unknown")
+ @GetMapping("/dashboards/{uid}/export")
+ public ResponseEntity<byte[]> exportDashboard(@PathVariable("uid") String
uid) {
+ String json = grafanaDashboardService.getDashboardJson(uid);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.setContentDispositionFormData("attachment", uid + ".json");
+ return new ResponseEntity<>(json.getBytes(StandardCharsets.UTF_8),
headers, HttpStatus.OK);
+ }
+}
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardInfo.java
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardInfo.java
new file mode 100644
index 00000000..44f74ec1
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardInfo.java
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.grafana;
+
+import java.util.List;
+
+/**
+ * Metadata describing a bundled Grafana dashboard. The {@code uid} matches the
+ * JSON file name (without the {@code .json} suffix) so dashboards can be
looked
+ * up deterministically.
+ */
+public record GrafanaDashboardInfo(String uid, String title, String
description, List<String> tags) {
+}
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
new file mode 100644
index 00000000..d8913a11
--- /dev/null
+++
b/server/src/main/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardService.java
@@ -0,0 +1,153 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.grafana;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.io.support.ResourcePatternResolver;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Loads the Grafana dashboard JSON assets bundled under {@code
classpath*:grafana/*.json}
+ * and exposes them for listing, viewing and exporting.
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class GrafanaDashboardService {
+
+ private static final String LOCATION_PATTERN = "classpath*:grafana/*.json";
+
+ private final ObjectMapper objectMapper;
+ private final ResourcePatternResolver resourceResolver = new
PathMatchingResourcePatternResolver();
+
+ /**
+ * Lists metadata for every bundled Grafana dashboard.
+ */
+ public List<GrafanaDashboardInfo> listDashboards() {
+ List<GrafanaDashboardInfo> infos = new ArrayList<>();
+ for (Resource resource : resolveResources()) {
+ String uid = uidOf(resource);
+ if (uid == null) {
+ continue;
+ }
+ try (InputStream in = resource.getInputStream()) {
+ JsonNode root = objectMapper.readTree(in);
+ String title = textOr(root, "title", uid);
+ String description = textOr(root, "description", "");
+ List<String> tags = parseTags(root);
+ infos.add(new GrafanaDashboardInfo(uid, title, description,
tags));
+ } catch (IOException e) {
+ log.warn("Skipping unreadable Grafana dashboard resource {}:
{}", resource, e.getMessage());
+ }
+ }
+ infos.sort((a, b) -> a.uid().compareTo(b.uid()));
+ return infos;
+ }
+
+ /**
+ * Returns the parsed dashboard model for the given uid.
+ *
+ * @throws BusinessException with code 404 when the uid is unknown
+ */
+ public Map<String, Object> getDashboard(String uid) {
+ Resource resource = findResource(uid);
+ if (resource == null) {
+ throw new BusinessException(404, "Grafana dashboard not found: " +
uid);
+ }
+ try (InputStream in = resource.getInputStream()) {
+ @SuppressWarnings("unchecked")
+ Map<String, Object> model = objectMapper.readValue(in, Map.class);
+ return model;
+ } catch (IOException e) {
+ throw new BusinessException(500, "Failed to read Grafana
dashboard: " + uid);
+ }
+ }
+
+ /**
+ * Returns the raw dashboard JSON for the given uid (used for export).
+ *
+ * @throws BusinessException with code 404 when the uid is unknown
+ */
+ public String getDashboardJson(String uid) {
+ Resource resource = findResource(uid);
+ if (resource == null) {
+ throw new BusinessException(404, "Grafana dashboard not found: " +
uid);
+ }
+ try (InputStream in = resource.getInputStream()) {
+ return new String(in.readAllBytes());
+ } catch (IOException e) {
+ throw new BusinessException(500, "Failed to read Grafana
dashboard: " + uid);
+ }
+ }
+
+ private Resource findResource(String uid) {
+ for (Resource resource : resolveResources()) {
+ if (uid.equals(uidOf(resource))) {
+ return resource;
+ }
+ }
+ return null;
+ }
+
+ private Resource[] resolveResources() {
+ try {
+ return resourceResolver.getResources(LOCATION_PATTERN);
+ } catch (IOException e) {
+ log.warn("Unable to resolve Grafana dashboard resources: {}",
e.getMessage());
+ return new Resource[0];
+ }
+ }
+
+ private static String uidOf(Resource resource) {
+ String filename = resource.getFilename();
+ if (filename == null || !filename.endsWith(".json")) {
+ return null;
+ }
+ return filename.substring(0, filename.length() - ".json".length());
+ }
+
+ private static String textOr(JsonNode root, String field, String fallback)
{
+ JsonNode node = root.get(field);
+ return node != null && node.isTextual() ? node.asText() : fallback;
+ }
+
+ private static List<String> parseTags(JsonNode root) {
+ JsonNode tags = root.get("tags");
+ if (tags == null || !tags.isArray()) {
+ return List.of();
+ }
+ List<String> result = new ArrayList<>();
+ tags.forEach(tag -> {
+ if (tag.isTextual()) {
+ result.add(tag.asText());
+ }
+ });
+ return result;
+ }
+}
diff --git a/server/src/main/resources/grafana/rocketmq-broker.json
b/server/src/main/resources/grafana/rocketmq-broker.json
new file mode 100644
index 00000000..100dd31f
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-broker.json
@@ -0,0 +1,234 @@
+{
+ "uid": "rocketmq-broker",
+ "title": "RocketMQ Broker",
+ "description": "Per-broker throughput, dispatch backlog and thread pool
pressure.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Broker Messages In TPS",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "sum by (broker)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\",broker=\"$broker\"}[1m]))",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Broker Messages Out TPS",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "sum by (broker)
(rate(rocketmq_messages_out_total{cluster=\"$cluster\",broker=\"$broker\"}[1m]))",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Dispatch Behind Bytes",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_dispatch_behind_bytes{cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "title": "Thread Pool Queue Size",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 24
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_threadpool_queue_size{cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-consumer.json
b/server/src/main/resources/grafana/rocketmq-consumer.json
new file mode 100644
index 00000000..417052ad
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-consumer.json
@@ -0,0 +1,193 @@
+{
+ "uid": "rocketmq-consumer",
+ "title": "RocketMQ Consumer",
+ "description": "Consumer group egress, lag and client footprint.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Consumer Count",
+ "type": "stat",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 6,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "thresholdsStyle": {
+ "mode": "none"
+ }
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "max(rocketmq_consumer_count{cluster=\"$cluster\"})",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Messages Out TPS by Group",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 6
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "sum by (group)
(rate(rocketmq_messages_out_total{cluster=\"$cluster\",group=\"$group\"}[1m]))",
+ "legendFormat": "{{group}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Consumer Message Size",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 14
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rocketmq_consumer_message_size{cluster=\"$cluster\"}",
+ "legendFormat": "{{group}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-dlq.json
b/server/src/main/resources/grafana/rocketmq-dlq.json
new file mode 100644
index 00000000..6fe5a12f
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-dlq.json
@@ -0,0 +1,150 @@
+{
+ "uid": "rocketmq-dlq",
+ "title": "RocketMQ DLQ & Retry",
+ "description": "Dead-letter queue resend volume and latency.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "DLQ Resend Count (1m)",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rate(rocketmq_dlq_resend_count{cluster=\"$cluster\"}[1m])",
+ "legendFormat": "{{topic}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "DLQ Resend Latency",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "s"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rocketmq_dlq_resend_latency{cluster=\"$cluster\"}",
+ "legendFormat": "{{topic}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-jvm.json
b/server/src/main/resources/grafana/rocketmq-jvm.json
new file mode 100644
index 00000000..9d9a69f7
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-jvm.json
@@ -0,0 +1,234 @@
+{
+ "uid": "rocketmq-jvm",
+ "title": "RocketMQ Broker JVM",
+ "description": "JVM memory, threads and garbage collection for brokers.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "JVM Heap",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"jvm_memory_used_bytes{area=\"heap\",cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "JVM Non-Heap",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"jvm_memory_used_bytes{area=\"nonheap\",cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Live Threads",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 16
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"jvm_threads_live_threads{cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "title": "GC Pause (1m rate)",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 24
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "s"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rate(jvm_gc_pause_seconds_sum{cluster=\"$cluster\",broker=\"$broker\"}[1m])",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-latency.json
b/server/src/main/resources/grafana/rocketmq-latency.json
new file mode 100644
index 00000000..028de8b5
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-latency.json
@@ -0,0 +1,150 @@
+{
+ "uid": "rocketmq-latency",
+ "title": "RocketMQ Latency",
+ "description": "Dispatch and client push latency percentiles.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Dispatch Latency p99",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "s"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "histogram_quantile(0.99, sum by (le)
(rate(rocketmq_dispatch_latency_bucket{cluster=\"$cluster\"}[5m])))",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Send To Client Latency p99",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "s"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "histogram_quantile(0.99, sum by (le)
(rate(rocketmq_send_to_client_latency_bucket{cluster=\"$cluster\"}[5m])))",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-network.json
b/server/src/main/resources/grafana/rocketmq-network.json
new file mode 100644
index 00000000..ddec7e09
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-network.json
@@ -0,0 +1,193 @@
+{
+ "uid": "rocketmq-network",
+ "title": "RocketMQ Network & Connections",
+ "description": "Client connections and produced/consumed connection counts.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Client Connections",
+ "type": "stat",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 6,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "thresholdsStyle": {
+ "mode": "none"
+ }
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rocketmq_producer_count{cluster=\"$cluster\"} +
rocketmq_consumer_count{cluster=\"$cluster\"}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Connections by Broker",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 6
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_connection_count{cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Producer Connections",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 14
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rocketmq_producer_count{cluster=\"$cluster\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-overview.json
b/server/src/main/resources/grafana/rocketmq-overview.json
new file mode 100644
index 00000000..d0c67694
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-overview.json
@@ -0,0 +1,322 @@
+{
+ "uid": "rocketmq-overview",
+ "title": "RocketMQ Cluster Overview",
+ "description": "Cluster-wide throughput, topic/group counts and producer
footprint.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Messages In TPS",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"sum(rate(rocketmq_messages_in_total{cluster=\"$cluster\"}[1m]))",
+ "legendFormat": "by cluster",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Messages Out TPS",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"sum(rate(rocketmq_messages_out_total{cluster=\"$cluster\"}[1m]))",
+ "legendFormat": "by cluster",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Total Topics",
+ "type": "stat",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 6,
+ "x": 0,
+ "y": 16
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "thresholdsStyle": {
+ "mode": "none"
+ }
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "count(count by (topic)
(rocketmq_messages_in_total{cluster=\"$cluster\"}))",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "title": "Total Consumer Groups",
+ "type": "stat",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 6,
+ "x": 12,
+ "y": 22
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "thresholdsStyle": {
+ "mode": "none"
+ }
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "count(count by (group)
(rocketmq_messages_out_total{cluster=\"$cluster\"}))",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 5,
+ "title": "Producer Count",
+ "type": "stat",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 6,
+ "x": 0,
+ "y": 28
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "thresholdsStyle": {
+ "mode": "none"
+ }
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "max(rocketmq_producer_count{cluster=\"$cluster\"})",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 6,
+ "title": "Broker Count",
+ "type": "stat",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 6,
+ "x": 12,
+ "y": 34
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "thresholdsStyle": {
+ "mode": "none"
+ }
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "count(rocketmq_messages_in_total{cluster=\"$cluster\"})",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-producer.json
b/server/src/main/resources/grafana/rocketmq-producer.json
new file mode 100644
index 00000000..2d99ad97
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-producer.json
@@ -0,0 +1,193 @@
+{
+ "uid": "rocketmq-producer",
+ "title": "RocketMQ Producer",
+ "description": "Producer presence, send size and per-topic ingress.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Producer Count",
+ "type": "stat",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 6,
+ "w": 6,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "short",
+ "custom": {
+ "thresholdsStyle": {
+ "mode": "none"
+ }
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "max(rocketmq_producer_count{cluster=\"$cluster\"})",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Producer Message Size",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 6
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "rocketmq_producer_message_size{cluster=\"$cluster\"}",
+ "legendFormat": "{{topic}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Messages In by Topic",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 14
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "sum by (topic)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\"}[1m]))",
+ "legendFormat": "{{topic}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-storage.json
b/server/src/main/resources/grafana/rocketmq-storage.json
new file mode 100644
index 00000000..ca3a98e3
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-storage.json
@@ -0,0 +1,192 @@
+{
+ "uid": "rocketmq-storage",
+ "title": "RocketMQ Storage",
+ "description": "Broker disk usage and JVM heap footprint.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Disk Use Ratio",
+ "type": "gauge",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "percent",
+ "max": 100,
+ "custom": {
+ "min": 0
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ]
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_disk_use_ratio{cluster=\"$cluster\",broker=\"$broker\"}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "JVM Heap Used",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"jvm_memory_used_bytes{area=\"heap\",cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Dispatch Behind Bytes",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_dispatch_behind_bytes{cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-threadpool.json
b/server/src/main/resources/grafana/rocketmq-threadpool.json
new file mode 100644
index 00000000..a5ea4784
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-threadpool.json
@@ -0,0 +1,192 @@
+{
+ "uid": "rocketmq-threadpool",
+ "title": "RocketMQ Thread Pool",
+ "description": "Broker thread pool queue depth, capacity and rejections.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Queue Size",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_threadpool_queue_size{cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Queue Capacity",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_threadpool_queue_capacity{cluster=\"$cluster\",broker=\"$broker\"}",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Reject Count (1m)",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"increase(rocketmq_threadpool_reject_count{cluster=\"$cluster\",broker=\"$broker\"}[1m])",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-topic.json
b/server/src/main/resources/grafana/rocketmq-topic.json
new file mode 100644
index 00000000..651879c8
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-topic.json
@@ -0,0 +1,192 @@
+{
+ "uid": "rocketmq-topic",
+ "title": "RocketMQ Topic",
+ "description": "Per-topic ingress/egress throughput and dispatch backlog.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Topic Messages In TPS",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "sum by (topic)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\",topic=\"$topic\"}[1m]))",
+ "legendFormat": "{{topic}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Topic Messages Out TPS",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "sum by (topic)
(rate(rocketmq_messages_out_total{cluster=\"$cluster\",topic=\"$topic\"}[1m]))",
+ "legendFormat": "{{topic}}",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Topic Dispatch Behind Bytes",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "bytes"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"rocketmq_dispatch_behind_bytes{cluster=\"$cluster\",topic=\"$topic\"}",
+ "legendFormat": "{{topic}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
diff --git a/server/src/main/resources/grafana/rocketmq-tps.json
b/server/src/main/resources/grafana/rocketmq-tps.json
new file mode 100644
index 00000000..14acc158
--- /dev/null
+++ b/server/src/main/resources/grafana/rocketmq-tps.json
@@ -0,0 +1,192 @@
+{
+ "uid": "rocketmq-tps",
+ "title": "RocketMQ TPS",
+ "description": "Cluster and per-broker message throughput trends.",
+ "tags": [
+ "rocketmq"
+ ],
+ "schemaVersion": 39,
+ "timezone": "browser",
+ "editable": true,
+ "templating": {
+ "list": [
+ {
+ "name": "DS_PROMETHEUS",
+ "type": "datasource",
+ "label": "Prometheus",
+ "query": "prometheus",
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "cluster",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query": "label_values(rocketmq_messages_in_total, cluster)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "broker",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, broker)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ },
+ {
+ "name": "topic",
+ "type": "query",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "query":
"label_values(rocketmq_messages_in_total{cluster=\"$cluster\"}, topic)",
+ "refresh": 2,
+ "current": {},
+ "hide": 0
+ }
+ ]
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "refresh": "30s",
+ "panels": [
+ {
+ "id": 1,
+ "title": "Cluster TPS In",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"sum(rate(rocketmq_messages_in_total{cluster=\"$cluster\"}[1m]))",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "Cluster TPS Out",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 8
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr":
"sum(rate(rocketmq_messages_out_total{cluster=\"$cluster\"}[1m]))",
+ "legendFormat": "",
+ "refId": "A"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Per-Broker TPS In",
+ "type": "timeseries",
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 16
+ },
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom"
+ }
+ },
+ "targets": [
+ {
+ "datasource": {
+ "type": "prometheus",
+ "uid": "${DS_PROMETHEUS}"
+ },
+ "expr": "sum by (broker)
(rate(rocketmq_messages_in_total{cluster=\"$cluster\",broker=\"$broker\"}[1m]))",
+ "legendFormat": "{{broker}}",
+ "refId": "A"
+ }
+ ]
+ }
+ ]
+}
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
new file mode 100644
index 00000000..09e828b8
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardControllerTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.grafana;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import
org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(GrafanaDashboardController.class)
+@AutoConfigureMockMvc(addFilters = false)
+class GrafanaDashboardControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private GrafanaDashboardService grafanaDashboardService;
+
+ @Test
+ void listDashboardsShouldReturnMetadatas() throws Exception {
+ when(grafanaDashboardService.listDashboards()).thenReturn(List.of(
+ new GrafanaDashboardInfo("rocketmq-overview", "RocketMQ
Cluster Overview",
+ "Overview", List.of("rocketmq")),
+ new GrafanaDashboardInfo("rocketmq-broker", "RocketMQ Broker",
+ "Broker", List.of("rocketmq"))
+ ));
+
+ mockMvc.perform(get("/api/metrics/grafana/dashboards"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.length()").value(2))
+
.andExpect(jsonPath("$.data[0].uid").value("rocketmq-overview"))
+ .andExpect(jsonPath("$.data[0].tags[0]").value("rocketmq"));
+ }
+
+ @Test
+ void getDashboardShouldReturnModel() throws Exception {
+ when(grafanaDashboardService.getDashboard("rocketmq-overview"))
+ .thenReturn(Map.of("uid", "rocketmq-overview", "title",
"RocketMQ Cluster Overview"));
+
+
mockMvc.perform(get("/api/metrics/grafana/dashboards/rocketmq-overview"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value(200))
+ .andExpect(jsonPath("$.data.uid").value("rocketmq-overview"))
+ .andExpect(jsonPath("$.data.title").value("RocketMQ Cluster
Overview"));
+ }
+
+ @Test
+ void exportDashboardShouldReturnAttachment() throws Exception {
+ when(grafanaDashboardService.getDashboardJson("rocketmq-overview"))
+ .thenReturn("{\"uid\":\"rocketmq-overview\"}");
+
+
mockMvc.perform(get("/api/metrics/grafana/dashboards/rocketmq-overview/export"))
+ .andExpect(status().isOk())
+ .andExpect(header().string("Content-Type", "application/json"))
+ .andExpect(header().string("Content-Disposition",
+ "form-data; name=\"attachment\";
filename=\"rocketmq-overview.json\""))
+ .andExpect(content().json("{\"uid\":\"rocketmq-overview\"}"));
+ }
+}
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
new file mode 100644
index 00000000..6d423cbe
--- /dev/null
+++
b/server/src/test/java/org/apache/rocketmq/studio/cluster/metrics/grafana/GrafanaDashboardServiceTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+package org.apache.rocketmq.studio.cluster.metrics.grafana;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class GrafanaDashboardServiceTest {
+
+ private final GrafanaDashboardService service = new
GrafanaDashboardService(new ObjectMapper());
+
+ @Test
+ void listDashboardsShouldExposeBundledAssets() {
+ List<GrafanaDashboardInfo> dashboards = service.listDashboards();
+
+ assertFalse(dashboards.isEmpty(), "expected bundled dashboards to be
present");
+ assertTrue(dashboards.size() >= 10, "expected at least 10 dashboards,
got " + dashboards.size());
+
+ for (GrafanaDashboardInfo info : dashboards) {
+ assertFalse(info.uid().isBlank(), "dashboard uid must not be
blank");
+ assertFalse(info.title().isBlank(), "dashboard title must not be
blank");
+ assertTrue(info.tags().contains("rocketmq"), "dashboard should be
tagged rocketmq");
+ }
+ }
+
+ @Test
+ void getDashboardShouldReturnParsedModel() {
+ Map<String, Object> model = service.getDashboard("rocketmq-overview");
+
+ assertEquals("rocketmq-overview", model.get("uid"));
+ assertEquals("RocketMQ Cluster Overview", model.get("title"));
+ assertTrue(model.containsKey("panels"), "dashboard should contain
panels");
+ }
+
+ @Test
+ void getDashboardShouldThrowWhenUidUnknown() {
+ BusinessException exception = assertThrows(BusinessException.class,
+ () -> service.getDashboard("no-such-dashboard"));
+ assertEquals(404, exception.getCode());
+ }
+
+ @Test
+ void getDashboardJsonShouldReturnRawContent() {
+ String json = service.getDashboardJson("rocketmq-broker");
+
+ assertFalse(json.isBlank());
+ assertTrue(json.contains("\"uid\""));
+ assertTrue(json.contains("rocketmq-broker"));
+ }
+
+ @Test
+ void getDashboardJsonShouldThrowWhenUidUnknown() {
+ BusinessException exception = assertThrows(BusinessException.class,
+ () -> service.getDashboardJson("no-such-dashboard"));
+ assertEquals(404, exception.getCode());
+ }
+}
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 86035f3d..cd9fba6c 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -48,6 +48,7 @@ const GroupManagementPage = lazy(() =>
import('./pages/studio/GroupManagement'))
const BrokerClusterPage = lazy(() => import('./pages/studio/BrokerCluster'));
const SslSettingsPage = lazy(() => import('./pages/studio/SslSettings'));
const AlertManagementPage = lazy(() =>
import('./pages/studio/AlertManagement'));
+const GrafanaDashboardsPage = lazy(() =>
import('./pages/studio/GrafanaDashboards'));
const ProducerPage = lazy(() => import('./pages/studio/Producer'));
const OpsPage = lazy(() => import('./pages/studio/Ops'));
@@ -158,6 +159,7 @@ function App() {
<Route path="cluster/certs" element={<K8sCertsPage />} />
<Route path="cluster/clients" element={<ClientsPage />} />
<Route path="ops/dashboard" element={<DashboardOpsPage />} />
+ <Route path="ops/grafana" element={<GrafanaDashboardsPage />} />
<Route path="ops/alerts" element={<AlertsPage />} />
<Route path="ops/system-alerts" element={<SystemAlertsPage />} />
<Route path="ops/audit" element={<AuditPage />} />
diff --git a/web/src/api/metrics.test.ts b/web/src/api/metrics.test.ts
index 7333aee7..b517ae4e 100644
--- a/web/src/api/metrics.test.ts
+++ b/web/src/api/metrics.test.ts
@@ -18,7 +18,14 @@
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import client from './client';
-import { getDashboard, listMetricProfiles, queryMetrics } from './metrics';
+import {
+ getDashboard,
+ listGrafanaDashboards,
+ getGrafanaDashboard,
+ exportGrafanaDashboard,
+ listMetricProfiles,
+ queryMetrics,
+} from './metrics';
const mock = new MockAdapter(client);
const dashboard = {
@@ -103,4 +110,44 @@ describe('metrics API', () => {
await expect(listMetricProfiles()).resolves.toEqual(profiles);
});
+
+ it('lists bundled Grafana dashboards', async () => {
+ const dashboards = [
+ {
+ uid: 'rocketmq-overview',
+ title: 'RocketMQ Cluster Overview',
+ description: 'd',
+ tags: ['rocketmq'],
+ },
+ { uid: 'rocketmq-broker', title: 'RocketMQ Broker', description: 'd',
tags: ['rocketmq'] },
+ ];
+
+ mock.onGet('/metrics/grafana/dashboards').reply(200, { code: 200, data:
dashboards });
+
+ await expect(listGrafanaDashboards()).resolves.toEqual(dashboards);
+ });
+
+ it('loads a single Grafana dashboard model', async () => {
+ const model = {
+ uid: 'rocketmq-overview',
+ title: 'RocketMQ Cluster Overview',
+ schemaVersion: 39,
+ };
+
+ mock
+ .onGet('/metrics/grafana/dashboards/rocketmq-overview')
+ .reply(200, { code: 200, data: model });
+
+ await
expect(getGrafanaDashboard('rocketmq-overview')).resolves.toEqual(model);
+ });
+
+ it('exports a Grafana dashboard as a blob', async () => {
+ const blob = new Blob(['{"uid":"rocketmq-overview"}'], { type:
'application/json' });
+
+
mock.onGet('/metrics/grafana/dashboards/rocketmq-overview/export').reply(200,
blob);
+
+ const result = await exportGrafanaDashboard('rocketmq-overview');
+ expect(result).toBeInstanceOf(Blob);
+ await expect(result.text()).resolves.toContain('rocketmq-overview');
+ });
});
diff --git a/web/src/api/metrics.ts b/web/src/api/metrics.ts
index b0665f08..8471b0af 100644
--- a/web/src/api/metrics.ts
+++ b/web/src/api/metrics.ts
@@ -107,3 +107,31 @@ export async function listMetricProfiles() {
const res = await client.get<{ data: MetricProfile[] }>('/metrics/profiles');
return res.data.data;
}
+
+// ─── Grafana dashboards ─────────────────────────────────────────
+export interface GrafanaDashboardInfo {
+ uid: string;
+ title: string;
+ description: string;
+ tags: string[];
+}
+
+export async function listGrafanaDashboards(): Promise<GrafanaDashboardInfo[]>
{
+ const res = await client.get<{ data: GrafanaDashboardInfo[]
}>('/metrics/grafana/dashboards');
+ return res.data.data;
+}
+
+export async function getGrafanaDashboard(uid: string): Promise<Record<string,
unknown>> {
+ const res = await client.get<{ data: Record<string, unknown> }>(
+ `/metrics/grafana/dashboards/${encodeURIComponent(uid)}`,
+ );
+ return res.data.data;
+}
+
+export async function exportGrafanaDashboard(uid: string): Promise<Blob> {
+ const res = await client.get<Blob>(
+ `/metrics/grafana/dashboards/${encodeURIComponent(uid)}/export`,
+ { responseType: 'blob' },
+ );
+ return res.data;
+}
diff --git a/web/src/components/GrafanaDashboardList.tsx
b/web/src/components/GrafanaDashboardList.tsx
new file mode 100644
index 00000000..894fb22d
--- /dev/null
+++ b/web/src/components/GrafanaDashboardList.tsx
@@ -0,0 +1,188 @@
+/*
+ * 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.
+ */
+
+import { useEffect, useState } from 'react';
+import { App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { DownloadSimple, Eye } from '@phosphor-icons/react';
+import { useLang } from '../i18n/LangContext';
+import {
+ getGrafanaDashboard,
+ exportGrafanaDashboard,
+ listGrafanaDashboards,
+} from '../services/grafanaService';
+import type { GrafanaDashboardInfo } from '../api/metrics';
+
+const { Paragraph, Text } = Typography;
+
+export const GrafanaDashboardList: React.FC = () => {
+ const { t } = useLang();
+ const { message } = App.useApp();
+ const [dashboards, setDashboards] = useState<GrafanaDashboardInfo[]>([]);
+ const [loading, setLoading] = useState(true);
+ const [viewing, setViewing] = useState<GrafanaDashboardInfo | null>(null);
+ const [viewContent, setViewContent] = useState('');
+ const [viewLoading, setViewLoading] = useState(false);
+ const [exportingUid, setExportingUid] = useState<string | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ const load = async () => {
+ try {
+ const data = await listGrafanaDashboards();
+ if (!cancelled) setDashboards(data);
+ } catch {
+ if (!cancelled) message.error(t('grafana.loadFailed'));
+ } finally {
+ if (!cancelled) setLoading(false);
+ }
+ };
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, [t, message]);
+
+ const handleView = async (info: GrafanaDashboardInfo) => {
+ setViewing(info);
+ setViewLoading(true);
+ try {
+ const model = await getGrafanaDashboard(info.uid);
+ setViewContent(JSON.stringify(model, null, 2));
+ } catch {
+ message.error(t('grafana.loadFailed'));
+ } finally {
+ setViewLoading(false);
+ }
+ };
+
+ const triggerDownload = (uid: 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.click();
+ URL.revokeObjectURL(url);
+ };
+
+ const handleExport = async (info: GrafanaDashboardInfo) => {
+ setExportingUid(info.uid);
+ try {
+ const blob = await exportGrafanaDashboard(info.uid);
+ triggerDownload(info.uid, blob);
+ message.success(t('grafana.exported'));
+ } catch {
+ message.error(t('grafana.exportFailed'));
+ } finally {
+ setExportingUid(null);
+ }
+ };
+
+ const columns: ColumnsType<GrafanaDashboardInfo> = [
+ {
+ title: t('grafana.title'),
+ dataIndex: 'title',
+ key: 'title',
+ },
+ {
+ title: t('grafana.description'),
+ dataIndex: 'description',
+ key: 'description',
+ ellipsis: true,
+ },
+ {
+ title: t('grafana.tags'),
+ dataIndex: 'tags',
+ key: 'tags',
+ width: 140,
+ render: (tags: string[]) => (
+ <Space size={[0, 4]} wrap>
+ {(tags || []).map((tag) => (
+ <Tag key={tag} color="blue">
+ {tag}
+ </Tag>
+ ))}
+ </Space>
+ ),
+ },
+ {
+ title: t('common.actions'),
+ key: 'actions',
+ width: 180,
+ render: (_: unknown, record: GrafanaDashboardInfo) => (
+ <Space size="small">
+ <Button size="small" icon={<Eye size={16} />} onClick={() =>
handleView(record)}>
+ {t('common.view')}
+ </Button>
+ <Button
+ size="small"
+ icon={<DownloadSimple size={16} />}
+ loading={exportingUid === record.uid}
+ onClick={() => handleExport(record)}
+ >
+ {t('common.export')}
+ </Button>
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div>
+ <Table
+ columns={columns}
+ dataSource={dashboards}
+ loading={loading}
+ rowKey="uid"
+ pagination={false}
+ size="small"
+ />
+
+ <Modal
+ title={viewing ? viewing.title : t('grafana.title')}
+ open={viewing !== null}
+ footer={<Button onClick={() =>
setViewing(null)}>{t('common.close')}</Button>}
+ onCancel={() => setViewing(null)}
+ width={760}
+ destroyOnHidden
+ >
+ {viewLoading ? (
+ <Text type="secondary">{t('common.loading')}</Text>
+ ) : (
+ <Paragraph>
+ <pre
+ style={{
+ maxHeight: 480,
+ overflow: 'auto',
+ background: '#f5f5f5',
+ padding: 16,
+ borderRadius: 6,
+ fontSize: 12,
+ }}
+ >
+ {viewContent}
+ </pre>
+ </Paragraph>
+ )}
+ </Modal>
+ </div>
+ );
+};
+
+export default GrafanaDashboardList;
diff --git a/web/src/components/__tests__/GrafanaDashboardList.test.tsx
b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
new file mode 100644
index 00000000..cc2c24f9
--- /dev/null
+++ b/web/src/components/__tests__/GrafanaDashboardList.test.tsx
@@ -0,0 +1,141 @@
+/*
+ * 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.
+ */
+
+import { App } from 'antd';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from
'vitest';
+
+import { LangProvider } from '../../i18n/LangContext';
+import {
+ exportGrafanaDashboard,
+ getGrafanaDashboard,
+ listGrafanaDashboards,
+} from '../../services/grafanaService';
+import GrafanaDashboardList from '../GrafanaDashboardList';
+
+vi.mock('../../services/grafanaService', () => ({
+ listGrafanaDashboards: vi.fn(),
+ getGrafanaDashboard: vi.fn(),
+ exportGrafanaDashboard: vi.fn(),
+}));
+
+const dashboards = [
+ {
+ uid: 'rocketmq-overview',
+ title: 'RocketMQ Cluster Overview',
+ description: 'Overview',
+ tags: ['rocketmq'],
+ },
+ { uid: 'rocketmq-broker', title: 'RocketMQ Broker', description: 'Broker',
tags: ['rocketmq'] },
+];
+
+const dashboardModel = {
+ uid: 'rocketmq-overview',
+ title: 'RocketMQ Cluster Overview',
+ schemaVersion: 39,
+ panels: [{ id: 1, title: 'Messages In TPS', type: 'timeseries' }],
+};
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+beforeEach(() => {
+ vi.mocked(listGrafanaDashboards).mockResolvedValue(dashboards);
+ vi.mocked(getGrafanaDashboard).mockResolvedValue(dashboardModel);
+ vi.mocked(exportGrafanaDashboard).mockResolvedValue(
+ new Blob([JSON.stringify(dashboardModel, null, 2)], { type:
'application/json' }),
+ );
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('GrafanaDashboardList', () => {
+ it('lists the configured dashboards', async () => {
+ render(
+ <App>
+ <LangProvider>
+ <GrafanaDashboardList />
+ </LangProvider>
+ </App>,
+ );
+
+ expect(await screen.findByText('RocketMQ Cluster
Overview')).toBeInTheDocument();
+ expect(screen.getByText('RocketMQ Broker')).toBeInTheDocument();
+ });
+
+ it('opens the view modal and renders the dashboard JSON', async () => {
+ const user = userEvent.setup();
+ render(
+ <App>
+ <LangProvider>
+ <GrafanaDashboardList />
+ </LangProvider>
+ </App>,
+ );
+
+ await screen.findByText('RocketMQ Cluster Overview');
+ const viewButtons = screen.getAllByRole('button', { name: /View|查看/ });
+ await user.click(viewButtons[0]);
+
+ const dialog = await screen.findByRole('dialog');
+ await waitFor(() =>
expect(getGrafanaDashboard).toHaveBeenCalledWith('rocketmq-overview'));
+ expect(within(dialog).getByText(/"uid":
"rocketmq-overview"/)).toBeInTheDocument();
+ });
+
+ it('exports a dashboard and triggers a download', async () => {
+ const user = userEvent.setup();
+ const createObjectURL = vi.fn().mockReturnValue('blob:grafana');
+ const revokeObjectURL = vi.fn();
+ const clickSpy = vi.spyOn(HTMLAnchorElement.prototype,
'click').mockImplementation(() => {});
+ 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');
+ const exportButtons = screen.getAllByRole('button', { name: /Export|导出/ });
+ await user.click(exportButtons[0]);
+
+ await waitFor(() =>
expect(exportGrafanaDashboard).toHaveBeenCalledWith('rocketmq-overview'));
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
+ expect(clickSpy).toHaveBeenCalled();
+
+ clickSpy.mockRestore();
+ });
+});
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 45161c42..9afb2e54 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -35,6 +35,7 @@ const translations: Record<string, Record<Lang, string>> = {
'nav.alertEvents': { zh: '告警事件', en: 'Alert Events' },
'nav.alertRules': { zh: '告警规则', en: 'Alert Rules' },
'nav.audit': { zh: '审计日志', en: 'Audit Log' },
+ 'nav.grafanaDashboards': { zh: 'Grafana 看板', en: 'Grafana Dashboards' },
'nav.ai': { zh: 'AI 交互', en: 'AI Chat' },
'nav.settings': { zh: '设置', en: 'Settings' },
@@ -733,6 +734,14 @@ const translations: Record<string, Record<Lang, string>> =
{
'alertMgmt.forDurationRequired': { zh: '持续时间为必填项', en: 'Duration is
required' },
'alertMgmt.summaryRequired': { zh: '摘要为必填项', en: 'Summary is required' },
+ // ─── Grafana dashboards ───
+ 'grafana.title': { zh: 'Grafana 看板', en: 'Grafana Dashboards' },
+ 'grafana.description': { zh: '说明', en: 'Description' },
+ 'grafana.tags': { zh: '标签', en: 'Tags' },
+ 'grafana.loadFailed': { zh: '加载看板失败', en: 'Failed to load dashboards' },
+ 'grafana.exported': { zh: '看板已导出', en: 'Dashboard exported' },
+ 'grafana.exportFailed': { zh: '导出看板失败', en: 'Failed to export dashboard' },
+
// ─── Topic (detailed) ───
'topic.subtitle': {
zh: '管理 Topic 的创建、配置与删除',
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index 045c9d6f..d85024ad 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -30,6 +30,7 @@ import {
ListDashes,
UserGear,
ChartBar,
+ ChartLine,
Sun,
Moon,
ShieldCheck,
@@ -125,6 +126,7 @@ const MainLayout = () => {
label: t('nav.clusterOps'),
children: [
{ key: '/ops/dashboard', icon: <ChartBar size={16} />, label:
t('nav.dashboard') },
+ { key: '/ops/grafana', icon: <ChartLine size={16} />, label:
t('nav.grafanaDashboards') },
{ key: '/cluster/certs', icon: <ShieldCheck size={16} />, label:
t('nav.certs') },
{ key: '/cluster', icon: <Database size={16} />, label:
t('nav.rocketmqCluster') },
{ key: '/cluster/clients', icon: <PlugsConnected size={16} />, label:
t('nav.clients') },
@@ -154,6 +156,7 @@ const MainLayout = () => {
'/cluster/certs': t('nav.certs'),
'/cluster/clients': t('nav.clients'),
'/ops/dashboard': t('nav.dashboard'),
+ '/ops/grafana': t('nav.grafanaDashboards'),
'/ops/system-alerts': t('nav.alertEvents'),
'/ops/alerts': t('nav.alertRules'),
'/ops/audit': t('nav.audit'),
diff --git a/web/src/mock/grafanaDashboards.ts
b/web/src/mock/grafanaDashboards.ts
new file mode 100644
index 00000000..e8e1e598
--- /dev/null
+++ b/web/src/mock/grafanaDashboards.ts
@@ -0,0 +1,184 @@
+/*
+ * 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.
+ */
+
+import type { GrafanaDashboardInfo } from '../api/metrics';
+
+export interface MockGrafanaDashboard extends GrafanaDashboardInfo {
+ model: Record<string, unknown>;
+}
+
+export const mockGrafanaDashboards: MockGrafanaDashboard[] = [
+ {
+ uid: 'rocketmq-overview',
+ title: 'RocketMQ Cluster Overview',
+ description: 'Cluster-wide throughput, topic/group counts and producer
footprint.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-overview',
+ title: 'RocketMQ Cluster Overview',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [
+ { id: 1, title: 'Messages In TPS', type: 'timeseries' },
+ { id: 2, title: 'Messages Out TPS', type: 'timeseries' },
+ ],
+ },
+ },
+ {
+ uid: 'rocketmq-broker',
+ title: 'RocketMQ Broker',
+ description: 'Per-broker throughput, dispatch backlog and thread pool
pressure.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-broker',
+ title: 'RocketMQ Broker',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Broker Messages In TPS', type: 'timeseries' }],
+ },
+ },
+ {
+ uid: 'rocketmq-producer',
+ title: 'RocketMQ Producer',
+ description: 'Producer presence, send size and per-topic ingress.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-producer',
+ title: 'RocketMQ Producer',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Producer Count', type: 'stat' }],
+ },
+ },
+ {
+ uid: 'rocketmq-consumer',
+ title: 'RocketMQ Consumer',
+ description: 'Consumer group egress, lag and client footprint.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-consumer',
+ title: 'RocketMQ Consumer',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Messages Out TPS by Group', type: 'timeseries'
}],
+ },
+ },
+ {
+ uid: 'rocketmq-topic',
+ title: 'RocketMQ Topic',
+ description: 'Per-topic ingress/egress throughput and dispatch backlog.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-topic',
+ title: 'RocketMQ Topic',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Topic Messages In TPS', type: 'timeseries' }],
+ },
+ },
+ {
+ uid: 'rocketmq-tps',
+ title: 'RocketMQ TPS',
+ description: 'Cluster and per-broker message throughput trends.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-tps',
+ title: 'RocketMQ TPS',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Cluster TPS In', type: 'timeseries' }],
+ },
+ },
+ {
+ uid: 'rocketmq-storage',
+ title: 'RocketMQ Storage',
+ description: 'Broker disk usage and JVM heap footprint.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-storage',
+ title: 'RocketMQ Storage',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Disk Use Ratio', type: 'gauge' }],
+ },
+ },
+ {
+ uid: 'rocketmq-jvm',
+ title: 'RocketMQ Broker JVM',
+ description: 'JVM memory, threads and garbage collection for brokers.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-jvm',
+ title: 'RocketMQ Broker JVM',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'JVM Heap', type: 'timeseries' }],
+ },
+ },
+ {
+ uid: 'rocketmq-threadpool',
+ title: 'RocketMQ Thread Pool',
+ description: 'Broker thread pool queue depth, capacity and rejections.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-threadpool',
+ title: 'RocketMQ Thread Pool',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Queue Size', type: 'timeseries' }],
+ },
+ },
+ {
+ uid: 'rocketmq-dlq',
+ title: 'RocketMQ DLQ & Retry',
+ description: 'Dead-letter queue resend volume and latency.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-dlq',
+ title: 'RocketMQ DLQ & Retry',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'DLQ Resend Count', type: 'timeseries' }],
+ },
+ },
+ {
+ uid: 'rocketmq-latency',
+ title: 'RocketMQ Latency',
+ description: 'Dispatch and client push latency percentiles.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-latency',
+ title: 'RocketMQ Latency',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Dispatch Latency p99', type: 'timeseries' }],
+ },
+ },
+ {
+ uid: 'rocketmq-network',
+ title: 'RocketMQ Network & Connections',
+ description: 'Client connections and produced/consumed connection counts.',
+ tags: ['rocketmq'],
+ model: {
+ uid: 'rocketmq-network',
+ title: 'RocketMQ Network & Connections',
+ schemaVersion: 39,
+ tags: ['rocketmq'],
+ panels: [{ id: 1, title: 'Client Connections', type: 'stat' }],
+ },
+ },
+];
diff --git a/web/src/pages/studio/GrafanaDashboards.tsx
b/web/src/pages/studio/GrafanaDashboards.tsx
new file mode 100644
index 00000000..a92d5bd8
--- /dev/null
+++ b/web/src/pages/studio/GrafanaDashboards.tsx
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+import React from 'react';
+import { Card, Space } from 'antd';
+import { ChartLine } from '@phosphor-icons/react';
+import { useLang } from '../../i18n/LangContext';
+import GrafanaDashboardList from '../../components/GrafanaDashboardList';
+
+const GrafanaDashboardsPage: React.FC = () => {
+ const { t } = useLang();
+
+ return (
+ <div style={{ padding: '0 0 24px' }}>
+ <Card
+ size="small"
+ title={
+ <Space>
+ <ChartLine size={18} />
+ <span>{t('grafana.title')}</span>
+ </Space>
+ }
+ >
+ <GrafanaDashboardList />
+ </Card>
+ </div>
+ );
+};
+
+export default GrafanaDashboardsPage;
diff --git a/web/src/services/grafanaService.test.ts
b/web/src/services/grafanaService.test.ts
new file mode 100644
index 00000000..b91bb4c2
--- /dev/null
+++ b/web/src/services/grafanaService.test.ts
@@ -0,0 +1,59 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import * as grafanaApi from '../api/metrics';
+import {
+ exportGrafanaDashboard,
+ getGrafanaDashboard,
+ listGrafanaDashboards,
+} from './grafanaService';
+
+const isMockModeMock = vi.hoisted(() => ({ isMockMode: () => true as boolean
}));
+vi.mock('./dataMode', () => isMockModeMock);
+
+describe('grafanaService', () => {
+ beforeEach(() => {
+ isMockModeMock.isMockMode = () => true;
+ vi.stubGlobal('localStorage', { getItem: vi.fn().mockReturnValue(null) });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it('lists at least 10 dashboards in mock mode', async () => {
+ const dashboards = await listGrafanaDashboards();
+ expect(dashboards.length).toBeGreaterThanOrEqual(10);
+ expect(dashboards.every((d) => d.uid && d.title)).toBe(true);
+ });
+
+ it('returns a dashboard model by uid in mock mode', async () => {
+ const model = await getGrafanaDashboard('rocketmq-overview');
+ expect(model.uid).toBe('rocketmq-overview');
+ });
+
+ it('throws for an unknown dashboard uid in mock mode', async () => {
+ await expect(getGrafanaDashboard('nope')).rejects.toThrow();
+ });
+
+ it('exports a dashboard as a blob in mock mode', async () => {
+ const blob = await exportGrafanaDashboard('rocketmq-overview');
+ expect(blob).toBeInstanceOf(Blob);
+ await expect(blob.text()).resolves.toContain('rocketmq-overview');
+ });
+
+ it('delegates to the api in real mode', async () => {
+ isMockModeMock.isMockMode = () => false;
+ const listSpy = vi
+ .spyOn(grafanaApi, 'listGrafanaDashboards')
+ .mockResolvedValue([
+ { uid: 'rocketmq-overview', title: 'Overview', description: '', tags:
['rocketmq'] },
+ ]);
+
+ const result = await listGrafanaDashboards();
+ expect(listSpy).toHaveBeenCalledTimes(1);
+ expect(result[0].uid).toBe('rocketmq-overview');
+ });
+});
diff --git a/web/src/services/grafanaService.ts
b/web/src/services/grafanaService.ts
new file mode 100644
index 00000000..1b8bb84b
--- /dev/null
+++ b/web/src/services/grafanaService.ts
@@ -0,0 +1,39 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements.
+
+import { isMockMode } from './dataMode';
+import * as metricsApi from '../api/metrics';
+import type { GrafanaDashboardInfo } from '../api/metrics';
+import { mockGrafanaDashboards } from '../mock/grafanaDashboards';
+
+export async function listGrafanaDashboards(): Promise<GrafanaDashboardInfo[]>
{
+ if (isMockMode()) {
+ return mockGrafanaDashboards.map(({ uid, title, description, tags }) => ({
+ uid,
+ title,
+ description,
+ tags,
+ }));
+ }
+ return metricsApi.listGrafanaDashboards();
+}
+
+export async function getGrafanaDashboard(uid: string): Promise<Record<string,
unknown>> {
+ if (isMockMode()) {
+ const found = mockGrafanaDashboards.find((dashboard) => dashboard.uid ===
uid);
+ if (!found) {
+ throw new Error(`Grafana dashboard not found: ${uid}`);
+ }
+ return found.model;
+ }
+ return metricsApi.getGrafanaDashboard(uid);
+}
+
+export async function exportGrafanaDashboard(uid: string): Promise<Blob> {
+ if (isMockMode()) {
+ const found = mockGrafanaDashboards.find((dashboard) => dashboard.uid ===
uid);
+ const model = found ? found.model : { uid };
+ return new Blob([JSON.stringify(model, null, 2)], { type:
'application/json' });
+ }
+ return metricsApi.exportGrafanaDashboard(uid);
+}