diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandler.java b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandler.java new file mode 100644 index 00000000..db7fd84c --- /dev/null +++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/ConsumerGroupListToolHandler.java @@ -0,0 +1,88 @@ +/* + * 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.ops.ai.tool; + +import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO; +import org.apache.rocketmq.studio.instance.topic.MetadataService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class ConsumerGroupListToolHandler implements ToolHandler { + + private static final String NAME = "rmq.group.list"; + + private final MetadataService metadataService; + + @Override + public String name() { + return NAME; + } + + @Override + public Object execute(Map input) { + String clusterId = (String) input.get("cluster"); + String search = (String) input.get("search"); + return metadataService.listConsumerGroups(clusterId, search).stream() + .map(ConsumerGroupListToolHandler::safeProjection) + .toList(); + } + + private static Map safeProjection(ConsumerGroupVO group) { + Map result = new LinkedHashMap<>(); + result.put("name", require(group.getName(), "name")); + result.put("namespace", blankIfNull(group.getNamespace())); + result.put("clusterId", blankIfNull(group.getClusterId())); + result.put("subscriptionMode", requiredEnumName( + group.getSubscriptionMode(), "subscriptionMode", group.getName())); + result.put("consumeType", requiredEnumName( + group.getConsumeType(), "consumeType", group.getName())); + result.put("onlineInstances", group.getOnlineInstances()); + result.put("totalLag", group.getTotalLag()); + result.put("subscribedTopics", copyList(group.getSubscribedTopics())); + result.put("retryMaxTimes", group.getRetryMaxTimes()); + return result; + } + + private static String requiredEnumName(Enum value, String field, String groupName) { + if (value == null) { + throw new IllegalStateException("Consumer group " + field + + " is unavailable: " + groupName); + } + return value.name(); + } + + private static String require(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("Consumer group " + field + " is unavailable"); + } + return value; + } + + private static List copyList(List value) { + return value == null ? List.of() : List.copyOf(value); + } + + private static String blankIfNull(String value) { + return value == null ? "" : value; + } +} diff --git a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/DashboardSummaryToolHandler.java new file mode 100644 index 00000000..0d8708e7 --- /dev/null +++ b/server/src/main/java/org/apache/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 org.apache.rocketmq.studio.ops.ai.tool; + +import org.apache.rocketmq.studio.common.exception.BusinessException; +import org.apache.rocketmq.studio.ops.dashboard.ClusterOverviewVO; +import org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO; +import org.apache.rocketmq.studio.ops.dashboard.DashboardService; +import org.apache.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/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandler.java b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandler.java new file mode 100644 index 00000000..6eb730c2 --- /dev/null +++ b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/tool/TopicListToolHandler.java @@ -0,0 +1,82 @@ +/* + * 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.ops.ai.tool; + +import org.apache.rocketmq.studio.instance.topic.MetadataService; +import org.apache.rocketmq.studio.instance.topic.TopicVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class TopicListToolHandler implements ToolHandler { + + private static final String NAME = "rmq.topic.list"; + + private final MetadataService metadataService; + + @Override + public String name() { + return NAME; + } + + @Override + public Object execute(Map input) { + String clusterId = (String) input.get("cluster"); + String type = (String) input.get("type"); + String search = (String) input.get("search"); + return metadataService.listTopics(clusterId, type, search).stream() + .map(TopicListToolHandler::safeProjection) + .toList(); + } + + private static Map safeProjection(TopicVO topic) { + Map result = new LinkedHashMap<>(); + result.put("name", require(topic.getName(), "name")); + result.put("namespace", blankIfNull(topic.getNamespace())); + result.put("clusterId", blankIfNull(topic.getClusterId())); + result.put("type", requiredEnumName(topic.getType(), "type", topic.getName())); + result.put("writeQueues", topic.getWriteQueues()); + result.put("readQueues", topic.getReadQueues()); + result.put("perm", requiredEnumName(topic.getPerm(), "perm", topic.getName())); + result.put("messageCount", topic.getMessageCount()); + result.put("tps", topic.getTps()); + result.put("consumerGroupCount", topic.getConsumerGroupCount()); + return result; + } + + private static String requiredEnumName(Enum value, String field, String topicName) { + if (value == null) { + throw new IllegalStateException("Topic " + field + " is unavailable: " + topicName); + } + return value.name(); + } + + private static String require(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalStateException("Topic " + field + " is unavailable"); + } + return value; + } + + private static String blankIfNull(String value) { + return value == null ? "" : value; + } +} diff --git a/server/src/main/resources/tool-catalog/rmq-tools.yaml b/server/src/main/resources/tool-catalog/rmq-tools.yaml index 4327667c..1ce2d105 100644 --- a/server/src/main/resources/tool-catalog/rmq-tools.yaml +++ b/server/src/main/resources/tool-catalog/rmq-tools.yaml @@ -74,3 +74,232 @@ 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 + - name: rmq.topic.list + cli: + resource: topic + verb: list + description: List RocketMQ topics in one Studio cluster. + riskLevel: L1 + permission: topic:read + requiredCapabilities: + - REMOTING + inputSchema: + type: object + required: + - cluster + additionalProperties: false + properties: + cluster: + type: string + minLength: 1 + type: + type: string + minLength: 1 + search: + type: string + minLength: 1 + outputSchema: + type: array + items: + type: object + required: + - name + - namespace + - clusterId + - type + - writeQueues + - readQueues + - perm + - messageCount + - tps + - consumerGroupCount + additionalProperties: false + properties: + name: + type: string + namespace: + type: string + clusterId: + type: string + type: + type: string + writeQueues: + type: integer + readQueues: + type: integer + perm: + type: string + messageCount: + type: integer + tps: + type: number + consumerGroupCount: + type: integer + viewHint: table + deprecated: false + - name: rmq.group.list + cli: + resource: group + verb: list + description: List RocketMQ consumer groups in one Studio cluster. + riskLevel: L1 + permission: group:read + requiredCapabilities: + - REMOTING + inputSchema: + type: object + required: + - cluster + additionalProperties: false + properties: + cluster: + type: string + minLength: 1 + search: + type: string + minLength: 1 + outputSchema: + type: array + items: + type: object + required: + - name + - namespace + - clusterId + - subscriptionMode + - consumeType + - onlineInstances + - totalLag + - subscribedTopics + - retryMaxTimes + additionalProperties: false + properties: + name: + type: string + namespace: + type: string + clusterId: + type: string + subscriptionMode: + type: string + consumeType: + type: string + onlineInstances: + type: integer + totalLag: + type: integer + subscribedTopics: + type: array + items: + type: string + retryMaxTimes: + type: integer + viewHint: table + deprecated: false diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java index c35a11e1..50f10175 100644 --- a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java +++ b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolCatalogTest.java @@ -38,7 +38,12 @@ 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", + "rmq.topic.list", + "rmq.group.list"); assertThat(catalog.find("rmq.cluster.list")).isPresent(); assertThat(catalog.find("rmq.unknown")).isEmpty(); } diff --git a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java index 13dd6fe7..46a0699d 100644 --- a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java +++ b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/tool/ToolGatewayServiceTest.java @@ -21,8 +21,19 @@ import org.apache.rocketmq.studio.cluster.broker.ClusterVO; import org.apache.rocketmq.studio.common.domain.enums.ClusterStatus; import org.apache.rocketmq.studio.common.domain.enums.ClusterType; +import org.apache.rocketmq.studio.common.domain.enums.ConsumeType; +import org.apache.rocketmq.studio.common.domain.enums.SubscriptionMode; +import org.apache.rocketmq.studio.common.domain.enums.TopicPerm; +import org.apache.rocketmq.studio.common.domain.enums.TopicType; import org.apache.rocketmq.studio.common.exception.BusinessException; +import org.apache.rocketmq.studio.instance.group.ConsumerGroupVO; +import org.apache.rocketmq.studio.instance.topic.MetadataService; +import org.apache.rocketmq.studio.instance.topic.TopicVO; import org.apache.rocketmq.studio.ops.ai.AiToolVO; +import org.apache.rocketmq.studio.ops.dashboard.ClusterOverviewVO; +import org.apache.rocketmq.studio.ops.dashboard.DashboardDataVO; +import org.apache.rocketmq.studio.ops.dashboard.DashboardService; +import org.apache.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 +54,35 @@ class ToolGatewayServiceTest { private ToolCatalog catalog; private ClusterService clusterService; + private DashboardService dashboardService; + private MetadataService metadataService; private CapabilityResolver capabilityResolver; private ClusterListToolHandler clusterListHandler; private CapabilitiesToolHandler capabilitiesHandler; + private DashboardSummaryToolHandler dashboardSummaryHandler; + private TopicListToolHandler topicListHandler; + private ConsumerGroupListToolHandler consumerGroupListHandler; private ToolGatewayService gateway; @BeforeEach void setUp() { catalog = canonicalCatalog(); clusterService = mock(ClusterService.class); + dashboardService = mock(DashboardService.class); + metadataService = mock(MetadataService.class); capabilityResolver = new CapabilityResolver(clusterService); clusterListHandler = new ClusterListToolHandler(clusterService); capabilitiesHandler = new CapabilitiesToolHandler(clusterService, capabilityResolver); - gateway = gateway(catalog, clusterListHandler, capabilitiesHandler); + dashboardSummaryHandler = new DashboardSummaryToolHandler(dashboardService); + topicListHandler = new TopicListToolHandler(metadataService); + consumerGroupListHandler = new ConsumerGroupListToolHandler(metadataService); + gateway = gateway( + catalog, + clusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler, + topicListHandler, + consumerGroupListHandler); } @Test @@ -72,7 +99,12 @@ 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", + "rmq.topic.list", + "rmq.group.list"); } @Test @@ -113,6 +145,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)); @@ -152,6 +251,73 @@ void rejectsClusterWithMissingType() { .hasMessageContaining("Cluster type is unavailable"); } + @Test + void executesTopicListThroughADataMinimizingProjection() { + when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER)); + TopicVO topic = topic(); + topic.setRemark("do-not-expose"); + when(metadataService.listTopics("cluster-v5", "NORMAL", "order")) + .thenReturn(List.of(topic)); + + Object output = gateway.execute("rmq.topic.list", Map.of( + "cluster", "cluster-v5", + "type", "NORMAL", + "search", "order")); + + assertThat(output).isEqualTo(List.of(Map.of( + "name", "order-topic", + "namespace", "default", + "clusterId", "cluster-v5", + "type", "NORMAL", + "writeQueues", 8, + "readQueues", 8, + "perm", "RW", + "messageCount", 1200L, + "tps", 23.5D, + "consumerGroupCount", 3))); + assertThat(output.toString()).doesNotContain("do-not-expose"); + } + + @Test + void rejectsTopicListWithoutAClusterBeforeHandlerRuns() { + assertThatThrownBy(() -> gateway.execute("rmq.topic.list", Map.of("type", "NORMAL"))) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("input validation failed"); + verifyNoInteractions(metadataService); + } + + @Test + void executesConsumerGroupListThroughADataMinimizingProjection() { + when(clusterService.getCluster("cluster-v5")).thenReturn(cluster(ClusterType.V5_PROXY_CLUSTER)); + ConsumerGroupVO group = consumerGroup(); + group.setDelaySeconds(30); + when(metadataService.listConsumerGroups("cluster-v5", "order")).thenReturn(List.of(group)); + + Object output = gateway.execute("rmq.group.list", Map.of( + "cluster", "cluster-v5", + "search", "order")); + + assertThat(output).isEqualTo(List.of(Map.of( + "name", "cg-order", + "namespace", "default", + "clusterId", "cluster-v5", + "subscriptionMode", "Push", + "consumeType", "CLUSTERING", + "onlineInstances", 2, + "totalLag", 42L, + "subscribedTopics", List.of("order-topic"), + "retryMaxTimes", 16))); + assertThat(output.toString()).doesNotContain("delaySeconds"); + } + + @Test + void rejectsConsumerGroupListWithoutAClusterBeforeHandlerRuns() { + assertThatThrownBy(() -> gateway.execute("rmq.group.list", Map.of())) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("input validation failed"); + verifyNoInteractions(metadataService); + } + @Test void rejectsCapabilitiesExecutionWhenClusterTypeIsMissing() { when(clusterService.getCluster("unknown")).thenReturn(cluster("unknown", null)); @@ -201,7 +367,13 @@ 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, + topicListHandler, + consumerGroupListHandler); assertThatThrownBy(() -> l2Gateway.execute("rmq.cluster.list", Map.of())) .isInstanceOf(BusinessException.class) @@ -212,7 +384,13 @@ void refusesNonL1CatalogEntriesEvenWhenAHandlerIsRegistered() throws IOException @Test void failsStartupForDuplicateHandlerNames() { assertThatThrownBy(() -> gateway( - catalog, clusterListHandler, clusterListHandler, capabilitiesHandler)) + catalog, + clusterListHandler, + clusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler, + topicListHandler, + consumerGroupListHandler)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("duplicate handler"); } @@ -241,7 +419,12 @@ void failsStartupWhenToolSchemaContainsAnUnresolvedReference() throws IOExceptio new ClassPathResource("tool-catalog/rmq-tools.schema.json")); assertThatThrownBy(() -> gateway( - invalidCatalog, clusterListHandler, capabilitiesHandler)) + invalidCatalog, + clusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler, + topicListHandler, + consumerGroupListHandler)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("input schema") .hasMessageContaining("rmq.cluster.list"); @@ -263,7 +446,12 @@ void failsStartupWhenToolSchemaViolatesTheJsonSchemaMetaSchema() throws IOExcept new ClassPathResource("tool-catalog/rmq-tools.schema.json")); assertThatThrownBy(() -> gateway( - invalidCatalog, clusterListHandler, capabilitiesHandler)) + invalidCatalog, + clusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler, + topicListHandler, + consumerGroupListHandler)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("input schema") .hasMessageContaining("rmq.cluster.list"); @@ -283,7 +471,12 @@ public Object execute(Map input) { } }; ToolGatewayService invalidGateway = gateway( - catalog, invalidClusterListHandler, capabilitiesHandler); + catalog, + invalidClusterListHandler, + capabilitiesHandler, + dashboardSummaryHandler, + topicListHandler, + consumerGroupListHandler); assertThatThrownBy(() -> invalidGateway.execute("rmq.cluster.list", Map.of())) .isInstanceOf(IllegalStateException.class) @@ -323,4 +516,33 @@ private static ClusterVO cluster(String id, ClusterType type) { cluster.setId(id); return cluster; } + + private static TopicVO topic() { + TopicVO topic = new TopicVO(); + topic.setName("order-topic"); + topic.setNamespace("default"); + topic.setClusterId("cluster-v5"); + topic.setType(TopicType.NORMAL); + topic.setWriteQueues(8); + topic.setReadQueues(8); + topic.setPerm(TopicPerm.RW); + topic.setMessageCount(1200L); + topic.setTps(23.5D); + topic.setConsumerGroupCount(3); + return topic; + } + + private static ConsumerGroupVO consumerGroup() { + ConsumerGroupVO group = new ConsumerGroupVO(); + group.setName("cg-order"); + group.setNamespace("default"); + group.setClusterId("cluster-v5"); + group.setSubscriptionMode(SubscriptionMode.Push); + group.setConsumeType(ConsumeType.CLUSTERING); + group.setOnlineInstances(2); + group.setTotalLag(42L); + group.setSubscribedTopics(List.of("order-topic")); + group.setRetryMaxTimes(16); + return group; + } } diff --git a/web/src/pages/ai/chatDraft.test.ts b/web/src/pages/ai/chatDraft.test.ts index 6f1e09e8..d7d89f42 100644 --- a/web/src/pages/ai/chatDraft.test.ts +++ b/web/src/pages/ai/chatDraft.test.ts @@ -20,7 +20,7 @@ import { getChatDraft } from './chatDraft'; describe('AI chat draft navigation state', () => { it('normalizes a prompt and preserves a selected model', () => { - expect(getChatDraft({ prompt: ' 检查集群状态 ', model: 'qwen3.7-max' })).toEqual({ + expect(getChatDraft({ prompt: ' 检查集群状态 ', model: ' qwen3.7-max ' })).toEqual({ prompt: '检查集群状态', model: 'qwen3.7-max', }); @@ -31,4 +31,10 @@ describe('AI chat draft navigation state', () => { expect(getChatDraft({ prompt: ' ' })).toBeNull(); expect(getChatDraft({ prompt: 42 })).toBeNull(); }); + + it('drops empty model values after normalization', () => { + expect(getChatDraft({ prompt: '检查集群状态', model: ' ' })).toEqual({ + prompt: '检查集群状态', + }); + }); }); diff --git a/web/src/pages/ai/chatDraft.ts b/web/src/pages/ai/chatDraft.ts index 115692ad..2ad1a936 100644 --- a/web/src/pages/ai/chatDraft.ts +++ b/web/src/pages/ai/chatDraft.ts @@ -24,9 +24,10 @@ export function getChatDraft(state: unknown): ChatDraft | null { if (typeof state !== 'object' || state === null) return null; const candidate = state as Record; if (typeof candidate.prompt !== 'string' || !candidate.prompt.trim()) return null; + const model = typeof candidate.model === 'string' ? candidate.model.trim() : ''; return { prompt: candidate.prompt.trim(), - ...(typeof candidate.model === 'string' && candidate.model ? { model: candidate.model } : {}), + ...(model ? { model } : {}), }; }