Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 as graph data (nodes and edges) 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))
}
}
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 +56
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) {
// 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
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
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
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,
})
Comment on lines +614 to +632
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
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)
}
Comment on lines +636 to +670
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