This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 1ab4fad  feat: process-relation edge metrics for network profiling + 
trace-source config
1ab4fad is described below

commit 1ab4fadf451c833bff8d29166281e27b792a12d1
Author: Wu Sheng <[email protected]>
AuthorDate: Wed May 20 10:17:21 2026 +0800

    feat: process-relation edge metrics for network profiling + trace-source 
config
    
    Network-profiling process topology — edge metrics:
      Clicking a process→process call in the network-profiling topology now
      opens an inline edge detail panel showing the ProcessRelation metrics
      (client + server families) as label / sparkline / latest-value rows.
      Validated live against the demo's mesh process topology
      (envoy→pilot-agent returns non-null process_relation_client_write_cpm).
    
      - api-client: ProcessTopologyConfig (edgeClient/edgeServer
        TopologyMetricDef lists), ProcessRelationMetric(sResponse),
        ProcessRelationEndpointRef.
      - BFF loader: `processTopology` block on LayerTemplate +
        BOOSTER_PROCESS_TOPOLOGY_DEFAULTS (process_relation_* client/server
        cpm + bytes + connect/close), processTopologyConfigFor() fallback.
      - BFF: POST /api/layer/:key/ebpf/network/process-relation-metrics —
        resolves the layer's processTopology config and runs aliased
        execExpression under ProcessRelation (entity keyed on source/dest
        service+instance+process NAMES, scope inferred from metric name,
        matching the service-map relation fragment). RBAC: profile:read.
      - UI: ProcessTopologyGraph highlights the selected edge;
        LayerNetworkProfilingView fetches + renders the relation metrics in
        the edge panel (cascade-clear on edge switch, Sparkline per metric).
      - Admin: new "network profiling" scope tab in the layer-dashboards
        editor with client/server ProcessRelation MQE list editors
        (AdminScope = DashboardScope | 'networkProfiling', kept local to the
        admin so DashboardScope stays a widget-grid concept).
    
    Trace backend config + two-tab split:
      Restored the trace-source selector (native / zipkin / both) in the
      layer-dashboards admin Trace tab — the field drives the per-layer
      trace backend and was editable nowhere. Fixed the stale comment that
      wrongly claimed `traces.source` was ignored (LayerTracesEntry reads it
      at runtime).
    
      Native and Zipkin spans have different formats + query conditions, so
      `source: both` now surfaces TWO sidebar tabs — "Trace" (native) and
      "Zipkin Trace" — instead of one tab with an in-place toggle. New
      `zipkin-trace` route; LayerTracesEntry is route-driven (no toggle);
      AppSidebar renders the second tab only when source is `both`.
---
 apps/bff/src/http/query/ebpf.ts                    | 126 +++++++++++++
 apps/bff/src/logic/layers/loader.ts                |  44 ++++-
 apps/bff/src/rbac/route-policy.ts                  |   1 +
 apps/ui/src/api/client.ts                          |   5 +
 apps/ui/src/api/scopes/network-profile.ts          |  20 ++
 .../admin/layer-templates/LayerDashboardsAdmin.vue | 204 +++++++++++++++++++--
 .../layer/profiling/LayerNetworkProfilingView.vue  | 149 ++++++++++++++-
 .../src/layer/profiling/ProcessTopologyGraph.vue   |  12 ++
 apps/ui/src/layer/traces/LayerTracesEntry.vue      |  76 ++------
 apps/ui/src/shell/AppSidebar.vue                   |  16 ++
 apps/ui/src/shell/router/index.ts                  |   6 +
 packages/api-client/src/index.ts                   |   4 +
 packages/api-client/src/topology.ts                |  43 +++++
 13 files changed, 627 insertions(+), 79 deletions(-)

diff --git a/apps/bff/src/http/query/ebpf.ts b/apps/bff/src/http/query/ebpf.ts
index 712be4e..9990dbc 100644
--- a/apps/bff/src/http/query/ebpf.ts
+++ b/apps/bff/src/http/query/ebpf.ts
@@ -42,12 +42,17 @@ import type {
   NetworkProfilingCreateRequest,
   NetworkProfilingCreateResponse,
   NetworkProfilingKeepAliveResponse,
+  ProcessRelationEndpointRef,
+  ProcessRelationMetric,
+  ProcessRelationMetricsResponse,
   ProcessTopologyResponse,
+  TopologyMetricDef,
 } from '@skywalking-horizon-ui/api-client';
 import type { ConfigSource } from '../../config/loader.js';
 import type { SessionStore } from '../../user/sessions.js';
 import { requireAuth } from '../../user/middleware.js';
 import { graphqlPost, buildOapOpts } from '../../client/graphql.js';
+import { getLayerTemplate, processTopologyConfigFor } from 
'../../logic/layers/loader.js';
 
 export interface EBPFRouteDeps {
   config: ConfigSource;
@@ -227,6 +232,54 @@ async function resolveServiceId(
   );
 }
 
+// ── Process-relation metrics (network-profiling edge panel) ──────────
+
+interface MqeEnv {
+  error?: string | null;
+  results?: Array<{ values?: Array<{ value: string | number | null }> }>;
+}
+
+/**
+ * One `execExpression` fragment for a ProcessRelation metric. Like the
+ * service-map relationFragment we deliberately omit `scope` — OAP infers
+ * ProcessRelation + side from the metric name (`process_relation_client_*`
+ * / `process_relation_server_*`). Names (not ids) key the entity: the
+ * source/dest process is identified by service + instance + process name.
+ */
+function processRelationFragment(
+  alias: string,
+  expr: string,
+  src: ProcessRelationEndpointRef,
+  dst: ProcessRelationEndpointRef,
+  w: { start: string; end: string },
+): string {
+  return (
+    `${alias}: execExpression(\n` +
+    `      expression: ${JSON.stringify(expr)},\n` +
+    `      entity: {` +
+    ` serviceName: ${JSON.stringify(src.serviceName)},` +
+    ` normal: ${src.normal === false ? 'false' : 'true'},` +
+    ` serviceInstanceName: ${JSON.stringify(src.serviceInstanceName)},` +
+    ` processName: ${JSON.stringify(src.processName)},` +
+    ` destServiceName: ${JSON.stringify(dst.serviceName)},` +
+    ` destNormal: ${dst.normal === false ? 'false' : 'true'},` +
+    ` destServiceInstanceName: ${JSON.stringify(dst.serviceInstanceName)},` +
+    ` destProcessName: ${JSON.stringify(dst.processName)} },\n` +
+    `      duration: { start: ${JSON.stringify(w.start)}, end: 
${JSON.stringify(w.end)}, step: MINUTE }\n` +
+    `    ) { error results { values { value } } }`
+  );
+}
+
+function relationSeries(env: MqeEnv | undefined): Array<number | null> {
+  if (!env || env.error) return [];
+  const values = env.results?.[0]?.values ?? [];
+  return values.map((v) => {
+    if (v.value === null || v.value === undefined) return null;
+    const n = Number(v.value);
+    return Number.isFinite(n) ? n : null;
+  });
+}
+
 export function registerEBPFRoutes(app: FastifyInstance, deps: EBPFRouteDeps): 
