From d4397cc647fbdc968670e083bc7b0fec6c45cc80 Mon Sep 17 00:00:00 2001 From: Comrade Yi Date: Sun, 8 Feb 2026 21:39:11 +0800 Subject: [PATCH 1/4] feat(graph): add service graph visualization and related data structures --- pkg/console/handler/service.go | 19 ++++ pkg/console/model/graph.go | 45 ++++++++++ pkg/console/router/router.go | 1 + pkg/console/service/service.go | 158 +++++++++++++++++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 pkg/console/model/graph.go diff --git a/pkg/console/handler/service.go b/pkg/console/handler/service.go index 936b796f..19d1e402 100644 --- a/pkg/console/handler/service.go +++ b/pkg/console/handler/service.go @@ -229,3 +229,22 @@ func ServiceConfigArgumentRoutePUT(ctx consolectx.Context) gin.HandlerFunc { c.JSON(http.StatusOK, model.NewSuccessResp(nil)) } } + +// GetServiceGraph returns the service graph in a cross-linked list structure for visualization +func GetServiceGraph(ctx consolectx.Context) gin.HandlerFunc { + return func(c *gin.Context) { + req := &model.ServiceGraphReq{} + if err := c.ShouldBindQuery(req); err != nil { + c.JSON(http.StatusBadRequest, model.NewErrorResp(err.Error())) + return + } + + resp, err := service.SearchServiceAsCrossLinkedList(ctx, req) + if err != nil { + util.HandleServiceError(c, err) + return + } + + c.JSON(http.StatusOK, model.NewSuccessResp(resp)) + } +} diff --git a/pkg/console/model/graph.go b/pkg/console/model/graph.go new file mode 100644 index 00000000..278eee7f --- /dev/null +++ b/pkg/console/model/graph.go @@ -0,0 +1,45 @@ +package model + +import ( + meshresource "github.com/apache/dubbo-admin/pkg/core/resource/apis/mesh/v1alpha1" +) + +// GraphNode represents a node in the graph for AntV G6 +type GraphNode struct { + ID string `json:"id"` + Label string `json:"label"` + Data interface{} `json:"data,omitempty"` // Additional data for the node +} + +// GraphEdge represents an edge in the graph for AntV G6 +type GraphEdge struct { + Source string `json:"source"` + Target string `json:"target"` + Data map[string]interface{} `json:"data,omitempty"` // Additional data for the edge +} + +// GraphData represents the complete graph structure for AntV G6 +type GraphData struct { + Nodes []GraphNode `json:"nodes"` + Edges []GraphEdge `json:"edges"` +} + +// CrossNode represents a node in the cross-linked list structure +type CrossNode struct { + Instance *meshresource.InstanceResource + Next *CrossNode // pointer to next node in the same row + Down *CrossNode // pointer to next node in the same column +} + +// CrossLinkedListGraph represents the cross-linked list structure as a directed graph +type CrossLinkedListGraph struct { + Head *CrossNode + Rows int // number of rows + Cols int // number of columns +} + +// ServiceGraphReq represents the request parameters for fetching the service graph +type ServiceGraphReq struct { + ServiceName string `json:"serviceName" form:"serviceName"` + Mesh string `json:"mesh" form:"mesh" binding:"required"` +} diff --git a/pkg/console/router/router.go b/pkg/console/router/router.go index c68c763d..1767cd9e 100644 --- a/pkg/console/router/router.go +++ b/pkg/console/router/router.go @@ -99,6 +99,7 @@ func InitRouter(r *gin.Engine, ctx consolectx.Context) { service := router.Group("/service") service.GET("/distribution", handler.GetServiceTabDistribution(ctx)) service.GET("/search", handler.SearchServices(ctx)) + service.GET("/graph", handler.GetServiceGraph(ctx)) //service.GET("/detail", handler.GetServiceDetail(ctx)) //service.GET("/interfaces", handler.GetServiceInterfaces(ctx)) } diff --git a/pkg/console/service/service.go b/pkg/console/service/service.go index 85d58a1a..cc8cb8b0 100644 --- a/pkg/console/service/service.go +++ b/pkg/console/service/service.go @@ -522,3 +522,161 @@ func isArgumentRoute(condition string) bool { } return false } + +// SearchServiceAsCrossLinkedList builds a service dependency graph. +// If serviceName is provided, it finds applications that consume or provide that specific service. +// If serviceName is empty, it returns all service dependencies in the mesh. +// It constructs nodes (applications) and edges (dependencies) for graph visualization. +func SearchServiceAsCrossLinkedList(ctx consolectx.Context, req *model.ServiceGraphReq) (*model.GraphData, error) { + // Build indexes conditionally based on whether serviceName is provided + consumerIndexes := map[string]string{ + index.ByMeshIndex: req.Mesh, + } + providerIndexes := map[string]string{ + index.ByMeshIndex: req.Mesh, + } + + // Only filter by serviceName if it's provided + if req.ServiceName != "" { + consumerIndexes[index.ByServiceConsumerServiceName] = req.ServiceName + providerIndexes[index.ByServiceProviderServiceName] = req.ServiceName + } + + // Use ListByIndexes instead of PageListByIndexes to get all related resources + // since we need complete dependency graph, not paginated results + consumers, err := manager.ListByIndexes[*meshresource.ServiceConsumerMetadataResource]( + ctx.ResourceManager(), + meshresource.ServiceConsumerMetadataKind, + consumerIndexes) + if err != nil { + logger.Errorf("get service consumer for mesh %s failed, cause: %v", req.Mesh, err) + return nil, bizerror.New(bizerror.InternalError, "get service consumer failed, please try again") + } + + providers, err := manager.ListByIndexes[*meshresource.ServiceProviderMetadataResource]( + ctx.ResourceManager(), + meshresource.ServiceProviderMetadataKind, + providerIndexes) + if err != nil { + logger.Errorf("get service provider for mesh %s failed, cause: %v", req.Mesh, err) + return nil, bizerror.New(bizerror.InternalError, "get service provider failed, please try again") + } + + // Collect all unique applications (both consumers and providers) + consumerApps := make(map[string]bool) + for _, consumer := range consumers { + if consumer.Spec != nil { + consumerApps[consumer.Spec.ConsumerAppName] = true + } + } + + providerApps := make(map[string]bool) + for _, provider := range providers { + if provider.Spec != nil { + providerApps[provider.Spec.ProviderAppName] = true + } + } + + allApps := make(map[string]bool) + for app := range consumerApps { + allApps[app] = true + } + for app := range providerApps { + allApps[app] = true + } + + nodes := make([]model.GraphNode, 0, len(allApps)) + edges := make([]model.GraphEdge, 0) + + // Build app to service instances mapping + // For providers: map providerAppName -> list of instances providing this service + // For consumers: map consumerAppName -> empty list (consumers don't provide instances) + appInstances := make(map[string][]*meshresource.InstanceResource) + + // Get all instances for the mesh first + allInstances, err := manager.ListByIndexes[*meshresource.InstanceResource]( + ctx.ResourceManager(), + meshresource.InstanceKind, + map[string]string{ + index.ByMeshIndex: req.Mesh, + }) + if err != nil { + logger.Errorf("get instances for mesh %s failed, cause: %v", req.Mesh, err) + } + + // Build app -> instances mapping + for _, instance := range allInstances { + if instance.Spec != nil && instance.Spec.AppName != "" { + appInstances[instance.Spec.AppName] = append(appInstances[instance.Spec.AppName], instance) + } + } + + // Build nodes for each app + // Provider nodes: data contains instances providing this service + // Consumer nodes: data is nil (consumers don't provide instances) + for appName := range allApps { + var instanceData interface{} + + instances := make([]interface{}, 0) + if appInsts, ok := appInstances[appName]; ok { + for _, instance := range appInsts { + instances = append(instances, toInstanceData(instance)) + } + } + instanceData = instances + + nodes = append(nodes, model.GraphNode{ + ID: appName, + Label: appName, + Data: instanceData, + }) + } + + // Build edges between consumers and providers + // Only create edges between apps that actually have the service relationship + for _, consumer := range consumers { + if consumer.Spec != nil { + for _, provider := range providers { + if provider.Spec != nil { + // This is where you should check if the provider's service name and the consumer's service name match. + if consumer.Spec.ServiceName != provider.Spec.ServiceName { + continue + } + edges = append(edges, model.GraphEdge{ + Source: consumer.Spec.ConsumerAppName, + Target: provider.Spec.ProviderAppName, + Data: map[string]interface{}{ + "type": "dependency", + "serviceName": req.ServiceName, + "consumerApp": consumer.Spec.ConsumerAppName, + "providerApp": provider.Spec.ProviderAppName, + }, + }) + } + } + } + } + + return &model.GraphData{ + Nodes: nodes, + Edges: edges, + }, nil +} + +func toInstanceData(instance *meshresource.InstanceResource) map[string]interface{} { + if instance == nil || instance.Spec == nil { + return nil + } + + data := map[string]interface{}{ + "appName": instance.Spec.AppName, + "ip": instance.Spec.Ip, + "name": instance.Spec.Name, + "protocol": instance.Spec.Protocol, + "qosPort": instance.Spec.QosPort, + "rpcPort": instance.Spec.RpcPort, + "tags": instance.Spec.Tags, + } + + return data +} From 13f7855bc669426756c0a6f5b1dda22e8539f98d Mon Sep 17 00:00:00 2001 From: Comrade Yi Date: Tue, 10 Feb 2026 12:47:06 +0800 Subject: [PATCH 2/4] feat(service): enhance SearchServiceAsCrossLinkedList to merge duplicate edges and add logging for nil specs --- pkg/console/service/service.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/console/service/service.go b/pkg/console/service/service.go index cc8cb8b0..3e9a13e8 100644 --- a/pkg/console/service/service.go +++ b/pkg/console/service/service.go @@ -631,6 +631,7 @@ func SearchServiceAsCrossLinkedList(ctx consolectx.Context, req *model.ServiceGr Data: instanceData, }) } + edgeKeyMap := make(map[string]struct{}) // Build edges between consumers and providers // Only create edges between apps that actually have the service relationship @@ -642,6 +643,14 @@ func SearchServiceAsCrossLinkedList(ctx consolectx.Context, req *model.ServiceGr if consumer.Spec.ServiceName != provider.Spec.ServiceName { continue } + // If there are two instances, such as p1->c1 and p2->c1, two edges will be generated, which need to be merged. + // Merging logic: Only one edge is kept for identical source and target. + // However, using a loop would result in three levels of nesting. Therefore, an auxiliary map is created for efficient filtering. + edgeKey := consumer.Spec.ConsumerAppName + "->" + provider.Spec.ProviderAppName + if _, exists := edgeKeyMap[edgeKey]; exists { + continue + } + edgeKeyMap[edgeKey] = struct{}{} edges = append(edges, model.GraphEdge{ Source: consumer.Spec.ConsumerAppName, Target: provider.Spec.ProviderAppName, @@ -652,8 +661,12 @@ func SearchServiceAsCrossLinkedList(ctx consolectx.Context, req *model.ServiceGr "providerApp": provider.Spec.ProviderAppName, }, }) + } else { + logger.Warnf("provider spec is nil for provider resource: %s", provider.Name) } } + } else { + logger.Warnf("consumer spec is nil for consumer resource: %s", consumer.Name) } } From c9e76b5b209004447e55ba7b61b54df4922180bd Mon Sep 17 00:00:00 2001 From: Comrade Yi Date: Tue, 10 Feb 2026 13:12:47 +0800 Subject: [PATCH 3/4] fix(license): restore license header in graph.go --- pkg/console/model/graph.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pkg/console/model/graph.go b/pkg/console/model/graph.go index 278eee7f..67bc913e 100644 --- a/pkg/console/model/graph.go +++ b/pkg/console/model/graph.go @@ -1,3 +1,20 @@ +/* + * 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 model import ( From 82474b64d2f998badfcc5c3dd2a4d11ace1eced5 Mon Sep 17 00:00:00 2001 From: Comrade Yi <119987662+ambiguous-pointer@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:11:48 +0800 Subject: [PATCH 4/4] Apply suggestion from @Copilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 确定无地方使用 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/console/handler/service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/console/handler/service.go b/pkg/console/handler/service.go index 19d1e402..041a0c2d 100644 --- a/pkg/console/handler/service.go +++ b/pkg/console/handler/service.go @@ -230,7 +230,7 @@ func ServiceConfigArgumentRoutePUT(ctx consolectx.Context) gin.HandlerFunc { } } -// GetServiceGraph returns the service graph in a cross-linked list structure for visualization +// GetServiceGraph returns the service graph as graph data (nodes and edges) for visualization func GetServiceGraph(ctx consolectx.Context) gin.HandlerFunc { return func(c *gin.Context) { req := &model.ServiceGraphReq{}