Skip to content
19 changes: 19 additions & 0 deletions pkg/console/handler/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
ambiguous-pointer marked this conversation as resolved.
Outdated
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))
}
}
62 changes: 62 additions & 0 deletions pkg/console/model/graph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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 (
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
}
Comment on lines +20 to +60
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CrossNode / CrossLinkedListGraph appear unused by the new endpoint (which returns GraphData), but they add API surface and force an extra import. If the graph is meant to be nodes/edges for G6, consider removing these types (and the meshresource import) or moving them behind an actual use to avoid confusion/maintenance burden.

Copilot uses AI. Check for mistakes.

// 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"`
}
1 change: 1 addition & 0 deletions pkg/console/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
171 changes: 171 additions & 0 deletions pkg/console/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,3 +522,174 @@ 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) {
Comment thread
ambiguous-pointer marked this conversation as resolved.
// 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 != "" {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction:

  1. 不应该有这个判断,服务名是必须的参数,没有服务名,应该在handler里面就返回错误。后续的拓扑也是针对某一个服务来展开的。
  2. 对于一个服务来说,serviceName不能完全定位一个服务,必须要serviceName:version:group——即ServiceKey才能完全定位一个服务。这里请求入参可以直接使用BaseServiceReq
  3. 这里对于ServiceConsumerMetadataKind和ServiceProviderMetadataKind的索引还缺少对ServiceKey的索引,需要先在索引里面加上,然后用ServiceKey的索引来搜索这两个Resource

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion:

  1. golang中没有内置的set,但可以用map[string]strunct{}来替代,value是一个空的struct,不占存储空间
  2. 可以使用工具类lancet中的set来替代,具体用法可以参考其他代码

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](
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea: 这里应该是想要把所有的实例搜出来,然后挂到每个应用下面,但这种做法在生产环境下万万不行,因为这里面会把所有的实例全部搜出来,如果实例数很多,可能会出现爆内存/DB打挂的情况。所以这里可以从交互入手,改一下交互, 在返回graph时不返回每个节点具体的数据,只返回id和label。用户点击一个节点时再请求对应节点(应用)的数据。这样,就不用在拓扑图这里捞这么多数据。

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)
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListByIndexes for instances logs an error but continues returning a successful graph response, which can silently drop instance data and make the API appear healthy when it's not. Consider returning an error (consistent with the consumer/provider fetches) or explicitly reflecting the partial-failure in the response so clients can react appropriately.

Suggested change
logger.Errorf("get instances for mesh %s failed, cause: %v", req.Mesh, err)
logger.Errorf("get instances for mesh %s failed, cause: %v", req.Mesh, err)
return nil, err

Copilot uses AI. Check for mistakes.
}

// 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,
})
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above this loop says consumer nodes should have data = nil, but the code always assigns instances (an empty slice when none exist). Because Data is an interface{} with omitempty, this will still serialize as "data": [] for apps with no instances. Decide on the intended API contract and either set Data to nil when there are no instances or update the comment/API docs accordingly.

Copilot uses AI. Check for mistakes.
}
edgeKeyMap := make(map[string]struct{})

// Build edges between consumers and providers
// Only create edges between apps that actually have the service relationship
for _, consumer := range consumers {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea: 这里的每一个消费者应用都会有一条边指向提供者应用?这个关系太复杂。可不可以转换成这种结构:
Image
用服务作为一个中间节点,左边是提供者应用,右边是消费者应用

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.
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This inline comment reads like a leftover TODO/instruction and doesn't add value in production code. Please remove it or replace it with a concise explanation of the actual matching rule being implemented (service-name equality).

Suggested change
// This is where you should check if the provider's service name and the consumer's service name match.
// Only create edges when the consumer and provider reference the same service name.

Copilot uses AI. Check for mistakes.
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,
Data: map[string]interface{}{
"type": "dependency",
"serviceName": req.ServiceName,
"consumerApp": consumer.Spec.ConsumerAppName,
"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)
}
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Building edges uses a nested consumers×providers loop, which is O(C*P) and can become very expensive when req.ServiceName is empty (lists include all metadata in the mesh) or when there are many instances per service. A more scalable approach is to pre-index providers by ServiceName (and possibly by ProviderAppName) and then only iterate over matching providers for each consumer.

Suggested change
// 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
}
// 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,
Data: map[string]interface{}{
"type": "dependency",
"serviceName": req.ServiceName,
"consumerApp": consumer.Spec.ConsumerAppName,
"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)
}
// Build an index of providers by service name to avoid O(C*P) nested loops.
providersByService := make(map[string][]int)
for pIdx, provider := range providers {
if provider.Spec == nil {
logger.Warnf("provider spec is nil for provider resource: %s", provider.Name)
continue
}
serviceName := provider.Spec.ServiceName
providersByService[serviceName] = append(providersByService[serviceName], pIdx)
}
// 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 {
logger.Warnf("consumer spec is nil for consumer resource: %s", consumer.Name)
continue
}
serviceName := consumer.Spec.ServiceName
providerIdxs := providersByService[serviceName]
for _, pIdx := range providerIdxs {
provider := providers[pIdx]
// provider.Spec is guaranteed non-nil here because we only indexed non-nil specs.
// 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,
Data: map[string]interface{}{
"type": "dependency",
"serviceName": req.ServiceName,
"consumerApp": consumer.Spec.ConsumerAppName,
"providerApp": provider.Spec.ProviderAppName,
},
})
}

Copilot uses AI. Check for mistakes.
}

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
}
Loading