Copilot commented on code in PR #1426:
URL: https://github.com/apache/dubbo-admin/pull/1426#discussion_r3043149513
##########
ui-vue3/src/api/service/service.ts:
##########
@@ -136,3 +144,13 @@ export const updateParamRouteAPI = (data: {
data
})
}
+
+export const getServiceGraph = (serviceName: string): Promise<any> => {
+ return request({
+ url: '/service/graph',
+ method: 'get',
+ params: {
+ serviceName
+ }
Review Comment:
`getServiceGraph` only sends `serviceName`, but the backend service graph is
keyed by `{serviceName}:{version}:{group}`. With non-empty version/group, this
will return no data. Consider accepting `version`/`group` (or a `serviceKey`)
here and passing them through.
```suggestion
export const getServiceGraph = (
service:
| string
| {
serviceName: string
version?: string | RouteParamValue[]
group?: string | RouteParamValue[]
}
): Promise<any> => {
const params =
typeof service === 'string'
? { serviceName: service }
: {
serviceName: service.serviceName,
...(service.version !== undefined ? { version: service.version } :
{}),
...(service.group !== undefined ? { group: service.group } : {})
}
return request({
url: '/service/graph',
method: 'get',
params
```
##########
ui-vue3/src/api/service/app.ts:
##########
@@ -25,11 +25,13 @@ export const searchApplications = (params: any):
Promise<any> => {
})
}
-export const getApplicationDetail = (params: any): Promise<any> => {
+export const getApplicationDetail = (appName: string): Promise<any> => {
return request({
url: '/application/detail',
method: 'get',
- params
+ params: {
+ appName
+ }
})
Review Comment:
`getApplicationDetail` now requires a plain `appName: string`, but there are
existing call sites passing an object (e.g.
`ui-vue3/src/views/resources/applications/tabs/detail.vue` calls
`getApplicationDetail({ appName })`). Either update those call sites in this PR
or keep backward-compatible parameter handling here to avoid runtime/type
errors.
##########
ui-vue3/src/api/service/service.ts:
##########
@@ -26,11 +26,19 @@ export const searchService = (params: any): Promise<any> =>
{
})
}
-export const getServiceDetail = (params: any): Promise<any> => {
+export const getServiceDetail = ({
+ serviceName,
+ version,
+ group
+}: {
+ serviceName: string
+ version?: string
+ group?: string
+}): Promise<any> => {
return request({
url: '/service/detail',
method: 'get',
- params
+ params: { serviceName, version, group }
})
Review Comment:
`getServiceDetail` signature changed to require `{ serviceName, version?,
group? }`, but there are existing call sites passing `{}` (e.g.
`ui-vue3/src/views/resources/services/tabs/detail.vue`). Update those call
sites in this PR or provide a backward-compatible overload/defaulting to avoid
crashes.
##########
ui-vue3/src/api/service/app.ts:
##########
@@ -174,3 +176,11 @@ export const updateAppGrayIsolation = (appName: string,
graySets: Array<any>): P
}
})
}
+
+export const getApplicationGraph = (serviceName: string): Promise<any> => {
+ return request({
+ url: '/application/graph',
+ method: 'get',
+ params: { serviceName }
Review Comment:
`getApplicationGraph` is calling `/application/graph` with query param
`serviceName`, but the backend request model binds `appName` (form:"appName").
This mismatch will result in empty/incorrect graphs. Align the param name (and
function argument naming) to `appName`.
```suggestion
export const getApplicationGraph = (appName: string): Promise<any> => {
return request({
url: '/application/graph',
method: 'get',
params: { appName }
```
##########
pkg/console/handler/service.go:
##########
@@ -229,3 +229,61 @@ 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.GraphServices(ctx, req)
+ if err != nil {
+ util.HandleServiceError(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, model.NewSuccessResp(resp))
+ }
+}
+
+// GetServiceDetail returns service detail information
+func GetServiceDetail(ctx consolectx.Context) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ req := &model.ServiceDetailReq{}
+ if err := c.ShouldBindQuery(req); err != nil {
+ c.JSON(http.StatusBadRequest,
model.NewErrorResp(err.Error()))
+ return
+ }
+
+ resp, err := service.GetServiceDetail(ctx, req)
+ if err != nil {
+ util.HandleServiceError(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, model.NewSuccessResp(resp))
+ }
+}
+
+// GetServiceInterfaces returns service interfaces information
+func GetServiceInterfaces(ctx consolectx.Context) gin.HandlerFunc {
+ // return func(c *gin.Context) {
+ // req := &model.ServiceInterfacesReq{}
+ // if err := c.ShouldBindQuery(req); err != nil {
+ // c.JSON(http.StatusBadRequest,
model.NewErrorResp(err.Error()))
+ // return
+ // }
+
+ // resp, err := service.GetServiceInterfaces(ctx, req)
+ // if err != nil {
+ // util.HandleServiceError(c, err)
+ // return
+ // }
+
+ // c.JSON(http.StatusOK, model.NewSuccessResp(resp))
+ // }
+ return nil
Review Comment:
`GetServiceInterfaces` currently returns `nil`. `gin` route registration
(and request handling) expects a non-nil `gin.HandlerFunc`; this will panic
when the router registers `/service/interfaces` or when invoked. Either
implement the handler or remove/disable the route until it’s ready.
```suggestion
return func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, model.NewErrorResp("service
interfaces endpoint is not implemented"))
}
```
##########
ui-vue3/src/views/resources/services/tabs/topology.vue:
##########
@@ -0,0 +1,343 @@
+<!--
+ ~ 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.
+-->
+<template>
+ <div class="__container_app_topology">
+ <a-flex>
+ <a-card class="topology-warpper"> <div id="topology"></div> </a-card>
+ </a-flex>
+ <a-drawer v-model:open="detailDrawerOpen" :title="detailTitle"
placement="right" width="520">
+ <a-spin :spinning="detailLoading">
+ <a-typography-text v-if="detailError" type="danger">{{ detailError
}}</a-typography-text>
+ <a-descriptions
+ v-else
+ :column="1"
+ size="small"
+ bordered
+ :labelStyle="{ fontWeight: 'bold', width: '160px' }"
+ >
+ <a-descriptions-item v-for="item in detailEntries" :key="item.key">
+ <template #label>{{ item.key }}</template>
+ {{ formatValueForDisplay(item.value) }}
+ </a-descriptions-item>
+ </a-descriptions>
+ </a-spin>
+ </a-drawer>
+ </div>
+</template>
+
+<script setup lang="tsx">
+import { PRIMARY_COLOR } from '@/base/constants'
+import { HTTP_STATUS } from '@/base/http/constants'
+import { getServiceDetail, getServiceGraph } from '@/api/service/service'
+import { getApplicationDetail } from '@/api/service/app'
+import { computed, defineComponent, onBeforeUnmount, onMounted, ref,
shallowRef, watch } from 'vue'
+import type { PropType } from 'vue'
+import { VueNode } from 'g6-extension-vue'
+
+import { useRoute } from 'vue-router'
+import { ExtensionCategory, register, Graph, NodeEvent } from '@antv/g6'
+const route = useRoute()
+
+const graphRef = shallowRef<Graph | null>(null)
+register(ExtensionCategory.NODE, 'vue-node', VueNode)
+const detailDrawerOpen = ref(false)
+const detailLoading = ref(false)
+const detailError = ref('')
+const detailData = shallowRef<Record<string, unknown>>({})
+const detailCache = new Map<string, Record<string, unknown>>()
+const currentDetailKey = ref('')
+const currentDetailType = ref('')
+const selectedNodeId = ref('')
+
+const clearSelectedNode = () => {
+ const id = selectedNodeId.value
+ if (id && graphRef.value) {
+ graphRef.value.setElementState(id, [])
+ }
+ selectedNodeId.value = ''
+}
+
+watch(detailDrawerOpen, (open) => {
+ if (!open) {
+ clearSelectedNode()
+ currentDetailKey.value = ''
+ currentDetailType.value = ''
+ }
+})
+
+type VueNodeViewData = {
+ id?: string | number
+ label?: string
+ type?: string
+ rule?: unknown
+ states?: string[]
+ data?: Record<string, unknown>
+}
+
+const resolveNodeIconClass = (type: unknown) => {
+ const t = String(type ?? '').toLowerCase()
+ if (t.includes('application')) return 'icon-yingyong'
+ return 'icon-jiekouzhushou'
+}
+
+const StatefulNode = defineComponent({
+ props: {
+ data: { type: Object as PropType<VueNodeViewData>, required: true }
+ },
+ setup(props) {
+ const label = computed(() => {
+ return String(props.data?.label ?? props.data?.id ?? '')
+ })
+ const type = computed(() => props.data?.type ?? 'application')
+ const iconClass = computed(() => resolveNodeIconClass(type.value))
+ const isSelected = computed(() => {
+ const states = props.data?.states
+ if (!Array.isArray(states)) return false
+ return states.includes('selected') || states.includes('active')
+ })
+
+ return () => (
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ >
+ <div
+ style={{
+ color: isSelected.value ? PRIMARY_COLOR.value : 'rgba(0,0,0,0.65)',
+ filter: isSelected.value ? `drop-shadow(0 0 6px
${PRIMARY_COLOR.value}88)` : 'none',
+ transform: `scale(${isSelected.value ? 1.06 : 1})`,
+ transition: 'all 0.12s ease-in-out'
+ }}
+ >
+ <span
+ class={['iconfont', iconClass.value]}
+ style={{ fontSize: '40px', lineHeight: '40px' }}
+ ></span>
+ </div>
+ <div
+ style={{
+ paddingTop: '4px',
+ userSelect: 'none',
+ textAlign: 'center',
+ fontSize: '12px',
+ lineHeight: '16px'
+ }}
+ onPointerdown={(e: any) => e.stopPropagation()}
+ onMousedown={(e: any) => e.stopPropagation()}
+ onClick={(e: any) => e.stopPropagation()}
+ >
+ {label.value}
+ </div>
+ </div>
+ )
+ }
+})
+
+const detailTitle = computed(() => {
+ const type = String(currentDetailType.value ?? '').toLowerCase()
+ const base = type === 'application' ? '应用详情' : '服务详情'
+ return currentDetailKey.value ? `${base}:${currentDetailKey.value}` : base
+})
+
+const detailEntries = computed(() => {
+ const data = detailData.value ?? {}
+ return Object.entries(data)
+ .filter(([, v]) => v !== undefined && v !== null && String(v) !== '')
+ .map(([key, value]) => ({ key, value }))
+})
+
+const formatValueForDisplay = (v: unknown) => {
+ if (v === null || v === undefined) return ''
+ if (typeof v === 'string' || typeof v === 'number' || typeof v ===
'boolean') return String(v)
+ if (Array.isArray(v))
+ return v
+ .map((x) => formatValueForDisplay(x))
+ .filter(Boolean)
+ .join(', ')
+ if (typeof v === 'object') {
+ try {
+ return JSON.stringify(v)
+ } catch {
+ return String(v)
+ }
+ }
+ return String(v)
+}
+
+const buildGraphData = (raw: any) => {
+ const nodes = Array.isArray(raw?.nodes)
+ ? raw.nodes.map((n: any) => ({
+ id: String(n?.id ?? ''),
+ label: n?.label ?? n?.id,
+ type: n?.type,
+ rule: n?.rule
+ }))
+ : []
+
+ const edges = Array.isArray(raw?.edges)
+ ? raw.edges.map((e: any, idx: number) => ({
+ id: e?.id ?? `edge-${idx}`,
+ source: String(e?.source ?? ''),
+ target: String(e?.target ?? '')
+ }))
+ : []
+
+ return { nodes, edges }
+}
+const renderTopology = (graphData: any) => {
+ const root = document.getElementById('topology')
+ if (!root) return
+
+ graphRef.value?.destroy()
+ graphRef.value = null
+
+ const primaryColor = PRIMARY_COLOR.value
+ const graph = new Graph({
+ container: root,
+ width: root.clientWidth || 800,
+ height: Math.max(root.clientHeight || 500, 500),
+ autoFit: 'view',
+ padding: 20,
+ data: graphData,
+ layout: {
+ type: 'd3-force',
+ link: { distance: 180, strength: 1 },
+ collide: { radius: 40 }
+ },
+ node: {
+ type: 'vue-node',
+ style: {
+ component: (data) => <StatefulNode data={Object.assign({}, data)} />
+ }
+ },
+ edge: {
+ style: {
+ stroke: primaryColor,
+ endArrow: true,
+ lineWidth: 1.2,
+ strokeOpacity: 0.8
+ }
+ },
+ behaviors: [
+ 'drag-canvas',
+ 'zoom-canvas',
+ 'click-select',
+ { type: 'drag-element-force', fixed: true }
+ ]
+ })
+
+ const handleNodeClick = async (e: any) => {
+ const serviceName = String(e?.target?.id ?? '')
+ const nodeData: any = serviceName ?
graphRef.value?.getNodeData(serviceName) : undefined
+
+ if (!serviceName) return
+
+ selectedNodeId.value = serviceName
+ detailDrawerOpen.value = true
+ currentDetailKey.value = serviceName
+ currentDetailType.value = String(nodeData?.type ?? '')
+ .toLowerCase()
+ .includes('application')
+ ? 'application'
+ : 'service'
+ detailError.value = ''
+
+ const cacheKey = `${serviceName}`
+ const cached = detailCache.get(cacheKey)
+ if (cached) {
+ detailData.value = cached
+ detailLoading.value = false
+ return
+ }
+
+ detailLoading.value = true
+ try {
+ let res
+ if (nodeData.type === 'application') {
+ res = await getApplicationDetail(nodeData.id)
+ } else if (nodeData.type === 'service') {
+ const splitParams = nodeData.id.split(':')
+ res = await getServiceDetail({
+ serviceName: splitParams[0],
+ version: splitParams[1],
+ group: splitParams[2]
+ })
+ }
+ console.log('res', res)
+ if (res?.code !== HTTP_STATUS.SUCCESS) {
+ detailError.value = String(res?.message ?? '请求失败')
+ detailData.value = {}
+ return
+ }
+
+ const data = (res?.data ?? {}) as Record<string, unknown>
+ detailCache.set(cacheKey, data)
+ detailData.value = data
+ } catch {
+ detailError.value = '请求失败'
+ detailData.value = {}
+ } finally {
+ detailLoading.value = false
+ }
+ }
+
+ graph.on(NodeEvent.CLICK, handleNodeClick)
+
+ graph.render()
+ graphRef.value = graph
+}
+
+let resizeHandler: (() => void) | null = null
+watch(detailEntries, () => {
+ console.log('detailEntries', detailEntries.value)
+})
+onMounted(async () => {
+ try {
+ const serviceName = String(route.params?.pathId ?? '')
+ console.log('topology', serviceName)
+ const res = await getServiceGraph(serviceName)
+ if (res?.code !== HTTP_STATUS.SUCCESS) return
+ const graphData = buildGraphData(res?.data)
+ renderTopology(graphData)
Review Comment:
This topology view route includes optional `:group`/`:version`, but the data
fetch calls `getServiceGraph(serviceName)` without passing group/version. That
will mismatch the backend’s service-identity key and can return empty graphs.
Pass `route.params.group` / `route.params.version` through (or build the full
serviceKey).
##########
ui-vue3/src/views/resources/services/tabs/topology.vue:
##########
@@ -0,0 +1,343 @@
+<!--
+ ~ 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.
+-->
+<template>
+ <div class="__container_app_topology">
+ <a-flex>
+ <a-card class="topology-warpper"> <div id="topology"></div> </a-card>
+ </a-flex>
+ <a-drawer v-model:open="detailDrawerOpen" :title="detailTitle"
placement="right" width="520">
+ <a-spin :spinning="detailLoading">
+ <a-typography-text v-if="detailError" type="danger">{{ detailError
}}</a-typography-text>
+ <a-descriptions
+ v-else
+ :column="1"
+ size="small"
+ bordered
+ :labelStyle="{ fontWeight: 'bold', width: '160px' }"
+ >
+ <a-descriptions-item v-for="item in detailEntries" :key="item.key">
+ <template #label>{{ item.key }}</template>
+ {{ formatValueForDisplay(item.value) }}
+ </a-descriptions-item>
+ </a-descriptions>
+ </a-spin>
+ </a-drawer>
+ </div>
+</template>
+
+<script setup lang="tsx">
+import { PRIMARY_COLOR } from '@/base/constants'
+import { HTTP_STATUS } from '@/base/http/constants'
+import { getServiceDetail, getServiceGraph } from '@/api/service/service'
+import { getApplicationDetail } from '@/api/service/app'
+import { computed, defineComponent, onBeforeUnmount, onMounted, ref,
shallowRef, watch } from 'vue'
+import type { PropType } from 'vue'
+import { VueNode } from 'g6-extension-vue'
+
+import { useRoute } from 'vue-router'
+import { ExtensionCategory, register, Graph, NodeEvent } from '@antv/g6'
+const route = useRoute()
+
+const graphRef = shallowRef<Graph | null>(null)
+register(ExtensionCategory.NODE, 'vue-node', VueNode)
+const detailDrawerOpen = ref(false)
+const detailLoading = ref(false)
+const detailError = ref('')
+const detailData = shallowRef<Record<string, unknown>>({})
+const detailCache = new Map<string, Record<string, unknown>>()
+const currentDetailKey = ref('')
+const currentDetailType = ref('')
+const selectedNodeId = ref('')
+
+const clearSelectedNode = () => {
+ const id = selectedNodeId.value
+ if (id && graphRef.value) {
+ graphRef.value.setElementState(id, [])
+ }
+ selectedNodeId.value = ''
+}
+
+watch(detailDrawerOpen, (open) => {
+ if (!open) {
+ clearSelectedNode()
+ currentDetailKey.value = ''
+ currentDetailType.value = ''
+ }
+})
+
+type VueNodeViewData = {
+ id?: string | number
+ label?: string
+ type?: string
+ rule?: unknown
+ states?: string[]
+ data?: Record<string, unknown>
+}
+
+const resolveNodeIconClass = (type: unknown) => {
+ const t = String(type ?? '').toLowerCase()
+ if (t.includes('application')) return 'icon-yingyong'
+ return 'icon-jiekouzhushou'
+}
+
+const StatefulNode = defineComponent({
+ props: {
+ data: { type: Object as PropType<VueNodeViewData>, required: true }
+ },
+ setup(props) {
+ const label = computed(() => {
+ return String(props.data?.label ?? props.data?.id ?? '')
+ })
+ const type = computed(() => props.data?.type ?? 'application')
+ const iconClass = computed(() => resolveNodeIconClass(type.value))
+ const isSelected = computed(() => {
+ const states = props.data?.states
+ if (!Array.isArray(states)) return false
+ return states.includes('selected') || states.includes('active')
+ })
+
+ return () => (
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ >
+ <div
+ style={{
+ color: isSelected.value ? PRIMARY_COLOR.value : 'rgba(0,0,0,0.65)',
+ filter: isSelected.value ? `drop-shadow(0 0 6px
${PRIMARY_COLOR.value}88)` : 'none',
+ transform: `scale(${isSelected.value ? 1.06 : 1})`,
+ transition: 'all 0.12s ease-in-out'
+ }}
+ >
+ <span
+ class={['iconfont', iconClass.value]}
+ style={{ fontSize: '40px', lineHeight: '40px' }}
+ ></span>
+ </div>
+ <div
+ style={{
+ paddingTop: '4px',
+ userSelect: 'none',
+ textAlign: 'center',
+ fontSize: '12px',
+ lineHeight: '16px'
+ }}
+ onPointerdown={(e: any) => e.stopPropagation()}
+ onMousedown={(e: any) => e.stopPropagation()}
+ onClick={(e: any) => e.stopPropagation()}
+ >
+ {label.value}
+ </div>
+ </div>
+ )
+ }
+})
+
+const detailTitle = computed(() => {
+ const type = String(currentDetailType.value ?? '').toLowerCase()
+ const base = type === 'application' ? '应用详情' : '服务详情'
+ return currentDetailKey.value ? `${base}:${currentDetailKey.value}` : base
+})
+
+const detailEntries = computed(() => {
+ const data = detailData.value ?? {}
+ return Object.entries(data)
+ .filter(([, v]) => v !== undefined && v !== null && String(v) !== '')
+ .map(([key, value]) => ({ key, value }))
+})
+
+const formatValueForDisplay = (v: unknown) => {
+ if (v === null || v === undefined) return ''
+ if (typeof v === 'string' || typeof v === 'number' || typeof v ===
'boolean') return String(v)
+ if (Array.isArray(v))
+ return v
+ .map((x) => formatValueForDisplay(x))
+ .filter(Boolean)
+ .join(', ')
+ if (typeof v === 'object') {
+ try {
+ return JSON.stringify(v)
+ } catch {
+ return String(v)
+ }
+ }
+ return String(v)
+}
+
+const buildGraphData = (raw: any) => {
+ const nodes = Array.isArray(raw?.nodes)
+ ? raw.nodes.map((n: any) => ({
+ id: String(n?.id ?? ''),
+ label: n?.label ?? n?.id,
+ type: n?.type,
+ rule: n?.rule
+ }))
+ : []
+
+ const edges = Array.isArray(raw?.edges)
+ ? raw.edges.map((e: any, idx: number) => ({
+ id: e?.id ?? `edge-${idx}`,
+ source: String(e?.source ?? ''),
+ target: String(e?.target ?? '')
+ }))
+ : []
+
+ return { nodes, edges }
+}
+const renderTopology = (graphData: any) => {
+ const root = document.getElementById('topology')
+ if (!root) return
+
+ graphRef.value?.destroy()
+ graphRef.value = null
+
+ const primaryColor = PRIMARY_COLOR.value
+ const graph = new Graph({
+ container: root,
+ width: root.clientWidth || 800,
+ height: Math.max(root.clientHeight || 500, 500),
+ autoFit: 'view',
+ padding: 20,
+ data: graphData,
+ layout: {
+ type: 'd3-force',
+ link: { distance: 180, strength: 1 },
+ collide: { radius: 40 }
+ },
+ node: {
+ type: 'vue-node',
+ style: {
+ component: (data) => <StatefulNode data={Object.assign({}, data)} />
+ }
+ },
+ edge: {
+ style: {
+ stroke: primaryColor,
+ endArrow: true,
+ lineWidth: 1.2,
+ strokeOpacity: 0.8
+ }
+ },
+ behaviors: [
+ 'drag-canvas',
+ 'zoom-canvas',
+ 'click-select',
+ { type: 'drag-element-force', fixed: true }
+ ]
+ })
+
+ const handleNodeClick = async (e: any) => {
+ const serviceName = String(e?.target?.id ?? '')
+ const nodeData: any = serviceName ?
graphRef.value?.getNodeData(serviceName) : undefined
+
+ if (!serviceName) return
+
+ selectedNodeId.value = serviceName
+ detailDrawerOpen.value = true
+ currentDetailKey.value = serviceName
+ currentDetailType.value = String(nodeData?.type ?? '')
+ .toLowerCase()
+ .includes('application')
+ ? 'application'
+ : 'service'
+ detailError.value = ''
+
+ const cacheKey = `${serviceName}`
+ const cached = detailCache.get(cacheKey)
+ if (cached) {
+ detailData.value = cached
+ detailLoading.value = false
+ return
+ }
+
+ detailLoading.value = true
+ try {
+ let res
+ if (nodeData.type === 'application') {
+ res = await getApplicationDetail(nodeData.id)
+ } else if (nodeData.type === 'service') {
+ const splitParams = nodeData.id.split(':')
+ res = await getServiceDetail({
+ serviceName: splitParams[0],
+ version: splitParams[1],
+ group: splitParams[2]
+ })
+ }
+ console.log('res', res)
+ if (res?.code !== HTTP_STATUS.SUCCESS) {
+ detailError.value = String(res?.message ?? '请求失败')
+ detailData.value = {}
+ return
+ }
+
+ const data = (res?.data ?? {}) as Record<string, unknown>
+ detailCache.set(cacheKey, data)
+ detailData.value = data
+ } catch {
+ detailError.value = '请求失败'
+ detailData.value = {}
+ } finally {
+ detailLoading.value = false
+ }
+ }
+
+ graph.on(NodeEvent.CLICK, handleNodeClick)
+
+ graph.render()
+ graphRef.value = graph
+}
+
+let resizeHandler: (() => void) | null = null
+watch(detailEntries, () => {
+ console.log('detailEntries', detailEntries.value)
+})
+onMounted(async () => {
+ try {
+ const serviceName = String(route.params?.pathId ?? '')
+ console.log('topology', serviceName)
+ const res = await getServiceGraph(serviceName)
Review Comment:
There are several debug logs left in this component (`console.log` plus a
`watch(detailEntries)` that only logs). These will spam the console in
production and can leak data. Please remove them (or gate behind a dev flag)
before merging.
##########
pkg/console/service/application.go:
##########
@@ -196,6 +196,143 @@ func getAppConsumeServiceInfo(ctx consolectx.Context, req
*model.ApplicationServ
return pageResult, nil
}
+// GraphApplications returns the application-level graph for a given
application.
+// It collects provider and consumer service relations and transforms them
into nodes and edges.
+// The current implementation is a simplified version (provider/consumer link
traversal).
+func GraphApplications(ctx consolectx.Context, req *model.ApplicationGraphReq)
(*model.GraphData, error) {
+
+ // Step 1: query all services provided by this application in the
namespace.
+ providerServiceList, err :=
manager.ListByIndexes[*meshresource.ServiceProviderMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceProviderMetadataKind,
+ map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceProviderAppName: req.AppName,
+ },
+ )
+ if err != nil {
+ return nil, bizerror.MeshNotFoundError("")
+ }
+
+ // Step 2: query all services consumed by this application in the
namespace.
+ consumerServiceList, err :=
manager.ListByIndexes[*meshresource.ServiceConsumerMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceConsumerMetadataKind,
+ map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceConsumerAppName: req.AppName,
+ },
+ )
+ if err != nil {
+ return nil, bizerror.MeshNotFoundError("")
+ }
+
+ // Step 3: build the graph nodes and edges from provider and consumer
relations.
+ // providerAppSet and consumerAppSet track already-added application
nodes.
+ providerAppSet := make(map[string]struct{})
+ consumerAppSet := make(map[string]struct{})
+
+ nodes := make([]model.GraphNode, 0)
+ edges := make([]model.GraphEdge, 0)
+ // init self node
+ nodes = append(nodes, model.GraphNode{
+ ID: req.AppName,
+ Label: req.AppName,
+ Type: "application",
+ Rule: "", // self node doesn't have a rule
+ Data: nil,
+ })
+ // 3.a: iterate over provided services, collect service nodes and
consumer app nodes.
+ for _, provider := range providerServiceList {
+ if provider.Spec == nil {
+ continue
+ }
+
+ // For each provided service, find consuming applications and
add them as nodes.
+ consumerAppServiceList, err :=
manager.ListByIndexes[*meshresource.ServiceConsumerMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceConsumerMetadataKind,
+ map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceConsumerServiceKey:
provider.Spec.ServiceName + ":" + provider.Spec.Version + ":" +
provider.Spec.Group,
+ },
+ )
+ if err != nil {
+ logger.Errorf("failed to list consumer apps by provider
service key, mesh: %s, serviceKey: %s, err: %s", req.Mesh,
provider.Spec.ProviderAppName+":"+provider.Spec.Version+":"+provider.Spec.Group,
err)
+ continue
+ }
+
+ for _, item := range consumerAppServiceList {
+ if item.Spec == nil {
+ continue
+ }
+ if _, ok := consumerAppSet[item.Spec.ConsumerAppName];
!ok {
+ consumerAppSet[item.Spec.ConsumerAppName] =
struct{}{}
+ nodes = append(nodes, model.GraphNode{
+ ID: item.Spec.ConsumerAppName,
+ Label: item.Spec.ConsumerAppName,
+ Type: "application",
+ Rule: constants.ConsumerSide,
+ Data: nil,
+ })
+ edges = append(edges, model.GraphEdge{
+ Source: item.Spec.ConsumerAppName,
+ Target: provider.Spec.ProviderAppName,
+ Data: nil,
+ })
+ }
Review Comment:
Edges are only appended inside the `if _, ok := consumerAppSet[...]` block.
This means if a consumer app node already exists (seen via another provided
service), additional edges to other providers won’t be created, resulting in an
incomplete graph. Deduplicate nodes separately from edges (e.g., always append
edge, but guard node insertion).
##########
pkg/core/store/index/service_provider_metadata.go:
##########
@@ -59,3 +61,15 @@ func byServiceProviderServiceName(obj interface{})
([]string, error) {
}
return []string{metadata.Spec.ServiceName}, nil
}
+
+func byServiceProviderServiceKey(obj interface{}) ([]string, error) {
+ metadata, ok := obj.(*meshresource.ServiceProviderMetadataResource)
+ if !ok {
+ return nil,
bizerror.NewAssertionError(meshresource.ServiceProviderMetadataKind,
reflect.TypeOf(obj).Name())
+ }
+ if metadata.Spec == nil {
+ return []string{}, nil
+ }
+ serviceKey := metadata.Spec.ServiceName + ":" + metadata.Spec.Version +
":" + metadata.Spec.Group
Review Comment:
`byServiceProviderServiceKey` manually concatenates `serviceName + ":" +
version + ":" + group`. Prefer using the shared helper
(`BuildServiceIdentityKey`) to avoid separator drift and keep key construction
consistent across the codebase.
```suggestion
serviceKey := BuildServiceIdentityKey(metadata.Spec.ServiceName,
metadata.Spec.Version, metadata.Spec.Group)
```
##########
pkg/console/service/application.go:
##########
@@ -196,6 +196,143 @@ func getAppConsumeServiceInfo(ctx consolectx.Context, req
*model.ApplicationServ
return pageResult, nil
}
+// GraphApplications returns the application-level graph for a given
application.
+// It collects provider and consumer service relations and transforms them
into nodes and edges.
+// The current implementation is a simplified version (provider/consumer link
traversal).
+func GraphApplications(ctx consolectx.Context, req *model.ApplicationGraphReq)
(*model.GraphData, error) {
+
+ // Step 1: query all services provided by this application in the
namespace.
+ providerServiceList, err :=
manager.ListByIndexes[*meshresource.ServiceProviderMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceProviderMetadataKind,
+ map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceProviderAppName: req.AppName,
+ },
+ )
+ if err != nil {
+ return nil, bizerror.MeshNotFoundError("")
+ }
+
+ // Step 2: query all services consumed by this application in the
namespace.
+ consumerServiceList, err :=
manager.ListByIndexes[*meshresource.ServiceConsumerMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceConsumerMetadataKind,
+ map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceConsumerAppName: req.AppName,
+ },
+ )
+ if err != nil {
+ return nil, bizerror.MeshNotFoundError("")
+ }
+
+ // Step 3: build the graph nodes and edges from provider and consumer
relations.
+ // providerAppSet and consumerAppSet track already-added application
nodes.
+ providerAppSet := make(map[string]struct{})
+ consumerAppSet := make(map[string]struct{})
+
+ nodes := make([]model.GraphNode, 0)
+ edges := make([]model.GraphEdge, 0)
+ // init self node
+ nodes = append(nodes, model.GraphNode{
+ ID: req.AppName,
+ Label: req.AppName,
+ Type: "application",
+ Rule: "", // self node doesn't have a rule
+ Data: nil,
+ })
+ // 3.a: iterate over provided services, collect service nodes and
consumer app nodes.
+ for _, provider := range providerServiceList {
+ if provider.Spec == nil {
+ continue
+ }
+
+ // For each provided service, find consuming applications and
add them as nodes.
+ consumerAppServiceList, err :=
manager.ListByIndexes[*meshresource.ServiceConsumerMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceConsumerMetadataKind,
+ map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceConsumerServiceKey:
provider.Spec.ServiceName + ":" + provider.Spec.Version + ":" +
provider.Spec.Group,
+ },
+ )
+ if err != nil {
+ logger.Errorf("failed to list consumer apps by provider
service key, mesh: %s, serviceKey: %s, err: %s", req.Mesh,
provider.Spec.ProviderAppName+":"+provider.Spec.Version+":"+provider.Spec.Group,
err)
+ continue
+ }
+
+ for _, item := range consumerAppServiceList {
+ if item.Spec == nil {
+ continue
+ }
+ if _, ok := consumerAppSet[item.Spec.ConsumerAppName];
!ok {
+ consumerAppSet[item.Spec.ConsumerAppName] =
struct{}{}
+ nodes = append(nodes, model.GraphNode{
+ ID: item.Spec.ConsumerAppName,
+ Label: item.Spec.ConsumerAppName,
+ Type: "application",
+ Rule: constants.ConsumerSide,
+ Data: nil,
+ })
+ edges = append(edges, model.GraphEdge{
+ Source: item.Spec.ConsumerAppName,
+ Target: provider.Spec.ProviderAppName,
+ Data: nil,
+ })
+ }
+ }
+ }
+
+ // 3.b: iterate over consumed services, collect service nodes and
provider app nodes.
+ for _, consumer := range consumerServiceList {
+ if consumer.Spec == nil {
+ continue
+ }
+
+ // For each consumed service, find providing applications and
add them as nodes.
+ providerAppList, err :=
manager.ListByIndexes[*meshresource.ServiceProviderMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceProviderMetadataKind,
+ map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceProviderServiceKey:
consumer.Spec.ServiceName + ":" + consumer.Spec.Version + ":" +
consumer.Spec.Group,
+ },
+ )
+ if err != nil {
+ logger.Errorf("failed to list consumer apps by provider
service key, mesh: %s, serviceKey: %s, err: %s", req.Mesh,
consumer.Spec.ConsumerAppName+":"+consumer.Spec.Version+":"+consumer.Spec.Group,
err)
+ continue
+ }
+
+ for _, item := range providerAppList {
+ if item.Spec == nil {
+ continue
+ }
+ if _, ok := providerAppSet[item.Spec.ProviderAppName];
!ok {
+ providerAppSet[item.Spec.ProviderAppName] =
struct{}{}
+ nodes = append(nodes, model.GraphNode{
+ ID: item.Spec.ProviderAppName,
+ Label: item.Spec.ProviderAppName,
+ Type: "application",
+ Rule: constants.ProviderSide,
+ Data: nil,
+ })
+ edges = append(edges, model.GraphEdge{
+ Source: consumer.Spec.ConsumerAppName,
+ Target: item.Spec.ProviderAppName,
+ Data: nil,
+ })
+ }
Review Comment:
Similarly, edges to provider apps are only added when the provider node is
first seen (`if _, ok := providerAppSet[...]`). This drops edges for subsequent
consumed services that map to an already-added provider app node. Consider
tracking edges with a separate set or always appending edges while only
deduping node creation.
##########
ui-vue3/src/main.ts:
##########
@@ -38,6 +37,10 @@ const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
+if (import.meta.env.DEV) {
+ import('./api/mock/index')
+}
+
app.use(Antd).use(Vue3ColorPicker).use(pinia).use(i18n).use(router).mount('#app')
Review Comment:
In dev mode the mock module is imported asynchronously but not awaited. If
the app makes requests immediately on mount, they can race before the mocks
register. Consider using top-level `await import(...)` (supported by Vite) or
an async bootstrap before `mount()` to ensure mocks are active first.
##########
ui-vue3/src/views/resources/services/tabs/topology.vue:
##########
@@ -0,0 +1,343 @@
+<!--
+ ~ 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.
+-->
+<template>
+ <div class="__container_app_topology">
+ <a-flex>
+ <a-card class="topology-warpper"> <div id="topology"></div> </a-card>
+ </a-flex>
+ <a-drawer v-model:open="detailDrawerOpen" :title="detailTitle"
placement="right" width="520">
+ <a-spin :spinning="detailLoading">
+ <a-typography-text v-if="detailError" type="danger">{{ detailError
}}</a-typography-text>
+ <a-descriptions
+ v-else
+ :column="1"
+ size="small"
+ bordered
+ :labelStyle="{ fontWeight: 'bold', width: '160px' }"
+ >
+ <a-descriptions-item v-for="item in detailEntries" :key="item.key">
+ <template #label>{{ item.key }}</template>
+ {{ formatValueForDisplay(item.value) }}
+ </a-descriptions-item>
+ </a-descriptions>
+ </a-spin>
+ </a-drawer>
+ </div>
+</template>
+
+<script setup lang="tsx">
+import { PRIMARY_COLOR } from '@/base/constants'
+import { HTTP_STATUS } from '@/base/http/constants'
+import { getServiceDetail, getServiceGraph } from '@/api/service/service'
+import { getApplicationDetail } from '@/api/service/app'
+import { computed, defineComponent, onBeforeUnmount, onMounted, ref,
shallowRef, watch } from 'vue'
+import type { PropType } from 'vue'
+import { VueNode } from 'g6-extension-vue'
+
+import { useRoute } from 'vue-router'
+import { ExtensionCategory, register, Graph, NodeEvent } from '@antv/g6'
+const route = useRoute()
+
+const graphRef = shallowRef<Graph | null>(null)
+register(ExtensionCategory.NODE, 'vue-node', VueNode)
+const detailDrawerOpen = ref(false)
+const detailLoading = ref(false)
+const detailError = ref('')
+const detailData = shallowRef<Record<string, unknown>>({})
+const detailCache = new Map<string, Record<string, unknown>>()
+const currentDetailKey = ref('')
+const currentDetailType = ref('')
+const selectedNodeId = ref('')
+
+const clearSelectedNode = () => {
+ const id = selectedNodeId.value
+ if (id && graphRef.value) {
+ graphRef.value.setElementState(id, [])
+ }
+ selectedNodeId.value = ''
+}
+
+watch(detailDrawerOpen, (open) => {
+ if (!open) {
+ clearSelectedNode()
+ currentDetailKey.value = ''
+ currentDetailType.value = ''
+ }
+})
+
+type VueNodeViewData = {
+ id?: string | number
+ label?: string
+ type?: string
+ rule?: unknown
+ states?: string[]
+ data?: Record<string, unknown>
+}
+
+const resolveNodeIconClass = (type: unknown) => {
+ const t = String(type ?? '').toLowerCase()
+ if (t.includes('application')) return 'icon-yingyong'
+ return 'icon-jiekouzhushou'
+}
+
+const StatefulNode = defineComponent({
+ props: {
+ data: { type: Object as PropType<VueNodeViewData>, required: true }
+ },
+ setup(props) {
+ const label = computed(() => {
+ return String(props.data?.label ?? props.data?.id ?? '')
+ })
+ const type = computed(() => props.data?.type ?? 'application')
+ const iconClass = computed(() => resolveNodeIconClass(type.value))
+ const isSelected = computed(() => {
+ const states = props.data?.states
+ if (!Array.isArray(states)) return false
+ return states.includes('selected') || states.includes('active')
+ })
+
+ return () => (
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ >
+ <div
+ style={{
+ color: isSelected.value ? PRIMARY_COLOR.value : 'rgba(0,0,0,0.65)',
+ filter: isSelected.value ? `drop-shadow(0 0 6px
${PRIMARY_COLOR.value}88)` : 'none',
+ transform: `scale(${isSelected.value ? 1.06 : 1})`,
+ transition: 'all 0.12s ease-in-out'
+ }}
+ >
+ <span
+ class={['iconfont', iconClass.value]}
+ style={{ fontSize: '40px', lineHeight: '40px' }}
+ ></span>
+ </div>
+ <div
+ style={{
+ paddingTop: '4px',
+ userSelect: 'none',
+ textAlign: 'center',
+ fontSize: '12px',
+ lineHeight: '16px'
+ }}
+ onPointerdown={(e: any) => e.stopPropagation()}
+ onMousedown={(e: any) => e.stopPropagation()}
+ onClick={(e: any) => e.stopPropagation()}
+ >
+ {label.value}
+ </div>
+ </div>
+ )
+ }
+})
+
+const detailTitle = computed(() => {
+ const type = String(currentDetailType.value ?? '').toLowerCase()
+ const base = type === 'application' ? '应用详情' : '服务详情'
+ return currentDetailKey.value ? `${base}:${currentDetailKey.value}` : base
+})
+
+const detailEntries = computed(() => {
+ const data = detailData.value ?? {}
+ return Object.entries(data)
+ .filter(([, v]) => v !== undefined && v !== null && String(v) !== '')
+ .map(([key, value]) => ({ key, value }))
+})
+
+const formatValueForDisplay = (v: unknown) => {
+ if (v === null || v === undefined) return ''
+ if (typeof v === 'string' || typeof v === 'number' || typeof v ===
'boolean') return String(v)
+ if (Array.isArray(v))
+ return v
+ .map((x) => formatValueForDisplay(x))
+ .filter(Boolean)
+ .join(', ')
+ if (typeof v === 'object') {
+ try {
+ return JSON.stringify(v)
+ } catch {
+ return String(v)
+ }
+ }
+ return String(v)
+}
+
+const buildGraphData = (raw: any) => {
+ const nodes = Array.isArray(raw?.nodes)
+ ? raw.nodes.map((n: any) => ({
+ id: String(n?.id ?? ''),
+ label: n?.label ?? n?.id,
+ type: n?.type,
+ rule: n?.rule
+ }))
+ : []
+
+ const edges = Array.isArray(raw?.edges)
+ ? raw.edges.map((e: any, idx: number) => ({
+ id: e?.id ?? `edge-${idx}`,
+ source: String(e?.source ?? ''),
+ target: String(e?.target ?? '')
+ }))
+ : []
+
+ return { nodes, edges }
+}
+const renderTopology = (graphData: any) => {
+ const root = document.getElementById('topology')
+ if (!root) return
+
+ graphRef.value?.destroy()
+ graphRef.value = null
+
+ const primaryColor = PRIMARY_COLOR.value
+ const graph = new Graph({
+ container: root,
+ width: root.clientWidth || 800,
+ height: Math.max(root.clientHeight || 500, 500),
+ autoFit: 'view',
+ padding: 20,
+ data: graphData,
+ layout: {
+ type: 'd3-force',
+ link: { distance: 180, strength: 1 },
+ collide: { radius: 40 }
+ },
+ node: {
+ type: 'vue-node',
+ style: {
+ component: (data) => <StatefulNode data={Object.assign({}, data)} />
+ }
+ },
+ edge: {
+ style: {
+ stroke: primaryColor,
+ endArrow: true,
+ lineWidth: 1.2,
+ strokeOpacity: 0.8
+ }
+ },
+ behaviors: [
+ 'drag-canvas',
+ 'zoom-canvas',
+ 'click-select',
+ { type: 'drag-element-force', fixed: true }
+ ]
+ })
+
+ const handleNodeClick = async (e: any) => {
+ const serviceName = String(e?.target?.id ?? '')
+ const nodeData: any = serviceName ?
graphRef.value?.getNodeData(serviceName) : undefined
+
+ if (!serviceName) return
+
+ selectedNodeId.value = serviceName
+ detailDrawerOpen.value = true
+ currentDetailKey.value = serviceName
+ currentDetailType.value = String(nodeData?.type ?? '')
+ .toLowerCase()
+ .includes('application')
+ ? 'application'
+ : 'service'
+ detailError.value = ''
+
+ const cacheKey = `${serviceName}`
+ const cached = detailCache.get(cacheKey)
+ if (cached) {
+ detailData.value = cached
+ detailLoading.value = false
+ return
+ }
+
+ detailLoading.value = true
+ try {
+ let res
+ if (nodeData.type === 'application') {
+ res = await getApplicationDetail(nodeData.id)
+ } else if (nodeData.type === 'service') {
+ const splitParams = nodeData.id.split(':')
+ res = await getServiceDetail({
Review Comment:
`handleNodeClick` dereferences `nodeData.type` / `nodeData.id` without
guarding `nodeData` being present. If `getNodeData(serviceName)` returns
`undefined` (or the click target id isn’t a node id), this will throw at
runtime. Add a defensive check and/or derive the node id from the G6 event
payload that is guaranteed to be the clicked node.
##########
pkg/core/discovery/subscriber/service_provider_metadata.go:
##########
@@ -106,3 +166,195 @@ func (s *ServiceProviderMetadataEventSubscriber)
processUpsert(r *meshresource.S
s.emitter.Send(events.NewResourceChangedEvent(cache.Added, nil, appRes))
return nil
}
+
+func (s *ServiceProviderMetadataEventSubscriber) syncService(mesh,
serviceName, version, group string) error {
+ serviceKey := meshresource.BuildServiceIdentityKey(serviceName,
version, group)
+ resources, err := s.providerStore.ListByIndexes(map[string]string{
+ index.ByMeshIndex: mesh,
+ index.ByServiceProviderServiceName: serviceName,
+ })
+ if err != nil {
+ return err
+ }
+
+ providers := make([]*meshresource.ServiceProviderMetadataResource, 0,
len(resources))
+ for _, item := range resources {
+ res, ok := item.(*meshresource.ServiceProviderMetadataResource)
+ if !ok {
+ return
bizerror.NewAssertionError(meshresource.ServiceProviderMetadataKind,
reflect.TypeOf(item).Name())
+ }
+ if res.Spec == nil {
+ continue
+ }
+ if res.Spec.Version == version && res.Spec.Group == group {
+ providers = append(providers, res)
+ }
+ }
Review Comment:
`syncService` lists provider metadata by `serviceName` and then filters by
`version/group` in-memory. Now that `ByServiceProviderServiceKey` index exists,
you can query directly by the full identity key (`{service}:{version}:{group}`)
to avoid scanning and reduce store load on large meshes.
##########
pkg/core/store/index/service_consumer_metadata.go:
##########
@@ -59,3 +61,15 @@ func byServiceConsumerServiceName(obj interface{})
([]string, error) {
}
return []string{metadata.Spec.ServiceName}, nil
}
+
+func byServiceConsumerServiceKey(obj interface{}) ([]string, error) {
+ metadata, ok := obj.(*meshresource.ServiceConsumerMetadataResource)
+ if !ok {
+ return nil,
bizerror.NewAssertionError(meshresource.ServiceConsumerMetadataKind,
reflect.TypeOf(obj).Name())
+ }
+ if metadata.Spec == nil {
+ return []string{}, nil
+ }
+ serviceKey := metadata.Spec.ServiceName + ":" + metadata.Spec.Version +
":" + metadata.Spec.Group
Review Comment:
`byServiceConsumerServiceKey` manually concatenates `serviceName + ":" +
version + ":" + group`. Prefer the shared helper (`BuildServiceIdentityKey`) to
ensure consistency with other service-key users/indexers.
```suggestion
serviceKey := BuildServiceIdentityKey(metadata.Spec.ServiceName,
metadata.Spec.Version, metadata.Spec.Group)
```
##########
pkg/console/service/service.go:
##########
@@ -522,3 +533,156 @@ func isArgumentRoute(condition string) bool {
}
return false
}
+
+func GetServiceDetail(ctx consolectx.Context, req *model.ServiceDetailReq)
(*model.ServiceDetailResp, error) {
+ serviceKey := coremodel.BuildResourceKey(req.Mesh,
meshresource.BuildServiceIdentityKey(req.ServiceName, req.Version, req.Group))
+ serviceRes, exists, err :=
manager.GetByKey[*meshresource.ServiceResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceKind,
+ serviceKey,
+ )
+ if err != nil {
+ logger.Errorf("get service detail failed, serviceKey: %s,
cause: %v", serviceKey, err)
+ return nil, err
+ }
+ if !exists || serviceRes.Spec == nil {
+ return nil, bizerror.New(bizerror.NotFoundError, "service not
found")
+ }
+
+ return &model.ServiceDetailResp{
+ Language: serviceRes.Spec.Language,
+ Methods: serviceRes.Spec.Methods,
+ }, nil
+}
+
+// GraphServices builds a service dependency graph for the given service key.
+//
+// It gathers both provider and consumer metadata for serviceKey and creates
+// graph nodes/edges where:
+// - application nodes are marked as provider/consumer
+// - service node is the target/subject service
+// - edges describe provide/consume relationships
+//
+// This API is used by topology view to visualize service-level dependencies.
+func GraphServices(ctx consolectx.Context, req *model.ServiceGraphReq)
(*model.GraphData, error) {
+ serviceKey := req.ServiceKey()
+
+ providerIndexes := map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceProviderServiceKey: serviceKey,
+ }
+
+ providers, err :=
manager.ListByIndexes[*meshresource.ServiceProviderMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceProviderMetadataKind,
+ providerIndexes)
+ if err != nil {
+ logger.Errorf("get service providers for mesh %s, serviceKey %s
failed, cause: %v", req.Mesh, serviceKey, err)
+ return nil, bizerror.New(bizerror.InternalError, "get service
providers failed, please try again")
+ }
+
+ if len(providers) == 0 {
+ logger.Errorf("no providers found for service %s in mesh %s",
serviceKey, req.Mesh)
+ return nil, bizerror.New(bizerror.NotFoundError, "no providers
found for this service")
+ }
+
+ consumerIndexes := map[string]string{
+ index.ByMeshIndex: req.Mesh,
+ index.ByServiceConsumerServiceKey: serviceKey,
+ }
+
+ // Nodes for this graph: provider apps, service itself, consumer apps.
+ // Edges represent provider->service and consumer->service
relationships.
+
+ consumers, err :=
manager.ListByIndexes[*meshresource.ServiceConsumerMetadataResource](
+ ctx.ResourceManager(),
+ meshresource.ServiceConsumerMetadataKind,
+ consumerIndexes)
+ if err != nil {
+ logger.Errorf("get service consumers for mesh %s, serviceKey %s
failed, cause: %v", req.Mesh, serviceKey, err)
+ return nil, bizerror.New(bizerror.InternalError, "get service
consumers failed, please try again")
+ }
+
+ nodes := make([]model.GraphNode, 0)
+ edges := make([]model.GraphEdge, 0)
+
+ // use struct{} as a zero-size value for a lightweight deduplication set
+ // this prevents duplicate application nodes when multiple providers
are recorded.
+ providerAppSet := make(map[string]struct{})
+ for _, provider := range providers {
+ if provider.Spec == nil {
+ continue
+ }
+ if _, ok := providerAppSet[provider.Spec.ProviderAppName]; !ok {
+ providerAppSet[provider.Spec.ProviderAppName] =
struct{}{}
+ nodes = append(nodes, model.GraphNode{
+ ID: provider.Spec.ProviderAppName,
+ Label: provider.Spec.ProviderAppName,
+ Type: "application",
+ Rule: "provider",
+ Data: nil,
+ })
+ }
+ }
+
+ nodes = append(nodes, model.GraphNode{
+ ID: serviceKey,
+ Label: serviceKey,
+ Type: "service",
+ Rule: "",
+ Data: nil,
+ })
+
+ consumerAppSet := make(map[string]struct{})
+ for _, consumer := range consumers {
+ if consumer.Spec == nil {
+ continue
+ }
+ if _, ok := consumerAppSet[consumer.Spec.ConsumerAppName]; !ok {
+ consumerAppSet[consumer.Spec.ConsumerAppName] =
struct{}{}
+ nodes = append(nodes, model.GraphNode{
+ ID: consumer.Spec.ConsumerAppName,
+ Label: consumer.Spec.ConsumerAppName,
+ Type: "application",
+ Rule: "consumer",
+ Data: nil,
+ })
+ }
+ }
+
+ // Connect provider applications to service node.
+ for providerApp := range providerAppSet {
+ edges = append(edges, model.GraphEdge{
+ Source: serviceKey,
+ Target: providerApp,
+ Data: map[string]interface{}{
+ "type": "provides",
+ },
+ })
+ }
Review Comment:
The comment says edges represent `provider->service`, but the code below
builds provider edges as `Source: serviceKey` and `Target: providerApp`
(service -> provider). Please fix the comment or flip the edge direction so
documentation matches behavior.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]