diff --git a/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/destination-list-item/index.tsx b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/destination-list-item/index.tsx index ac4736b17..8101829b0 100644 --- a/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/destination-list-item/index.tsx +++ b/frontend/webapp/containers/main/destinations/destination-modal/choose-destination-body/destinations-list/destination-list-item/index.tsx @@ -92,7 +92,7 @@ export const DestinationListItem: React.FC = ({ item, }; return ( - onSelect(item)}> + onSelect(item)}> destination diff --git a/frontend/webapp/containers/main/overview/multi-source-control/index.tsx b/frontend/webapp/containers/main/overview/multi-source-control/index.tsx index b67bc2b08..b173e8297 100644 --- a/frontend/webapp/containers/main/overview/multi-source-control/index.tsx +++ b/frontend/webapp/containers/main/overview/multi-source-control/index.tsx @@ -24,7 +24,7 @@ const Container = styled.div` background-color: ${({ theme }) => theme.colors.dropdown_bg}; `; -const MultiSourceControl = () => { +export const MultiSourceControl = () => { const Transition = useTransition({ container: Container, animateIn: slide.in['center'], @@ -88,5 +88,3 @@ const MultiSourceControl = () => { ); }; - -export default MultiSourceControl; diff --git a/frontend/webapp/containers/main/overview/overview-actions-menu/index.tsx b/frontend/webapp/containers/main/overview/overview-actions-menu/index.tsx index 8afd90ac4..9cf7ffb78 100644 --- a/frontend/webapp/containers/main/overview/overview-actions-menu/index.tsx +++ b/frontend/webapp/containers/main/overview/overview-actions-menu/index.tsx @@ -18,7 +18,7 @@ const PushToEnd = styled.div` margin-left: auto; `; -export function OverviewActionMenuContainer() { +export const OverviewActionsMenu = () => { return ( @@ -32,4 +32,4 @@ export function OverviewActionMenuContainer() { ); -} +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/build-action-nodes.ts b/frontend/webapp/containers/main/overview/overview-data-flow/build-action-nodes.ts new file mode 100644 index 000000000..5cffcba92 --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/build-action-nodes.ts @@ -0,0 +1,97 @@ +import { type Node } from '@xyflow/react'; +import nodeConfig from './node-config.json'; +import { type EntityCounts } from './get-entity-counts'; +import { type NodePositions } from './get-node-positions'; +import { getActionIcon, getEntityIcon, getEntityLabel } from '@/utils'; +import { OVERVIEW_ENTITY_TYPES, OVERVIEW_NODE_TYPES, STATUSES, type ComputePlatformMapped } from '@/types'; + +interface Params { + entities: ComputePlatformMapped['computePlatform']['actions']; + positions: NodePositions; + unfilteredCounts: EntityCounts; +} + +const { nodeWidth, nodeHeight, framePadding } = nodeConfig; + +const mapToNodeData = (entity: Params['entities'][0]) => { + return { + nodeWidth, + id: entity.id, + type: OVERVIEW_ENTITY_TYPES.ACTION, + status: STATUSES.HEALTHY, + title: getEntityLabel(entity, OVERVIEW_ENTITY_TYPES.ACTION, { prioritizeDisplayName: true }), + subTitle: entity.type, + imageUri: getActionIcon(entity.type), + monitors: entity.spec.signals, + isActive: !entity.spec.disabled, + raw: entity, + }; +}; + +export const buildActionNodes = ({ entities, positions, unfilteredCounts }: Params) => { + const nodes: Node[] = []; + const position = positions[OVERVIEW_ENTITY_TYPES.ACTION]; + const unfilteredCount = unfilteredCounts[OVERVIEW_ENTITY_TYPES.ACTION]; + + nodes.push({ + id: 'action-header', + type: 'header', + position: { + x: positions[OVERVIEW_ENTITY_TYPES.ACTION]['x'], + y: 0, + }, + data: { + nodeWidth, + title: 'Actions', + icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.ACTION), + tagValue: unfilteredCounts[OVERVIEW_ENTITY_TYPES.ACTION], + }, + }); + + if (!entities.length) { + nodes.push({ + id: 'action-add', + type: 'add', + position: { + x: position['x'], + y: position['y'](), + }, + data: { + nodeWidth, + type: OVERVIEW_NODE_TYPES.ADD_ACTION, + status: STATUSES.HEALTHY, + title: 'ADD ACTION', + subTitle: `Add ${!!unfilteredCount ? 'a new' : 'first'} action to modify the OpenTelemetry data`, + }, + }); + } else { + nodes.push({ + id: 'action-frame', + type: 'frame', + position: { + x: position['x'] - framePadding, + y: position['y']() - framePadding, + }, + data: { + nodeWidth: nodeWidth + 2 * framePadding, + nodeHeight: nodeHeight * entities.length + framePadding, + }, + }); + + entities.forEach((action, idx) => { + nodes.push({ + id: `action-${idx}`, + type: 'base', + extent: 'parent', + parentId: 'action-frame', + position: { + x: framePadding, + y: position['y'](idx) - (nodeHeight - framePadding), + }, + data: mapToNodeData(action), + }); + }); + } + + return nodes; +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/build-destination-nodes.ts b/frontend/webapp/containers/main/overview/overview-data-flow/build-destination-nodes.ts new file mode 100644 index 000000000..357c5bd2a --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/build-destination-nodes.ts @@ -0,0 +1,81 @@ +import { type Node } from '@xyflow/react'; +import nodeConfig from './node-config.json'; +import { type EntityCounts } from './get-entity-counts'; +import { type NodePositions } from './get-node-positions'; +import { extractMonitors, getEntityIcon, getEntityLabel, getHealthStatus } from '@/utils'; +import { OVERVIEW_ENTITY_TYPES, OVERVIEW_NODE_TYPES, STATUSES, type ComputePlatformMapped } from '@/types'; + +interface Params { + entities: ComputePlatformMapped['computePlatform']['destinations']; + positions: NodePositions; + unfilteredCounts: EntityCounts; +} + +const { nodeWidth } = nodeConfig; + +const mapToNodeData = (entity: Params['entities'][0]) => { + return { + nodeWidth, + id: entity.id, + type: OVERVIEW_ENTITY_TYPES.DESTINATION, + status: getHealthStatus(entity), + title: getEntityLabel(entity, OVERVIEW_ENTITY_TYPES.DESTINATION, { prioritizeDisplayName: true }), + subTitle: entity.destinationType.displayName, + imageUri: entity.destinationType.imageUrl || '/brand/odigos-icon.svg', + monitors: extractMonitors(entity.exportedSignals), + raw: entity, + }; +}; + +export const buildDestinationNodes = ({ entities, positions, unfilteredCounts }: Params) => { + const nodes: Node[] = []; + const position = positions[OVERVIEW_ENTITY_TYPES.DESTINATION]; + const unfilteredCount = unfilteredCounts[OVERVIEW_ENTITY_TYPES.DESTINATION]; + + nodes.push({ + id: 'destination-header', + type: 'header', + position: { + x: positions[OVERVIEW_ENTITY_TYPES.DESTINATION]['x'], + y: 0, + }, + data: { + nodeWidth, + title: 'Destinations', + icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.DESTINATION), + tagValue: unfilteredCounts[OVERVIEW_ENTITY_TYPES.DESTINATION], + }, + }); + + if (!entities.length) { + nodes.push({ + id: 'destination-add', + type: 'add', + position: { + x: position['x'], + y: position['y'](), + }, + data: { + nodeWidth, + type: OVERVIEW_NODE_TYPES.ADD_DESTIONATION, + status: STATUSES.HEALTHY, + title: 'ADD DESTIONATION', + subTitle: `Add ${!!unfilteredCount ? 'a new' : 'first'} destination to monitor the OpenTelemetry data`, + }, + }); + } else { + entities.forEach((destination, idx) => { + nodes.push({ + id: `destination-${idx}`, + type: 'base', + position: { + x: position['x'], + y: position['y'](idx), + }, + data: mapToNodeData(destination), + }); + }); + } + + return nodes; +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/build-edges.ts b/frontend/webapp/containers/main/overview/overview-data-flow/build-edges.ts new file mode 100644 index 000000000..5b7d893ec --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/build-edges.ts @@ -0,0 +1,80 @@ +import theme from '@/styles/theme'; +import { formatBytes } from '@/utils'; +import { type Edge, type Node } from '@xyflow/react'; +import { OVERVIEW_ENTITY_TYPES, STATUSES, WorkloadId, type OverviewMetricsResponse } from '@/types'; +import nodeConfig from './node-config.json'; + +interface Params { + nodes: Node[]; + metrics?: OverviewMetricsResponse; + containerHeight: number; +} + +const { nodeHeight, framePadding } = nodeConfig; + +const createEdge = (edgeId: string, params?: { label?: string; isMultiTarget?: boolean; isError?: boolean; animated?: boolean }): Edge => { + const { label, isMultiTarget, isError, animated } = params || {}; + const [sourceNodeId, targetNodeId] = edgeId.split('-to-'); + + return { + id: edgeId, + type: !!label ? 'labeled' : 'default', + source: sourceNodeId, + target: targetNodeId, + animated, + data: { label, isMultiTarget, isError }, + style: { stroke: isError ? theme.colors.dark_red : theme.colors.border }, + }; +}; + +export const buildEdges = ({ nodes, metrics, containerHeight }: Params) => { + const edges: Edge[] = []; + const actionNodeId = nodes.find(({ id: nodeId }) => ['action-frame', 'action-add'].includes(nodeId))?.id; + + nodes.forEach(({ type: nodeType, id: nodeId, data: { type: entityType, id: entityId, status }, position }) => { + if (nodeType === 'base') { + switch (entityType) { + case OVERVIEW_ENTITY_TYPES.SOURCE: { + const { namespace, name, kind } = entityId as WorkloadId; + const metric = metrics?.getOverviewMetrics.sources.find((m) => m.kind === kind && m.name === name && m.namespace === namespace); + + const topLimit = -nodeHeight / 2 + framePadding; + const bottomLimit = containerHeight - nodeHeight + framePadding * 2 + topLimit; + + if (position.y >= topLimit && position.y <= bottomLimit) { + edges.push( + createEdge(`${nodeId}-to-${actionNodeId}`, { + animated: false, + isMultiTarget: false, + label: formatBytes(metric?.throughput), + isError: status === STATUSES.UNHEALTHY, + }), + ); + } + + break; + } + + case OVERVIEW_ENTITY_TYPES.DESTINATION: { + const metric = metrics?.getOverviewMetrics.destinations.find((m) => m.id === entityId); + + edges.push( + createEdge(`${actionNodeId}-to-${nodeId}`, { + animated: false, + isMultiTarget: true, + label: formatBytes(metric?.throughput), + isError: status === STATUSES.UNHEALTHY, + }), + ); + + break; + } + + default: + break; + } + } + }); + + return edges; +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/build-rule-nodes.ts b/frontend/webapp/containers/main/overview/overview-data-flow/build-rule-nodes.ts new file mode 100644 index 000000000..08bbda592 --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/build-rule-nodes.ts @@ -0,0 +1,81 @@ +import { type Node } from '@xyflow/react'; +import nodeConfig from './node-config.json'; +import { type EntityCounts } from './get-entity-counts'; +import { type NodePositions } from './get-node-positions'; +import { getEntityIcon, getEntityLabel, getRuleIcon } from '@/utils'; +import { OVERVIEW_ENTITY_TYPES, OVERVIEW_NODE_TYPES, STATUSES, type ComputePlatformMapped } from '@/types'; + +interface Params { + entities: ComputePlatformMapped['computePlatform']['instrumentationRules']; + positions: NodePositions; + unfilteredCounts: EntityCounts; +} + +const { nodeWidth } = nodeConfig; + +const mapToNodeData = (entity: Params['entities'][0]) => { + return { + nodeWidth, + id: entity.ruleId, + type: OVERVIEW_ENTITY_TYPES.RULE, + status: STATUSES.HEALTHY, + title: getEntityLabel(entity, OVERVIEW_ENTITY_TYPES.RULE, { prioritizeDisplayName: true }), + subTitle: entity.type, + imageUri: getRuleIcon(entity.type), + isActive: !entity.disabled, + raw: entity, + }; +}; + +export const buildRuleNodes = ({ entities, positions, unfilteredCounts }: Params) => { + const nodes: Node[] = []; + const position = positions[OVERVIEW_ENTITY_TYPES.RULE]; + const unfilteredCount = unfilteredCounts[OVERVIEW_ENTITY_TYPES.RULE]; + + nodes.push({ + id: 'rule-header', + type: 'header', + position: { + x: positions[OVERVIEW_ENTITY_TYPES.RULE]['x'], + y: 0, + }, + data: { + nodeWidth, + title: 'Instrumentation Rules', + icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.RULE), + tagValue: unfilteredCounts[OVERVIEW_ENTITY_TYPES.RULE], + }, + }); + + if (!entities.length) { + nodes.push({ + id: 'rule-add', + type: 'add', + position: { + x: position['x'], + y: position['y'](), + }, + data: { + nodeWidth, + type: OVERVIEW_NODE_TYPES.ADD_RULE, + status: STATUSES.HEALTHY, + title: 'ADD RULE', + subTitle: `Add ${!!unfilteredCount ? 'a new' : 'first'} rule to modify the OpenTelemetry data`, + }, + }); + } else { + entities.forEach((rule, idx) => { + nodes.push({ + id: `rule-${idx}`, + type: 'base', + position: { + x: position['x'], + y: position['y'](idx), + }, + data: mapToNodeData(rule), + }); + }); + } + + return nodes; +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/build-source-nodes.ts b/frontend/webapp/containers/main/overview/overview-data-flow/build-source-nodes.ts new file mode 100644 index 000000000..70166f1f6 --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/build-source-nodes.ts @@ -0,0 +1,115 @@ +import { type Node } from '@xyflow/react'; +import nodeConfig from './node-config.json'; +import { type EntityCounts } from './get-entity-counts'; +import { type NodePositions } from './get-node-positions'; +import { getMainContainerLanguage } from '@/utils/constants/programming-languages'; +import { getEntityIcon, getEntityLabel, getHealthStatus, getProgrammingLanguageIcon } from '@/utils'; +import { OVERVIEW_ENTITY_TYPES, OVERVIEW_NODE_TYPES, STATUSES, type ComputePlatformMapped } from '@/types'; + +interface Params { + entities: ComputePlatformMapped['computePlatform']['k8sActualSources']; + positions: NodePositions; + unfilteredCounts: EntityCounts; + + containerHeight: number; + onScroll: (params: { clientHeight: number; scrollHeight: number; scrollTop: number }) => void; +} + +const { nodeWidth, nodeHeight, framePadding } = nodeConfig; + +const mapToNodeData = (entity: Params['entities'][0]) => { + return { + nodeWidth, + id: { + namespace: entity.namespace, + name: entity.name, + kind: entity.kind, + }, + type: OVERVIEW_ENTITY_TYPES.SOURCE, + status: getHealthStatus(entity), + title: getEntityLabel(entity, OVERVIEW_ENTITY_TYPES.SOURCE, { extended: true }), + subTitle: entity.kind, + imageUri: getProgrammingLanguageIcon(getMainContainerLanguage(entity)), + raw: entity, + }; +}; + +export const buildSourceNodes = ({ entities, positions, unfilteredCounts, containerHeight, onScroll }: Params) => { + const nodes: Node[] = []; + const position = positions[OVERVIEW_ENTITY_TYPES.SOURCE]; + const unfilteredCount = unfilteredCounts[OVERVIEW_ENTITY_TYPES.SOURCE]; + + nodes.push({ + id: 'source-header', + type: 'header', + position: { + x: positions[OVERVIEW_ENTITY_TYPES.SOURCE]['x'], + y: 0, + }, + data: { + nodeWidth, + title: 'Sources', + icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.SOURCE), + tagValue: unfilteredCounts[OVERVIEW_ENTITY_TYPES.SOURCE], + }, + }); + + if (!entities.length) { + nodes.push({ + id: 'source-add', + type: 'add', + position: { + x: position['x'], + y: position['y'](), + }, + data: { + nodeWidth, + type: OVERVIEW_NODE_TYPES.ADD_SOURCE, + status: STATUSES.HEALTHY, + title: 'ADD SOURCE', + subTitle: `Add ${!!unfilteredCount ? 'a new' : 'first'} source to collect OpenTelemetry data`, + }, + }); + } else { + nodes.push({ + id: 'source-scroll', + type: 'scroll', + position: { + x: position['x'], + y: position['y']() - framePadding, + }, + data: { + nodeWidth, + nodeHeight: containerHeight - nodeHeight + framePadding * 2, + items: entities.map((source, idx) => ({ + id: `source-${idx}`, + data: { + framePadding, + ...mapToNodeData(source), + }, + })), + onScroll, + }, + }); + + entities.forEach((source, idx) => { + nodes.push({ + id: `source-${idx}-hidden`, + type: 'base', + extent: 'parent', + parentId: 'source-scroll', + position: { + x: framePadding, + y: position['y'](idx) - (nodeHeight - framePadding), + }, + data: mapToNodeData(source), + style: { + opacity: 0, + zIndex: -1, + }, + }); + }); + } + + return nodes; +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/get-entity-counts.ts b/frontend/webapp/containers/main/overview/overview-data-flow/get-entity-counts.ts new file mode 100644 index 000000000..bee138503 --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/get-entity-counts.ts @@ -0,0 +1,18 @@ +import { type ComputePlatformMapped, OVERVIEW_ENTITY_TYPES } from '@/types'; + +interface Params { + computePlatform?: ComputePlatformMapped['computePlatform']; +} + +export type EntityCounts = Record; + +export const getEntityCounts = ({ computePlatform }: Params) => { + const unfilteredCounts: EntityCounts = { + [OVERVIEW_ENTITY_TYPES.RULE]: computePlatform?.instrumentationRules.length || 0, + [OVERVIEW_ENTITY_TYPES.SOURCE]: computePlatform?.k8sActualSources.length || 0, + [OVERVIEW_ENTITY_TYPES.ACTION]: computePlatform?.actions.length || 0, + [OVERVIEW_ENTITY_TYPES.DESTINATION]: computePlatform?.destinations.length || 0, + }; + + return unfilteredCounts; +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/get-node-positions.ts b/frontend/webapp/containers/main/overview/overview-data-flow/get-node-positions.ts new file mode 100644 index 000000000..adc7fdca8 --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/get-node-positions.ts @@ -0,0 +1,48 @@ +import { getValueForRange } from '@/utils'; +import { OVERVIEW_ENTITY_TYPES } from '@/types'; +import { nodeWidth, nodeHeight } from './node-config.json'; + +interface Params { + containerWidth: number; +} + +export type NodePositions = Record< + OVERVIEW_ENTITY_TYPES, + { + x: number; + y: (idx?: number) => number; + } +>; + +export const getNodePositions = ({ containerWidth }: Params) => { + const startX = 24; + const endX = (containerWidth <= 1500 ? 1500 : containerWidth) - nodeWidth - startX; + const getY = (idx?: number) => nodeHeight * ((idx || 0) + 1); + + const positions: NodePositions = { + [OVERVIEW_ENTITY_TYPES.RULE]: { + x: startX, + y: getY, + }, + [OVERVIEW_ENTITY_TYPES.SOURCE]: { + x: getValueForRange(containerWidth, [ + [0, 1600, endX / 3.5], + [1600, null, endX / 4], + ]), + y: getY, + }, + [OVERVIEW_ENTITY_TYPES.ACTION]: { + x: getValueForRange(containerWidth, [ + [0, 1600, endX / 1.55], + [1600, null, endX / 1.6], + ]), + y: getY, + }, + [OVERVIEW_ENTITY_TYPES.DESTINATION]: { + x: endX, + y: getY, + }, + }; + + return positions; +}; diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx b/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx index 60151769f..be27b5f30 100644 --- a/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx +++ b/frontend/webapp/containers/main/overview/overview-data-flow/index.tsx @@ -1,50 +1,107 @@ 'use client'; -import React, { useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; -import MultiSourceControl from '../multi-source-control'; -import { OverviewActionMenuContainer } from '../overview-actions-menu'; -import { buildNodesAndEdges, NodeBaseDataFlow } from '@/reuseable-components'; +import { OVERVIEW_ENTITY_TYPES } from '@/types'; +import { NodeDataFlow } from '@/reuseable-components'; +import { MultiSourceControl } from '../multi-source-control'; +import { OverviewActionsMenu } from '../overview-actions-menu'; +import { type Edge, useEdgesState, useNodesState, type Node, applyNodeChanges } from '@xyflow/react'; import { useComputePlatform, useContainerSize, useMetrics, useNodeDataFlowHandlers } from '@/hooks'; -const OverviewDataFlowWrapper = styled.div` +import { buildEdges } from './build-edges'; +import { getEntityCounts } from './get-entity-counts'; +import { getNodePositions } from './get-node-positions'; +import { buildRuleNodes } from './build-rule-nodes'; +import { buildActionNodes } from './build-action-nodes'; +import { buildDestinationNodes } from './build-destination-nodes'; +import { buildSourceNodes } from './build-source-nodes'; +import nodeConfig from './node-config.json'; + +export * from './get-entity-counts'; +export * from './get-node-positions'; +export { nodeConfig }; + +const Container = styled.div` width: 100%; height: calc(100vh - 176px); position: relative; `; -const NODE_WIDTH = 255; -const NODE_HEIGHT = 80; - export default function OverviewDataFlowContainer() { - const { containerRef, containerWidth, containerHeight } = useContainerSize(); - const { data, filteredData, startPolling } = useComputePlatform(); + const [scrollYOffset, setScrollYOffset] = useState(0); + const { handleNodeClick } = useNodeDataFlowHandlers(); + const { containerRef, containerWidth, containerHeight } = useContainerSize(); + const positions = useMemo(() => getNodePositions({ containerWidth }), [containerWidth]); + const { metrics } = useMetrics(); + const { data, filteredData, startPolling } = useComputePlatform(); + const unfilteredCounts = useMemo(() => getEntityCounts({ computePlatform: data?.computePlatform }), [data]); useEffect(() => { // this is to start polling on component mount in an attempt to fix any initial errors with sources/destinations if (!!data?.computePlatform.k8sActualSources.length || !!data?.computePlatform.destinations.length) startPolling(); - // only on-mount, if we include "data" this might trigger on every refetch + // only on-mount, if we include "data" this will trigger on every refetch, causing an infinite loop + }, []); + + const ruleNodes = useMemo( + () => buildRuleNodes({ entities: filteredData?.computePlatform.instrumentationRules || [], positions, unfilteredCounts }), + [filteredData?.computePlatform.instrumentationRules, positions, unfilteredCounts], + ); + const actionNodes = useMemo( + () => buildActionNodes({ entities: filteredData?.computePlatform.actions || [], positions, unfilteredCounts }), + [filteredData?.computePlatform.actions, positions, unfilteredCounts], + ); + const destinationNodes = useMemo( + () => buildDestinationNodes({ entities: filteredData?.computePlatform.destinations || [], positions, unfilteredCounts }), + [filteredData?.computePlatform.destinations, positions, unfilteredCounts], + ); + const sourceNodes = useMemo( + () => + buildSourceNodes({ + entities: filteredData?.computePlatform.k8sActualSources || [], + positions, + unfilteredCounts, + containerHeight, + onScroll: ({ clientHeight, scrollHeight, scrollTop }) => { + console.log('Node scrolled', { clientHeight, scrollHeight, scrollTop }); + setScrollYOffset(scrollTop); + }, + }), + [filteredData?.computePlatform.k8sActualSources, positions, unfilteredCounts, containerHeight], + ); + + const [nodes, setNodes, onNodesChange] = useNodesState(([] as Node[]).concat(actionNodes, ruleNodes, sourceNodes, destinationNodes)); + const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); + + const handleNodeState = useCallback((prevNodes: Node[], currNodes: Node[], key: OVERVIEW_ENTITY_TYPES, yOffset?: number) => { + const filtered = [...prevNodes].filter(({ id }) => id.split('-')[0] !== key); + + if (!!yOffset) { + const changed = applyNodeChanges( + currNodes.filter((node) => node.extent === 'parent').map((node) => ({ id: node.id, type: 'position', position: { ...node.position, y: node.position.y - yOffset } })), + prevNodes, + ); + + return changed; + } else { + filtered.push(...currNodes); + } + + return filtered; }, []); - // Memoized node and edge builder to improve performance - const { nodes, edges } = useMemo(() => { - return buildNodesAndEdges({ - computePlatform: data?.computePlatform, - computePlatformFiltered: filteredData?.computePlatform, - metrics, - containerWidth, - containerHeight, - nodeWidth: NODE_WIDTH, - nodeHeight: NODE_HEIGHT, - }); - }, [data, filteredData, metrics, containerWidth, containerHeight]); + useEffect(() => setNodes((prev) => handleNodeState(prev, ruleNodes, OVERVIEW_ENTITY_TYPES.RULE)), [ruleNodes]); + useEffect(() => setNodes((prev) => handleNodeState(prev, actionNodes, OVERVIEW_ENTITY_TYPES.ACTION)), [actionNodes]); + useEffect(() => setNodes((prev) => handleNodeState(prev, destinationNodes, OVERVIEW_ENTITY_TYPES.DESTINATION)), [destinationNodes]); + useEffect(() => setNodes((prev) => handleNodeState(prev, sourceNodes, OVERVIEW_ENTITY_TYPES.SOURCE, scrollYOffset)), [sourceNodes, scrollYOffset]); + useEffect(() => setEdges(buildEdges({ nodes, metrics, containerHeight })), [nodes, metrics, containerHeight]); return ( - - + + - - + + ); } diff --git a/frontend/webapp/containers/main/overview/overview-data-flow/node-config.json b/frontend/webapp/containers/main/overview/overview-data-flow/node-config.json new file mode 100644 index 000000000..c4c070097 --- /dev/null +++ b/frontend/webapp/containers/main/overview/overview-data-flow/node-config.json @@ -0,0 +1,5 @@ +{ + "nodeWidth": 295, + "nodeHeight": 80, + "framePadding": 12 +} \ No newline at end of file diff --git a/frontend/webapp/cypress/constants/index.ts b/frontend/webapp/cypress/constants/index.ts index 3ee26dbb6..8afbf4d6e 100644 --- a/frontend/webapp/cypress/constants/index.ts +++ b/frontend/webapp/cypress/constants/index.ts @@ -28,14 +28,15 @@ export const SELECTED_ENTITIES = { NAMESPACE: NAMESPACES.DEFAULT, SOURCE: 'frontend', DESTINATION: 'Jaeger', + DESTINATION_AUTOFILL_FIELD: 'JAEGER_URL', ACTION: 'PiiMasking', INSTRUMENTATION_RULE: 'PayloadCollection', }; export const DATA_IDS = { - SELECT_NAMESPACE: '[data-id=namespace-default]', - SELECT_DESTINATION: '[data-id=destination-jaeger]', - SELECT_DESTINATION_AUTOFILL_FIELD: '[data-id=JAEGER_URL]', + SELECT_NAMESPACE: `[data-id=namespace-${SELECTED_ENTITIES.NAMESPACE}]`, + SELECT_DESTINATION: `[data-id=destination-${SELECTED_ENTITIES.DESTINATION}]`, + SELECT_DESTINATION_AUTOFILL_FIELD: `[data-id=${SELECTED_ENTITIES.DESTINATION_AUTOFILL_FIELD}]`, ADD_ENTITY: '[data-id=add-entity]', ADD_SOURCE: '[data-id=add-source]', diff --git a/frontend/webapp/cypress/e2e/03-sources.cy.ts b/frontend/webapp/cypress/e2e/03-sources.cy.ts index abc316cd7..59f69e44b 100644 --- a/frontend/webapp/cypress/e2e/03-sources.cy.ts +++ b/frontend/webapp/cypress/e2e/03-sources.cy.ts @@ -34,36 +34,40 @@ describe('Sources CRUD', () => { it('Should update the CRD in the cluster', () => { cy.visit(ROUTES.OVERVIEW); - updateEntity( - { - nodeId: DATA_IDS.SOURCE_NODE, - nodeContains: SELECTED_ENTITIES.SOURCE, - fieldKey: DATA_IDS.SOURCE_TITLE, - fieldValue: TEXTS.UPDATED_NAME, - }, - () => { - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 5 }, (crdIds) => { - const crdId = CRD_IDS.SOURCE; - expect(crdIds).includes(crdId); - getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'serviceName', expectedValue: TEXTS.UPDATED_NAME }); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 5 }, () => { + updateEntity( + { + nodeId: DATA_IDS.SOURCE_NODE, + nodeContains: SELECTED_ENTITIES.SOURCE, + fieldKey: DATA_IDS.SOURCE_TITLE, + fieldValue: TEXTS.UPDATED_NAME, + }, + () => { + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 5 }, (crdIds) => { + const crdId = CRD_IDS.SOURCE; + expect(crdIds).includes(crdId); + getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'serviceName', expectedValue: TEXTS.UPDATED_NAME }); + }); }); - }); - }, - ); + }, + ); + }); }); it('Should delete the CRD from the cluster', () => { cy.visit(ROUTES.OVERVIEW); - cy.get(DATA_IDS.SOURCE_NODE_HEADER).find(DATA_IDS.CHECKBOX).click(); - cy.get(DATA_IDS.MULTI_SOURCE_CONTROL).should('exist').find('button').contains(BUTTONS.UNINSTRUMENT).click(); - cy.get(DATA_IDS.MODAL).contains(TEXTS.SOURCE_WARN_MODAL_TITLE).should('exist'); - cy.get(DATA_IDS.MODAL).contains(TEXTS.SOURCE_WARN_MODAL_NOTE).should('exist'); - cy.get(DATA_IDS.APPROVE).click(); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 5 }, () => { + cy.get(DATA_IDS.SOURCE_NODE_HEADER).find(DATA_IDS.CHECKBOX).click(); + cy.get(DATA_IDS.MULTI_SOURCE_CONTROL).should('exist').find('button').contains(BUTTONS.UNINSTRUMENT).click(); + cy.get(DATA_IDS.MODAL).contains(TEXTS.SOURCE_WARN_MODAL_TITLE).should('exist'); + cy.get(DATA_IDS.MODAL).contains(TEXTS.SOURCE_WARN_MODAL_NOTE).should('exist'); + cy.get(DATA_IDS.APPROVE).click(); - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); + }); }); }); }); diff --git a/frontend/webapp/cypress/e2e/04-destinations.cy.ts b/frontend/webapp/cypress/e2e/04-destinations.cy.ts index a3f1e427a..bc4ec28f3 100644 --- a/frontend/webapp/cypress/e2e/04-destinations.cy.ts +++ b/frontend/webapp/cypress/e2e/04-destinations.cy.ts @@ -31,39 +31,44 @@ describe('Destinations CRUD', () => { it('Should update the CRD in the cluster', () => { cy.visit(ROUTES.OVERVIEW); - updateEntity( - { - nodeId: DATA_IDS.DESTINATION_NODE, - nodeContains: SELECTED_ENTITIES.DESTINATION, - fieldKey: DATA_IDS.TITLE, - fieldValue: TEXTS.UPDATED_NAME, - }, - () => { - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, (crdIds) => { - const crdId = crdIds[0]; - getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'destinationName', expectedValue: TEXTS.UPDATED_NAME }); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, () => { + updateEntity( + { + nodeId: DATA_IDS.DESTINATION_NODE, + nodeContains: SELECTED_ENTITIES.DESTINATION, + fieldKey: DATA_IDS.TITLE, + fieldValue: TEXTS.UPDATED_NAME, + }, + () => { + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, (crdIds) => { + const crdId = crdIds[0]; + getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'destinationName', expectedValue: TEXTS.UPDATED_NAME }); + }); }); - }); - }, - ); + }, + ); + }); }); it('Should delete the CRD from the cluster', () => { cy.visit(ROUTES.OVERVIEW); - deleteEntity( - { - nodeId: DATA_IDS.DESTINATION_NODE, - nodeContains: SELECTED_ENTITIES.DESTINATION, - warnModalTitle: TEXTS.DESTINATION_WARN_MODAL_TITLE, - warnModalNote: TEXTS.DESTINATION_WARN_MODAL_NOTE, - }, - () => { - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); - }); - }, - ); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, () => { + deleteEntity( + { + nodeId: DATA_IDS.DESTINATION_NODE, + nodeContains: SELECTED_ENTITIES.DESTINATION, + warnModalTitle: TEXTS.DESTINATION_WARN_MODAL_TITLE, + warnModalNote: TEXTS.DESTINATION_WARN_MODAL_NOTE, + }, + () => { + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); + }); + }, + ); + }); }); }); +// destination-odigos.io.dest.jaeger-chc52 diff --git a/frontend/webapp/cypress/e2e/05-actions.cy.ts b/frontend/webapp/cypress/e2e/05-actions.cy.ts index ff892921b..2fdf1ad85 100644 --- a/frontend/webapp/cypress/e2e/05-actions.cy.ts +++ b/frontend/webapp/cypress/e2e/05-actions.cy.ts @@ -31,38 +31,42 @@ describe('Actions CRUD', () => { it('Should update the CRD in the cluster', () => { cy.visit(ROUTES.OVERVIEW); - updateEntity( - { - nodeId: DATA_IDS.ACTION_NODE, - nodeContains: SELECTED_ENTITIES.ACTION, - fieldKey: DATA_IDS.TITLE, - fieldValue: TEXTS.UPDATED_NAME, - }, - () => { - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, (crdIds) => { - const crdId = crdIds[0]; - getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'actionName', expectedValue: TEXTS.UPDATED_NAME }); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, () => { + updateEntity( + { + nodeId: DATA_IDS.ACTION_NODE, + nodeContains: SELECTED_ENTITIES.ACTION, + fieldKey: DATA_IDS.TITLE, + fieldValue: TEXTS.UPDATED_NAME, + }, + () => { + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, (crdIds) => { + const crdId = crdIds[0]; + getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'actionName', expectedValue: TEXTS.UPDATED_NAME }); + }); }); - }); - }, - ); + }, + ); + }); }); it('Should delete the CRD from the cluster', () => { cy.visit(ROUTES.OVERVIEW); - deleteEntity( - { - nodeId: DATA_IDS.ACTION_NODE, - nodeContains: SELECTED_ENTITIES.ACTION, - warnModalTitle: TEXTS.ACTION_WARN_MODAL_TITLE, - }, - () => { - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); - }); - }, - ); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, () => { + deleteEntity( + { + nodeId: DATA_IDS.ACTION_NODE, + nodeContains: SELECTED_ENTITIES.ACTION, + warnModalTitle: TEXTS.ACTION_WARN_MODAL_TITLE, + }, + () => { + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); + }); + }, + ); + }); }); }); diff --git a/frontend/webapp/cypress/e2e/06-rules.cy.ts b/frontend/webapp/cypress/e2e/06-rules.cy.ts index 566d5e153..ad4ece129 100644 --- a/frontend/webapp/cypress/e2e/06-rules.cy.ts +++ b/frontend/webapp/cypress/e2e/06-rules.cy.ts @@ -29,38 +29,42 @@ describe('Instrumentation Rules CRUD', () => { it('Should update the CRD in the cluster', () => { cy.visit(ROUTES.OVERVIEW); - updateEntity( - { - nodeId: DATA_IDS.INSTRUMENTATION_RULE_NODE, - nodeContains: SELECTED_ENTITIES.INSTRUMENTATION_RULE, - fieldKey: DATA_IDS.TITLE, - fieldValue: TEXTS.UPDATED_NAME, - }, - () => { - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, (crdIds) => { - const crdId = crdIds[0]; - getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'ruleName', expectedValue: TEXTS.UPDATED_NAME }); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, () => { + updateEntity( + { + nodeId: DATA_IDS.INSTRUMENTATION_RULE_NODE, + nodeContains: SELECTED_ENTITIES.INSTRUMENTATION_RULE, + fieldKey: DATA_IDS.TITLE, + fieldValue: TEXTS.UPDATED_NAME, + }, + () => { + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, (crdIds) => { + const crdId = crdIds[0]; + getCrdById({ namespace, crdName, crdId, expectedError: '', expectedKey: 'ruleName', expectedValue: TEXTS.UPDATED_NAME }); + }); }); - }); - }, - ); + }, + ); + }); }); it('Should delete the CRD from the cluster', () => { cy.visit(ROUTES.OVERVIEW); - deleteEntity( - { - nodeId: DATA_IDS.INSTRUMENTATION_RULE_NODE, - nodeContains: SELECTED_ENTITIES.INSTRUMENTATION_RULE, - warnModalTitle: TEXTS.INSTRUMENTATION_RULE_WARN_MODAL_TITLE, - }, - () => { - cy.wait('@gql').then(() => { - getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); - }); - }, - ); + getCrdIds({ namespace, crdName, expectedError: '', expectedLength: 1 }, () => { + deleteEntity( + { + nodeId: DATA_IDS.INSTRUMENTATION_RULE_NODE, + nodeContains: SELECTED_ENTITIES.INSTRUMENTATION_RULE, + warnModalTitle: TEXTS.INSTRUMENTATION_RULE_WARN_MODAL_TITLE, + }, + () => { + cy.wait('@gql').then(() => { + getCrdIds({ namespace, crdName, expectedError: TEXTS.NO_RESOURCES(namespace), expectedLength: 0 }); + }); + }, + ); + }); }); }); diff --git a/frontend/webapp/hooks/overview/index.tsx b/frontend/webapp/hooks/overview/index.ts similarity index 100% rename from frontend/webapp/hooks/overview/index.tsx rename to frontend/webapp/hooks/overview/index.ts diff --git a/frontend/webapp/hooks/overview/useNodeDataFlowHandlers.ts b/frontend/webapp/hooks/overview/useNodeDataFlowHandlers.ts index e29c5dff9..821730559 100644 --- a/frontend/webapp/hooks/overview/useNodeDataFlowHandlers.ts +++ b/frontend/webapp/hooks/overview/useNodeDataFlowHandlers.ts @@ -1,4 +1,3 @@ -// src/hooks/useNodeDataFlowHandlers.ts import { useCallback } from 'react'; import { type Node } from '@xyflow/react'; import { useSourceCRUD } from '../sources'; @@ -15,7 +14,7 @@ export function useNodeDataFlowHandlers() { const { instrumentationRules } = useInstrumentationRuleCRUD(); const { setCurrentModal } = useModalStore(); - const setSelectedItem = useDrawerStore(({ setSelectedItem }) => setSelectedItem); + const { setSelectedItem } = useDrawerStore(); const handleNodeClick = useCallback( ( @@ -42,7 +41,11 @@ export function useNodeDataFlowHandlers() { if (type === OVERVIEW_ENTITY_TYPES.SOURCE) { const { kind, name, namespace } = id as WorkloadId; const selectedDrawerItem = entities['sources'].find((item) => item.kind === kind && item.name === name && item.namespace === namespace); - if (!selectedDrawerItem) return; + + if (!selectedDrawerItem) { + console.warn('Selected item not found', { id, [`${type}sCount`]: entities[`${type}s`].length }); + return; + } setSelectedItem({ id, @@ -51,7 +54,11 @@ export function useNodeDataFlowHandlers() { }); } else if ([OVERVIEW_ENTITY_TYPES.RULE, OVERVIEW_ENTITY_TYPES.ACTION, OVERVIEW_ENTITY_TYPES.DESTINATION].includes(type as OVERVIEW_ENTITY_TYPES)) { const selectedDrawerItem = entities[`${type}s`].find((item) => id && [item.id, item.ruleId].includes(id)); - if (!selectedDrawerItem) return; + + if (!selectedDrawerItem) { + console.warn('Selected item not found', { id, [`${type}sCount`]: entities[`${type}s`].length }); + return; + } setSelectedItem({ id, @@ -66,6 +73,8 @@ export function useNodeDataFlowHandlers() { setCurrentModal(OVERVIEW_ENTITY_TYPES.ACTION); } else if (type === OVERVIEW_NODE_TYPES.ADD_DESTIONATION) { setCurrentModal(OVERVIEW_ENTITY_TYPES.DESTINATION); + } else { + console.warn('Unhandled node click', object); } }, [sources, actions, destinations, instrumentationRules, setSelectedItem, setCurrentModal], diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts deleted file mode 100644 index dcb96ffd8..000000000 --- a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts +++ /dev/null @@ -1,327 +0,0 @@ -import theme from '@/styles/theme'; -import { type Edge, type Node } from '@xyflow/react'; -import { getMainContainerLanguage } from '@/utils/constants/programming-languages'; -import { extractMonitors, formatBytes, getActionIcon, getEntityIcon, getEntityLabel, getHealthStatus, getProgrammingLanguageIcon, getRuleIcon, getValueForRange } from '@/utils'; -import { OVERVIEW_ENTITY_TYPES, OVERVIEW_NODE_TYPES, STATUSES, type OverviewMetricsResponse, type SingleDestinationMetricsResponse, type ComputePlatformMapped } from '@/types'; - -const createNode = (nodeId: string, nodeType: string, x: number, y: number, data: Record, style?: React.CSSProperties): Node => { - // const [columnType] = nodeId.split('-'); - - return { - id: nodeId, - type: nodeType, - data, - style, - position: { x, y }, - }; -}; - -const createEdge = (edgeId: string, params?: { label?: string; isMultiTarget?: boolean; isError?: boolean; animated?: boolean }): Edge => { - const { label, isMultiTarget, isError, animated } = params || {}; - const [sourceNodeId, targetNodeId] = edgeId.split('-to-'); - - return { - id: edgeId, - type: !!label ? 'labeled' : 'default', - source: sourceNodeId, - target: targetNodeId, - animated, - data: { label, isMultiTarget, isError }, - style: { stroke: isError ? theme.colors.dark_red : theme.colors.border }, - }; -}; - -interface Params { - computePlatform?: ComputePlatformMapped['computePlatform']; - computePlatformFiltered?: ComputePlatformMapped['computePlatform']; - metrics?: OverviewMetricsResponse; - containerWidth: number; - containerHeight: number; - nodeWidth: number; - nodeHeight: number; -} - -export const buildNodesAndEdges = ({ computePlatform, computePlatformFiltered, metrics, containerWidth, containerHeight, nodeWidth, nodeHeight }: Params) => { - const nodes: Node[] = []; - const edges: Edge[] = []; - - if (!containerWidth) return { nodes, edges }; - - const { instrumentationRules: rules = [], k8sActualSources: sources = [], actions = [], destinations = [] } = computePlatformFiltered || {}; - - const nonFilteredLengths = { - rules: computePlatform?.instrumentationRules.length || 0, - sources: computePlatform?.k8sActualSources.length || 0, - actions: computePlatform?.actions.length || 0, - destinations: computePlatform?.destinations.length || 0, - }; - - const startX = 24; - const endX = (containerWidth <= 1500 ? 1500 : containerWidth) - nodeWidth - 40 - startX; - const getY = (idx?: number) => nodeHeight * ((idx || 0) + 1); - - const postions = { - rules: { - x: startX, - y: getY, - }, - sources: { - x: getValueForRange(containerWidth, [ - [0, 1600, endX / 3.5], - [1600, null, endX / 4], - ]), - y: getY, - }, - actions: { - x: getValueForRange(containerWidth, [ - [0, 1600, endX / 1.55], - [1600, null, endX / 1.6], - ]), - y: getY, - }, - destinations: { - x: endX, - y: getY, - }, - }; - - const tempNodes = { - rules: [ - createNode('rule-header', 'header', postions['rules']['x'], 0, { - icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.RULE), - title: 'Instrumentation Rules', - tagValue: nonFilteredLengths['rules'], - }), - ], - sources: [ - createNode('source-header', 'header', postions['sources']['x'], 0, { - icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.SOURCE), - title: 'Sources', - tagValue: nonFilteredLengths['sources'], - }), - ], - actions: [ - createNode('action-header', 'header', postions['actions']['x'] - (!!actions.length ? 15 : 0), 0, { - icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.ACTION), - title: 'Actions', - tagValue: nonFilteredLengths['actions'], - }), - ], - destinations: [ - createNode('destination-header', 'header', postions['destinations']['x'], 0, { - icon: getEntityIcon(OVERVIEW_ENTITY_TYPES.DESTINATION), - title: 'Destinations', - tagValue: nonFilteredLengths['destinations'], - }), - ], - }; - - // Build Rules Nodes - if (!rules.length) { - tempNodes['rules'].push( - createNode('rule-0', 'add', postions['rules']['x'], postions['rules']['y'](), { - type: OVERVIEW_NODE_TYPES.ADD_RULE, - status: STATUSES.HEALTHY, - title: 'ADD RULE', - subTitle: `Add ${!!nonFilteredLengths['rules'] ? 'a new' : 'first'} rule to modify the OpenTelemetry data`, - }), - ); - } else { - rules.forEach((rule, idx) => { - tempNodes['rules'].push( - createNode(`rule-${idx}`, 'base', postions['rules']['x'], postions['rules']['y'](idx), { - id: rule.ruleId, - type: OVERVIEW_ENTITY_TYPES.RULE, - status: STATUSES.HEALTHY, - title: getEntityLabel(rule, OVERVIEW_ENTITY_TYPES.RULE, { prioritizeDisplayName: true }), - subTitle: rule.type, - imageUri: getRuleIcon(rule.type), - isActive: !rule.disabled, - raw: rule, - }), - ); - }); - } - - // Build Source Nodes - if (!sources.length) { - tempNodes['sources'].push( - createNode('source-0', 'add', postions['sources']['x'], postions['rules']['y'](), { - type: OVERVIEW_NODE_TYPES.ADD_SOURCE, - status: STATUSES.HEALTHY, - title: 'ADD SOURCE', - subTitle: `Add ${!!nonFilteredLengths['sources'] ? 'a new' : 'first'} source to collect OpenTelemetry data`, - }), - ); - } else { - sources.forEach((source, idx) => { - const metric = metrics?.getOverviewMetrics.sources.find(({ kind, name, namespace }) => kind === source.kind && name === source.name && namespace === source.namespace); - - tempNodes['sources'].push( - createNode(`source-${idx}`, 'base', postions['sources']['x'], postions['rules']['y'](idx), { - id: { - kind: source.kind, - name: source.name, - namespace: source.namespace, - }, - type: OVERVIEW_ENTITY_TYPES.SOURCE, - status: getHealthStatus(source), - title: getEntityLabel(source, OVERVIEW_ENTITY_TYPES.SOURCE, { extended: true }), - subTitle: source.kind, - imageUri: getProgrammingLanguageIcon(getMainContainerLanguage(source)), - metric, - raw: source, - }), - ); - }); - } - - // Build Action Nodes - if (!actions.length) { - tempNodes['actions'].push( - createNode('action-0', 'add', postions['actions']['x'], postions['rules']['y'](), { - type: OVERVIEW_NODE_TYPES.ADD_ACTION, - status: STATUSES.HEALTHY, - title: 'ADD ACTION', - subTitle: `Add ${!!nonFilteredLengths['actions'] ? 'a new' : 'first'} action to modify the OpenTelemetry data`, - }), - ); - } else { - actions.forEach((action, idx) => { - tempNodes['actions'].push( - createNode(`action-${idx}`, 'base', postions['actions']['x'], postions['rules']['y'](idx), { - id: action.id, - type: OVERVIEW_ENTITY_TYPES.ACTION, - status: STATUSES.HEALTHY, - title: getEntityLabel(action, OVERVIEW_ENTITY_TYPES.ACTION, { prioritizeDisplayName: true }), - subTitle: action.type, - imageUri: getActionIcon(action.type), - monitors: action.spec.signals, - isActive: !action.spec.disabled, - raw: action, - }), - ); - }); - - // Create group - const padding = 15; - const widthMultiplier = 4.5; - const heightMultiplier = 1.5; - - tempNodes['actions'].push( - createNode( - 'action-group', - 'group', - postions['actions']['x'] - padding, - postions['rules']['y']() - padding, - {}, - { - width: nodeWidth + padding * widthMultiplier, - height: nodeHeight * actions.length + padding * heightMultiplier, - background: 'transparent', - border: `1px dashed ${theme.colors.border}`, - borderRadius: 24, - zIndex: -1, - }, - ), - ); - } - - // Build Destination Nodes - if (!destinations.length) { - tempNodes['destinations'].push( - createNode('destination-0', 'add', postions['destinations']['x'], postions['rules']['y'](), { - type: OVERVIEW_NODE_TYPES.ADD_DESTIONATION, - status: STATUSES.HEALTHY, - title: 'ADD DESTIONATION', - subTitle: `Add ${!!nonFilteredLengths['destinations'] ? 'a new' : 'first'} destination to monitor OpenTelemetry data`, - }), - ); - } else { - destinations.forEach((destination, idx) => { - const metric = metrics?.getOverviewMetrics.destinations.find(({ id }) => id === destination.id); - - tempNodes['destinations'].push( - createNode(`destination-${idx}`, 'base', postions['destinations']['x'], postions['rules']['y'](idx), { - id: destination.id, - type: OVERVIEW_ENTITY_TYPES.DESTINATION, - status: getHealthStatus(destination), - title: getEntityLabel(destination, OVERVIEW_ENTITY_TYPES.DESTINATION, { prioritizeDisplayName: true }), - subTitle: destination.destinationType.displayName, - imageUri: destination.destinationType.imageUrl || '/brand/odigos-icon.svg', - monitors: extractMonitors(destination.exportedSignals), - metric, - raw: destination, - }), - ); - }); - } - - // Connect sources to actions - if (!!sources.length) { - tempNodes['sources'].forEach((node, idx) => { - if (idx > 0) { - const sourceIndex = idx - 1; - const actionIndex = actions.length ? 'group' : 0; - - edges.push( - createEdge(`source-${sourceIndex}-to-action-${actionIndex}`, { - animated: false, - isMultiTarget: false, - label: formatBytes((node.data.metric as SingleDestinationMetricsResponse)?.throughput), - isError: node.data.status === STATUSES.UNHEALTHY, - }), - ); - } - }); - } - - // Connect actions to actions - if (!!actions.length) { - actions.forEach((_, sourceActionIndex) => { - if (sourceActionIndex < actions.length - 1) { - const targetActionIndex = sourceActionIndex + 1; - edges.push(createEdge(`action-${sourceActionIndex}-to-action-${targetActionIndex}`)); - } - }); - } - - // Connect actions to destinations - if (!!destinations.length) { - tempNodes['destinations'].forEach((node, idx) => { - if (idx > 0) { - const destinationIndex = idx - 1; - const actionIndex = actions.length ? 'group' : 0; - - edges.push( - createEdge(`action-${actionIndex}-to-destination-${destinationIndex}`, { - animated: false, - isMultiTarget: true, - label: formatBytes((node.data.metric as SingleDestinationMetricsResponse)?.throughput), - isError: node.data.status === STATUSES.UNHEALTHY, - }), - ); - } - }); - } - - tempNodes['rules'].push( - createNode( - 'hidden', - 'default', - postions['rules']['x'], - containerHeight, - {}, - { - width: 1, - height: 1, - opacity: 0, - pointerEvents: 'none', - }, - ), - ); - - Object.values(tempNodes).forEach((arr) => nodes.push(...arr)); - - return { nodes, edges }; -}; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx index 4cd9c2f4b..bae0649db 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx @@ -1,19 +1,20 @@ -'use client'; -import React, { useMemo } from 'react'; +import React from 'react'; import '@xyflow/react/dist/style.css'; import styled from 'styled-components'; import AddNode from './nodes/add-node'; import BaseNode from './nodes/base-node'; -import GroupNode from './nodes/group-node'; +import FrameNode from './nodes/frame-node'; +import ScrollNode from './nodes/scroll-node'; import HeaderNode from './nodes/header-node'; import LabeledEdge from './edges/labeled-edge'; -import { Controls, type Edge, type Node, ReactFlow } from '@xyflow/react'; +import { Controls, type Edge, type Node, type OnEdgesChange, type OnNodesChange, ReactFlow } from '@xyflow/react'; interface Props { nodes: Node[]; edges: Edge[]; onNodeClick?: (event: React.MouseEvent, object: Node) => void; - nodeWidth: number; + onNodesChange?: OnNodesChange; + onEdgesChange?: OnEdgesChange; } const FlowWrapper = styled.div` @@ -39,27 +40,32 @@ const ControllerWrapper = styled.div` } `; -export const NodeBaseDataFlow: React.FC = ({ nodes, edges, onNodeClick, nodeWidth }) => { - const nodeTypes = useMemo( - () => ({ - header: (props) => , - add: (props) => , - base: (props) => , - group: GroupNode, - }), - [nodeWidth], - ); +const nodeTypes = { + header: HeaderNode, + add: AddNode, + base: BaseNode, + frame: FrameNode, + scroll: ScrollNode, +}; - const edgeTypes = useMemo( - () => ({ - labeled: LabeledEdge, - }), - [], - ); +const edgeTypes = { + labeled: LabeledEdge, +}; +export const NodeDataFlow: React.FC = ({ nodes, edges, onNodeClick, onNodesChange, onEdgesChange }) => { return ( - + = ({ nodes, edges, onNodeClick, n ); }; - -export * from './builder'; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/add-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/add-node.tsx index 17bb5df6d..10389bd8d 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/add-node.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/add-node.tsx @@ -9,6 +9,8 @@ interface Props extends NodeProps< Node< { + nodeWidth: number; + type: OVERVIEW_NODE_TYPES; status: STATUSES; title: string; @@ -16,12 +18,11 @@ interface Props }, 'add' > - > { - nodeWidth: number; -} + > {} -const BaseNodeContainer = styled.div<{ $nodeWidth: Props['nodeWidth'] }>` - width: ${({ $nodeWidth }) => `${$nodeWidth}px`}; +const Container = styled.div<{ $nodeWidth: Props['data']['nodeWidth'] }>` + // negative width applied here because of the padding left&right + width: ${({ $nodeWidth }) => `${$nodeWidth - 40}px`}; padding: 16px 24px 16px 16px; display: flex; flex-direction: column; @@ -58,17 +59,20 @@ const SubTitle = styled(Text)` text-align: center; `; -const AddNode: React.FC = ({ nodeWidth, data, id, isConnectable }) => { +const AddNode: React.FC = ({ data }) => { + const { nodeWidth, title, subTitle } = data; + return ( - + plus - {data.title} + {title} - {data.subTitle} - - - + {subTitle} + + + + ); }; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx index df7d39e99..fb3cb44b1 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/base-node.tsx @@ -5,14 +5,16 @@ import styled from 'styled-components'; import { getStatusIcon } from '@/utils'; import { Checkbox, DataTab } from '@/reuseable-components'; import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; -import { type ActionDataParsed, type ActualDestination, type InstrumentationRuleSpec, type K8sActualSource, STATUSES } from '@/types'; +import { type ActionDataParsed, type ActualDestination, type InstrumentationRuleSpec, type K8sActualSource, OVERVIEW_ENTITY_TYPES, STATUSES, WorkloadId } from '@/types'; interface Props extends NodeProps< Node< { - id: string; - type: 'source' | 'action' | 'destination'; + nodeWidth: number; + + id: string | WorkloadId; + type: OVERVIEW_ENTITY_TYPES; status: STATUSES; title: string; subTitle: string; @@ -23,16 +25,14 @@ interface Props }, 'base' > - > { - nodeWidth: number; -} + > {} -const Container = styled.div<{ $nodeWidth: Props['nodeWidth'] }>` - width: ${({ $nodeWidth }) => `${$nodeWidth + 40}px`}; +const Container = styled.div<{ $nodeWidth: Props['data']['nodeWidth'] }>` + width: ${({ $nodeWidth }) => `${$nodeWidth}px`}; `; -const BaseNode: React.FC = ({ nodeWidth, data, isConnectable }) => { - const { type, status, title, subTitle, imageUri, monitors, isActive, raw } = data; +const BaseNode: React.FC = ({ id: nodeId, data }) => { + const { nodeWidth, type, status, title, subTitle, imageUri, monitors, isActive, raw } = data; const isError = status === STATUSES.UNHEALTHY; const { configuredSources, setConfiguredSources } = useAppStore((state) => state); @@ -75,23 +75,16 @@ const BaseNode: React.FC = ({ nodeWidth, data, isConnectable }) => { const renderHandles = () => { switch (type) { case 'source': - return ; - case 'action': - return ( - <> - - - - ); + return ; case 'destination': - return ; + return ; default: return null; } }; return ( - + {}}> {renderActions()} {renderHandles()} diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/frame-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/frame-node.tsx new file mode 100644 index 000000000..c93d20a18 --- /dev/null +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/frame-node.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; + +interface Props + extends NodeProps< + Node< + { + nodeWidth: number; + nodeHeight: number; + }, + 'frame' + > + > {} + +const Container = styled.div<{ $nodeWidth: Props['data']['nodeWidth']; $nodeHeight: Props['data']['nodeHeight'] }>` + width: ${({ $nodeWidth }) => $nodeWidth}px; + height: ${({ $nodeHeight }) => $nodeHeight}px; + background: transparent; + border: 1px dashed ${({ theme }) => theme.colors.border}; + border-radius: 24px; +`; + +const FrameNode: React.FC = ({ data }) => { + const { nodeWidth, nodeHeight } = data; + + return ( + + + + + ); +}; + +export default FrameNode; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/group-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/group-node.tsx deleted file mode 100644 index 54f990a8d..000000000 --- a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/group-node.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; - -interface Props extends NodeProps> {} - -const GroupNode: React.FC = () => { - return ( - <> - - - - ); -}; - -export default GroupNode; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx index 61b6578e7..3261de9bc 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/header-node.tsx @@ -10,17 +10,17 @@ interface Props extends NodeProps< Node< { + nodeWidth: number; + icon: string; title: string; tagValue: number; }, 'header' > - > { - nodeWidth: number; -} + > {} -const Container = styled.div<{ $nodeWidth: Props['nodeWidth'] }>` +const Container = styled.div<{ $nodeWidth: Props['data']['nodeWidth'] }>` width: ${({ $nodeWidth }) => `${$nodeWidth}px`}; padding: 12px 0px 16px 0px; gap: 8px; @@ -38,11 +38,9 @@ const ActionsWrapper = styled.div` margin-right: 16px; `; -const HeaderNode: React.FC = ({ nodeWidth, data }) => { - const { title, icon, tagValue } = data; +const HeaderNode: React.FC = ({ data }) => { + const { nodeWidth, title, icon, tagValue } = data; const isSources = title === 'Sources'; - const isActions = title === 'Actions'; - const extraWidth = isActions && !!tagValue ? 70 : 40; const { configuredSources, setConfiguredSources } = useAppStore((state) => state); const { sources } = useSourceCRUD(); @@ -86,7 +84,7 @@ const HeaderNode: React.FC = ({ nodeWidth, data }) => { }; return ( - + {title} {title} diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/nodes/scroll-node.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/scroll-node.tsx new file mode 100644 index 000000000..3479fe43e --- /dev/null +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/scroll-node.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useRef } from 'react'; +import BaseNode from './base-node'; +import styled from 'styled-components'; +import { type Node, type NodeProps } from '@xyflow/react'; +import { type K8sActualSource, OVERVIEW_ENTITY_TYPES, STATUSES, type WorkloadId } from '@/types'; +import { useDrawerStore } from '@/store'; + +interface Props + extends NodeProps< + Node< + { + nodeWidth: number; + nodeHeight: number; + items: NodeProps< + Node< + { + nodeWidth: number; + framePadding: number; + id: WorkloadId; + type: OVERVIEW_ENTITY_TYPES; + status: STATUSES; + title: string; + subTitle: string; + imageUri: string; + raw: K8sActualSource; + }, + 'scroll-item' + > + >[]; + onScroll: (params: { clientHeight: number; scrollHeight: number; scrollTop: number }) => void; + }, + 'scroll' + > + > {} + +const Container = styled.div<{ $nodeWidth: number; $nodeHeight: number }>` + width: ${({ $nodeWidth }) => $nodeWidth}px; + height: ${({ $nodeHeight }) => $nodeHeight}px; + background: transparent; + border: none; + overflow-y: auto; +`; + +const BaseNodeWrapper = styled.div<{ $framePadding: number }>` + margin: ${({ $framePadding }) => $framePadding}px 0; +`; + +const ScrollNode: React.FC = ({ data, ...rest }) => { + const { nodeWidth, nodeHeight, items, onScroll } = data; + + const { setSelectedItem } = useDrawerStore(); + const containerRef = useRef(null); + + useEffect(() => { + const handleScroll = (e: Event) => { + e.stopPropagation(); + + // @ts-ignore - these properties are available on the EventTarget, TS is not aware of it + const { clientHeight, scrollHeight, scrollTop } = e.target || { clientHeight: 0, scrollHeight: 0, scrollTop: 0 }; + const isTop = scrollTop === 0; + const isBottom = scrollHeight - scrollTop <= clientHeight; + + if (isTop) { + console.log('Reached top of scroll-node'); + } else if (isBottom) { + console.log('Reached bottom of scroll-node'); + } + + if (!!onScroll) onScroll({ clientHeight, scrollHeight, scrollTop }); + }; + + const { current } = containerRef; + + current?.addEventListener('scroll', handleScroll); + return () => current?.removeEventListener('scroll', handleScroll); + }, [onScroll]); + + return ( + + {items.map((item) => ( + { + e.stopPropagation(); + setSelectedItem({ id: item.data.id, type: item.data.type, item: item.data.raw }); + }} + > + + + ))} + + ); +}; + +export default ScrollNode;