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

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/2.0.0 by this push:
     new 5fda65c4b0 Use response handoffs for entity signal routes
5fda65c4b0 is described below

commit 5fda65c4b0ad8aac986f0abf9483a6551913e5a9
Author: Logic <[email protected]>
AuthorDate: Tue Jun 9 20:14:44 2026 +0800

    Use response handoffs for entity signal routes
---
 web-next/lib/entity-detail/view-model.test.ts | 237 +++++++++++++++++++++++++-
 web-next/lib/entity-detail/view-model.ts      | 197 ++++++++++++++++++---
 web-next/lib/types.ts                         |  36 ++++
 3 files changed, 442 insertions(+), 28 deletions(-)

diff --git a/web-next/lib/entity-detail/view-model.test.ts 
b/web-next/lib/entity-detail/view-model.test.ts
index e23536e30e..755a8ddc92 100644
--- a/web-next/lib/entity-detail/view-model.test.ts
+++ b/web-next/lib/entity-detail/view-model.test.ts
@@ -296,6 +296,182 @@ describe('entity detail view model', () => {
     ]);
   });
 
+  it('lets backend response handoffs seed signal routes without overriding 
inherited route context', () => {
+    const detail = {
+      entity: {
+        entity: {
+          id: 42,
+          type: 'service',
+          name: 'checkout',
+          displayName: 'Checkout',
+          environment: 'prod'
+        }
+      },
+      responseHandoffs: {
+        monitors: {
+          entityId: 42,
+          entityType: 'service',
+          entityName: 'Checkout API',
+          serviceName: 'checkout-metric',
+          serviceNamespace: 'commerce',
+          environment: 'prod',
+          start: 1713200000000,
+          end: 1713202700000,
+          source: 'monitor'
+        },
+        logs: {
+          entityId: 42,
+          entityType: 'service',
+          entityName: 'Checkout API',
+          serviceName: 'checkout-log',
+          serviceNamespace: 'commerce',
+          environment: 'prod',
+          traceId: 'trace-log',
+          spanId: 'span-log',
+          start: 1713200000000,
+          end: 1713202700000,
+          source: 'otlp'
+        },
+        traces: {
+          entityId: 42,
+          entityType: 'service',
+          entityName: 'Checkout API',
+          serviceName: 'checkout-trace',
+          serviceNamespace: 'commerce',
+          environment: 'prod',
+          traceId: 'trace-trace',
+          spanId: 'span-trace',
+          start: 1713200000000,
+          end: 1713202700000,
+          source: 'otlp'
+        }
+      }
+    } as any;
+
+    const links = buildEntityContextHandoffLinks(detail, 'last-1h');
+    const metrics = new URL(links.find(link => link.key === 'metrics')?.copy 
|| '/', 'http://localhost').searchParams;
+    expect(metrics.get('entityType')).toBe('service');
+    expect(metrics.get('entityName')).toBe('Checkout API');
+    expect(metrics.get('serviceName')).toBe('checkout-metric');
+    expect(metrics.get('serviceNamespace')).toBe('commerce');
+    expect(metrics.get('start')).toBe('1713200000000');
+    expect(metrics.get('end')).toBe('1713202700000');
+    expect(metrics.get('source')).toBe('monitor');
+
+    const logs = new URL(links.find(link => link.key === 'logs')?.copy || '/', 
'http://localhost').searchParams;
+    expect(logs.get('serviceName')).toBe('checkout-log');
+    expect(logs.get('traceId')).toBe('trace-log');
+    expect(logs.get('spanId')).toBe('span-log');
+    expect(logs.get('source')).toBe('otlp');
+
+    const traces = new URL(links.find(link => link.key === 'traces')?.copy || 
'/', 'http://localhost').searchParams;
+    expect(traces.get('serviceName')).toBe('checkout-trace');
+    expect(traces.get('traceId')).toBe('trace-trace');
+    expect(traces.get('spanId')).toBe('span-trace');
+
+    const inheritedLinks = buildEntityContextHandoffLinks(detail, {
+      entityName: 'Route Checkout',
+      serviceName: 'route-service',
+      serviceNamespace: 'route-namespace',
+      environment: 'route-env',
+      traceId: 'route-trace',
+      spanId: 'route-span',
+      start: '10',
+      end: '20',
+      source: 'route-source'
+    });
+    const inheritedLogs = new URL(inheritedLinks.find(link => link.key === 
'logs')?.copy || '/', 'http://localhost').searchParams;
+    expect(inheritedLogs.get('entityName')).toBe('Route Checkout');
+    expect(inheritedLogs.get('serviceName')).toBe('route-service');
+    expect(inheritedLogs.get('serviceNamespace')).toBe('route-namespace');
+    expect(inheritedLogs.get('environment')).toBe('route-env');
+    expect(inheritedLogs.get('traceId')).toBe('route-trace');
+    expect(inheritedLogs.get('spanId')).toBe('route-span');
+    expect(inheritedLogs.get('start')).toBe('10');
+    expect(inheritedLogs.get('end')).toBe('20');
+    expect(inheritedLogs.get('source')).toBe('route-source');
+  });
+
+  it('builds host entity handoff links with resource filters instead of 
inventing a service name', () => {
+    const links = buildEntityContextHandoffLinks(
+      {
+        entity: {
+          entity: {
+            id: 4201,
+            name: 'checkout-node-a',
+            displayName: 'Checkout Node A',
+            type: 'host'
+          },
+          identities: [{ key: 'host.name', value: 'checkout-node-a' }]
+        }
+      } as any,
+      'last-30m'
+    );
+
+    const metrics = new URL(links[0]?.copy || '/', 'http://localhost');
+    expect(metrics.pathname).toBe('/ingestion/otlp/metrics');
+    expect(metrics.searchParams.get('entityId')).toBe('4201');
+    expect(metrics.searchParams.get('serviceName')).toBeNull();
+    
expect(metrics.searchParams.get('filter')).toBe('host.name="checkout-node-a"');
+
+    const logs = new URL(links[1]?.copy || '/', 'http://localhost');
+    expect(logs.pathname).toBe('/log/manage');
+    expect(logs.searchParams.get('serviceName')).toBeNull();
+    
expect(logs.searchParams.get('resourceFilter')).toBe('host.name="checkout-node-a"');
+
+    const traces = new URL(links[2]?.copy || '/', 'http://localhost');
+    expect(traces.pathname).toBe('/trace/manage');
+    expect(traces.searchParams.get('serviceName')).toBeNull();
+    
expect(traces.searchParams.get('resourceFilter')).toBe('host.name="checkout-node-a"');
+  });
+
+  it('builds k8s workload handoff links with inherited service context and pod 
resource filters', () => {
+    const links = buildEntityContextHandoffLinks(
+      {
+        entity: {
+          entity: {
+            id: 4202,
+            name: 'checkout-v1-78dfd',
+            displayName: 'Checkout Pod',
+            type: 'k8s_workload',
+            namespace: 'payments'
+          },
+          identities: [
+            { key: 'k8s.namespace.name', value: 'payments' },
+            { key: 'k8s.pod.name', value: 'checkout-v1-78dfd' },
+            { key: 'container.name', value: 'checkout' }
+          ]
+        }
+      } as any,
+      {
+        timeRange: 'last-45m',
+        source: 'otlp',
+        collector: 'collector-demo-a',
+        template: 'spring-boot',
+        serviceName: 'checkout',
+        serviceNamespace: 'hertzbeat-demo',
+        environment: 'demo',
+        returnTo: '/entities/4200'
+      }
+    );
+    const expectedFilter = 'k8s.namespace.name="payments" and 
k8s.pod.name="checkout-v1-78dfd" and container.name="checkout"';
+
+    const metrics = new URL(links[0]?.copy || '/', 'http://localhost');
+    expect(metrics.searchParams.get('entityId')).toBe('4202');
+    expect(metrics.searchParams.get('serviceName')).toBe('checkout');
+    
expect(metrics.searchParams.get('serviceNamespace')).toBe('hertzbeat-demo');
+    expect(metrics.searchParams.get('source')).toBe('otlp');
+    expect(metrics.searchParams.get('filter')).toBe(expectedFilter);
+
+    const logs = new URL(links[1]?.copy || '/', 'http://localhost');
+    expect(logs.searchParams.get('resourceFilter')).toBe(expectedFilter);
+    expect(logs.searchParams.get('collector')).toBe('collector-demo-a');
+
+    const traces = new URL(links[2]?.copy || '/', 'http://localhost');
+    expect(traces.searchParams.get('resourceFilter')).toBe(expectedFilter);
+    expect(traces.searchParams.get('template')).toBe('spring-boot');
+  });
+
   it('builds alert, topology, and runbook handoffs only from real entity 
evidence', () => {
     const rows = buildEntityEvidenceHandoffRows(
       {
@@ -491,7 +667,12 @@ describe('entity detail view model', () => {
       { title: 'Current alert #1', copy: 'error rate high', meta: 'firing ยท 
severity=critical', tone: 'danger' }
     ]);
     expect(buildRelationshipRows(detail)).toEqual([
-      { title: 'calls', copy: 'mysql-prod', meta: 'mysql-1' },
+      {
+        title: 'calls',
+        copy: 'mysql-prod',
+        meta: 'mysql-1',
+        href: 
'/entities/mysql-1?entityId=mysql-1&entityName=mysql-prod&timeRange=last-1h'
+      },
       { title: 'owned-by', copy: 'payment-app', meta: 'Upstream/downstream 
relationship' }
     ]);
     expect(buildCollectionSourceRows(detail)).toEqual([
@@ -502,6 +683,60 @@ describe('entity detail view model', () => {
     ]);
   });
 
+  it('keeps OTLP entity relation drilldowns linked to target entities with 
signal context', () => {
+    const detail = {
+      entity: {
+        entity: {
+          id: 4200,
+          name: 'checkout',
+          displayName: 'Checkout API',
+          source: 'otlp'
+        },
+        relations: [
+          {
+            relationType: 'runs_on',
+            targetEntityId: 4201,
+            targetRef: 'host:checkout-node-a',
+            relationSource: 'otel_resource',
+            status: 'confirmed'
+          },
+          {
+            relationType: 'deployed_on',
+            targetEntityId: 4202,
+            targetRef: 'k8s_workload:payments/checkout-v1-78dfd',
+            relationSource: 'otel_resource',
+            status: 'confirmed'
+          }
+        ]
+      }
+    } as any;
+
+    expect(
+      buildRelationshipRows(detail, {
+        timeRange: 'last-45m',
+        source: 'otlp',
+        collector: 'collector-demo-a',
+        template: 'spring-boot',
+        serviceName: 'checkout',
+        serviceNamespace: 'hertzbeat-demo',
+        environment: 'demo'
+      })
+    ).toEqual([
+      {
+        title: 'runs_on',
+        copy: 'host:checkout-node-a',
+        meta: '4201',
+        href: 
'/entities/4201?entityId=4201&entityName=host%3Acheckout-node-a&serviceName=checkout&environment=demo&timeRange=last-45m&source=otlp&collector=collector-demo-a&template=spring-boot&serviceNamespace=hertzbeat-demo&returnTo=%2Fentities%2F4200'
+      },
+      {
+        title: 'deployed_on',
+        copy: 'k8s_workload:payments/checkout-v1-78dfd',
+        meta: '4202',
+        href: 
'/entities/4202?entityId=4202&entityName=k8s_workload%3Apayments%2Fcheckout-v1-78dfd&serviceName=checkout&environment=demo&timeRange=last-45m&source=otlp&collector=collector-demo-a&template=spring-boot&serviceNamespace=hertzbeat-demo&returnTo=%2Fentities%2F4200'
+      }
+    ]);
+  });
+
   it('builds entity attribution rows across traditional monitoring and OTLP 
evidence', () => {
     const detail = {
       entity: {
diff --git a/web-next/lib/entity-detail/view-model.ts 
b/web-next/lib/entity-detail/view-model.ts
index bf7e0f55a8..694f19a32c 100644
--- a/web-next/lib/entity-detail/view-model.ts
+++ b/web-next/lib/entity-detail/view-model.ts
@@ -1,4 +1,4 @@
-import type { Entity, EntityDetailDto, EntityLinkRef } from '@/lib/types';
+import type { Entity, EntityDetailDto, EntityLinkRef, 
EntityResponseHandoffInfo } from '@/lib/types';
 import { buildCollectorHealthEvidence } from '../collector-health-evidence';
 import { interpolate, type TranslationParams } from '../i18n';
 import { SUPPLEMENTAL_MESSAGES } from '../i18n-runtime-messages';
@@ -37,6 +37,13 @@ type HandoffRow = DetailRow & {
   key: string;
 };
 
+type SignalHandoffKind = 'metrics' | 'logs' | 'traces';
+
+type EntityResourceScope = {
+  metricsFilter: string;
+  resourceFilter: string;
+};
+
 type EvidenceHandoffRow = DetailRow & {
   key: 'alerts' | 'topology' | 'runbook';
   evidence: 'active-alerts' | 'topology-relation' | 'runbook';
@@ -178,7 +185,7 @@ function localizeActionText(
 export function buildSummaryRows(
   detail: Pick<EntityDetailDto, 'evidenceSummary' | 'monitorSummary' | 
'logSummary' | 'traceSummary' | 'signalEvidence' | 'boundMonitors'>,
   t: EntityDetailViewModelTranslator = translateEntityDetailViewModel
-) {
+): DetailRow[] {
   const boundMonitorCount = detail.monitorSummary?.totalBoundMonitors ?? 
detail.boundMonitors?.length ?? 0;
   const downMonitorCount = detail.evidenceSummary?.downMonitorCount ?? 0;
   const logSummary = getSignalLogSummary(detail);
@@ -542,6 +549,83 @@ function normalizeUnknownText(value: unknown) {
   return undefined;
 }
 
+function responseHandoffForSignal(detail: EntityDetailDto, signal?: 
SignalHandoffKind): EntityResponseHandoffInfo | null {
+  if (signal === 'metrics') return detail.responseHandoffs?.monitors || null;
+  if (signal === 'logs') return detail.responseHandoffs?.logs || null;
+  if (signal === 'traces') return detail.responseHandoffs?.traces || null;
+  return null;
+}
+
+function normalizeEntityType(entity: Entity) {
+  return normalizeUnknownText(entity.type)?.toLowerCase().replaceAll('-', '_');
+}
+
+function isKnownResourceEntity(entity: Entity) {
+  const type = normalizeEntityType(entity);
+  return type === 'host' || type === 'k8s_workload' || type === 'k8s_pod' || 
type === 'kubernetes_workload';
+}
+
+function collectEntityIdentityValues(detail: EntityDetailDto) {
+  const values = new Map<string, string>();
+  const put = (key: unknown, value: unknown) => {
+    const normalizedKey = normalizeUnknownText(key);
+    const normalizedValue = normalizeUnknownText(value);
+    if (normalizedKey && normalizedValue && !values.has(normalizedKey)) {
+      values.set(normalizedKey, normalizedValue);
+    }
+  };
+
+  (detail.entity?.identities || []).forEach(identity => {
+    if (!identity || typeof identity !== 'object') return;
+    const record = identity as Record<string, unknown>;
+    put(record.key || record.name || record.identityKey, record.value || 
record.identityValue);
+  });
+
+  const labels = detail.entity?.entity?.labels;
+  if (labels && typeof labels === 'object' && !Array.isArray(labels)) {
+    Object.entries(labels as Record<string, unknown>).forEach(([key, value]) 
=> put(key, value));
+  }
+
+  return values;
+}
+
+function quoteResourceFilterValue(value: string) {
+  return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
+}
+
+function buildResourceFilterExpression(pairs: Array<[string, string | 
undefined]>) {
+  return pairs
+    .map(([key, value]) => (value ? 
`${key}=${quoteResourceFilterValue(value)}` : undefined))
+    .filter((value): value is string => Boolean(value))
+    .join(' and ');
+}
+
+function buildEntityResourceScope(detail: EntityDetailDto): 
EntityResourceScope | null {
+  const entity = getEntityRecord(detail);
+  const type = normalizeEntityType(entity);
+  const identityValues = collectEntityIdentityValues(detail);
+
+  if (type === 'host') {
+    const hostName = identityValues.get('host.name') || 
normalizeUnknownText(entity.name);
+    const filter = buildResourceFilterExpression([['host.name', hostName]]);
+    return filter ? { metricsFilter: filter, resourceFilter: filter } : null;
+  }
+
+  if (type === 'k8s_workload' || type === 'k8s_pod' || type === 
'kubernetes_workload') {
+    const namespace = identityValues.get('k8s.namespace.name') || 
normalizeUnknownText(entity.namespace);
+    const pod = identityValues.get('k8s.pod.name') || 
normalizeUnknownText(entity.name);
+    const container = identityValues.get('container.name');
+    const filter = buildResourceFilterExpression([
+      ['k8s.namespace.name', namespace],
+      ['k8s.pod.name', pod],
+      ['container.name', container]
+    ]);
+    return filter ? { metricsFilter: filter, resourceFilter: filter } : null;
+  }
+
+  return null;
+}
+
 function resolveEntityTimeContext(context: EntityDetailTimeContext | 
undefined): SignalRouteContext {
   if (!context) return { timeRange: 'last-1h' };
   return typeof context === 'string' ? { timeRange: context } : context;
@@ -579,29 +663,55 @@ function appendPreservedEntityContext(params: 
URLSearchParams, routeContext: Sig
   });
 }
 
+function appendResponseHandoffContext(params: URLSearchParams, handoff: 
EntityResponseHandoffInfo | null) {
+  if (!handoff) return;
+  addContextParam(params, 'start', normalizeUnknownText(handoff.start));
+  addContextParam(params, 'end', normalizeUnknownText(handoff.end));
+  addContextParam(params, 'source', normalizeUnknownText(handoff.source));
+}
+
 function buildContextParams(
   detail: EntityDetailDto,
   timeContext: EntityDetailTimeContext,
-  options: { includeTrace?: boolean; includeEntityName?: boolean; 
includeReturnTo?: boolean } = {}
+  options: { includeTrace?: boolean; includeEntityName?: boolean; 
includeReturnTo?: boolean; signal?: SignalHandoffKind } = {}
 ) {
   const entity = getEntityRecord(detail);
   const routeContext = resolveEntityTimeContext(timeContext);
-  const entityId = entity.id != null ? String(entity.id) : '';
-  const entityName = entity.displayName || entity.name || '';
-  const serviceName = normalizeContextText(routeContext.serviceName) || 
entity.name || entity.displayName || '';
+  const responseHandoff = responseHandoffForSignal(detail, options.signal);
+  const responseEntityName = normalizeUnknownText(responseHandoff?.entityName);
+  const routeEntityName = normalizeContextText(routeContext.entityName);
+  const entityId = normalizeContextText(routeContext.entityId) || 
normalizeUnknownText(responseHandoff?.entityId) || (entity.id != null ? 
String(entity.id) : '');
+  const entityName = routeEntityName || responseEntityName || 
entity.displayName || entity.name || '';
+  const entityType = normalizeContextText(routeContext.entityType) || 
normalizeUnknownText(responseHandoff?.entityType) || 
normalizeEntityType(entity);
+  const resourceScope = buildEntityResourceScope(detail);
+  const serviceName =
+    normalizeContextText(routeContext.serviceName) ||
+    normalizeUnknownText(responseHandoff?.serviceName) ||
+    (isKnownResourceEntity(entity) ? '' : entity.name || entity.displayName || 
'');
+  const serviceNamespace = normalizeContextText(routeContext.serviceNamespace) 
|| normalizeUnknownText(responseHandoff?.serviceNamespace);
+  const environment = normalizeContextText(routeContext.environment) || 
normalizeUnknownText(responseHandoff?.environment) || entity.environment;
   const traceSummary = getSignalTraceSummary(detail);
   const timeRange = normalizeContextText(routeContext.timeRange) || 'last-1h';
   const params = new URLSearchParams();
 
   addContextParam(params, 'entityId', entityId);
-  if (options.includeEntityName) addContextParam(params, 'entityName', 
entityName);
+  addContextParam(params, 'entityType', entityType);
+  if (options.includeEntityName || routeEntityName || responseEntityName) 
addContextParam(params, 'entityName', entityName);
   addContextParam(params, 'serviceName', serviceName);
-  addContextParam(params, 'environment', 
normalizeContextText(routeContext.environment) || entity.environment);
+  addContextParam(params, 'serviceNamespace', serviceNamespace);
+  addContextParam(params, 'environment', environment);
   addContextParam(params, 'timeRange', timeRange);
+  appendResponseHandoffContext(params, responseHandoff);
   appendPreservedEntityContext(params, routeContext);
+  if (options.signal === 'metrics' && resourceScope?.metricsFilter) {
+    addContextParam(params, 'filter', resourceScope.metricsFilter);
+  }
+  if ((options.signal === 'logs' || options.signal === 'traces') && 
resourceScope?.resourceFilter) {
+    addContextParam(params, 'resourceFilter', resourceScope.resourceFilter);
+  }
   if (options.includeTrace) {
-    addContextParam(params, 'traceId', 
normalizeContextText(routeContext.traceId) || traceSummary?.latestTraceId);
-    addContextParam(params, 'spanId', 
normalizeContextText(routeContext.spanId) || traceSummary?.latestSpanId);
+    addContextParam(params, 'traceId', 
normalizeContextText(routeContext.traceId) || 
normalizeUnknownText(responseHandoff?.traceId) || traceSummary?.latestTraceId);
+    addContextParam(params, 'spanId', 
normalizeContextText(routeContext.spanId) || 
normalizeUnknownText(responseHandoff?.spanId) || traceSummary?.latestSpanId);
   }
   if (options.includeReturnTo && entityId) {
     params.set('returnTo', `/entities/${entityId}`);
@@ -622,26 +732,28 @@ export function buildEntityContextHandoffLinks(
   const entity = getEntityRecord(detail);
   const entityId = entity.id != null ? String(entity.id) : '';
   const shared = buildContextParams(detail, timeContext);
-  const sharedWithTrace = buildContextParams(detail, timeContext, { 
includeTrace: true });
+  const metricsQuery = buildContextParams(detail, timeContext, { signal: 
'metrics' });
+  const logsQuery = buildContextParams(detail, timeContext, { includeTrace: 
true, signal: 'logs' });
+  const tracesQuery = buildContextParams(detail, timeContext, { includeTrace: 
true, signal: 'traces' });
   const monitorQuery = buildContextParams(detail, timeContext, { 
includeEntityName: true, includeReturnTo: true });
 
   return [
     {
       key: 'metrics',
       title: t('entities.detail.handoff.metrics.title'),
-      copy: withQuery('/ingestion/otlp/metrics', shared),
+      copy: withQuery('/ingestion/otlp/metrics', metricsQuery),
       meta: t('entities.detail.handoff.metrics.meta')
     },
     {
       key: 'logs',
       title: t('entities.detail.handoff.logs.title'),
-      copy: withQuery('/log/manage', sharedWithTrace),
+      copy: withQuery('/log/manage', logsQuery),
       meta: t('entities.detail.handoff.logs.meta')
     },
     {
       key: 'traces',
       title: t('entities.detail.handoff.traces.title'),
-      copy: withQuery('/trace/manage', sharedWithTrace),
+      copy: withQuery('/trace/manage', tracesQuery),
       meta: t('entities.detail.handoff.traces.meta')
     },
     {
@@ -809,6 +921,7 @@ export function buildCurrentAlertRows(
 
 export function buildRelationshipRows(
   detail: EntityDetailDto,
+  timeContext: EntityDetailTimeContext = 'last-1h',
   t: EntityDetailViewModelTranslator = translateEntityDetailViewModel
 ): DetailRow[] {
   const relations = (detail.entity?.relations || []) as Array<Record<string, 
unknown>>;
@@ -823,19 +936,49 @@ export function buildRelationshipRows(
     ];
   }
 
-  return relations.map(relation => ({
-    title: String(relation.type || relation.relationType || 'related'),
-    copy: String(
-      relation.targetEntityName ||
-      relation.targetName ||
-      relation.targetEntityId ||
-      t('entities.detail.relationship-row.unknown-target')
-    ),
-    meta:
-      relation.targetEntityId != null
-        ? String(relation.targetEntityId)
-        : t('entities.detail.relationship-row.default-meta')
-  }));
+  const sourceEntity = getEntityRecord(detail);
+  const sourceEntityId = normalizeUnknownText(sourceEntity.id);
+  const routeContext = resolveEntityTimeContext(timeContext);
+  const timeRange = normalizeContextText(routeContext.timeRange) || 'last-1h';
+
+  return relations.map(relation => {
+    const targetEntityId = normalizeUnknownText(relation.targetEntityId) || 
normalizeUnknownText(relation.targetId);
+    const targetEntityName =
+      normalizeUnknownText(relation.targetEntityName) ||
+      normalizeUnknownText(relation.targetName) ||
+      normalizeUnknownText(relation.targetRef) ||
+      targetEntityId ||
+      t('entities.detail.relationship-row.unknown-target');
+    const params = new URLSearchParams();
+
+    if (targetEntityId) {
+      addContextParam(params, 'entityId', targetEntityId);
+      addContextParam(params, 'entityName', targetEntityName);
+      addContextParam(params, 'serviceName', 
normalizeContextText(routeContext.serviceName));
+      addContextParam(params, 'environment', 
normalizeContextText(routeContext.environment));
+      addContextParam(params, 'timeRange', timeRange);
+      appendPreservedEntityContext(params, routeContext);
+      if (sourceEntityId) {
+        params.set('returnTo', `/entities/${sourceEntityId}`);
+      }
+    }
+
+    return {
+      title: String(relation.type || relation.relationType || 'related'),
+      copy: String(
+        relation.targetEntityName ||
+        relation.targetName ||
+        relation.targetRef ||
+        relation.targetEntityId ||
+        t('entities.detail.relationship-row.unknown-target')
+      ),
+      meta:
+        relation.targetEntityId != null
+          ? String(relation.targetEntityId)
+          : t('entities.detail.relationship-row.default-meta'),
+      href: targetEntityId ? withQuery(`/entities/${targetEntityId}`, 
params.toString()) : undefined
+    };
+  });
 }
 
 function identitySummary(identities: unknown[]) {
diff --git a/web-next/lib/types.ts b/web-next/lib/types.ts
index a04f05c94d..00636d3716 100644
--- a/web-next/lib/types.ts
+++ b/web-next/lib/types.ts
@@ -534,12 +534,48 @@ export interface EntityDetailDto {
   traceSummary?: EntityTraceSummary & { latestSpanId?: string | null };
   unifiedEvidenceSummary?: EntityUnifiedEvidenceSummary;
   signalEvidence?: EntitySignalEvidenceBundle;
+  responseHandoffs?: EntityResponseHandoffsInfo;
   boundMonitors?: Monitor[];
   activeAlerts?: unknown[];
   nextActions?: EntityNextAction[];
   noiseControlSummary?: EntityNoiseControlSummary;
 }
 
+export interface EntityResponseHandoffInfo {
+  search?: string | null;
+  status?: string | null;
+  severity?: string | null;
+  app?: string | null;
+  content?: string | null;
+  entityId?: number | string | null;
+  entityType?: string | null;
+  entityName?: string | null;
+  traceId?: string | null;
+  spanId?: string | null;
+  serviceName?: string | null;
+  serviceNamespace?: string | null;
+  severityText?: string | null;
+  query?: string | null;
+  owner?: string | null;
+  system?: string | null;
+  environment?: string | null;
+  start?: number | string | null;
+  end?: number | string | null;
+  source?: string | null;
+  focus?: string | null;
+  returnTo?: string | null;
+  returnLabel?: string | null;
+}
+
+export interface EntityResponseHandoffsInfo {
+  alerts?: EntityResponseHandoffInfo | null;
+  monitors?: EntityResponseHandoffInfo | null;
+  logs?: EntityResponseHandoffInfo | null;
+  traces?: EntityResponseHandoffInfo | null;
+  discovery?: EntityResponseHandoffInfo | null;
+  editor?: EntityResponseHandoffInfo | null;
+}
+
 export interface EntitySignalEvidenceBundle {
   logSummary?: EntityLogSummary;
   traceSummary?: EntityTraceSummary & { latestSpanId?: string | null };


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to