void {
   const auth = requireAuth(deps);
 
@@ -474,4 +527,77 @@ export function registerEBPFRoutes(app: FastifyInstance, 
deps: EBPFRouteDeps): v
       }
     },
   );
+
+  /** Process-relation metrics for a clicked edge in the network-
+   *  profiling process topology. Resolves the layer's processTopology
+   *  MQE config (operator override or bundled default), evaluates every
+   *  client + server metric under the ProcessRelation scope for the
+   *  source→dest process pair, and returns the per-bucket series for the
+   *  edge detail panel. */
+  app.post(
+    '/api/layer/:key/ebpf/network/process-relation-metrics',
+    { preHandler: auth },
+    async (req: FastifyRequest, reply: FastifyReply) => {
+      const params = req.params as { key: string };
+      const body = req.body as
+        | {
+            source?: ProcessRelationEndpointRef;
+            dest?: ProcessRelationEndpointRef;
+            windowMinutes?: number;
+          }
+        | undefined;
+      const payload: ProcessRelationMetricsResponse = { client: [], server: 
[], reachable: true };
+      const src = body?.source;
+      const dst = body?.dest;
+      if (!src?.processName || !dst?.processName) {
+        payload.error = 'missing source/dest process';
+        return reply.send(payload);
+      }
+
+      const cfg = processTopologyConfigFor(getLayerTemplate(params.key));
+      const minutes = Math.max(5, Math.min(180, Number(body?.windowMinutes) || 
30));
+      const end = new Date();
+      const start = new Date(end.getTime() - minutes * 60_000);
+      // Match the network-topology route's UTC formatting so the edge
+      // metrics window lines up with the rendered graph window.
+      const fmt = (d: Date) => {
+        const z = (n: number) => String(n).padStart(2, '0');
+        return `${d.getUTCFullYear()}-${z(d.getUTCMonth() + 
1)}-${z(d.getUTCDate())} ${z(d.getUTCHours())}${z(d.getUTCMinutes())}`;
+      };
+      const w = { start: fmt(start), end: fmt(end) };
+
+      // Build one aliased execExpression per metric across both sides.
+      const aliasMap = new Map<string, { side: 'client' | 'server'; metric: 
TopologyMetricDef }>();
+      const fragments: string[] = [];
+      const push = (side: 'client' | 'server', list: TopologyMetricDef[]) => {
+        list.forEach((m, i) => {
+          const alias = `${side}_${i}`;
+          aliasMap.set(alias, { side, metric: m });
+          fragments.push(processRelationFragment(alias, m.mqe, src, dst, w));
+        });
+      };
+      push('client', cfg.edgeClientMetrics);
+      push('server', cfg.edgeServerMetrics);
+      if (fragments.length === 0) return reply.send(payload);
+
+      const query = `query HorizonProcessRelationMetrics {\n  
${fragments.join('\n  ')}\n}`;
+      const opts = buildOapOpts(deps.config.current, deps.fetch);
+      try {
+        const raw = await graphqlPost<Record<string, MqeEnv>>(opts, query);
+        for (const [alias, { side, metric }] of aliasMap) {
+          const out: ProcessRelationMetric = {
+            id: metric.id,
+            label: metric.label,
+            unit: metric.unit,
+            values: relationSeries(raw[alias]),
+          };
+          if (side === 'client') payload.client.push(out);
+          else payload.server.push(out);
+        }
+        return reply.send(payload);
+      } catch (err) {
+        return reply.send(softErr(payload, err));
+      }
+    },
+  );
 }
diff --git a/apps/bff/src/logic/layers/loader.ts 
b/apps/bff/src/logic/layers/loader.ts
index c7b7770..68c9350 100644
--- a/apps/bff/src/logic/layers/loader.ts
+++ b/apps/bff/src/logic/layers/loader.ts
@@ -39,13 +39,14 @@ import type {
   DashboardScope,
   DashboardWidget,
   EndpointDependencyConfig,
+  ProcessTopologyConfig,
   ServiceNamingRule,
   TopologyConfig,
   TopologyMetricDef,
   TracesConfig,
 } from '@skywalking-horizon-ui/api-client';
 
