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]

Reply via email to