Skip to content

Commit 23f5fc7

Browse files
authored
feat: APM service map options panel, find-in-page, and filters (elastic#263531)
Closes elastic#262464 Closes elastic#262511 Closes elastic#263029 ## Summary - Add a floating **options panel** on the service map (`EuiPanel`): **alert / SLO / anomaly** status filters (combo options with counts, placeholder labels), **presentation** toggle (horizontal vs vertical / dagre rankdir), and **show/hide** controls aligned with map zoom chrome. - Add **find-in-page** (`ServiceMapFindInPage`): Discover-style **n/m** counter in the search append, prev/next chevrons, corrected **previous-match** indexing, and **Ctrl/Cmd+K** that focuses find when focus is on the map or document body (without stealing from other inputs). - **Visibility pipeline** (`apply_service_map_visibility`) + **filter option counts**; **layout** updates for edge source/target positions by orientation; **types** tweak for React Flow nodes. - **Tests**: Jest (`graph_controls`, `service_map_find_in_page`, screen reader, visibility, layout) and **Scout** (`service_map.spec.ts` — placeholders, find counter, keyboard shortcut, hide/show). <img width="3086" height="1726" alt="image" src="https://github.com/user-attachments/assets/bb896f57-6e14-4da1-a541-70bb81ea60ce" /> <img width="3102" height="1728" alt="image" src="https://github.com/user-attachments/assets/1614449b-9505-4514-bf6d-e95d072478cb" /> <img width="3106" height="1748" alt="image" src="https://github.com/user-attachments/assets/6e0abddb-f755-4155-b7e9-430a67561e3c" /> ## Testing ### Run tests and checks - `node x-pack/solutions/observability/packages/kbn-ts-type-check-oblt-cli/type_check.js --project x-pack/solutions/observability/plugins/apm/tsconfig.json` - `node scripts/jest` (service map tests listed above) - Scout `service_map.spec.ts` against an environment with APM service map data ### Manual - Go to APM > Service map - Try the new controls on the left: https://github.com/user-attachments/assets/8fdaf044-5b65-4ba4-a41f-104f390bab1e Made with [Cursor](https://cursor.com)
1 parent b066d86 commit 23f5fc7

18 files changed

Lines changed: 1712 additions & 54 deletions

x-pack/solutions/observability/plugins/apm/common/service_map/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import type { Node, Edge, EdgeMarker as ReactFlowEdgeMarker } from '@xyflow/react';
9+
import type { AlertStatus } from '@kbn/rule-data-utils';
910
import type { AgentName } from '@kbn/apm-types/src/es_schemas/ui/fields';
1011
import type { AGENT_NAME, SERVICE_ENVIRONMENT, SERVICE_NAME } from '@kbn/apm-types';
1112
import type { SPAN_DESTINATION_SERVICE_RESOURCE, SPAN_SUBTYPE, SPAN_TYPE } from '@kbn/apm-types';
@@ -171,9 +172,11 @@ export interface ServiceMapExitSpan extends ServiceMapService {
171172
export type ServiceMapSpan = ServiceMapExitSpan & {
172173
destinationService?: ServiceMapService;
173174
};
174-
interface BaseNodeData extends Record<string, unknown> {
175+
interface BaseNodeData {
175176
id: string;
176177
label: string;
178+
/** Allows `Node<ServiceMapNodeData>` to satisfy React Flow's `Record<string, unknown>` node data constraint. */
179+
[key: string]: unknown;
177180
}
178181

179182
export interface ServiceNodeData extends BaseNodeData {
@@ -182,6 +185,8 @@ export interface ServiceNodeData extends BaseNodeData {
182185
serviceAnomalyStats?: ServiceAnomalyStats;
183186
/** Active alerts count for service map badges (merged client-side). */
184187
alertsCount?: number;
188+
/** Per-status counts when badge merge supplies a breakdown. */
189+
alertsByStatus?: Partial<Record<AlertStatus, number>>;
185190
sloStatus?: SloStatus | 'noSLOs';
186191
sloCount?: number;
187192
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils';
9+
import type { ServiceHealthStatus } from '../../../../common/service_health_status';
10+
import type { ServiceMapNode, ServiceMapEdge } from '../../../../common/service_map';
11+
import {
12+
applyServiceMapVisibility,
13+
DEFAULT_SERVICE_MAP_VIEW_FILTERS,
14+
} from './apply_service_map_visibility';
15+
16+
const mkService = (
17+
id: string,
18+
overrides: Partial<{
19+
alertsCount: number;
20+
alertsByStatus: Partial<Record<string, number>>;
21+
sloStatus: 'healthy' | 'noSLOs';
22+
health: ServiceHealthStatus;
23+
}> = {}
24+
): ServiceMapNode => ({
25+
id,
26+
type: 'service',
27+
position: { x: 0, y: 0 },
28+
data: {
29+
id,
30+
label: id,
31+
isService: true,
32+
...(overrides.alertsCount !== undefined ? { alertsCount: overrides.alertsCount } : {}),
33+
...(overrides.alertsByStatus !== undefined ? { alertsByStatus: overrides.alertsByStatus } : {}),
34+
...(overrides.sloStatus !== undefined ? { sloStatus: overrides.sloStatus } : {}),
35+
...(overrides.health !== undefined
36+
? { serviceAnomalyStats: { healthStatus: overrides.health } }
37+
: {}),
38+
},
39+
});
40+
41+
const mkEdge = (id: string, source: string, target: string): ServiceMapEdge =>
42+
({
43+
id,
44+
source,
45+
target,
46+
type: 'default',
47+
style: { stroke: '#ccc', strokeWidth: 1 },
48+
markerEnd: {
49+
type: 'arrowclosed',
50+
width: 10,
51+
height: 10,
52+
color: '#ccc',
53+
},
54+
data: { isBidirectional: false },
55+
} as ServiceMapEdge);
56+
57+
describe('applyServiceMapVisibility', () => {
58+
it('hides services with no alerts in the selected alert statuses (OR across statuses)', () => {
59+
const nodes: ServiceMapNode[] = [
60+
mkService('a', { alertsCount: 2 }),
61+
mkService('b', { alertsCount: 0 }),
62+
mkService('c', {
63+
alertsByStatus: { [ALERT_STATUS_RECOVERED]: 1 },
64+
alertsCount: 0,
65+
}),
66+
{
67+
id: 'ext',
68+
type: 'dependency',
69+
position: { x: 0, y: 0 },
70+
data: { id: 'ext', label: 'redis', isService: false },
71+
},
72+
];
73+
const edges: ServiceMapEdge[] = [
74+
mkEdge('e1', 'a', 'ext'),
75+
mkEdge('e2', 'ext', 'b'),
76+
mkEdge('e3', 'ext', 'c'),
77+
];
78+
79+
const { nodes: outNodesActive, edges: outEdgesActive } = applyServiceMapVisibility(
80+
nodes,
81+
edges,
82+
{
83+
...DEFAULT_SERVICE_MAP_VIEW_FILTERS,
84+
alertStatusFilter: [ALERT_STATUS_ACTIVE],
85+
}
86+
);
87+
88+
const byIdActive = Object.fromEntries(outNodesActive.map((n) => [n.id, n.hidden]));
89+
expect(byIdActive.a).toBe(false);
90+
expect(byIdActive.b).toBe(true);
91+
expect(byIdActive.c).toBe(true);
92+
expect(byIdActive.ext).toBe(false);
93+
94+
const edgeHiddenActive = Object.fromEntries(outEdgesActive.map((e) => [e.id, e.hidden]));
95+
expect(edgeHiddenActive.e1).toBe(false);
96+
expect(edgeHiddenActive.e2).toBe(true);
97+
expect(edgeHiddenActive.e3).toBe(true);
98+
99+
const { nodes: outNodesRecovered } = applyServiceMapVisibility(nodes, edges, {
100+
...DEFAULT_SERVICE_MAP_VIEW_FILTERS,
101+
alertStatusFilter: [ALERT_STATUS_RECOVERED],
102+
});
103+
const byIdRecovered = Object.fromEntries(outNodesRecovered.map((n) => [n.id, n.hidden]));
104+
expect(byIdRecovered.c).toBe(false);
105+
expect(byIdRecovered.b).toBe(true);
106+
});
107+
108+
it('shows all nodes when filters are default', () => {
109+
const nodes: ServiceMapNode[] = [mkService('a'), mkService('b')];
110+
const edges: ServiceMapEdge[] = [mkEdge('e1', 'a', 'b')];
111+
const { nodes: outNodes, edges: outEdges } = applyServiceMapVisibility(
112+
nodes,
113+
edges,
114+
DEFAULT_SERVICE_MAP_VIEW_FILTERS
115+
);
116+
expect(outNodes.every((n) => !n.hidden)).toBe(true);
117+
expect(outEdges.every((e) => !e.hidden)).toBe(true);
118+
});
119+
120+
it('treats sloStatus noSLOs like noData for SLO filter', () => {
121+
const nodes: ServiceMapNode[] = [
122+
mkService('a', { sloStatus: 'noSLOs' }),
123+
mkService('b', { sloStatus: 'healthy' }),
124+
];
125+
const edges: ServiceMapEdge[] = [mkEdge('e1', 'a', 'b')];
126+
const { nodes: onlyNoData } = applyServiceMapVisibility(nodes, edges, {
127+
...DEFAULT_SERVICE_MAP_VIEW_FILTERS,
128+
sloStatusFilter: ['noData'],
129+
});
130+
const byId = Object.fromEntries(onlyNoData.map((n) => [n.id, n.hidden]));
131+
expect(byId.a).toBe(false);
132+
expect(byId.b).toBe(true);
133+
134+
const { nodes: onlyHealthy } = applyServiceMapVisibility(nodes, edges, {
135+
...DEFAULT_SERVICE_MAP_VIEW_FILTERS,
136+
sloStatusFilter: ['healthy'],
137+
});
138+
const byIdH = Object.fromEntries(onlyHealthy.map((n) => [n.id, n.hidden]));
139+
expect(byIdH.a).toBe(true);
140+
expect(byIdH.b).toBe(false);
141+
});
142+
143+
it('pulls a multi-hop dependency chain into view in one pass', () => {
144+
const nodes: ServiceMapNode[] = [
145+
mkService('svc', { alertsCount: 1 }),
146+
{
147+
id: 'd1',
148+
type: 'dependency',
149+
position: { x: 0, y: 0 },
150+
data: { id: 'd1', label: 'd1', isService: false },
151+
},
152+
{
153+
id: 'd2',
154+
type: 'dependency',
155+
position: { x: 0, y: 0 },
156+
data: { id: 'd2', label: 'd2', isService: false },
157+
},
158+
];
159+
const edges: ServiceMapEdge[] = [mkEdge('e1', 'svc', 'd1'), mkEdge('e2', 'd1', 'd2')];
160+
const { nodes: out } = applyServiceMapVisibility(
161+
nodes,
162+
edges,
163+
DEFAULT_SERVICE_MAP_VIEW_FILTERS
164+
);
165+
const hidden = Object.fromEntries(out.map((n) => [n.id, n.hidden]));
166+
expect(hidden.svc).toBe(false);
167+
expect(hidden.d1).toBe(false);
168+
expect(hidden.d2).toBe(false);
169+
});
170+
});
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import type { AlertStatus } from '@kbn/rule-data-utils';
9+
import { ALERT_STATUS_ACTIVE } from '@kbn/rule-data-utils';
10+
import type { SloStatus } from '../../../../common/service_inventory';
11+
import type { ServiceHealthStatus } from '../../../../common/service_health_status';
12+
import type {
13+
ServiceMapEdge as ServiceMapEdgeType,
14+
ServiceMapNode,
15+
ServiceNodeData,
16+
} from '../../../../common/service_map';
17+
import { isServiceNode, isServiceNodeData } from '../../../../common/service_map';
18+
19+
export interface ServiceMapViewFilters {
20+
/** Empty = show all alert statuses. If non-empty, service must have ≥1 alert in any selected status. */
21+
alertStatusFilter: AlertStatus[];
22+
sloStatusFilter: SloStatus[];
23+
anomalyStatusFilter: ServiceHealthStatus[];
24+
}
25+
26+
export const DEFAULT_SERVICE_MAP_VIEW_FILTERS: ServiceMapViewFilters = {
27+
alertStatusFilter: [],
28+
sloStatusFilter: [],
29+
anomalyStatusFilter: [],
30+
};
31+
32+
/**
33+
* SLO bucket for map filters and option counts: `undefined` or `noSLOs` (no SLO summary on the node)
34+
* is treated as **`noData`** so tallies and combo options stay aligned with `SloStatus`.
35+
*/
36+
export function getNormalizedSloStatusForMapFilters(data: ServiceNodeData): SloStatus {
37+
const raw = data.sloStatus;
38+
if (raw === undefined || raw === 'noSLOs') {
39+
return 'noData';
40+
}
41+
return raw;
42+
}
43+
44+
/** Alert count for a status on one service (used by filters and filter-option counts). */
45+
export function getServiceNodeAlertCountForStatus(
46+
data: ServiceNodeData,
47+
status: AlertStatus
48+
): number {
49+
const fromBreakdown = data.alertsByStatus?.[status];
50+
if (fromBreakdown !== undefined) {
51+
return fromBreakdown;
52+
}
53+
if (status === ALERT_STATUS_ACTIVE && data.alertsCount !== undefined) {
54+
return data.alertsCount;
55+
}
56+
return 0;
57+
}
58+
59+
function serviceMatchesFilters(data: ServiceNodeData, filters: ServiceMapViewFilters): boolean {
60+
if (filters.alertStatusFilter.length > 0) {
61+
const matchesAny = filters.alertStatusFilter.some(
62+
(status) => getServiceNodeAlertCountForStatus(data, status) > 0
63+
);
64+
if (!matchesAny) {
65+
return false;
66+
}
67+
}
68+
69+
if (filters.sloStatusFilter.length > 0) {
70+
const slo = getNormalizedSloStatusForMapFilters(data);
71+
if (!filters.sloStatusFilter.includes(slo)) {
72+
return false;
73+
}
74+
}
75+
76+
if (filters.anomalyStatusFilter.length > 0) {
77+
const health = data.serviceAnomalyStats?.healthStatus ?? 'unknown';
78+
if (!filters.anomalyStatusFilter.includes(health as ServiceHealthStatus)) {
79+
return false;
80+
}
81+
}
82+
83+
return true;
84+
}
85+
86+
/**
87+
* Applies client-side visibility for service map nodes and edges. Service nodes must match all
88+
* active filters. Dependency and grouped nodes are shown when connected to any visible node;
89+
* non-matching services are never pulled in through dependencies.
90+
*/
91+
export function applyServiceMapVisibility(
92+
nodes: ServiceMapNode[],
93+
edges: ServiceMapEdgeType[],
94+
filters: ServiceMapViewFilters
95+
): { nodes: ServiceMapNode[]; edges: ServiceMapEdgeType[] } {
96+
const visibleIds = new Set<string>();
97+
const nodeById = new Map(nodes.map((n) => [n.id, n] as const));
98+
99+
for (const node of nodes) {
100+
if (isServiceNodeData(node.data) && serviceMatchesFilters(node.data, filters)) {
101+
visibleIds.add(node.id);
102+
}
103+
}
104+
105+
const adjacency = new Map<string, string[]>();
106+
const link = (a: string, b: string) => {
107+
let na = adjacency.get(a);
108+
if (!na) {
109+
na = [];
110+
adjacency.set(a, na);
111+
}
112+
na.push(b);
113+
};
114+
for (const edge of edges) {
115+
link(edge.source, edge.target);
116+
link(edge.target, edge.source);
117+
}
118+
119+
const queue = [...visibleIds];
120+
for (let i = 0; i < queue.length; i++) {
121+
const id = queue[i]!;
122+
for (const neighborId of adjacency.get(id) ?? []) {
123+
if (visibleIds.has(neighborId)) {
124+
continue;
125+
}
126+
const neighbor = nodeById.get(neighborId);
127+
if (!neighbor || isServiceNode(neighbor)) {
128+
continue;
129+
}
130+
visibleIds.add(neighborId);
131+
queue.push(neighborId);
132+
}
133+
}
134+
135+
return {
136+
nodes: nodes.map((node) => ({
137+
...node,
138+
hidden: !visibleIds.has(node.id),
139+
})),
140+
edges: edges.map((edge) => ({
141+
...edge,
142+
hidden: !visibleIds.has(edge.source) || !visibleIds.has(edge.target),
143+
})),
144+
};
145+
}

x-pack/solutions/observability/plugins/apm/public/components/app/service_map/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export const MOCK_EUI_THEME = {
102102
export const MOCK_EUI_THEME_FOR_USE_THEME = {
103103
colors: MOCK_EUI_THEME.colors,
104104
size: {
105+
base: '16px',
105106
xxs: '2px',
106107
xs: '4px',
107108
s: '8px',

0 commit comments

Comments
 (0)