From ced6950988c16ea2fd4c76091a37da4072219123 Mon Sep 17 00:00:00 2001 From: Ben Elferink Date: Thu, 12 Dec 2024 17:39:48 +0200 Subject: [PATCH] [GEN-1710]: add scroll to overview sources (#1978) This pull request introduces several updates to the frontend web application, primarily focused on refactoring components and adding new functionalities to the overview data flow. The key changes include converting components to named exports, building nodes for various entity types, and constructing edges for data flow visualization. ### Component Refactoring: * [`frontend/webapp/containers/main/overview/multi-source-control/index.tsx`](diffhunk://#diff-cdd99a8fcd0484b24586f22b1f7b1bcf8d964bead53eeb0b2c07646c7301af70L27-R27): Converted `MultiSourceControl` to a named export. [[1]](diffhunk://#diff-cdd99a8fcd0484b24586f22b1f7b1bcf8d964bead53eeb0b2c07646c7301af70L27-R27) [[2]](diffhunk://#diff-cdd99a8fcd0484b24586f22b1f7b1bcf8d964bead53eeb0b2c07646c7301af70L91-L92) * [`frontend/webapp/containers/main/overview/overview-actions-menu/index.tsx`](diffhunk://#diff-d5bd2313f84205e47d83344e7ac50ad55a85f3bad2fba1ca4b0288484584f0dfL21-R21): Converted `OverviewActionsMenu` to a named export. [[1]](diffhunk://#diff-d5bd2313f84205e47d83344e7ac50ad55a85f3bad2fba1ca4b0288484584f0dfL21-R21) [[2]](diffhunk://#diff-d5bd2313f84205e47d83344e7ac50ad55a85f3bad2fba1ca4b0288484584f0dfL35-R35) ### Node Building for Overview Data Flow: * [`frontend/webapp/containers/main/overview/overview-data-flow/build-action-nodes.ts`](diffhunk://#diff-2a2957e6035bd001e3c0c52f29f01f4473b6b99a8ce4381a08e9de504916c3b1R1-R97): Added functionality to build nodes for actions in the overview data flow. * [`frontend/webapp/containers/main/overview/overview-data-flow/build-destination-nodes.ts`](diffhunk://#diff-aa30a6b7893a9d1393f196b9846b1fdc19956c7c07f4099570cb25ff2f77fa43R1-R81): Added functionality to build nodes for destinations in the overview data flow. * [`frontend/webapp/containers/main/overview/overview-data-flow/build-rule-nodes.ts`](diffhunk://#diff-a37edc5ed2e31d0b06f407f3d1d5b155d499c689f95609db388f0e8246a1800bR1-R81): Added functionality to build nodes for instrumentation rules in the overview data flow. * [`frontend/webapp/containers/main/overview/overview-data-flow/build-source-nodes.ts`](diffhunk://#diff-278da68d858c88d202c10e2011bc1263fe6c0b543dc9d0fc4995a0f0675d3db1R1-R115): Added functionality to build nodes for sources in the overview data flow. ### Edge Building for Data Flow: * [`frontend/webapp/containers/main/overview/overview-data-flow/build-edges.ts`](diffhunk://#diff-39cdfd27eadcda95cac1b52b794dc8feae0d0d46bb5f29ac1a39f96473eab483R1-R80): Added functionality to build edges for connecting nodes in the overview data flow. ### Utility Functions: * [`frontend/webapp/containers/main/overview/overview-data-flow/get-entity-counts.ts`](diffhunk://#diff-9180e7735f3b0a44ca96a2b3211d3f6adf63d412713f4f6e6b8cfe4566eb0e86R1-R18): Added a utility function to get entity counts for different types in the overview data flow. * [`frontend/webapp/containers/main/overview/overview-data-flow/get-node-positions.ts`](diffhunk://#diff-76a303d4463294b366faf6aa5f23672be0bbbc7907480ec7a19868ad114a6f59R1-R48): Added a utility function to calculate node positions based on container width. --- .../destination-list-item/index.tsx | 2 +- .../overview/multi-source-control/index.tsx | 4 +- .../overview/overview-actions-menu/index.tsx | 4 +- .../overview-data-flow/build-action-nodes.ts | 97 ++++++ .../build-destination-nodes.ts | 81 +++++ .../overview-data-flow/build-edges.ts | 80 +++++ .../overview-data-flow/build-rule-nodes.ts | 81 +++++ .../overview-data-flow/build-source-nodes.ts | 115 ++++++ .../overview-data-flow/get-entity-counts.ts | 18 + .../overview-data-flow/get-node-positions.ts | 48 +++ .../overview/overview-data-flow/index.tsx | 111 ++++-- .../overview-data-flow/node-config.json | 5 + frontend/webapp/cypress/constants/index.ts | 7 +- frontend/webapp/cypress/e2e/03-sources.cy.ts | 50 +-- .../webapp/cypress/e2e/04-destinations.cy.ts | 61 ++-- frontend/webapp/cypress/e2e/05-actions.cy.ts | 58 ++-- frontend/webapp/cypress/e2e/06-rules.cy.ts | 58 ++-- .../hooks/overview/{index.tsx => index.ts} | 0 .../hooks/overview/useNodeDataFlowHandlers.ts | 17 +- .../nodes-data-flow/builder.ts | 327 ------------------ .../nodes-data-flow/index.tsx | 52 +-- .../nodes-data-flow/nodes/add-node.tsx | 28 +- .../nodes-data-flow/nodes/base-node.tsx | 33 +- .../nodes-data-flow/nodes/frame-node.tsx | 35 ++ .../nodes-data-flow/nodes/group-node.tsx | 15 - .../nodes-data-flow/nodes/header-node.tsx | 16 +- .../nodes-data-flow/nodes/scroll-node.tsx | 96 +++++ 27 files changed, 947 insertions(+), 552 deletions(-) create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/build-action-nodes.ts create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/build-destination-nodes.ts create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/build-edges.ts create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/build-rule-nodes.ts create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/build-source-nodes.ts create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/get-entity-counts.ts create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/get-node-positions.ts create mode 100644 frontend/webapp/containers/main/overview/overview-data-flow/node-config.json rename frontend/webapp/hooks/overview/{index.tsx => index.ts} (100%) delete mode 100644 frontend/webapp/reuseable-components/nodes-data-flow/builder.ts create mode 100644 frontend/webapp/reuseable-components/nodes-data-flow/nodes/frame-node.tsx delete mode 100644 frontend/webapp/reuseable-components/nodes-data-flow/nodes/group-node.tsx create mode 100644 frontend/webapp/reuseable-components/nodes-data-flow/nodes/scroll-node.tsx 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;