From 1aca5f7c199e820615c8167ae621a463e7a290d0 Mon Sep 17 00:00:00 2001 From: Ben Elferink Date: Mon, 4 Nov 2024 17:38:20 +0200 Subject: [PATCH] feat: added UI for metrics --- .../nodes-data-flow/builder.ts | 156 ++++++++++++------ .../nodes-data-flow/edges/labeled-edge.tsx | 60 +++++++ .../nodes-data-flow/index.tsx | 20 ++- .../nodes-data-flow/nodes/base-node.tsx | 54 +++--- .../nodes-data-flow/nodes/group-node.tsx | 13 ++ .../reuseable-components/status/index.tsx | 3 +- frontend/webapp/styles/theme.ts | 4 +- 7 files changed, 228 insertions(+), 82 deletions(-) create mode 100644 frontend/webapp/reuseable-components/nodes-data-flow/edges/labeled-edge.tsx create mode 100644 frontend/webapp/reuseable-components/nodes-data-flow/nodes/group-node.tsx diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts index 38d24702b..b259d28f9 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts +++ b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts @@ -1,5 +1,5 @@ import theme from '@/styles/theme'; -import { getActionIcon, INSTRUMENTATION_RULES } from '@/utils'; +import { getActionIcon } from '@/utils'; import { Node, Edge } from 'react-flow-renderer'; import { getRuleIcon } from '@/utils/functions'; import { getMainContainerLanguageLogo } from '@/utils/constants/programming-languages'; @@ -12,31 +12,45 @@ import { type ActionItem, type ActualDestination, type K8sActualSource, - InstrumentationRuleType, } from '@/types'; -// Constants const NODE_HEIGHT = 80; - -const STROKE_COLOR = theme.colors.border; const HEADER_ICON_PATH = '/icons/overview/'; -// Helper to create a node -const createNode = (id: string, type: string, x: number, y: number, data: Record): Node => ({ - id, - type, - position: { x, y }, - data, -}); - -// Helper to create an edge -const createEdge = (id: string, source: string, target: string, animated = true): Edge => ({ - id, - source, - target, - animated, - style: { stroke: STROKE_COLOR }, -}); +const createNode = (nodeId: string, nodeType: string, x: number, y: number, data: Record, style?: React.CSSProperties): Node => { + // const [columnType] = id.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 }, + }; +}; // Extract the monitors from exported signals const extractMonitors = (exportedSignals: Record) => @@ -60,25 +74,25 @@ export const buildNodesAndEdges = ({ // Calculate x positions for each column const columnPostions = { rules: 0, - sources: (containerWidth - columnWidth) / 3, - actions: (containerWidth - columnWidth) / 1.5, + sources: (containerWidth - columnWidth) / 4, + actions: (containerWidth - columnWidth) / 1.6, destinations: containerWidth - columnWidth, }; // Build Rules Nodes const ruleNodes: Node[] = [ - createNode('header-rule', 'header', columnPostions['rules'], 0, { + createNode('rule-header', 'header', columnPostions['rules'], 0, { icon: `${HEADER_ICON_PATH}rules.svg`, title: 'Instrumentation Rules', tagValue: rules.length, }), ...(!rules.length ? [ - createNode(`rule-0`, 'add', columnPostions['rules'], NODE_HEIGHT, { + createNode('rule-0', 'add', columnPostions['rules'], NODE_HEIGHT, { type: OVERVIEW_NODE_TYPES.ADD_RULE, + status: STATUSES.HEALTHY, title: 'ADD RULE', subTitle: 'Add first rule to modify the OpenTelemetry data', - status: STATUSES.HEALTHY, }), ] : rules.map((rule, index) => @@ -96,50 +110,46 @@ export const buildNodesAndEdges = ({ // Build Source Nodes const sourceNodes: Node[] = [ - createNode('header-source', 'header', columnPostions['sources'], 0, { + createNode('source-header', 'header', columnPostions['sources'], 0, { icon: `${HEADER_ICON_PATH}sources.svg`, title: 'Sources', tagValue: sources.length, }), ...(!sources.length ? [ - createNode(`source-0`, 'add', columnPostions['sources'], NODE_HEIGHT, { + createNode('source-0', 'add', columnPostions['sources'], NODE_HEIGHT, { type: OVERVIEW_NODE_TYPES.ADD_SOURCE, + status: STATUSES.HEALTHY, title: 'ADD SOURCE', subTitle: 'Add first source to collect OpenTelemetry data', - status: STATUSES.HEALTHY, }), ] : sources.map((source, index) => createNode(`source-${index}`, 'base', columnPostions['sources'], NODE_HEIGHT * (index + 1), { + id: { kind: source.kind, name: source.name, namespace: source.namespace }, type: OVERVIEW_ENTITY_TYPES.SOURCE, + status: index === 0 ? STATUSES.UNHEALTHY : STATUSES.HEALTHY, title: source.name + (source.reportedName ? ` (${source.reportedName})` : ''), subTitle: source.kind, imageUri: getMainContainerLanguageLogo(source), - status: STATUSES.HEALTHY, - id: { - kind: source.kind, - name: source.name, - namespace: source.namespace, - }, }) )), ]; // Build Action Nodes const actionNodes: Node[] = [ - createNode('header-action', 'header', columnPostions['actions'], 0, { + createNode('action-header', 'header', columnPostions['actions'], 0, { icon: `${HEADER_ICON_PATH}actions.svg`, title: 'Actions', tagValue: actions.length, }), ...(!actions.length ? [ - createNode(`action-0`, 'add', columnPostions['actions'], NODE_HEIGHT, { + createNode('action-0', 'add', columnPostions['actions'], NODE_HEIGHT, { type: OVERVIEW_NODE_TYPES.ADD_ACTION, + status: STATUSES.HEALTHY, title: 'ADD ACTION', subTitle: 'Add first action to modify the OpenTelemetry data', - status: STATUSES.HEALTHY, }), ] : actions.map((action, index) => { @@ -158,31 +168,59 @@ export const buildNodesAndEdges = ({ })), ]; + // Create group for actions + if (actions.length) { + const padding = 15; + const getDifference = (x: number) => { + const a = 23.24; // coefficient + const b = -0.589; // exponent + return a * Math.pow(x, b); + }; + + actionNodes.push( + createNode( + 'action-group', + 'group', + columnPostions['actions'] - padding, + NODE_HEIGHT - padding, + {}, + { + width: columnWidth + padding * getDifference(padding), + height: NODE_HEIGHT * actions.length + padding, + background: 'transparent', + border: `1px dashed ${theme.colors.border}`, + borderRadius: 24, + zIndex: -1, + } + ) + ); + } + // Build Destination Nodes const destinationNodes: Node[] = [ - createNode('header-destination', 'header', columnPostions['destinations'], 0, { + createNode('destination-header', 'header', columnPostions['destinations'], 0, { icon: `${HEADER_ICON_PATH}destinations.svg`, title: 'Destinations', tagValue: destinations.length, }), ...(!destinations.length ? [ - createNode(`destination-0`, 'add', columnPostions['destinations'], NODE_HEIGHT, { + createNode('destination-0', 'add', columnPostions['destinations'], NODE_HEIGHT, { type: OVERVIEW_NODE_TYPES.ADD_DESTIONATION, + status: STATUSES.HEALTHY, title: 'ADD DESTIONATION', subTitle: 'Add first destination to monitor OpenTelemetry data', - status: STATUSES.HEALTHY, }), ] : destinations.map((destination, index) => createNode(`destination-${index}`, 'base', columnPostions['destinations'], NODE_HEIGHT * (index + 1), { + id: destination.id, type: OVERVIEW_ENTITY_TYPES.DESTINATION, + status: index === 0 ? STATUSES.UNHEALTHY : STATUSES.HEALTHY, title: destination.name, subTitle: destination.destinationType.displayName, imageUri: destination.destinationType.imageUrl, - status: STATUSES.HEALTHY, monitors: extractMonitors(destination.exportedSignals), - id: destination.id, }) )), ]; @@ -195,21 +233,41 @@ export const buildNodesAndEdges = ({ // Connect sources to actions if (!sources.length) { - edges.push(createEdge('source-0-to-action-0', 'source-0', 'action-0', false)); + edges.push(createEdge('source-0-to-action-0')); } else { sources.forEach((_, sourceIndex) => { - const actionIndex = 0; - edges.push(createEdge(`source-${sourceIndex}-to-action-${actionIndex}`, `source-${sourceIndex}`, `action-${actionIndex}`, false)); + const actionIndex = actions.length ? 'group' : 0; + edges.push( + createEdge(`source-${sourceIndex}-to-action-${actionIndex}`, { + label: `${sourceIndex === 0 ? 0 : (Math.random() * 50).toFixed(1)} kb/s`, + isMultiTarget: false, + isError: sourceIndex === 0, + }) + ); + }); + } + + // Connect actions to actions + if (!!actions.length) { + actions.forEach((_, sourceActionIndex) => { + const targetActionIndex = sourceActionIndex + 1; + edges.push(createEdge(`action-${sourceActionIndex}-to-action-${targetActionIndex}`)); }); } // Connect actions to destinations if (!destinations.length) { - edges.push(createEdge('action-0-to-destination-0', 'action-0', 'destination-0')); + edges.push(createEdge('action-0-to-destination-0')); } else { destinations.forEach((_, destinationIndex) => { - const actionIndex = !actions.length ? 0 : actions.length - 1; - edges.push(createEdge(`action-${actionIndex}-to-destination-${destinationIndex}`, `action-${actionIndex}`, `destination-${destinationIndex}`)); + const actionIndex = actions.length ? 'group' : 0; + edges.push( + createEdge(`action-${actionIndex}-to-destination-${destinationIndex}`, { + label: `${destinationIndex === 0 ? 0 : (Math.random() * 10).toFixed(1)} kb/s`, + isMultiTarget: true, + isError: destinationIndex === 0, + }) + ); }); } diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/edges/labeled-edge.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/edges/labeled-edge.tsx new file mode 100644 index 000000000..e86982753 --- /dev/null +++ b/frontend/webapp/reuseable-components/nodes-data-flow/edges/labeled-edge.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import styled from 'styled-components'; +import { EdgeLabelRenderer, BaseEdge, type EdgeProps, type Edge, getSmoothStepPath } from '@xyflow/react'; + +const Label = styled.div<{ labelX: number; labelY: number; isError?: boolean }>` + position: absolute; + transform: ${({ labelX, labelY }) => `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`}; + width: 60px; + padding: 2px 6px; + background-color: ${({ theme }) => theme.colors.primary}; + border-radius: 360px; + border: 1px solid ${({ isError, theme }) => (isError ? theme.colors.dark_red : theme.colors.border)}; + color: ${({ isError, theme }) => (isError ? theme.text.error : theme.text.light_grey)}; + font-family: ${({ theme }) => theme.font_family.secondary}; + font-size: 10px; + font-weight: 400; + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: center; +`; + +const LabeledEdge: React.FC>> = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + style, +}) => { + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + <> + + + + + + ); +}; + +export default LabeledEdge; diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx b/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx index 33a0039f2..d48c2eedf 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx +++ b/frontend/webapp/reuseable-components/nodes-data-flow/index.tsx @@ -4,7 +4,9 @@ import '@xyflow/react/dist/style.css'; import AddNode from './nodes/add-node'; import BaseNode from './nodes/base-node'; import { ReactFlow } from '@xyflow/react'; +import GroupNode from './nodes/group-node'; import HeaderNode from './nodes/header-node'; +import LabeledEdge from './edges/labeled-edge'; interface NodeBaseDataFlowProps { nodes: any[]; @@ -19,13 +21,29 @@ export function NodeBaseDataFlow({ nodes, edges, onNodeClick, columnWidth }: Nod header: (props) => , add: (props) => , base: (props) => , + group: GroupNode, }), [columnWidth] ); + const edgeTypes = useMemo( + () => ({ + labeled: LabeledEdge, + }), + [] + ); + return (
- +
); } 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 8b32f881a..60fd58832 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 @@ -1,11 +1,12 @@ import React from 'react'; import Image from 'next/image'; import styled from 'styled-components'; -import type { STATUSES } from '@/types'; +import { STATUSES } from '@/types'; import { Handle, Position } from '@xyflow/react'; import { Status, Text } from '@/reuseable-components'; +import { getStatusIcon } from '@/utils'; -const BaseNodeContainer = styled.div<{ columnWidth: number }>` +const BaseNodeContainer = styled.div<{ columnWidth: number; isError?: boolean }>` width: ${({ columnWidth }) => `${columnWidth}px`}; padding: 16px 24px 16px 16px; gap: 8px; @@ -14,14 +15,14 @@ const BaseNodeContainer = styled.div<{ columnWidth: number }>` align-self: stretch; border-radius: 16px; cursor: pointer; - background-color: ${({ theme }) => theme.colors.white_opacity['004']}; + background-color: ${({ isError, theme }) => (isError ? '#281515' : theme.colors.white_opacity['004'])}; &:hover { - background-color: ${({ theme }) => theme.colors.white_opacity['008']}; + background-color: ${({ isError, theme }) => (isError ? '#351515' : theme.colors.white_opacity['008'])}; } `; -const SourceIconWrapper = styled.div` +const SourceIconWrapper = styled.div<{ isError?: boolean }>` display: flex; width: 36px; height: 36px; @@ -29,7 +30,10 @@ const SourceIconWrapper = styled.div` align-items: center; gap: 8px; border-radius: 8px; - background: linear-gradient(180deg, rgba(249, 249, 249, 0.06) 0%, rgba(249, 249, 249, 0.02) 100%); + background: ${({ isError }) => + `linear-gradient(180deg, ${isError ? 'rgba(237, 124, 124, 0.08)' : 'rgba(249, 249, 249, 0.06)'} 0%, ${ + isError ? 'rgba(237, 124, 124, 0.02)' : 'rgba(249, 249, 249, 0.02)' + } 100%)`}; `; const BodyWrapper = styled.div` @@ -46,7 +50,7 @@ const FooterWrapper = styled.div` `; const Title = styled(Text)<{ columnWidth: number }>` - width: ${({ columnWidth }) => `${columnWidth - 42}px`}; + width: ${({ columnWidth }) => `${columnWidth - 75}px`}; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -70,37 +74,28 @@ export interface NodeDataProps { interface BaseNodeProps { id: string; + columnWidth: number; isConnectable: boolean; data: NodeDataProps; - columnWidth: number; } -const BaseNode = ({ isConnectable, data, columnWidth }: BaseNodeProps) => { - const { title, subTitle, imageUri, type, monitors, isActive } = data; +const BaseNode = ({ columnWidth, isConnectable, data }: BaseNodeProps) => { + const { type, status, title, subTitle, imageUri, monitors, isActive } = data; function renderHandles() { switch (type) { case 'source': - return ( - <> - {/* Source nodes have an output handle */} - - - ); + return ; case 'action': return ( <> - {/* Action nodes have both input and output handles */} - - + + ); case 'destination': return ( - <> - {/* Destination nodes only have an input handle */} - - + ); default: return null; @@ -108,9 +103,7 @@ const BaseNode = ({ isConnectable, data, columnWidth }: BaseNodeProps) => { } function renderMonitors() { - if (!monitors) { - return null; - } + if (!monitors) return null; return ( @@ -123,9 +116,7 @@ const BaseNode = ({ isConnectable, data, columnWidth }: BaseNodeProps) => { } function renderStatus() { - if (typeof isActive !== 'boolean') { - return null; - } + if (typeof isActive !== 'boolean') return null; return ( @@ -135,8 +126,10 @@ const BaseNode = ({ isConnectable, data, columnWidth }: BaseNodeProps) => { ); } + const isError = status === STATUSES.UNHEALTHY; + return ( - + source @@ -148,6 +141,7 @@ const BaseNode = ({ isConnectable, data, columnWidth }: BaseNodeProps) => { {renderStatus()} + {isError ? : null} {renderHandles()} ); 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 new file mode 100644 index 000000000..f2203074f --- /dev/null +++ b/frontend/webapp/reuseable-components/nodes-data-flow/nodes/group-node.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { Handle, Position } from '@xyflow/react'; + +const GroupNode = () => { + return ( + <> + + + + ); +}; + +export default GroupNode; diff --git a/frontend/webapp/reuseable-components/status/index.tsx b/frontend/webapp/reuseable-components/status/index.tsx index 9d60a81ba..59038ae18 100644 --- a/frontend/webapp/reuseable-components/status/index.tsx +++ b/frontend/webapp/reuseable-components/status/index.tsx @@ -21,7 +21,8 @@ const StatusWrapper = styled.div` width: fit-content; padding: ${({ withIcon, withBorder, withSmaller }) => (withIcon || withBorder ? (withSmaller ? '4px 8px' : '8px 24px') : '0')}; border-radius: 32px; - border: 1px solid ${({ withBorder, isActive, theme }) => (withBorder ? (isActive ? '#2d4323' : '#802828') : 'transparent')}; + border: 1px solid + ${({ withBorder, isActive, theme }) => (withBorder ? (isActive ? theme.colors.dark_green : theme.colors.dark_red) : 'transparent')}; background: ${({ withBackground, isActive }) => withBackground ? isActive diff --git a/frontend/webapp/styles/theme.ts b/frontend/webapp/styles/theme.ts index 50924dcb6..d67fab542 100644 --- a/frontend/webapp/styles/theme.ts +++ b/frontend/webapp/styles/theme.ts @@ -13,8 +13,10 @@ const colors = { card: '#F9F9F90A', dropdown_bg: '#242424', blank_background: '#11111100', - danger: '#EF7676', warning: '#E9CF35', + danger: '#EF7676', + dark_red: '#802828', + dark_green: '#2d4323', white_opacity: { '004': 'rgba(249, 249, 249, 0.04)', '008': 'rgba(249, 249, 249, 0.08)',