From 8ddad7ee49d7261ee841cbd498e99b4b25f4e4cd Mon Sep 17 00:00:00 2001 From: liuhy Date: Fri, 24 Jul 2026 01:21:34 -0700 Subject: [PATCH] feat: add dashboard summary AI tool --- .../ai/tool/DashboardSummaryToolHandler.java | 111 ++++++++++++++++++ .../resources/tool-catalog/rmq-tools.yaml | 107 +++++++++++++++++ .../studio/ops/ai/tool/ToolCatalogTest.java | 5 +- .../ops/ai/tool/ToolGatewayServiceTest.java | 107 +++++++++++++++-- 4 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 server/src/main/java/com/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java diff --git a/server/src/main/java/com/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java new file mode 100644 index 00000000..9a4887e3 --- /dev/null +++ b/server/src/main/java/com/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java @@ -0,0 +1,111 @@ +/* + * 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 com.rocketmq.studio.ops.ai.tool; + +import com.rocketmq.studio.common.exception.BusinessException; +import com.rocketmq.studio.ops.dashboard.ClusterOverviewVO; +import com.rocketmq.studio.ops.dashboard.DashboardDataVO; +import com.rocketmq.studio.ops.dashboard.DashboardService; +import com.rocketmq.studio.ops.dashboard.DashboardStatsVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class DashboardSummaryToolHandler implements ToolHandler { + + private static final String NAME = "rmq.dashboard.summary"; + + private final DashboardService dashboardService; + + @Override + public String name() { + return NAME; + } + + @Override + public Object execute(Map input) { + String clusterId = (String) input.get("cluster"); + DashboardDataVO dashboard = dashboardService.getDashboard(); + ClusterOverviewVO cluster = clusters(dashboard).stream() + .filter(item -> clusterId.equals(item.getId())) + .findFirst() + .orElseThrow(() -> new BusinessException( + 404, "Dashboard cluster not found: " + clusterId)); + + Map result = new LinkedHashMap<>(); + result.put("cluster", clusterProjection(cluster)); + result.put("stats", statsProjection(dashboard.getStats())); + return result; + } + + private static List clusters(DashboardDataVO dashboard) { + if (dashboard == null || dashboard.getClusters() == null) { + return List.of(); + } + return dashboard.getClusters(); + } + + private static Map clusterProjection(ClusterOverviewVO cluster) { + Map result = new LinkedHashMap<>(); + result.put("id", cluster.getId()); + result.put("name", cluster.getName()); + result.put("type", requiredEnumName(cluster.getType(), "type", cluster.getId())); + result.put("status", requiredEnumName( + cluster.getStatus(), "status", cluster.getId())); + result.put("brokers", cluster.getBrokers()); + result.put("proxies", cluster.getProxies()); + result.put("topics", cluster.getTopics()); + result.put("groups", cluster.getGroups()); + result.put("tpsIn", cluster.getTpsIn()); + result.put("tpsOut", cluster.getTpsOut()); + result.put("version", cluster.getVersion()); + result.put("throughput", cluster.getThroughput() == null + ? List.of() + : cluster.getThroughput()); + return result; + } + + private static Map statsProjection(DashboardStatsVO stats) { + DashboardStatsVO safeStats = stats == null ? new DashboardStatsVO() : stats; + Map result = new LinkedHashMap<>(); + result.put("totalClusters", safeStats.getTotalClusters()); + result.put("healthyClusters", safeStats.getHealthyClusters()); + result.put("totalBrokers", safeStats.getTotalBrokers()); + result.put("totalProxies", safeStats.getTotalProxies()); + result.put("totalNameServers", safeStats.getTotalNameServers()); + result.put("totalTopics", safeStats.getTotalTopics()); + result.put("totalConsumerGroups", safeStats.getTotalConsumerGroups()); + result.put("totalMessagesToday", safeStats.getTotalMessagesToday()); + result.put("messagesPerSecond", safeStats.getMessagesPerSecond()); + result.put("tpsIn", safeStats.getTpsIn()); + result.put("tpsOut", safeStats.getTpsOut()); + return result; + } + + private static String requiredEnumName(Enum value, String field, String clusterId) { + if (value == null) { + throw new IllegalStateException( + "Cluster " + field + " is unavailable: " + clusterId); + } + return value.name(); + } +} diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml b/server/src/main/resources/tool-catalog/rmq-tools.yaml index 4327667c..bd7173a2 100644 --- a/server/src/main/resources/tool-catalog/rmq-tools.yaml +++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml @@ -74,3 +74,110 @@ tools: type: string viewHint: object deprecated: false + - name: rmq.dashboard.summary + cli: + resource: dashboard + verb: summary + description: Get the Studio dashboard summary for one RocketMQ cluster. + riskLevel: L1 + permission: dashboard:read + requiredCapabilities: [] + inputSchema: + type: object + required: + - cluster + additionalProperties: false + properties: + cluster: + type: string + minLength: 1 + outputSchema: + type: object + required: + - cluster + - stats + additionalProperties: false + properties: + cluster: + type: object + required: + - id + - name + - type + - status + - brokers + - proxies + - topics + - groups + - tpsIn + - tpsOut + - version + - throughput + additionalProperties: false + properties: + id: + type: string + name: + type: string + type: + type: string + status: + type: string + brokers: + type: integer + proxies: + type: integer + topics: + type: integer + groups: + type: integer + tpsIn: + type: integer + tpsOut: + type: integer + version: + type: string + throughput: + type: array + items: + type: integer + stats: + type: object + required: + - totalClusters + - healthyClusters + - totalBrokers + - totalProxies + - totalNameServers + - totalTopics + - totalConsumerGroups + - totalMessagesToday + - messagesPerSecond + - tpsIn + - tpsOut + additionalProperties: false + properties: + totalClusters: + type: integer + healthyClusters: + type: integer + totalBrokers: + type: integer + totalProxies: + type: integer + totalNameServers: + type: integer + totalTopics: + type: integer + totalConsumerGroups: + type: integer + totalMessagesToday: + type: integer + messagesPerSecond: + type: integer + tpsIn: + type: integer + tpsOut: + type: integer + viewHint: object + deprecated: false diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java index 285965b8..74371394 100644 --- a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java @@ -38,7 +38,10 @@ void loadsAndIndexesTheCanonicalCatalog() { assertThat(catalog.getMinimumClientVersion()).isEqualTo("1.0.0"); assertThat(catalog.getDigest()).matches("[0-9a-f]{64}"); assertThat(catalog.list()).extracting(ToolDefinition::getName) - .containsExactly("rmq.cluster.list", "rmq.capabilities"); + .containsExactly( + "rmq.cluster.list", + "rmq.capabilities", + "rmq.dashboard.summary"); assertThat(catalog.find("rmq.cluster.list")).isPresent(); assertThat(catalog.find("rmq.unknown")).isEmpty(); } diff --git a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java index 7d7cb37c..1b927d1a 100644 --- a/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java +++ b/server/src/test/java/com/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java @@ -23,6 +23,10 @@ import com.rocketmq.studio.common.domain.enums.ClusterType; import com.rocketmq.studio.common.exception.BusinessException; import com.rocketmq.studio.ops.ai.AiToolVO; +import com.rocketmq.studio.ops.dashboard.ClusterOverviewVO; +import com.rocketmq.studio.ops.dashboard.DashboardDataVO; +import com.rocketmq.studio.ops.dashboard.DashboardService; +import com.rocketmq.studio.ops.dashboard.DashboardStatsVO; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.core.io.ByteArrayResource; @@ -43,19 +47,24 @@ class ToolGatewayServiceTest { private ToolCatalog catalog; private ClusterService clusterService; + private DashboardService dashboardService; private CapabilityResolver capabilityResolver; private ClusterListToolHandler clusterListHandler; private CapabilitiesToolHandler capabilitiesHandler; + private DashboardSummaryToolHandler dashboardSummaryHandler; private ToolGatewayService gateway; @BeforeEach void setUp() { catalog = canonicalCatalog(); clusterService = mock(ClusterService.class); + dashboardService = mock(DashboardService.class); capabilityResolver = new CapabilityResolver(clusterService); clusterListHandler = new ClusterListToolHandler(clusterService); capabilitiesHandler = new CapabilitiesToolHandler(clusterService, capabilityResolver); - gateway = gateway(catalog, clusterListHandler, capabilitiesHandler); + dashboardSummaryHandler = new DashboardSummaryToolHandler(dashboardService); + gateway = gateway( + catalog, clusterListHandler, capabilitiesHandler, dashboardSummaryHandler); } @Test @@ -72,7 +81,10 @@ void discoveryWithClusterExposesRegisteredSupportedTools() { assertThat(gateway.discover("cluster-v5")) .extracting(AiToolVO::getName) - .containsExactly("rmq.cluster.list", "rmq.capabilities"); + .containsExactly( + "rmq.cluster.list", + "rmq.capabilities", + "rmq.dashboard.summary"); } @Test @@ -113,6 +125,73 @@ void executesCapabilitiesWithAStableSortedCapabilityList() { "ROCKETMQ_5"))); } + @Test + @SuppressWarnings("unchecked") + void executesDashboardSummaryWithADataMinimizingProjection() { + when(dashboardService.getDashboard()).thenReturn(DashboardDataVO.builder() + .stats(DashboardStatsVO.builder() + .totalClusters(1) + .healthyClusters(1) + .totalBrokers(2) + .totalProxies(1) + .totalNameServers(1) + .totalTopics(3) + .totalConsumerGroups(4) + .totalMessagesToday(500L) + .messagesPerSecond(6L) + .tpsIn(7L) + .tpsOut(8L) + .build()) + .clusters(List.of(ClusterOverviewVO.builder() + .id("cluster-v5") + .name("test") + .type(ClusterType.V5_PROXY_CLUSTER) + .status(ClusterStatus.healthy) + .brokers(2) + .proxies(1) + .topics(3) + .groups(4) + .tpsIn(7) + .tpsOut(8) + .version("5.2.0") + .throughput(List.of(1, 2, 3)) + .build())) + .build()); + + Object output = gateway.execute( + "rmq.dashboard.summary", Map.of("cluster", "cluster-v5")); + + Map result = (Map) output; + assertThat(result).containsOnlyKeys("cluster", "stats"); + Map cluster = (Map) result.get("cluster"); + assertThat(cluster).containsEntry("id", "cluster-v5"); + assertThat(cluster).containsEntry("name", "test"); + assertThat(cluster).containsEntry("type", "V5_PROXY_CLUSTER"); + assertThat(cluster).containsEntry("status", "healthy"); + assertThat(cluster).containsEntry("brokers", 2); + assertThat(cluster).containsEntry("proxies", 1); + assertThat(cluster).containsEntry("topics", 3); + assertThat(cluster).containsEntry("groups", 4); + assertThat(cluster).containsEntry("tpsIn", 7); + assertThat(cluster).containsEntry("tpsOut", 8); + assertThat(cluster).containsEntry("version", "5.2.0"); + assertThat(cluster).containsEntry("throughput", List.of(1, 2, 3)); + assertThat(cluster).doesNotContainKeys("endpoint"); + assertThat((Map) result.get("stats")).containsAllEntriesOf(Map.of( + "totalMessagesToday", 500L, + "messagesPerSecond", 6L, + "tpsIn", 7L, + "tpsOut", 8L)); + } + + @Test + void rejectsDashboardSummaryWithoutRequiredClusterBeforeHandlerRuns() { + assertThatThrownBy(() -> gateway.execute("rmq.dashboard.summary", Map.of())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("input validation failed"); + verifyNoInteractions(dashboardService); + } + @Test void resolvesCapabilitiesForEveryExistingClusterType() { when(clusterService.getCluster("v4")).thenReturn(cluster("v4", ClusterType.V4_DIRECT)); @@ -201,7 +280,8 @@ void refusesNonL1CatalogEntriesEvenWhenAHandlerIsRegistered() throws IOException ToolCatalog l2Catalog = ToolCatalog.load( new ByteArrayResource(yaml.getBytes(StandardCharsets.UTF_8)), new ClassPathResource("tool-catalog/rmq-tools.schema.json")); - ToolGatewayService l2Gateway = gateway(l2Catalog, clusterListHandler, capabilitiesHandler); + ToolGatewayService l2Gateway = gateway( + l2Catalog, clusterListHandler, capabilitiesHandler, dashboardSummaryHandler); assertThatThrownBy(() -> l2Gateway.execute("rmq.cluster.list", Map.of())) .isInstanceOf(BusinessException.class) @@ -212,7 +292,11 @@ void refusesNonL1CatalogEntriesEvenWhenAHandlerIsRegistered() throws IOException @Test void failsStartupForDuplicateHandlerNames() { assertThatThrownBy(() -> gateway( - catalog, clusterListHandler, clusterListHandler, capabilitiesHandler)) + catalog, + clusterListHandler, + clusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("duplicate handler"); } @@ -241,7 +325,10 @@ void failsStartupWhenToolSchemaContainsAnUnresolvedReference() throws IOExceptio new ClassPathResource("tool-catalog/rmq-tools.schema.json")); assertThatThrownBy(() -> gateway( - invalidCatalog, clusterListHandler, capabilitiesHandler)) + invalidCatalog, + clusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("input schema") .hasMessageContaining("rmq.cluster.list"); @@ -263,7 +350,10 @@ void failsStartupWhenToolSchemaViolatesTheJsonSchemaMetaSchema() throws IOExcept new ClassPathResource("tool-catalog/rmq-tools.schema.json")); assertThatThrownBy(() -> gateway( - invalidCatalog, clusterListHandler, capabilitiesHandler)) + invalidCatalog, + clusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("input schema") .hasMessageContaining("rmq.cluster.list"); @@ -283,7 +373,10 @@ public Object execute(Map input) { } }; ToolGatewayService invalidGateway = gateway( - catalog, invalidClusterListHandler, capabilitiesHandler); + catalog, + invalidClusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler); assertThatThrownBy(() -> invalidGateway.execute("rmq.cluster.list", Map.of())) .isInstanceOf(IllegalStateException.class)