-export type { TopologyConfig, EndpointDependencyConfig, TopologyMetricDef, 
TracesConfig, ServiceNamingRule };
+export type { TopologyConfig, EndpointDependencyConfig, ProcessTopologyConfig, 
TopologyMetricDef, TracesConfig, ServiceNamingRule };
 
 export interface LayerComponentFlags {
   service?: boolean;
@@ -205,6 +206,10 @@ export interface LayerTemplate {
   /** API-dependency dashboard config — operator-editable node + edge MQE.
    *  When absent the loader fills it from {@link 
BOOSTER_ENDPOINT_DEP_DEFAULTS}. */
   endpointDependency?: EndpointDependencyConfig;
+  /** Process-topology (network-profiling) edge-metric config — operator-
+   *  editable ProcessRelation MQE. When absent the loader fills it from
+   *  {@link BOOSTER_PROCESS_TOPOLOGY_DEFAULTS}. */
+  processTopology?: ProcessTopologyConfig;
   /** Traces tab config. The `source` field picks which trace backend
    *  the UI's filter selector defaults to (`both` shows two parallel
    *  tables; `native` / `zipkin` pin to one). Default `both` when
@@ -305,6 +310,34 @@ export const BOOSTER_ENDPOINT_DEP_DEFAULTS: 
EndpointDependencyConfig = {
   ],
 };
 
+/**
+ * Defaults for the network-profiling process-topology edge panel. The
+ * metric names come from OAP's `meter-analyzer-config/network-profiling.yaml`
+ * (metricPrefix `process_relation`), validated live against the demo's
+ * mesh process topology (envoy → pilot-agent returns non-null cpm). OAP
+ * observes each conversation from both eBPF probe sides, so client and
+ * server families both exist. cpm metrics are per-minute rates; the
+ * `*_total_bytes` are cumulative counters summed over the window.
+ */
+export const BOOSTER_PROCESS_TOPOLOGY_DEFAULTS: ProcessTopologyConfig = {
+  edgeClientMetrics: [
+    { id: 'write_cpm', label: 'Write CPM', mqe: 
'process_relation_client_write_cpm', unit: 'cpm', aggregation: 'avg' },
+    { id: 'read_cpm', label: 'Read CPM', mqe: 
'process_relation_client_read_cpm', unit: 'cpm', aggregation: 'avg' },
+    { id: 'write_bytes', label: 'Write bytes', mqe: 
'process_relation_client_write_total_bytes', unit: 'B', aggregation: 'sum' },
+    { id: 'read_bytes', label: 'Read bytes', mqe: 
'process_relation_client_read_total_bytes', unit: 'B', aggregation: 'sum' },
+    { id: 'connect_cpm', label: 'Connect CPM', mqe: 
'process_relation_client_connect_cpm', unit: 'cpm', aggregation: 'avg' },
+    { id: 'close_cpm', label: 'Close CPM', mqe: 
'process_relation_client_close_cpm', unit: 'cpm', aggregation: 'avg' },
+  ],
+  edgeServerMetrics: [
+    { id: 'write_cpm', label: 'Write CPM', mqe: 
'process_relation_server_write_cpm', unit: 'cpm', aggregation: 'avg' },
+    { id: 'read_cpm', label: 'Read CPM', mqe: 
'process_relation_server_read_cpm', unit: 'cpm', aggregation: 'avg' },
+    { id: 'write_bytes', label: 'Write bytes', mqe: 
'process_relation_server_write_total_bytes', unit: 'B', aggregation: 'sum' },
+    { id: 'read_bytes', label: 'Read bytes', mqe: 
'process_relation_server_read_total_bytes', unit: 'B', aggregation: 'sum' },
+    { id: 'connect_cpm', label: 'Connect CPM', mqe: 
'process_relation_server_connect_cpm', unit: 'cpm', aggregation: 'avg' },
+    { id: 'close_cpm', label: 'Close CPM', mqe: 
'process_relation_server_close_cpm', unit: 'cpm', aggregation: 'avg' },
+  ],
+};
+
 const __dirname = dirname(fileURLToPath(import.meta.url));
 /** Locate bundled_templates/layers/ at runtime.
  *
@@ -542,6 +575,15 @@ export function endpointDependencyConfigFor(
   return BOOSTER_ENDPOINT_DEP_DEFAULTS;
 }
 
+/** Resolve the process-topology (network-profiling) config — same
+ *  fallback rule. */
+export function processTopologyConfigFor(
+  template: LayerTemplate | null,
+): ProcessTopologyConfig {
+  if (template?.processTopology) return template.processTopology;
+  return BOOSTER_PROCESS_TOPOLOGY_DEFAULTS;
+}
+
 /** Resolve the traces tab config. Defaults to surfacing both
  *  SkyWalking-native and Zipkin trace lists side-by-side. */
 export function tracesConfigFor(template: LayerTemplate | null): TracesConfig {
diff --git a/apps/bff/src/rbac/route-policy.ts 
b/apps/bff/src/rbac/route-policy.ts
index 2229e6d..2f02c50 100644
--- a/apps/bff/src/rbac/route-policy.ts
+++ b/apps/bff/src/rbac/route-policy.ts
@@ -134,6 +134,7 @@ export const ROUTE_POLICY: Record<string, RoutePolicy> = {
   'GET /api/ebpf/network/tasks':                   'profile:read',
   'POST /api/ebpf/network/tasks':                  'profile:enable',
   'GET /api/ebpf/network/topology':                'profile:read',
+  'POST /api/layer/:key/ebpf/network/process-relation-metrics': 'profile:read',
   'POST /api/ebpf/network/tasks/:taskId/keep-alive': 'profile:enable',
 
   // ── Config — alarm-page setup, layer setup, overview, dashboards ─
diff --git a/apps/ui/src/api/client.ts b/apps/ui/src/api/client.ts
index bebb3bd..249c34a 100644
--- a/apps/ui/src/api/client.ts
+++ b/apps/ui/src/api/client.ts
@@ -39,6 +39,7 @@ import type {
   EndpointDependencyConfig,
   LocalState,
   MetricRow,
+  ProcessTopologyConfig,
   RuleStatus,
   TopologyConfig,
   TracesConfig,
@@ -175,6 +176,9 @@ export type {
   ProcessNode,
   ProcessCall,
   ProcessTopologyResponse,
+  ProcessRelationEndpointRef,
+  ProcessRelationMetric,
+  ProcessRelationMetricsResponse,
   NetworkProfilingSampling,
   NetworkProfilingCreateRequest,
   NetworkProfilingCreateResponse,
@@ -272,6 +276,7 @@ export interface AdminLayerTemplate {
   widgets: DashboardWidget[];
   topology?: TopologyConfig;
   endpointDependency?: EndpointDependencyConfig;
+  processTopology?: ProcessTopologyConfig;
   traces?: TracesConfig;
   naming?: {
     pattern: string;
diff --git a/apps/ui/src/api/scopes/network-profile.ts 
b/apps/ui/src/api/scopes/network-profile.ts
index f71114a..96aa235 100644
--- a/apps/ui/src/api/scopes/network-profile.ts
+++ b/apps/ui/src/api/scopes/network-profile.ts
@@ -20,6 +20,8 @@ import type {
   NetworkProfilingCreateRequest,
   NetworkProfilingCreateResponse,
   NetworkProfilingKeepAliveResponse,
+  ProcessRelationEndpointRef,
+  ProcessRelationMetricsResponse,
   ProcessTopologyResponse,
 } from '@skywalking-horizon-ui/api-client';
 import type { BffClient } from '../client';
@@ -64,4 +66,22 @@ export class NetworkProfileApi {
       `/api/ebpf/network/tasks/${encodeURIComponent(taskId)}/keep-alive`,
     );
   }
+
+  /** Process-relation (edge) metrics for a clicked process→process call.
+   *  `layerKey` selects the processTopology MQE config; source/dest are
+   *  identified by service / instance / process NAME. */
+  relationMetrics(
+    layerKey: string,
+    body: {
+      source: ProcessRelationEndpointRef;
+      dest: ProcessRelationEndpointRef;
+      windowMinutes?: number;
+    },
+  ): Promise<ProcessRelationMetricsResponse> {
+    return this.bff.request<ProcessRelationMetricsResponse>(
+      'POST',
+      
`/api/layer/${encodeURIComponent(layerKey)}/ebpf/network/process-relation-metrics`,
+      body,
+    );
+  }
 }
diff --git 
a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue 
b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
index 105c268..1321082 100644
--- a/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
+++ b/apps/ui/src/features/admin/layer-templates/LayerDashboardsAdmin.vue
@@ -34,9 +34,17 @@ import type {
   DashboardScope,
   DashboardWidget,
   EndpointDependencyConfig,
+  ProcessTopologyConfig,
   TopologyConfig,
   TopologyMetricDef,
 } from '@skywalking-horizon-ui/api-client';
+
+/** Admin-only scope. `networkProfiling` isn't a dashboard-widget scope
+ *  (the network-profiling page is the process topology + edge panel, not
+ *  a widget grid), so it lives outside `DashboardScope` — but the admin's
+ *  scope-tab strip surfaces it as an editable config tab for the
+ *  ProcessRelation MQE. */
+type AdminScope = DashboardScope | 'networkProfiling';
 import { bff, bffClient } from '@/api/client';
 import TimeChart from '@/components/charts/TimeChart.vue';
 import TopList from '@/components/charts/TopList.vue';
@@ -51,7 +59,7 @@ import { useTemplateSync } from 
'@/features/admin/_shared/useTemplateSync';
 // Save gating + per-row badge below.
 const sync = useTemplateSync({ kind: 'layer' });
 
-const SCOPES: DashboardScope[] = [
+const SCOPES: AdminScope[] = [
   'service',
   'instance',
   'endpoint',
@@ -64,11 +72,12 @@ const SCOPES: DashboardScope[] = [
   'traceProfiling',
   'ebpfProfiling',
   'asyncProfiling',
+  'networkProfiling',
 ];
 /** Display label for each scope — kebab-cases the profiling scopes
  *  so the scope tab strip reads as "trace profiling" instead of the
  *  camelCase key. */
-const SCOPE_LABELS: Record<DashboardScope, string> = {
+const SCOPE_LABELS: Record<AdminScope, string> = {
   service: 'service',
   instance: 'instance',
   endpoint: 'endpoint',
@@ -79,13 +88,14 @@ const SCOPE_LABELS: Record<DashboardScope, string> = {
   traceProfiling: 'trace profiling',
   ebpfProfiling: 'eBPF profiling',
   asyncProfiling: 'async profiling',
+  networkProfiling: 'network profiling',
 };
 
 const templates = ref<AdminLayerTemplate[]>([]);
 const isLoading = ref(true);
 const error = ref<string | null>(null);
 const selectedKey = ref<string>('');
-const activeScope = ref<DashboardScope>('service');
+const activeScope = ref<AdminScope>('service');
 const isSaving = ref(false);
 const saveMsg = ref<string | null>(null);
 /** When false the layers rail collapses to a thin dot-strip so the
@@ -114,8 +124,8 @@ async function loadAll(): Promise<void> {
       selectedKey.value = res.templates[0].key;
     }
     const queryScope = String(route.query.scope ?? '');
-    if (SCOPES.includes(queryScope as DashboardScope)) {
-      activeScope.value = queryScope as DashboardScope;
+    if (SCOPES.includes(queryScope as AdminScope)) {
+      activeScope.value = queryScope as AdminScope;
     }
     syncDraft();
   } catch (err) {
@@ -162,7 +172,7 @@ onMounted(loadAll);
  * Used to filter the scope tab strip so admin only surfaces tabs for
  * components the operator has toggled on.
  */
-const SCOPE_COMPONENT: Record<DashboardScope, ComponentKey> = {
+const SCOPE_COMPONENT: Record<AdminScope, ComponentKey> = {
   service: 'service',
   instance: 'instances',
   endpoint: 'endpoints',
@@ -176,8 +186,9 @@ const SCOPE_COMPONENT: Record<DashboardScope, ComponentKey> 
= {
   traceProfiling: 'traceProfiling' as ComponentKey,
   ebpfProfiling: 'ebpfProfiling' as ComponentKey,
   asyncProfiling: 'asyncProfiling' as ComponentKey,
+  networkProfiling: 'networkProfiling' as ComponentKey,
 };
-const visibleScopes = computed<DashboardScope[]>(() => {
+const visibleScopes = computed<AdminScope[]>(() => {
   const tpl = draft.template;
   if (!tpl?.components) return SCOPES;
   return SCOPES.filter((s) => tpl.components[SCOPE_COMPONENT[s]]);
@@ -196,7 +207,7 @@ const dirty = computed(() => {
   return JSON.stringify(original) !== JSON.stringify(draft.template);
 });
 
-function widgetsFor(scope: DashboardScope): DashboardWidget[] {
+function widgetsFor(scope: AdminScope): DashboardWidget[] {
   const tpl = draft.template;
   if (!tpl) return [];
   // Read from `dashboards.<scope>`, falling back to legacy `widgets`
@@ -208,7 +219,7 @@ function widgetsFor(scope: DashboardScope): 
DashboardWidget[] {
   return [];
 }
 
-function setWidgetsFor(scope: DashboardScope, widgets: DashboardWidget[]): 
void {
+function setWidgetsFor(scope: AdminScope, widgets: DashboardWidget[]): void {
   const tpl = draft.template;
   if (!tpl) return;
   const dashboards =
@@ -535,6 +546,9 @@ function emptyTopology(): TopologyConfig {
 function emptyEndpointDep(): EndpointDependencyConfig {
   return { nodeMetrics: [], linkMetrics: [] };
 }
+function emptyProcessTopology(): ProcessTopologyConfig {
+  return { edgeClientMetrics: [], edgeServerMetrics: [] };
+}
 
 function ensureTopology(): TopologyConfig {
   if (!draft.template) throw new Error('no template selected');
@@ -551,8 +565,16 @@ function ensureEndpointDep(): EndpointDependencyConfig {
   if (!tpl.endpointDependency.linkMetrics) tpl.endpointDependency.linkMetrics 
= [];
   return tpl.endpointDependency;
 }
+function ensureProcessTopology(): ProcessTopologyConfig {
+  if (!draft.template) throw new Error('no template selected');
+  const tpl = draft.template;
+  if (!tpl.processTopology) tpl.processTopology = emptyProcessTopology();
+  if (!tpl.processTopology.edgeClientMetrics) 
tpl.processTopology.edgeClientMetrics = [];
+  if (!tpl.processTopology.edgeServerMetrics) 
tpl.processTopology.edgeServerMetrics = [];
+  return tpl.processTopology;
+}
 
-type MetricBucket = 'node' | 'linkServer' | 'linkClient' | 'link';
+type MetricBucket = 'node' | 'linkServer' | 'linkClient' | 'link' | 
'edgeClient' | 'edgeServer';
 
 function getMetricList(bucket: MetricBucket): TopologyMetricDef[] {
   if (!draft.template) return [];
@@ -565,6 +587,10 @@ function getMetricList(bucket: MetricBucket): 
TopologyMetricDef[] {
     const t = ensureEndpointDep();
     if (bucket === 'node') return t.nodeMetrics;
     if (bucket === 'link') return t.linkMetrics ?? [];
+  } else if (activeScope.value === 'networkProfiling') {
+    const t = ensureProcessTopology();
+    if (bucket === 'edgeClient') return t.edgeClientMetrics;
+    if (bucket === 'edgeServer') return t.edgeServerMetrics;
   }
   return [];
 }
@@ -604,11 +630,37 @@ const topologyServerMetrics = computed(() => 
getMetricList('linkServer'));
 const topologyClientMetrics = computed(() => getMetricList('linkClient'));
 const epDepNodeMetrics = computed(() => activeScope.value === 'dependency' ? 
getMetricList('node') : []);
 const epDepLinkMetrics = computed(() => getMetricList('link'));
+const processEdgeClientMetrics = computed(() =>
+  activeScope.value === 'networkProfiling' ? getMetricList('edgeClient') : [],
+);
+const processEdgeServerMetrics = computed(() =>
+  activeScope.value === 'networkProfiling' ? getMetricList('edgeServer') : [],
+);
+
+/* Trace backend selector. `traces.source` decides which trace store the
+ * per-layer Trace tab dispatches to: `native` (SkyWalking query-protocol),
+ * `zipkin` (Envoy ALS / rover spans), or `both` (parallel tables). The
+ * field IS live — `LayerTracesEntry` reads `layer.traces.source` at
+ * runtime — so it belongs in the config UI. Default `both` when unset. */
+type TraceSource = 'native' | 'zipkin' | 'both';
+const traceSource = computed<TraceSource>({
+  get: () => draft.template?.traces?.source ?? 'both',
+  set: (v: TraceSource) => {
+    if (!draft.template) return;
+    if (draft.template.traces) draft.template.traces.source = v;
+    else draft.template.traces = { source: v };
+  },
+});
+const TRACE_SOURCE_OPTIONS: Array<{ value: TraceSource; label: string; hint: 
string }> = [
+  { value: 'native', label: 'Native', hint: 'SkyWalking query-protocol traces 
(agent-instrumented).' },
+  { value: 'zipkin', label: 'Zipkin', hint: 'Traces emitted from the Zipkin & 
OpenTelemetry ecosystem.' },
+  { value: 'both', label: 'Both', hint: 'Layer carries both native and Zipkin 
traces — their span formats and query conditions differ, so each gets its own 
trace tab.' },
+];
 
-/* Trace + Logs tabs have no per-layer config — only the
- * enable/disable toggle in the Components block. The old
- * `traces.source` field is gone; legacy JSONs with a `traces` block
- * are ignored by the SPA. */
+/* Logs has no per-layer config beyond the enable/disable Components
+ * toggle. Trace carries one setting — `traces.source` (native / zipkin /
+ * both), edited via `traceSource` above — which the per-layer Trace tab
+ * honors at runtime to pick the trace backend. */
 
 /**
  * Metrics block editor — drives the service-list columns + default
@@ -1519,6 +1571,77 @@ const namingTest = computed<NamingTestResult>(() => {
           </div>
         </section>
 
+        <section
+          v-else-if="activeScope === 'networkProfiling'"
+          class="sw-card editor-card topo-cfg-card"
+        >
+          <div class="card-head">
+            <h4>Network profiling — process-relation config</h4>
+            <span class="sub">edge MQE for the process-topology detail panel. 
Queried under ProcessRelation when an operator clicks a process→process 
call.</span>
+          </div>
+          <div class="topo-cfg-body">
+            <div class="topo-cfg-section">
+              <header class="topo-cfg-head">
+                <h5>Client-side metrics</h5>
+                <span class="sub">edge metrics queried as 
<code>process_relation_client_*</code></span>
+                <button class="sw-btn add" type="button" 
@click="addMetric('edgeClient')">+ Add</button>
+              </header>
+              <div v-if="processEdgeClientMetrics.length === 0" 
class="topo-cfg-empty">No client-side metrics.</div>
+              <div v-else class="metric-list">
+                <article v-for="(m, i) in processEdgeClientMetrics" :key="i" 
class="metric-row">
+                  <div class="metric-row-head">
+                    <label class="mf"><span>id</span><input v-model="m.id" 
type="text" class="mf-input mono" /></label>
+                    <label class="mf"><span>label</span><input 
v-model="m.label" type="text" class="mf-input" /></label>
+                    <label class="mf mf-wide"><span>MQE</span><input 
v-model="m.mqe" type="text" class="mf-input mono" 
placeholder="process_relation_client_write_cpm" /></label>
+                    <label class="mf mf-narrow"><span>unit</span><input 
v-model="m.unit" type="text" class="mf-input" /></label>
+                    <label class="mf mf-narrow"><span>agg</span>
+                      <select v-model="m.aggregation" class="mf-input">
+                        <option value="avg">avg</option>
+                        <option value="sum">sum</option>
+                      </select>
+                    </label>
+                    <div class="metric-row-actions">
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === 0" @click="moveMetric('edgeClient', i, -1)">↑</button>
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === processEdgeClientMetrics.length - 1" 
@click="moveMetric('edgeClient', i, 1)">↓</button>
+                      <button class="sw-btn small ghost danger" type="button" 
@click="removeMetric('edgeClient', i)">×</button>
+                    </div>
+                  </div>
+                </article>
+              </div>
+            </div>
+
+            <div class="topo-cfg-section">
+              <header class="topo-cfg-head">
+                <h5>Server-side metrics</h5>
+                <span class="sub">edge metrics queried as 
<code>process_relation_server_*</code></span>
+                <button class="sw-btn add" type="button" 
@click="addMetric('edgeServer')">+ Add</button>
+              </header>
+              <div v-if="processEdgeServerMetrics.length === 0" 
class="topo-cfg-empty">No server-side metrics.</div>
+              <div v-else class="metric-list">
+                <article v-for="(m, i) in processEdgeServerMetrics" :key="i" 
class="metric-row">
+                  <div class="metric-row-head">
+                    <label class="mf"><span>id</span><input v-model="m.id" 
type="text" class="mf-input mono" /></label>
+                    <label class="mf"><span>label</span><input 
v-model="m.label" type="text" class="mf-input" /></label>
+                    <label class="mf mf-wide"><span>MQE</span><input 
v-model="m.mqe" type="text" class="mf-input mono" 
placeholder="process_relation_server_write_cpm" /></label>
+                    <label class="mf mf-narrow"><span>unit</span><input 
v-model="m.unit" type="text" class="mf-input" /></label>
+                    <label class="mf mf-narrow"><span>agg</span>
+                      <select v-model="m.aggregation" class="mf-input">
+                        <option value="avg">avg</option>
+                        <option value="sum">sum</option>
+                      </select>
+                    </label>
+                    <div class="metric-row-actions">
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === 0" @click="moveMetric('edgeServer', i, -1)">↑</button>
+                      <button class="sw-btn small ghost" type="button" 
:disabled="i === processEdgeServerMetrics.length - 1" 
@click="moveMetric('edgeServer', i, 1)">↓</button>
+                      <button class="sw-btn small ghost danger" type="button" 
@click="removeMetric('edgeServer', i)">×</button>
+                    </div>
+                  </div>
+                </article>
+              </div>
+            </div>
+          </div>
+        </section>
+
         <!-- Trace + Logs are built-in views with no per-layer config
              other than enable/disable, which is already handled via
              the Components toggle in the right sidebar. -->
@@ -1528,10 +1651,35 @@ const namingTest = computed<NamingTestResult>(() => {
         >
           <div class="card-head">
             <h4>{{ SCOPE_LABELS[activeScope] }} tab</h4>
-            <span class="sub">No per-layer config required — toggle visibility 
via Components in the right sidebar.</span>
+            <span class="sub">
+              {{ activeScope === 'trace'
+                ? 'Pick the trace backend this layer reads from.'
+                : 'No per-layer config required — toggle visibility via 
Components in the right sidebar.' }}
+            </span>
           </div>
           <div class="topo-cfg-body">
-            <p class="topo-cfg-help">
+            <div v-if="activeScope === 'trace'" class="trace-source-cfg">
+              <div class="trace-source-head">Trace source</div>
+              <div class="trace-source-opts">
+                <label
+                  v-for="o in TRACE_SOURCE_OPTIONS"
+                  :key="o.value"
+                  class="trace-source-opt"
+                  :class="{ on: traceSource === o.value }"
+                >
+                  <input
+                    type="radio"
+                    name="trace-source"
+                    :value="o.value"
+                    :checked="traceSource === o.value"
+                    @change="traceSource = o.value"
+                  />
+                  <span class="ts-label">{{ o.label }}</span>
+                  <span class="ts-hint">{{ o.hint }}</span>
+                </label>
+              </div>
+            </div>
+            <p v-else class="topo-cfg-help">
               The <b>{{ SCOPE_LABELS[activeScope] }}</b> tab is a built-in 
view that uses
               SkyWalking-native query-protocol APIs directly. Operators 
configure filters
               and time range at runtime from the page itself; nothing to wire 
up here.
@@ -2380,6 +2528,30 @@ const namingTest = computed<NamingTestResult>(() => {
   color: var(--sw-fg-3);
   line-height: 1.5;
 }
+.trace-source-cfg { display: flex; flex-direction: column; gap: 8px; }
+.trace-source-head {
+  font-size: 10px;
+  font-weight: 600;
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
+}
+.trace-source-opts { display: flex; flex-direction: column; gap: 6px; }
+.trace-source-opt {
+  display: grid;
+  grid-template-columns: 16px 64px 1fr;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 10px;
+  border: 1px solid var(--sw-line);
+  border-radius: 4px;
+  background: var(--sw-bg-1);
+  cursor: pointer;
+  font-size: 11.5px;
+}
+.trace-source-opt.on { border-color: var(--sw-accent); background: 
var(--sw-bg-2); }
+.trace-source-opt .ts-label { font-weight: 600; color: var(--sw-fg-0); }
+.trace-source-opt .ts-hint { color: var(--sw-fg-3); }
 .topo-cfg-help code {
   font-family: var(--sw-mono);
   color: var(--sw-fg-1);
diff --git a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue 
b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
index 721b515..28baf96 100644
--- a/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
+++ b/apps/ui/src/layer/profiling/LayerNetworkProfilingView.vue
@@ -41,8 +41,11 @@ import type {
   NetworkProfilingSampling,
   ProcessCall,
   ProcessNode,
+  ProcessRelationEndpointRef,
+  ProcessRelationMetricsResponse,
 } from '@/api/client';
 import ProcessTopologyGraph from '@/layer/profiling/ProcessTopologyGraph.vue';
+import Sparkline from '@/components/charts/Sparkline.vue';
 import Icon from '@/components/icons/Icon.vue';
 
 const route = useRoute();
@@ -130,6 +133,80 @@ async function loadTopology(): Promise<void> {
 const selectedNode = ref<ProcessNode | null>(null);
 const selectedCall = ref<ProcessCall | null>(null);
 
+// ── Edge (process-relation) metrics ────────────────────────────────
+const relationMetrics = ref<ProcessRelationMetricsResponse | null>(null);
+const relationLoading = ref(false);
+const relationError = ref<string | null>(null);
+
+function nodeById(id: string): ProcessNode | undefined {
+  return nodes.value.find((n) => n.id === id);
+}
+function endpointRef(n: ProcessNode): ProcessRelationEndpointRef {
+  return {
+    serviceName: n.serviceName,
+    serviceInstanceName: n.serviceInstanceName,
+    processName: n.name,
+    // Process-topology services are agent/rover-monitored = normal.
+    normal: true,
+  };
+}
+
+// Refire when the operator clicks a different edge. The detail area
+// resets first (cascade-clear), then resolves async — never leaves a
+// stale conversation's numbers under the new edge's header.
+watch(selectedCall, async (call) => {
+  relationMetrics.value = null;
+  relationError.value = null;
+  if (!call) return;
+  const src = nodeById(call.source);
+  const dst = nodeById(call.target);
+  if (!src || !dst) {
+    relationError.value = 'Edge endpoints not in the current topology.';
+    return;
+  }
+  relationLoading.value = true;
+  try {
+    relationMetrics.value = await 
bffClient.networkProfile.relationMetrics(layerKey.value, {
+      source: endpointRef(src),
+      dest: endpointRef(dst),
+      windowMinutes: windowMinutes.value,
+    });
+    if (!relationMetrics.value.reachable && relationMetrics.value.error) {
+      relationError.value = relationMetrics.value.error;
+    }
+  } catch (e) {
+    relationError.value = e instanceof Error ? e.message : String(e);
+  } finally {
+    relationLoading.value = false;
+  }
+});
+
+/** Latest non-null value in a series, for the headline number next to
+ *  each metric's sparkline. cpm/bytes can go null at the tail (no data
+ *  in the most recent bucket) so we scan backwards. */
+function latestValue(values: Array<number | null>): number | null {
+  for (let i = values.length - 1; i >= 0; i--) {
+    if (values[i] !== null && values[i] !== undefined) return values[i];
+  }
+  return null;
+}
+function fmtMetric(v: number | null, unit?: string): string {
+  if (v === null) return '—';
+  if (unit === 'B') {
+    if (v >= 1024 * 1024) return `${(v / 1024 / 1024).toFixed(1)} MB`;
+    if (v >= 1024) return `${(v / 1024).toFixed(1)} KB`;
+    return `${v} B`;
+  }
+  const n = Number.isInteger(v) ? v : Number(v.toFixed(2));
+  return unit ? `${n} ${unit}` : String(n);
+}
+const sourceProcessName = computed(
+  () => (selectedCall.value && nodeById(selectedCall.value.source)?.name) || 
'—',
+);
+const targetProcessName = computed(
+  () => (selectedCall.value && nodeById(selectedCall.value.target)?.name) || 
'—',
+);
+
 async function keepAlive(): Promise<void> {
   if (!currentTask.value) return;
   aliveStatus.value = null;
@@ -307,13 +384,40 @@ function fmtTime(ms: number): string {
           </dl>
         </div>
         <div v-else-if="selectedCall">
-          <h5>Edge</h5>
+          <h5>Conversation</h5>
+          <div class="edge-pair">
+            <span class="mono">{{ sourceProcessName }}</span>
+            <span class="muted">→</span>
+            <span class="mono">{{ targetProcessName }}</span>
+          </div>
           <dl class="kv">
             <dt>Detect points</dt><dd>{{ selectedCall.detectPoints.join(', ') 
}}</dd>
             <dt>Source comp.</dt><dd>{{ (selectedCall.sourceComponents ?? 
[]).join(', ') || '—' }}</dd>
             <dt>Target comp.</dt><dd>{{ (selectedCall.targetComponents ?? 
[]).join(', ') || '—' }}</dd>
-            <dt>ID</dt><dd class="mono">{{ selectedCall.id }}</dd>
           </dl>
+
+          <div class="edge-metrics">
+            <div v-if="relationLoading" class="muted sm">Reading 
process-relation metrics…</div>
+            <div v-else-if="relationError" class="banner err sm">{{ 
relationError }}</div>
+            <template v-else-if="relationMetrics">
+              <div
+                v-for="side in (['client', 'server'] as const)"
+                :key="side"
+                class="metric-side"
+              >
+                <div class="side-label">{{ side }} side</div>
+                <div
+                  v-for="m in relationMetrics[side]"
+                  :key="m.id"
+                  class="metric-row"
+                >
+                  <span class="m-label">{{ m.label }}</span>
+                  <Sparkline :values="m.values" :width="72" :height="16" />
+                  <span class="m-val mono">{{ fmtMetric(latestValue(m.values), 
m.unit) }}</span>
+                </div>
+              </div>
+            </template>
+          </div>
         </div>
       </div>
     </div>
@@ -608,7 +712,7 @@ function fmtTime(ms: number): string {
   border-top: 1px solid var(--sw-line);
   background: var(--sw-bg-1);
   padding: 8px 14px;
-  max-height: 220px;
+  max-height: 320px;
   overflow-y: auto;
 }
 .detail h5 {
@@ -641,6 +745,45 @@ function fmtTime(ms: number): string {
   font-size: 10.5px;
   color: var(--sw-fg-1);
 }
+.edge-pair {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+  font-size: 12px;
+  color: var(--sw-fg-0);
+}
+.edge-pair .mono { font-family: var(--sw-mono); }
+.edge-metrics {
+  margin-top: 10px;
+  border-top: 1px dashed var(--sw-line);
+  padding-top: 8px;
+}
+.sm { font-size: 11px; }
+.metric-side { margin-bottom: 8px; }
+.side-label {
+  font-size: 9.5px;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--sw-fg-3);
+  margin-bottom: 4px;
+}
+.metric-row {
+  display: grid;
+  grid-template-columns: 1fr 72px 96px;
+  align-items: center;
+  gap: 8px;
+  padding: 2px 0;
+  font-size: 11px;
+}
+.metric-row .m-label { color: var(--sw-fg-2); }
+.metric-row .m-val {
+  text-align: right;
+  color: var(--sw-fg-0);
+  font-family: var(--sw-mono);
+  font-variant-numeric: tabular-nums;
+}
 
 .dlg-mask {
   position: fixed;
diff --git a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue 
b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
index 99c8403..d3589b9 100644
--- a/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
+++ b/apps/ui/src/layer/profiling/ProcessTopologyGraph.vue
@@ -157,11 +157,22 @@ function render(): void {
     .on('click', (_ev, d) => {
       selectedCallId.value = d.id;
       selectedNodeId.value = null;
+      restyleEdges();
       emit(
         'select-call',
         props.calls.find((c) => c.id === d.id) ?? null,
       );
     });
+
+  // Highlight the selected edge (accent + thicker) so the operator sees
+  // which conversation the detail panel is bound to.
+  function restyleEdges(): void {
+    edge
+      .attr('stroke', (d) =>
+        d.id === selectedCallId.value ? 'var(--sw-accent, #f97316)' : 
'var(--sw-line-2, #3a3d47)',
+      )
+      .attr('stroke-width', (d) => (d.id === selectedCallId.value ? 2.4 : 
1.4));
+  }
   // Detect-point pills
   const pills = linkG
     .selectAll('g.pill')
@@ -199,6 +210,7 @@ function render(): void {
     .on('click', (_ev, d) => {
       selectedNodeId.value = d.id;
       selectedCallId.value = null;
+      restyleEdges();
       emit(
         'select-node',
         props.nodes.find((n) => n.id === d.id) ?? null,
diff --git a/apps/ui/src/layer/traces/LayerTracesEntry.vue 
b/apps/ui/src/layer/traces/LayerTracesEntry.vue
index e5dcca6..968f781 100644
--- a/apps/ui/src/layer/traces/LayerTracesEntry.vue
+++ b/apps/ui/src/layer/traces/LayerTracesEntry.vue
@@ -27,7 +27,7 @@
   client-side and swaps the inner component.
 -->
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed } from 'vue';
 import { useRoute } from 'vue-router';
 import type { LayerDef } from '@skywalking-horizon-ui/api-client';
 import { useLayers } from '@/shell/useLayers';
@@ -44,73 +44,31 @@ const layer = computed<LayerDef | null>(() => 
layers.value.find((l) => l.key ===
 const configuredSource = computed<'native' | 'zipkin' | 'both'>(
   () => layer.value?.traces?.source ?? 'native',
 );
-/** Active source. When `configured = both`, the operator toggle wins;
- *  otherwise the configured value is locked. */
-const activeSource = ref<'native' | 'zipkin'>('native');
-watch(
-  configuredSource,
-  (s) => {
-    if (s === 'zipkin') activeSource.value = 'zipkin';
-    else if (s === 'native') activeSource.value = 'native';
-    else if (activeSource.value !== 'zipkin') activeSource.value = 'native';
-  },
-  { immediate: true },
+
+/**
+ * Which trace store to render. Native and Zipkin spans have different
+ * formats and query conditions, so a layer configured for `both`
+ * surfaces TWO sidebar tabs — `/trace` (native) and `/zipkin-trace`
+ * (Zipkin) — rather than one tab with an in-place toggle. This entry
+ * is route-driven:
+ *   - the `/zipkin-trace` route always renders the Zipkin view;
+ *   - the `/trace` route renders Zipkin only when the layer is
+ *     pure-`zipkin`, otherwise native (covers `native` and the native
+ *     half of `both`).
+ */
+const isZipkinRoute = computed(() => 
/\/zipkin-trace(\/|$|\?)/.test(route.path));
+const showZipkin = computed(
+  () => isZipkinRoute.value || configuredSource.value === 'zipkin',
 );
-const showToggle = computed(() => configuredSource.value === 'both');
 </script>
 
 <template>
   <div class="trc-entry">
-    <div v-if="showToggle" class="trc-source-toggle">
-      <span class="kicker">Source</span>
-      <button
-        type="button"
-        class="trc-source-btn"
-        :class="{ on: activeSource === 'native' }"
-        @click="activeSource = 'native'"
-      >Native</button>
-      <button
-        type="button"
-        class="trc-source-btn"
-        :class="{ on: activeSource === 'zipkin' }"
-        @click="activeSource = 'zipkin'"
-      >Zipkin</button>
-    </div>
-    <LayerZipkinTracesView v-if="activeSource === 'zipkin'" />
+    <LayerZipkinTracesView v-if="showZipkin" />
     <LayerTracesView v-else />
   </div>
 </template>
 
 <style scoped>
 .trc-entry { display: flex; flex-direction: column; gap: 8px; }
-.trc-source-toggle {
-  display: inline-flex;
-  align-items: center;
-  gap: 6px;
-  padding: 4px 8px;
-  align-self: flex-start;
-  background: var(--sw-bg-1);
-  border: 1px solid var(--sw-line);
-  border-radius: 6px;
-}
-.kicker {
-  font-size: 10px;
-  text-transform: uppercase;
-  letter-spacing: 0.1em;
-  color: var(--sw-fg-3);
-  font-weight: 600;
-  margin-right: 2px;
-}
-.trc-source-btn {
-  padding: 4px 10px;
-  font-size: 11px;
-  font-weight: 500;
-  color: var(--sw-fg-2);
-  background: transparent;
-  border: none;
-  border-radius: 3px;
-  cursor: pointer;
-}
-.trc-source-btn:hover { background: var(--sw-bg-2); color: var(--sw-fg-0); }
-.trc-source-btn.on { background: var(--sw-bg-3); color: var(--sw-fg-0); 
font-weight: 600; }
 </style>
diff --git a/apps/ui/src/shell/AppSidebar.vue b/apps/ui/src/shell/AppSidebar.vue
index c0a1598..fb7b08e 100644
--- a/apps/ui/src/shell/AppSidebar.vue
+++ b/apps/ui/src/shell/AppSidebar.vue
@@ -404,6 +404,14 @@ watch(
                 >
                   <Icon name="trace" /><span>Trace</span>
                 </RouterLink>
+                <RouterLink
+                  v-if="L.caps.traces && L.traces?.source === 'both'"
+                  :to="`/layer/${L.key}/zipkin-trace`"
+                  class="sw-nav-item"
+                  :class="{ 'is-active': 
isActive(`/layer/${L.key}/zipkin-trace`) }"
+                >
+                  <Icon name="trace" /><span>Zipkin Trace</span>
+                </RouterLink>
                 <RouterLink
                   v-if="L.caps.logs"
                   :to="`/layer/${L.key}/logs`"
@@ -535,6 +543,14 @@ watch(
           >
             <Icon name="trace" /><span>Traces</span>
           </RouterLink>
+          <RouterLink
+            v-if="E.layer.caps.traces && E.layer.traces?.source === 'both'"
+            :to="`/layer/${E.layer.key}/zipkin-trace`"
+            class="sw-nav-item"
+            :class="{ 'is-active': 
isActive(`/layer/${E.layer.key}/zipkin-trace`) }"
+          >
+            <Icon name="trace" /><span>Zipkin Traces</span>
+          </RouterLink>
           <RouterLink
             v-if="E.layer.caps.logs"
             :to="`/layer/${E.layer.key}/logs`"
diff --git a/apps/ui/src/shell/router/index.ts 
b/apps/ui/src/shell/router/index.ts
index fe13b30..a7424ba 100644
--- a/apps/ui/src/shell/router/index.ts
+++ b/apps/ui/src/shell/router/index.ts
@@ -65,6 +65,12 @@ function layerRoute(): RouteRecordRaw {
       // service universe drifts from SkyWalking's; that input lives
       // inside the view and is independent of the shell picker.
       { path: 'trace', component: () => 
import('@/layer/traces/LayerTracesEntry.vue') },
+      // Second trace tab — only surfaced in the sidebar when the layer's
+      // `traces.source` is `both`. Native + Zipkin spans differ in format
+      // and query conditions, so they get separate tabs rather than an
+      // in-tab toggle. The entry component renders the Zipkin view for
+      // this path regardless of source.
+      { path: 'zipkin-trace', component: () => 
import('@/layer/traces/LayerTracesEntry.vue') },
       { path: 'logs', component: () => 
import('@/layer/logs/LayerLogsView.vue') },
       { path: 'trace-profiling', component: () => 
import('@/layer/profiling/LayerTraceProfilingView.vue') },
       { path: 'ebpf-profiling', component: () => 
import('@/layer/profiling/LayerEBPFProfilingView.vue') },
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index 72a5225..9b7b617 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -54,6 +54,10 @@ export type {
   TopologyMetricDef,
   TopologyConfig,
   EndpointDependencyConfig,
+  ProcessTopologyConfig,
+  ProcessRelationMetric,
+  ProcessRelationMetricsResponse,
+  ProcessRelationEndpointRef,
   TopologyNode,
   TopologyCall,
   TopologyResponse,
diff --git a/packages/api-client/src/topology.ts 
b/packages/api-client/src/topology.ts
index e3a6221..4cfd970 100644
--- a/packages/api-client/src/topology.ts
+++ b/packages/api-client/src/topology.ts
@@ -105,6 +105,49 @@ export interface TopologyConfig {
   showGroup?: boolean;
 }
 
+/** Operator-editable process-topology (network-profiling) dashboard
+ *  config. Lives in the layer JSON's `processTopology` block. Drives the
+ *  network-profiling page's edge detail panel: clicking a process→process
+ *  call evaluates these MQE expressions under the ProcessRelation scope.
+ *  OAP exposes a client family and a server family (the conversation is
+ *  observed from both sides of the eBPF probe), so both lists exist —
+ *  mirrors `process_relation_client_*` / `process_relation_server_*`. */
+export interface ProcessTopologyConfig {
+  /** Per-edge MQE under ProcessRelation, client side
+   *  (`process_relation_client_*`). */
+  edgeClientMetrics: TopologyMetricDef[];
+  /** Per-edge MQE under ProcessRelation, server side
+   *  (`process_relation_server_*`). */
+  edgeServerMetrics: TopologyMetricDef[];
+}
+
+/** One resolved process-relation metric series for the edge panel. */
+export interface ProcessRelationMetric {
+  id: string;
+  label: string;
+  unit?: string;
+  /** Per-bucket values over the duration window (MINUTE step). */
+  values: Array<number | null>;
+}
+
+/** Response of `POST /api/ebpf/network/process-relation-metrics`. */
+export interface ProcessRelationMetricsResponse {
+  client: ProcessRelationMetric[];
+  server: ProcessRelationMetric[];
+  reachable: boolean;
+  error?: string;
+}
+
+/** Source / dest descriptor the edge panel sends to resolve relation
+ *  metrics. All names (not ids) — the ProcessRelation MQE entity keys on
+ *  service / instance / process NAMES. */
+export interface ProcessRelationEndpointRef {
+  serviceName: string;
+  serviceInstanceName: string;
+  processName: string;
+  normal?: boolean;
+}
+
 /** Operator-editable endpoint-dependency dashboard config. Lives in the
  *  layer JSON's `endpointDependency` block. */
 export interface EndpointDependencyConfig {

Reply via email to