diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_collector_config/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_collector_config/index.tsx index a45f2b0c38db7..186ac039d550e 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_collector_config/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_collector_config/index.tsx @@ -22,7 +22,7 @@ export const AgentCollectorConfig: React.FunctionComponent<{ agent: Agent }> = ( return ( <> - + ); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail.test.tsx deleted file mode 100644 index d35707f3cbd0f..0000000000000 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; - -import type { OTelCollectorConfig } from '../../../../common/types'; - -import { OTelComponentDetail } from './component_detail'; - -const config: OTelCollectorConfig = { - receivers: { - otlp: { - protocols: { - grpc: { endpoint: '0.0.0.0:4317' }, - http: { endpoint: '0.0.0.0:4318' }, - }, - }, - 'hostmetrics/system': { - collection_interval: '10s', - scrapers: { cpu: {}, memory: {} }, - }, - }, - processors: { - batch: { - timeout: '1s', - send_batch_size: 1024, - }, - nop: undefined, - }, - exporters: { - 'elasticsearch/default': { - endpoints: ['https://localhost:9200'], - }, - }, - service: { - pipelines: { - 'logs/default': { - receivers: ['otlp'], - processors: ['batch'], - exporters: ['elasticsearch/default'], - }, - }, - }, -}; - -describe('OTelComponentDetail', () => { - it('renders the component title with type label and ID', () => { - render( - - ); - - expect(screen.getByText(/Receiver: otlp/)).toBeInTheDocument(); - }); - - it('renders the component configuration as YAML', () => { - render( - - ); - - expect(screen.getByText(/protocols:/)).toBeInTheDocument(); - expect(screen.getByText(/grpc:/)).toBeInTheDocument(); - expect(screen.getByText(/0.0.0.0:4317/)).toBeInTheDocument(); - }); - - it('renders "No additional configuration" when component has no config', () => { - const minimalConfig: OTelCollectorConfig = { - receivers: { noop: null }, - service: { pipelines: {} }, - }; - - render( - - ); - - expect(screen.getByText('No additional configuration')).toBeInTheDocument(); - }); - - it('calls onClose when the close button is clicked', () => { - const onClose = jest.fn(); - - render( - - ); - - fireEvent.click(screen.getByTestId('otelComponentDetailCloseButton')); - expect(onClose).toHaveBeenCalledTimes(1); - }); - - it('renders exporter configuration correctly', () => { - render( - - ); - - expect(screen.getByText(/Exporter: elasticsearch\/default/)).toBeInTheDocument(); - expect(screen.getByText(/localhost:9200/)).toBeInTheDocument(); - }); -}); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail.tsx deleted file mode 100644 index c9d9d6fd8bc55..0000000000000 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; -import { - EuiButtonIcon, - EuiCodeBlock, - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import { css } from '@emotion/react'; -import { dump } from 'js-yaml'; -import { i18n } from '@kbn/i18n'; - -import type { OTelCollectorConfig, OTelCollectorComponentID } from '../../../../common/types'; - -import type { OTelComponentType } from './graph_view/constants'; -import { COMPONENT_TYPE_LABELS } from './graph_view/constants'; - -const getComponentSection = ( - config: OTelCollectorConfig, - componentType: OTelComponentType -): Record | undefined => { - switch (componentType) { - case 'receiver': - return config.receivers; - case 'processor': - return config.processors; - case 'connector': - return config.connectors; - case 'exporter': - return config.exporters; - } -}; - -interface OTelComponentDetailProps { - componentId: string; - componentType: OTelComponentType; - config: OTelCollectorConfig; - onClose: () => void; -} - -export const OTelComponentDetail: React.FunctionComponent = ({ - componentId, - componentType, - config, - onClose, -}) => { - const section = getComponentSection(config, componentType); - const componentConfig = section?.[componentId]; - - const yamlContent = useMemo(() => { - if (componentConfig == null) { - return null; - } - return dump({ [componentId]: componentConfig }, { lineWidth: -1, quotingType: '"' }); - }, [componentId, componentConfig]); - - return ( - - - - -

- {COMPONENT_TYPE_LABELS[componentType]}: {componentId} -

-
-
- - - -
- {yamlContent ? ( - - {yamlContent} - - ) : ( - - {i18n.translate('xpack.fleet.otelUi.componentDetail.noConfiguration', { - defaultMessage: 'No additional configuration', - })} - - )} -
- ); -}; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_config_tab.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_config_tab.tsx new file mode 100644 index 0000000000000..a38e17a614f23 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_config_tab.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiCodeBlock, EuiText } from '@elastic/eui'; +import { dump } from 'js-yaml'; +import { i18n } from '@kbn/i18n'; + +interface ComponentConfigTabProps { + componentId: string; + componentConfig: unknown; +} + +export const ComponentConfigTab: React.FunctionComponent = ({ + componentId, + componentConfig, +}) => { + const yamlContent = useMemo(() => { + if (componentConfig == null) { + return null; + } + return dump({ [componentId]: componentConfig }, { lineWidth: -1, quotingType: '"' }); + }, [componentId, componentConfig]); + + if (!yamlContent) { + return ( + + {i18n.translate('xpack.fleet.otelUi.componentDetail.noConfiguration', { + defaultMessage: 'No additional configuration', + })} + + ); + } + + return ( + + {yamlContent} + + ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_detail.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_detail.test.tsx new file mode 100644 index 0000000000000..653e34d641215 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_detail.test.tsx @@ -0,0 +1,308 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent } from '@testing-library/react'; + +import type { TestRenderer } from '../../../../mock'; +import { createFleetTestRendererMock } from '../../../../mock'; + +import type { OTelCollectorConfig, ComponentHealth } from '../../../../../common/types'; + +import { OTelComponentDetail } from './component_detail'; + +const config: OTelCollectorConfig = { + receivers: { + otlp: { + protocols: { + grpc: { endpoint: '0.0.0.0:4317' }, + http: { endpoint: '0.0.0.0:4318' }, + }, + }, + 'hostmetrics/system': { + collection_interval: '10s', + scrapers: { cpu: {}, memory: {} }, + }, + }, + processors: { + batch: { + timeout: '1s', + send_batch_size: 1024, + }, + nop: undefined, + }, + exporters: { + 'elasticsearch/default': { + endpoints: ['https://localhost:9200'], + }, + }, + service: { + pipelines: { + 'logs/default': { + receivers: ['otlp'], + processors: ['batch'], + exporters: ['elasticsearch/default'], + }, + }, + }, +}; + +describe('OTelComponentDetail', () => { + let testRenderer: TestRenderer; + + beforeEach(() => { + testRenderer = createFleetTestRendererMock(); + }); + + it('renders the component title with type label and ID', () => { + const result = testRenderer.render( + + ); + + expect(result.getByText(/Receiver: otlp/)).toBeInTheDocument(); + }); + + it('renders the component configuration as YAML', () => { + const result = testRenderer.render( + + ); + + expect(result.getByText(/protocols:/)).toBeInTheDocument(); + expect(result.getByText(/grpc:/)).toBeInTheDocument(); + expect(result.getByText(/0.0.0.0:4317/)).toBeInTheDocument(); + }); + + it('renders "No additional configuration" when component has no config', () => { + const minimalConfig: OTelCollectorConfig = { + receivers: { noop: null }, + service: { pipelines: {} }, + }; + + const result = testRenderer.render( + + ); + + expect(result.getByText('No additional configuration')).toBeInTheDocument(); + }); + + it('calls onClose when the close button is clicked', () => { + const onClose = jest.fn(); + + const result = testRenderer.render( + + ); + + fireEvent.click(result.getByTestId('otelComponentDetailCloseButton')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('renders exporter configuration correctly', () => { + const result = testRenderer.render( + + ); + + expect(result.getByText(/Exporter: elasticsearch\/default/)).toBeInTheDocument(); + expect(result.getByText(/localhost:9200/)).toBeInTheDocument(); + }); + + it('renders three tabs: Config, Health, Metrics', () => { + const result = testRenderer.render( + + ); + + expect(result.getByTestId('otelComponentDetailTab-config')).toBeInTheDocument(); + expect(result.getByTestId('otelComponentDetailTab-health')).toBeInTheDocument(); + expect(result.getByTestId('otelComponentDetailTab-metrics')).toBeInTheDocument(); + }); + + it('shows Config tab content by default', () => { + const result = testRenderer.render( + + ); + + expect(result.getByTestId('otelComponentDetailTab-config')).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(result.getByText(/protocols:/)).toBeInTheDocument(); + }); + + it('switches to Health tab and shows no data message when no health prop', () => { + const result = testRenderer.render( + + ); + + fireEvent.click(result.getByTestId('otelComponentDetailTab-health')); + expect(result.getByTestId('otelComponentDetailHealthNoData')).toBeInTheDocument(); + expect(result.queryByText(/protocols:/)).not.toBeInTheDocument(); + }); + + it('shows health description list with status, reported status, and last updated', () => { + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'receiver:otlp': { + healthy: true, + status: 'StatusOK', + status_time_unix_nano: 1_714_500_000_000_000_000, + }, + }, + }; + + const result = testRenderer.render( + + ); + + fireEvent.click(result.getByTestId('otelComponentDetailTab-health')); + const healthPanel = result.getByTestId('otelComponentDetailHealth'); + expect(healthPanel).toBeInTheDocument(); + expect(healthPanel.textContent).toContain('Healthy'); + expect(healthPanel.textContent).toContain('StatusOK'); + }); + + it('shows unhealthy status badge when component is unhealthy', () => { + const health: ComponentHealth = { + healthy: false, + status: 'error', + component_health_map: { + 'processor:batch': { + healthy: false, + status: 'StatusPermanentError', + status_time_unix_nano: 1_714_500_000_000_000_000, + }, + }, + }; + + const result = testRenderer.render( + + ); + + fireEvent.click(result.getByTestId('otelComponentDetailTab-health')); + const healthPanel = result.getByTestId('otelComponentDetailHealth'); + expect(healthPanel.textContent).toContain('Unhealthy'); + expect(healthPanel.textContent).toContain('StatusPermanentError'); + }); + + it('finds component health in nested health map', () => { + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + pipeline: { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'receiver:otlp': { + healthy: true, + status: 'StatusOK', + status_time_unix_nano: 1_714_500_000_000_000_000, + }, + }, + }, + }, + }; + + const result = testRenderer.render( + + ); + + fireEvent.click(result.getByTestId('otelComponentDetailTab-health')); + const healthPanel = result.getByTestId('otelComponentDetailHealth'); + expect(healthPanel).toBeInTheDocument(); + expect(healthPanel.textContent).toContain('Healthy'); + }); + + it('renders pipeline detail with pipeline YAML', () => { + const result = testRenderer.render( + + ); + + expect(result.getByText(/Pipeline: logs\/default/)).toBeInTheDocument(); + expect(result.getByText(/receivers:/)).toBeInTheDocument(); + expect(result.getByText(/processors:/)).toBeInTheDocument(); + expect(result.getByText(/exporters:/)).toBeInTheDocument(); + }); + + it('switches to Metrics tab and shows placeholder', () => { + const result = testRenderer.render( + + ); + + fireEvent.click(result.getByTestId('otelComponentDetailTab-metrics')); + expect(result.getByTestId('otelComponentDetailMetricsPlaceholder')).toBeInTheDocument(); + expect(result.queryByText(/protocols:/)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_detail.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_detail.tsx new file mode 100644 index 0000000000000..a30f9aee21816 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_detail.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiPanel, + EuiSpacer, + EuiTab, + EuiTabs, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import type { + OTelCollectorConfig, + OTelCollectorComponentID, + ComponentHealth, +} from '../../../../../common/types'; + +import type { OTelComponentType } from '../graph_view/constants'; +import { COMPONENT_TYPE_LABELS } from '../graph_view/constants'; +import { findComponentHealth } from '../graph_view/enrich_nodes_with_health'; +import { getComponentHealthStatus, getHealthStatusLabel, HEALTH_STATUS_COLORS } from '../utils'; + +import { ComponentConfigTab } from './component_config_tab'; +import { ComponentHealthTab } from './component_health_tab'; + +const getComponentSection = ( + config: OTelCollectorConfig, + componentType: OTelComponentType +): Record | undefined => { + switch (componentType) { + case 'receiver': + return config.receivers; + case 'processor': + return config.processors; + case 'connector': + return config.connectors; + case 'exporter': + return config.exporters; + case 'pipeline': + return config.service?.pipelines as Record | undefined; + } +}; + +type ComponentDetailTabId = 'config' | 'health' | 'metrics'; + +const COMPONENT_DETAIL_TABS: Array<{ id: ComponentDetailTabId; name: string }> = [ + { + id: 'config', + name: i18n.translate('xpack.fleet.otelUi.componentDetail.tab.config', { + defaultMessage: 'Config', + }), + }, + { + id: 'health', + name: i18n.translate('xpack.fleet.otelUi.componentDetail.tab.health', { + defaultMessage: 'Health', + }), + }, + { + id: 'metrics', + name: i18n.translate('xpack.fleet.otelUi.componentDetail.tab.metrics', { + defaultMessage: 'Metrics', + }), + }, +]; + +interface OTelComponentDetailProps { + componentId: string; + componentType: OTelComponentType; + config: OTelCollectorConfig; + health?: ComponentHealth; + onClose: () => void; +} + +export const OTelComponentDetail: React.FunctionComponent = ({ + componentId, + componentType, + config, + health, + onClose, +}) => { + const [selectedTabId, setSelectedTabId] = useState('config'); + const section = getComponentSection(config, componentType); + const componentConfig = section?.[componentId]; + const componentHealth = useMemo( + () => findComponentHealth(health, componentType, componentId), + [health, componentType, componentId] + ); + + const healthStatus = getComponentHealthStatus(componentHealth); + const healhtLabel = ( + + + {getHealthStatusLabel(healthStatus)} + + + ); + + return ( + + + + + + +

+ {COMPONENT_TYPE_LABELS[componentType]}: {componentId} +

+
+
+
+
+ + + + {componentHealth && healhtLabel} + + + + + +
+ + + {COMPONENT_DETAIL_TABS.map((tab) => ( + setSelectedTabId(tab.id)} + data-test-subj={`otelComponentDetailTab-${tab.id}`} + > + {tab.name} + + ))} + + + {selectedTabId === 'config' && ( + + )} + {selectedTabId === 'health' && } + + {selectedTabId === 'metrics' && ( + + {i18n.translate('xpack.fleet.otelUi.componentDetail.metricsPlaceholder', { + defaultMessage: 'Metrics will be available here.', + })} + + )} +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_health_tab.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_health_tab.tsx new file mode 100644 index 0000000000000..b4b345aebb0aa --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/component_health_tab.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiBadge, EuiDescriptionList, EuiText, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedDate, FormattedRelative } from '@kbn/i18n-react'; + +import type { ComponentHealth } from '../../../../../common/types'; +import { getComponentHealthStatus, getHealthStatusLabel, HEALTH_STATUS_COLORS } from '../utils'; + +interface ComponentHealthTabProps { + componentHealth?: ComponentHealth; +} + +export const ComponentHealthTab: React.FunctionComponent = ({ + componentHealth, +}) => { + if (!componentHealth) { + return ( + + {i18n.translate('xpack.fleet.otelUi.componentDetail.health.noData', { + defaultMessage: 'No health data available', + })} + + ); + } + + const healthStatus = getComponentHealthStatus(componentHealth); + + return ( + + {getHealthStatusLabel(healthStatus)} + + ), + }, + { + title: i18n.translate('xpack.fleet.otelUi.componentDetail.health.reportedStatusLabel', { + defaultMessage: 'Reported status', + }), + description: componentHealth.status || '-', + }, + { + title: i18n.translate('xpack.fleet.otelUi.componentDetail.health.lastUpdatedLabel', { + defaultMessage: 'Last updated', + }), + description: componentHealth.status_time_unix_nano ? ( + + } + > + + + + + ) : ( + '-' + ), + }, + ]} + /> + ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/index.ts b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/index.ts new file mode 100644 index 0000000000000..b913dac2f6897 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/component_detail/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { OTelComponentDetail } from './component_detail'; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/component_node.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/component_node.tsx index 397bfef1cff21..6398e94894c63 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/component_node.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/component_node.tsx @@ -7,9 +7,18 @@ import React, { memo } from 'react'; import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; -import { useEuiTheme, EuiText, EuiToolTip } from '@elastic/eui'; +import { + useEuiTheme, + EuiText, + EuiToolTip, + EuiHealth, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { css } from '@emotion/react'; +import { HEALTH_STATUS_COLORS } from '../utils'; + import type { OTelGraphNodeData } from './constants'; import { COMPONENT_TYPE_VIS_COLORS, COMPONENT_TYPE_LABELS } from './constants'; @@ -63,9 +72,18 @@ export const ComponentNode = memo(
{COMPONENT_TYPE_LABELS[data.componentType]}
- - {data.label} - + + {data.healthStatus && ( + + + + )} + + + {data.label} + + + diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.test.ts b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.test.ts index b17c80deff46b..3554d477536ce 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.test.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.test.ts @@ -48,8 +48,8 @@ describe('configToGraph', () => { expect(result.nodes).toHaveLength(2); expect(result.edges).toHaveLength(1); expect(result.edges[0]).toMatchObject({ - source: 'otlp', - target: 'elasticsearch/default', + source: 'receiver::otlp', + target: 'exporter::elasticsearch/default', }); }); @@ -71,9 +71,12 @@ describe('configToGraph', () => { const result = configToGraph(config); const edgePairs = result.edges.map((e) => [e.source, e.target]); - expect(edgePairs).toContainEqual(['otlp', 'batch']); - expect(edgePairs).toContainEqual(['batch', 'transform/routing']); - expect(edgePairs).toContainEqual(['transform/routing', 'elasticsearch/default']); + expect(edgePairs).toContainEqual(['receiver::otlp', 'processor::batch']); + expect(edgePairs).toContainEqual(['processor::batch', 'processor::transform/routing']); + expect(edgePairs).toContainEqual([ + 'processor::transform/routing', + 'exporter::elasticsearch/default', + ]); expect(result.edges).toHaveLength(3); }); @@ -95,13 +98,33 @@ describe('configToGraph', () => { const result = configToGraph(config); const edgePairs = result.edges.map((e) => [e.source, e.target]); - expect(edgePairs).toContainEqual(['otlp', 'batch']); - expect(edgePairs).toContainEqual(['zipkin', 'batch']); - expect(edgePairs).toContainEqual(['batch', 'elasticsearch/default']); - expect(edgePairs).toContainEqual(['batch', 'debug/verbose']); + expect(edgePairs).toContainEqual(['receiver::otlp', 'processor::batch']); + expect(edgePairs).toContainEqual(['receiver::zipkin', 'processor::batch']); + expect(edgePairs).toContainEqual(['processor::batch', 'exporter::elasticsearch/default']); + expect(edgePairs).toContainEqual(['processor::batch', 'exporter::debug/verbose']); expect(result.edges).toHaveLength(4); }); + it('assigns correct component type when same ID exists in receivers and exporters', () => { + const config: OTelCollectorConfig = { + receivers: { otlp: {} }, + exporters: { 'elasticsearch/otel': {}, otlp: {} }, + service: { + pipelines: { + traces: { + receivers: ['otlp'], + processors: [], + exporters: ['elasticsearch/otel'], + }, + }, + }, + }; + const result = configToGraph(config, 'traces'); + const typeMap = new Map(result.nodes.map((n) => [n.id, n.data.componentType])); + expect(typeMap.get('receiver::otlp')).toBe('receiver'); + expect(typeMap.get('exporter::elasticsearch/otel')).toBe('exporter'); + }); + it('handles pipeline with empty arrays gracefully', () => { const config: OTelCollectorConfig = { service: { @@ -374,7 +397,11 @@ describe('configToGraph', () => { expect(result.isMergedView).toBe(true); const nodeIds = result.nodes.map((n) => n.id).sort(); - expect(nodeIds).toEqual(['batch', 'elasticsearch/default', 'otlp']); + expect(nodeIds).toEqual([ + 'exporter::elasticsearch/default', + 'processor::batch', + 'receiver::otlp', + ]); expect(result.edges).toHaveLength(2); }); @@ -383,7 +410,11 @@ describe('configToGraph', () => { expect(result.isMergedView).toBe(true); const nodeIds = result.nodes.map((n) => n.id).sort(); - expect(nodeIds).toEqual(['forward', 'httpcheck/stream-1', 'transform/routing']); + expect(nodeIds).toEqual([ + 'exporter::forward', + 'processor::transform/routing', + 'receiver::httpcheck/stream-1', + ]); expect(result.edges).toHaveLength(2); }); @@ -412,7 +443,11 @@ describe('configToGraph', () => { expect(result.isMergedView).toBe(true); const nodeIds = result.nodes.map((n) => n.id).sort(); - expect(nodeIds).toEqual(['batch', 'elasticsearch/default', 'otlp']); + expect(nodeIds).toEqual([ + 'exporter::elasticsearch/default', + 'processor::batch', + 'receiver::otlp', + ]); expect(result.edges).toHaveLength(2); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.ts b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.ts index 514ee9dcdeb82..081d29698d968 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/config_to_graph.ts @@ -8,12 +8,14 @@ import type { Node, Edge } from '@xyflow/react'; import type { OTelCollectorConfig } from '../../../../../common/types'; -import { ALL_PIPELINES, SIGNAL_PREFIX, getSignalType } from '../utils'; +import { ALL_PIPELINES, type ComponentHealthStatus, SIGNAL_PREFIX, getSignalType } from '../utils'; import type { OTelComponentType, OTelGraphNodeData } from './constants'; export interface OTelPipelineGroupNodeData { label: string; + isSelected?: boolean; + healthStatus?: ComponentHealthStatus; [key: string]: unknown; } @@ -37,34 +39,24 @@ const getPipelineEntries = ( return Object.entries(allPipelines).filter(([id]) => id === selectedPipelineId); }; -const buildComponentTypeMap = ( - config: OTelCollectorConfig, - referencedComponents: Set -): Map => { - const typeMap = new Map(); - const sections: Array<{ section: Record | undefined; type: OTelComponentType }> = [ - { section: config.receivers, type: 'receiver' }, - { section: config.processors, type: 'processor' }, - { section: config.connectors, type: 'connector' }, - { section: config.exporters, type: 'exporter' }, - ]; - for (const { section, type } of sections) { - if (section) { - for (const id of Object.keys(section)) { - if (referencedComponents.has(id)) { - typeMap.set(id, type); - } - } - } +type PipelineRole = 'receiver' | 'processor' | 'exporter'; + +const getComponentType = ( + componentId: string, + role: PipelineRole, + config: OTelCollectorConfig +): OTelComponentType => { + if (config.connectors && componentId in config.connectors) { + return 'connector'; } - return typeMap; + return role; }; const buildEdges = ( pipelineEntries: Array< [string, { receivers?: string[]; processors?: string[]; exporters?: string[] }] >, - nodeIdFn: (componentId: string, pipelineId: string) => string + nodeIdFn: (componentId: string, pipelineId: string, role: PipelineRole) => string ): Edge[] => { const edgeSet = new Set(); const edges: Edge[] = []; @@ -82,21 +74,30 @@ const buildEdges = ( if (processors.length > 0) { for (const receiver of receivers) { - addEdge(nodeIdFn(receiver, pipelineId), nodeIdFn(processors[0], pipelineId)); + addEdge( + nodeIdFn(receiver, pipelineId, 'receiver'), + nodeIdFn(processors[0], pipelineId, 'processor') + ); } for (let i = 0; i < processors.length - 1; i++) { - addEdge(nodeIdFn(processors[i], pipelineId), nodeIdFn(processors[i + 1], pipelineId)); + addEdge( + nodeIdFn(processors[i], pipelineId, 'processor'), + nodeIdFn(processors[i + 1], pipelineId, 'processor') + ); } for (const exporter of exporters) { addEdge( - nodeIdFn(processors[processors.length - 1], pipelineId), - nodeIdFn(exporter, pipelineId) + nodeIdFn(processors[processors.length - 1], pipelineId, 'processor'), + nodeIdFn(exporter, pipelineId, 'exporter') ); } } else { for (const receiver of receivers) { for (const exporter of exporters) { - addEdge(nodeIdFn(receiver, pipelineId), nodeIdFn(exporter, pipelineId)); + addEdge( + nodeIdFn(receiver, pipelineId, 'receiver'), + nodeIdFn(exporter, pipelineId, 'exporter') + ); } } } @@ -105,25 +106,44 @@ const buildEdges = ( return edges; }; +const getPipelineRoleEntries = (pipeline: { + receivers?: string[]; + processors?: string[]; + exporters?: string[]; +}): Array<{ ids: string[]; role: PipelineRole }> => [ + { ids: pipeline.receivers ?? [], role: 'receiver' }, + { ids: pipeline.processors ?? [], role: 'processor' }, + { ids: pipeline.exporters ?? [], role: 'exporter' }, +]; + const buildMergedGraph = ( config: OTelCollectorConfig, pipelineEntries: Array< [string, { receivers?: string[]; processors?: string[]; exporters?: string[] }] - >, - componentTypeMap: Map + > ): ConfigToGraphResult => { const nodeMap = new Map>(); - for (const [id, type] of componentTypeMap) { - nodeMap.set(id, { - id, - type: 'component', - position: { x: 0, y: 0 }, - data: { label: id, componentType: type }, - }); + for (const [, pipeline] of pipelineEntries) { + for (const { ids, role } of getPipelineRoleEntries(pipeline)) { + for (const id of ids) { + const nodeId = `${role}::${id}`; + if (!nodeMap.has(nodeId)) { + nodeMap.set(nodeId, { + id: nodeId, + type: 'component', + position: { x: 0, y: 0 }, + data: { label: id, componentType: getComponentType(id, role, config) }, + }); + } + } + } } - const edges = buildEdges(pipelineEntries, (componentId) => componentId); + const edges = buildEdges( + pipelineEntries, + (componentId, _pipelineId, role) => `${role}::${componentId}` + ); return { nodes: Array.from(nodeMap.values()), edges, isMergedView: true }; }; @@ -134,8 +154,7 @@ const buildGroupedGraph = ( config: OTelCollectorConfig, pipelineEntries: Array< [string, { receivers?: string[]; processors?: string[]; exporters?: string[] }] - >, - componentTypeMap: Map + > ): ConfigToGraphResult => { const nodes: Array | Node> = []; const nodeSet = new Set(); @@ -150,25 +169,21 @@ const buildGroupedGraph = ( data: { label: pipelineId }, }); - const componentIds = [ - ...(pipeline.receivers ?? []), - ...(pipeline.processors ?? []), - ...(pipeline.exporters ?? []), - ]; - - for (const componentId of componentIds) { - const nodeId = `${pipelineId}::${componentId}`; - if (!nodeSet.has(nodeId)) { - nodeSet.add(nodeId); - const componentType = componentTypeMap.get(componentId); - if (componentType) { + for (const { ids, role } of getPipelineRoleEntries(pipeline)) { + for (const componentId of ids) { + const nodeId = `${pipelineId}::${componentId}`; + if (!nodeSet.has(nodeId)) { + nodeSet.add(nodeId); nodes.push({ id: nodeId, type: 'component', position: { x: 0, y: 0 }, parentId: groupId, extent: 'parent' as const, - data: { label: componentId, componentType }, + data: { + label: componentId, + componentType: getComponentType(componentId, role, config), + }, }); } } @@ -177,7 +192,7 @@ const buildGroupedGraph = ( const edges = buildEdges( pipelineEntries, - (componentId, pipelineId) => `${pipelineId}::${componentId}` + (componentId, pipelineId, _role) => `${pipelineId}::${componentId}` ); const connectorIds = config.connectors ? Object.keys(config.connectors) : []; @@ -224,18 +239,9 @@ export const configToGraph = ( const pipelineEntries = getPipelineEntries(allPipelines, selectedPipelineId); - const referencedComponents = new Set(); - for (const [, pipeline] of pipelineEntries) { - for (const id of pipeline.receivers ?? []) referencedComponents.add(id); - for (const id of pipeline.processors ?? []) referencedComponents.add(id); - for (const id of pipeline.exporters ?? []) referencedComponents.add(id); - } - - const componentTypeMap = buildComponentTypeMap(config, referencedComponents); - if (pipelineEntries.length > 1) { - return buildGroupedGraph(config, pipelineEntries, componentTypeMap); + return buildGroupedGraph(config, pipelineEntries); } - return buildMergedGraph(config, pipelineEntries, componentTypeMap); + return buildMergedGraph(config, pipelineEntries); }; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/constants.ts b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/constants.ts index 437d53040d7d4..e21034a5d4353 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/constants.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/constants.ts @@ -8,6 +8,8 @@ import type { EuiThemeComputed } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import type { ComponentHealthStatus } from '../utils'; + export const NODE_WIDTH = 200; export const NODE_HEIGHT = 60; export const RANK_SEPARATION = 80; @@ -15,11 +17,12 @@ export const NODE_SEPARATION = 30; export const GRAPH_MARGIN = 20; export const GROUP_PADDING = 40; -export type OTelComponentType = 'receiver' | 'processor' | 'connector' | 'exporter'; +export type OTelComponentType = 'receiver' | 'processor' | 'connector' | 'exporter' | 'pipeline'; export interface OTelGraphNodeData { label: string; componentType: OTelComponentType; + healthStatus?: ComponentHealthStatus; [key: string]: unknown; } @@ -31,6 +34,7 @@ export const COMPONENT_TYPE_VIS_COLORS: Record< processor: 'euiColorVis8', connector: 'euiColorVis4', exporter: 'euiColorVis2', + pipeline: 'euiColorVis6', }; export const COMPONENT_TYPE_LABELS: Record = { @@ -46,4 +50,7 @@ export const COMPONENT_TYPE_LABELS: Record = { exporter: i18n.translate('xpack.fleet.otelUi.componentType.exporter', { defaultMessage: 'Exporter', }), + pipeline: i18n.translate('xpack.fleet.otelUi.componentType.pipeline', { + defaultMessage: 'Pipeline', + }), }; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/enrich_nodes_with_health.test.ts b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/enrich_nodes_with_health.test.ts new file mode 100644 index 0000000000000..133943a717036 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/enrich_nodes_with_health.test.ts @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Node } from '@xyflow/react'; + +import type { ComponentHealth } from '../../../../../common/types'; + +import type { OTelGraphNodeData } from './constants'; +import type { OTelPipelineGroupNodeData } from './config_to_graph'; +import { enrichNodesWithHealth, findComponentHealth } from './enrich_nodes_with_health'; + +const makeComponentNode = ( + id: string, + label: string, + componentType: OTelGraphNodeData['componentType'] +): Node => ({ + id, + type: 'component', + position: { x: 0, y: 0 }, + data: { label, componentType }, +}); + +const makePipelineGroupNode = (id: string, label: string): Node => ({ + id, + type: 'pipelineGroup', + position: { x: 0, y: 0 }, + data: { label }, +}); + +describe('findComponentHealth', () => { + it('returns undefined when health is undefined', () => { + expect(findComponentHealth(undefined, 'receiver', 'otlp')).toBeUndefined(); + }); + + it('returns undefined when component is not in health map', () => { + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'receiver:filelog': { healthy: true, status: 'StatusOK' }, + }, + }; + expect(findComponentHealth(health, 'receiver', 'otlp')).toBeUndefined(); + }); + + it('finds component at top level', () => { + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'receiver:otlp': { healthy: true, status: 'StatusOK' }, + }, + }; + expect(findComponentHealth(health, 'receiver', 'otlp')).toEqual({ + healthy: true, + status: 'StatusOK', + }); + }); + + it('finds component in nested health map', () => { + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'extension:health_check': { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'receiver:otlp': { healthy: false, status: 'StatusRecoverableError' }, + }, + }, + }, + }; + expect(findComponentHealth(health, 'receiver', 'otlp')).toEqual({ + healthy: false, + status: 'StatusRecoverableError', + }); + }); + + it('finds pipeline health', () => { + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'pipeline:traces': { healthy: true, status: 'StatusOK' }, + }, + }; + expect(findComponentHealth(health, 'pipeline', 'traces')).toEqual({ + healthy: true, + status: 'StatusOK', + }); + }); +}); + +describe('enrichNodesWithHealth', () => { + it('does not modify nodes when health is undefined', () => { + const nodes = [makeComponentNode('receiver::otlp', 'otlp', 'receiver')]; + enrichNodesWithHealth(nodes, undefined); + expect(nodes[0].data.healthStatus).toBeUndefined(); + }); + + it('sets healthStatus to healthy for healthy components', () => { + const nodes = [makeComponentNode('receiver::otlp', 'otlp', 'receiver')]; + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'receiver:otlp': { healthy: true, status: 'StatusOK' }, + }, + }; + enrichNodesWithHealth(nodes, health); + expect(nodes[0].data.healthStatus).toBe('healthy'); + }); + + it('sets healthStatus to unhealthy for unhealthy components', () => { + const nodes = [makeComponentNode('exporter::elasticsearch', 'elasticsearch', 'exporter')]; + const health: ComponentHealth = { + healthy: false, + status: 'StatusPermanentError', + component_health_map: { + 'exporter:elasticsearch': { healthy: false, status: 'StatusPermanentError' }, + }, + }; + enrichNodesWithHealth(nodes, health); + expect(nodes[0].data.healthStatus).toBe('unhealthy'); + }); + + it('sets healthStatus to unknown for components not in health map', () => { + const nodes = [makeComponentNode('processor::batch', 'batch', 'processor')]; + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: {}, + }; + enrichNodesWithHealth(nodes, health); + expect(nodes[0].data.healthStatus).toBe('unknown'); + }); + + it('sets healthStatus on pipelineGroup nodes', () => { + const nodes = [makePipelineGroupNode('pipeline::traces', 'traces')]; + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'pipeline:traces': { healthy: true, status: 'StatusOK' }, + }, + }; + enrichNodesWithHealth(nodes, health); + expect(nodes[0].data.healthStatus).toBe('healthy'); + }); + + it('sets unknown healthStatus on pipelineGroup nodes not in health map', () => { + const nodes = [makePipelineGroupNode('pipeline::metrics', 'metrics')]; + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: {}, + }; + enrichNodesWithHealth(nodes, health); + expect(nodes[0].data.healthStatus).toBe('unknown'); + }); + + it('enriches mixed node types correctly', () => { + const nodes: Node[] = [ + makePipelineGroupNode('pipeline::traces', 'traces'), + makeComponentNode('traces::otlp', 'otlp', 'receiver'), + makeComponentNode('traces::batch', 'batch', 'processor'), + makeComponentNode('traces::elasticsearch', 'elasticsearch', 'exporter'), + ]; + const health: ComponentHealth = { + healthy: true, + status: 'StatusOK', + component_health_map: { + 'pipeline:traces': { healthy: true, status: 'StatusOK' }, + 'receiver:otlp': { healthy: true, status: 'StatusOK' }, + 'exporter:elasticsearch': { healthy: false, status: 'StatusFatalError' }, + }, + }; + enrichNodesWithHealth(nodes, health); + expect(nodes[0].data.healthStatus).toBe('healthy'); + expect(nodes[1].data.healthStatus).toBe('healthy'); + expect(nodes[2].data.healthStatus).toBe('unknown'); + expect(nodes[3].data.healthStatus).toBe('unhealthy'); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/enrich_nodes_with_health.ts b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/enrich_nodes_with_health.ts new file mode 100644 index 0000000000000..bb976d35b420c --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/enrich_nodes_with_health.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Node } from '@xyflow/react'; + +import type { ComponentHealth } from '../../../../../common/types'; +import { getComponentHealthStatus } from '../utils'; + +import type { OTelComponentType, OTelGraphNodeData } from './constants'; +import type { OTelPipelineGroupNodeData } from './config_to_graph'; + +export const findComponentHealth = ( + health: ComponentHealth | undefined, + componentType: OTelComponentType, + componentId: string +): ComponentHealth | undefined => { + const key = `${componentType}:${componentId}`; + const map = health?.component_health_map; + if (!map) { + return undefined; + } + if (map[key]) { + return map[key]; + } + for (const entry of Object.values(map)) { + const found = findComponentHealth(entry, componentType, componentId); + if (found) { + return found; + } + } + return undefined; +}; + +export const enrichNodesWithHealth = ( + nodes: Array, + health: ComponentHealth | undefined +): void => { + if (!health) return; + for (const node of nodes) { + if (node.type === 'component') { + const { componentType, label } = node.data as OTelGraphNodeData; + const componentHealth = findComponentHealth(health, componentType, label); + node.data.healthStatus = getComponentHealthStatus(componentHealth); + } else if (node.type === 'pipelineGroup') { + const { label } = node.data as OTelPipelineGroupNodeData; + const pipelineHealth = findComponentHealth(health, 'pipeline', label); + node.data.healthStatus = getComponentHealthStatus(pipelineHealth); + } + } +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/index.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/index.tsx index 81fb077dbbabe..009808da09fac 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/index.tsx @@ -24,16 +24,22 @@ import '@xyflow/react/dist/style.css'; import { EuiFlexGroup, EuiFlexItem, EuiPanel, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import type { OTelCollectorConfig } from '../../../../../common/types'; +import type { OTelCollectorConfig, ComponentHealth } from '../../../../../common/types'; import { OTelComponentDetail } from '../component_detail'; import { configToGraph } from './config_to_graph'; +import type { OTelPipelineGroupNodeData } from './config_to_graph'; import type { OTelGraphNodeData } from './constants'; import { ComponentNode } from './component_node'; import { PipelineGroupNode } from './pipeline_group_node'; import { applyDagreLayout } from './layout'; +import { enrichNodesWithHealth } from './enrich_nodes_with_health'; + +type DetailSelection = + | { type: 'component'; node: Node } + | { type: 'pipeline'; pipelineId: string }; const nodeTypes: NodeTypes = { component: ComponentNode, @@ -43,16 +49,19 @@ const nodeTypes: NodeTypes = { interface GraphViewProps { config: OTelCollectorConfig; selectedPipelineId: string; + health?: ComponentHealth; } const GraphViewInner: React.FunctionComponent = ({ config, selectedPipelineId, + health, }) => { const { euiTheme } = useEuiTheme(); - const [selectedNode, setSelectedNode] = useState | null>(null); - const selectedNodeIdRef = useRef(null); - selectedNodeIdRef.current = selectedNode?.id ?? null; + + const [selection, setSelection] = useState(null); + const selectionRef = useRef(null); + selectionRef.current = selection; const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -64,20 +73,33 @@ const GraphViewInner: React.FunctionComponent = ({ const layoutNodes = applyDagreLayout(graph.nodes, graph.edges); setNodes(layoutNodes); setEdges(graph.edges); - setSelectedNode(null); + setSelection(null); }, [config, selectedPipelineId, setNodes, setEdges]); + useEffect(() => { + setNodes((currentNodes) => { + const updated = currentNodes.map((n) => ({ ...n, data: { ...n.data } })); + enrichNodesWithHealth(updated, health); + + return updated; + }); + }, [selectedPipelineId, health, setNodes, config]); + useEffect(() => { fitView({ padding: 0.1 }); }, [selectedPipelineId, fitView]); - const updateNodeSelection = useCallback( - (newSelectedId: string | null) => { + const updateNodesVisualState = useCallback( + (selectedComponentId: string | null, selectedPipelineLabel: string | null) => { setNodes((currentNodes) => - currentNodes.map((node) => ({ - ...node, - selected: node.id === newSelectedId, - })) + currentNodes.map((node) => + node.type === 'pipelineGroup' + ? { + ...node, + data: { ...node.data, isSelected: node.data.label === selectedPipelineLabel }, + } + : { ...node, selected: node.id === selectedComponentId } + ) ); }, [setNodes] @@ -85,20 +107,30 @@ const GraphViewInner: React.FunctionComponent = ({ const handleNodeClick: NodeMouseHandler = useCallback( (_, node) => { - if (node.type !== 'component') { - return; + if (node.type === 'component') { + const current = selectionRef.current; + const isAlreadySelected = current?.type === 'component' && current.node.id === node.id; + const newSelection = isAlreadySelected + ? null + : { type: 'component' as const, node: node as Node }; + setSelection(newSelection); + updateNodesVisualState(newSelection?.node.id ?? null, null); + } else if (node.type === 'pipelineGroup') { + const pipelineId = (node as Node).data.label; + const current = selectionRef.current; + const isAlreadySelected = current?.type === 'pipeline' && current.pipelineId === pipelineId; + const newPipelineId = isAlreadySelected ? null : pipelineId; + setSelection(newPipelineId ? { type: 'pipeline', pipelineId: newPipelineId } : null); + updateNodesVisualState(null, newPipelineId); } - const newSelectedId = selectedNodeIdRef.current === node.id ? null : node.id; - setSelectedNode(newSelectedId ? (node as Node) : null); - updateNodeSelection(newSelectedId); }, - [updateNodeSelection] + [updateNodesVisualState] ); const handleClose = useCallback(() => { - setSelectedNode(null); - updateNodeSelection(null); - }, [updateNodeSelection]); + setSelection(null); + updateNodesVisualState(null, null); + }, [updateNodesVisualState]); const defaultEdgeOptions = useMemo( () => ({ @@ -119,6 +151,7 @@ const GraphViewInner: React.FunctionComponent = ({ css={css` min-width: 0; `} + grow={4} > = ({ - {selectedNode && ( - + {selection && ( + diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/pipeline_group_node.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/pipeline_group_node.tsx index ae94f6bd9f285..f59c7f668ee17 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/pipeline_group_node.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/graph_view/pipeline_group_node.tsx @@ -7,9 +7,11 @@ import React, { memo } from 'react'; import { type NodeProps, type Node } from '@xyflow/react'; -import { useEuiTheme, EuiText } from '@elastic/eui'; +import { useEuiTheme, EuiText, EuiHealth, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { css } from '@emotion/react'; +import { HEALTH_STATUS_COLORS } from '../utils'; + import type { OTelPipelineGroupNodeData } from './config_to_graph'; type PipelineGroupNodeType = Node; @@ -20,9 +22,14 @@ export const PipelineGroupNode = memo(({ data }: NodeProps
- - {data.label} - + + {data.healthStatus && ( + + + + )} + + + {data.label} + + +
); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/index.tsx b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/index.tsx index a8982c0846676..08cb6bed5f7e8 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/index.tsx @@ -10,7 +10,7 @@ import React, { lazy, Suspense, useMemo, useState } from 'react'; import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; -import type { OTelCollectorConfig } from '../../../../common/types'; +import type { OTelCollectorConfig, ComponentHealth } from '../../../../common/types'; import { ALL_PIPELINES } from './utils'; import { PipelineSelector } from './pipeline_selector'; @@ -20,10 +20,12 @@ const GraphView = lazy(() => import('./graph_view').then((m) => ({ default: m.Gr interface CollectorConfigViewProps { config: OTelCollectorConfig; + health?: ComponentHealth; } export const CollectorConfigView: React.FunctionComponent = ({ config, + health, }) => { const [selectedPipelineId, setSelectedPipelineId] = useState(ALL_PIPELINES); @@ -65,7 +67,7 @@ export const CollectorConfigView: React.FunctionComponent }> - + diff --git a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/utils.ts b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/utils.ts index fad58571b532a..7a5dfab28fb4f 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/utils.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/otel_ui/collector_config_view/utils.ts @@ -5,7 +5,51 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; +import type { ComponentHealth } from '../../../../common/types'; + +export type ComponentHealthStatus = 'healthy' | 'unhealthy' | 'unknown'; + export const ALL_PIPELINES = '__all__'; export const SIGNAL_PREFIX = '__signal__'; export const getSignalType = (pipelineId: string): string => pipelineId.split('/')[0]; + +export const getComponentHealthStatus = ( + componentHealth: ComponentHealth | undefined +): ComponentHealthStatus => { + if (!componentHealth) return 'unknown'; + switch (componentHealth.status) { + case 'StatusOK': + case 'StatusStarting': + return 'healthy'; + case 'StatusRecoverableError': + case 'StatusPermanentError': + case 'StatusFatalError': + return 'unhealthy'; + case 'StatusNone': + default: + return 'unknown'; + } +}; + +export const getHealthStatusLabel = (healthStatus: ComponentHealthStatus) => { + if (healthStatus === 'healthy') { + return i18n.translate('xpack.fleet.otelUi.componentDetail.health.statusHealthy', { + defaultMessage: 'Healthy', + }); + } else if (healthStatus === 'unhealthy') { + return i18n.translate('xpack.fleet.otelUi.componentDetail.health.statusUnhealthy', { + defaultMessage: 'Unhealthy', + }); + } + return i18n.translate('xpack.fleet.otelUi.componentDetail.health.statusUnknown', { + defaultMessage: 'Unknown', + }); +}; + +export const HEALTH_STATUS_COLORS: Record = { + healthy: 'success', + unhealthy: 'warning', + unknown: 'subdued', +};