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 {