diff --git a/package.json b/package.json index 3c36ca13f..7a7e1078f 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "react-dom": "^16.13.1", "serve-static": "^1.12.3", "source-map-loader": "^4.0.1", + "traverse": "^0.6.7", "ts-jest": "^26.3.0", "ts-loader": "^9.2.6", "ts-node": "^8.0.2", @@ -126,7 +127,8 @@ "@storybook/builder-webpack5": "^6.4.19", "@storybook/manager-webpack5": "^6.4.19", "@storybook/react": "^6.4.19", - "@storybook/testing-library": "^0.0.9" + "@storybook/testing-library": "^0.0.9", + "@types/traverse": "^0.6.32" }, "resolutions": { "@babel/cli": "~7.16.0", diff --git a/packages/console/package.json b/packages/console/package.json index c90846828..2646c5c20 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@flyteorg/console", - "version": "0.0.28", + "version": "0.0.29", "description": "Flyteconsole main app module", "main": "./dist/index.js", "module": "./lib/index.js", diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx b/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx index c7423da39..3fd9a32d3 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx @@ -1,4 +1,10 @@ -import React, { createRef, useEffect, useRef, useState } from 'react'; +import React, { + createRef, + useContext, + useEffect, + useRef, + useState, +} from 'react'; import { makeStyles, Typography } from '@material-ui/core'; import { tableHeaderColor } from 'components/Theme/constants'; import { timestampToDate } from 'common/utils'; @@ -10,6 +16,8 @@ import { import { useQueryClient } from 'react-query'; import { eq, merge } from 'lodash'; import { useNodeExecutionsById } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { ExecutionContext } from 'components/Executions/contexts'; +import { useExecutionMetrics } from 'components/Executions/useExecutionMetrics'; import { convertToPlainNodes } from './helpers'; import { ChartHeader } from './ChartHeader'; import { useScaleContext } from './scaleContext'; @@ -17,6 +25,10 @@ import { TaskNames } from './TaskNames'; import { getChartDurationData } from './TimelineChart/chartData'; import { TimelineChart } from './TimelineChart'; import t from '../strings'; +import { + getExecutionMetricsOperationIds, + parseSpanData, +} from './TimelineChart/utils'; interface StyleProps { chartWidth: number; @@ -91,6 +103,8 @@ export const ExecutionTimeline: React.FC = ({ const { nodeExecutionsById, setCurrentNodeExecutionsById } = useNodeExecutionsById(); const { chartInterval: chartTimeInterval } = useScaleContext(); + const { execution } = useContext(ExecutionContext); + const executionMetricsData = useExecutionMetrics(execution.id, 10); useEffect(() => { setOriginalNodes(ogn => { @@ -178,6 +192,12 @@ export const ExecutionTimeline: React.FC = ({ setOriginalNodes([...originalNodes]); }; + const operationIds = getExecutionMetricsOperationIds( + executionMetricsData.value, + ); + + const parsedExecutionMetricsData = parseSpanData(executionMetricsData.value); + return ( <>
@@ -213,6 +233,9 @@ export const ExecutionTimeline: React.FC = ({
diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/barOptions.ts b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/barOptions.ts index 7ec613a58..5f3b07eec 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/barOptions.ts +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/barOptions.ts @@ -1,5 +1,6 @@ import { Chart as ChartJS, registerables, Tooltip } from 'chart.js'; import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { isEqual, isNil } from 'lodash'; ChartJS.register(...registerables, ChartDataLabels); @@ -11,6 +12,9 @@ Tooltip.positioners.cursor = function (_chartElements, coordinates) { export const getBarOptions = ( chartTimeIntervalSec: number, tooltipLabels: string[][], + chartRef: React.MutableRefObject, + tooltip: any, + setTooltip: any, ) => { return { animation: false as const, @@ -31,17 +35,56 @@ export const getBarOptions = ( }, tooltip: { // Setting up tooltip: https://www.chartjs.org/docs/latest/configuration/tooltip.html + enabled: false, position: 'cursor', filter: function (tooltipItem) { // no tooltip for offsets - return tooltipItem.datasetIndex === 1; + return tooltipItem.datasetIndex !== 0; }, callbacks: { label: function (context) { const index = context.dataIndex; - return tooltipLabels[index] ?? ''; + + return tooltipLabels ? [`${tooltipLabels[index]}`] : ''; + }, + labelColor: function () { + return { + fontColor: 'white', + }; }, }, + external: context => { + const tooltipModel = context.tooltip; + + if (!chartRef || !chartRef.current) { + return; + } + + if (tooltipModel.opacity === 0) { + if (tooltip.opacity !== 0) + setTooltip(prev => ({ ...prev, opacity: 0 })); + return; + } + + const position = context.chart.canvas.getBoundingClientRect(); + + const dataIndex = tooltipModel.dataPoints[0]?.dataIndex; + + if (isNil(dataIndex)) { + return; + } + + const newTooltipData = { + opacity: 1, + left: position.left + tooltipModel.caretX, + top: position.top + tooltipModel.caretY, + dataIndex: dataIndex, + }; + + if (!isEqual(tooltip, newTooltipData)) { + setTooltip(newTooltipData); + } + }, }, }, scales: { diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/index.tsx b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/index.tsx index 08a898af6..23b747a85 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/index.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/index.tsx @@ -1,22 +1,117 @@ import * as React from 'react'; import { Bar } from 'react-chartjs-2'; +import { dNode } from 'models/Graph/types'; +import { Box, Theme, makeStyles } from '@material-ui/core'; + +import { NodeExecutionPhase } from 'models'; +import { getNodeExecutionPhaseConstants } from 'components/Executions/utils'; +import { + BarItemData, + formatSecondsToHmsFormat, + generateChartData, + getChartData, + getDuration, + parseSpanData, +} from './utils'; import { getBarOptions } from './barOptions'; -import { BarItemData, generateChartData, getChartData } from './utils'; interface TimelineChartProps { items: BarItemData[]; + nodes: dNode[]; chartTimeIntervalSec: number; + operationIds: string[]; + parsedExecutionMetricsData: ReturnType; +} + +interface StyleProps { + opacity: number; + top: number; + left: number; + phaseColor: string; } +const useStyles = makeStyles(theme => ({ + tooltipContainer: { + position: 'absolute', + background: theme.palette.grey[100], + color: theme.palette.common.black, + padding: theme.spacing(2), + borderRadius: 8, + width: 'fit-content', + maxContent: 'fit-content', + top: ({ top }) => top + 10, + left: ({ left }) => left + 10, + display: ({ opacity }) => (opacity ? 'block' : 'none'), + }, + phaseText: { + width: 'fit-content', + marginBlockEnd: theme.spacing(1), + }, + tooltipText: { + minWidth: '50px', + }, + tooltipTextContainer: { + display: 'flex', + gap: 1, + color: theme.palette.grey[700], + }, + operationIdContainer: { + textAlign: 'left', + flex: 1, + }, +})); + export const TimelineChart = (props: TimelineChartProps) => { + const [tooltip, setTooltip] = React.useState({ + opacity: 0, + top: 0, + left: 0, + dataIndex: -1, + }); + const chartRef = React.useRef(null); const phaseData = generateChartData(props.items); + const options = getBarOptions( + props.chartTimeIntervalSec, + phaseData.tooltipLabel, + chartRef, + tooltip, + setTooltip, + ) as any; + + const data = getChartData(phaseData); + const node = props.nodes[tooltip.dataIndex]; + const phase = node?.execution?.closure.phase ?? NodeExecutionPhase.UNDEFINED; + const phaseConstant = getNodeExecutionPhaseConstants(phase); + const spans = (node && props.parsedExecutionMetricsData[node.scopedId]) || []; + + const styles = useStyles({ + opacity: tooltip.opacity, + top: tooltip.top, + left: tooltip.left, + phaseColor: phaseConstant.badgeColor, + }); + return ( - + <> + + + {phase && {phaseConstant.text}} + {spans?.map(span => ( + + + {formatSecondsToHmsFormat( + Math.round( + (getDuration(span.startTime, span.endTime) / 1000) * 100, + ) / 100, + )} + + + {span.operationId} + + + ))} + + ); }; diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/utils.ts b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/utils.ts index 8afceb910..388b009a4 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/utils.ts +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TimelineChart/utils.ts @@ -2,6 +2,10 @@ import { getNodeExecutionPhaseConstants } from 'components/Executions/utils'; import { primaryTextColor } from 'components/Theme/constants'; import { NodeExecutionPhase } from 'models/Execution/enums'; import t from 'components/Executions/strings'; +import { Admin, Core, Protobuf } from '@flyteorg/flyteidl-types'; +import { get, uniq } from 'lodash'; +import { timestampToDate } from 'common'; +import traverse from 'traverse'; export const CASHED_GREEN = 'rgba(74,227,174,0.25)'; // statusColors.SUCCESS (Mint20) with 25% opacity export const TRANSPARENT = 'rgba(0, 0, 0, 0)'; @@ -24,6 +28,58 @@ interface ChartDataInput { barColor: string[]; } +/** + * Recursively traverses span data and returns a map of nodeId/taskId to span data. + * Example return: + * { + * "n0": [span, span, span], + * "n1": [span, span] + * } + */ +export const parseSpanData = ( + data: Admin.WorkflowExecutionGetMetricsResponse, +) => { + const results: Record = {}; + const workflowSpans = data?.span ?? {}; + + const traverseSpanData = (rootSpan: Core.Span) => { + const spanNodeId = + rootSpan.nodeId?.nodeId || + rootSpan.taskId?.nodeExecutionId?.nodeId || + rootSpan.workflowId?.name || + ''; + + if (!results[spanNodeId]) { + results[spanNodeId] = []; + } + + if (rootSpan.spans?.length > 0) { + rootSpan.spans.forEach(span => { + /* Recurse if taskId/nodeId; else add to record */ + if (span.nodeId?.nodeId || span.taskId?.nodeExecutionId?.nodeId) { + traverseSpanData(span as Core.Span); + } else { + results[spanNodeId].push(span); + } + }); + } + }; + traverseSpanData(workflowSpans as Core.Span); + return results; +}; + +export const getOperationsFromWorkflowExecutionMetrics = ( + data: Admin.WorkflowExecutionGetMetricsResponse, +): string[] => { + const operationIds = uniq( + traverse(data) + .paths() + .filter(path => path.at(-1) === 'operationId') + .map(path => get(data, path)), + ); + + return operationIds; +}; /** * Depending on amounf of second provided shows data in * XhXmXs or XmXs or Xs format @@ -33,12 +89,23 @@ export const formatSecondsToHmsFormat = (seconds: number) => { seconds %= 3600; const minutes = Math.floor(seconds / 60); seconds = seconds % 60; + /** + * Note: + * if we're showing hours or minutes, round seconds + * if we're (only) showing seconds, round to nearest 1/100 + */ if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; + return `${hours}h ${minutes}m ${Math.round(seconds)}s`; } else if (minutes > 0) { - return `${minutes}m ${seconds}s`; + return `${minutes}m ${Math.round(seconds)}s`; + } else { + seconds = Math.round(seconds * 100) / 100; + return `${seconds}s`; } - return `${seconds}s`; +}; + +export const getDurationString = (element: BarItemData): string => { + return formatSecondsToHmsFormat(element.durationSec); }; /** @@ -94,6 +161,28 @@ export const generateChartData = (data: BarItemData[]): ChartDataInput => { }; }; +export const getDuration = ( + startTime: Protobuf.ITimestamp, + endTime?: Protobuf.ITimestamp, +) => { + const endTimeInMS = endTime ? timestampToDate(endTime).getTime() : Date.now(); + const duration = endTimeInMS - timestampToDate(startTime).getTime(); + return duration; +}; + +export const getExecutionMetricsOperationIds = ( + data: Admin.WorkflowExecutionGetMetricsResponse, +): string[] => { + const operationIds = uniq( + traverse(data) + .paths() + .filter(path => path.at(-1) === 'operationId') + .map(path => get(data, path)), + ); + + return operationIds; +}; + /** * Generates chart data format suitable for Chart.js Bar. Each bar consists of two data items: * |-----------|XXXXXXXXXXXXXXXX| @@ -127,6 +216,13 @@ export const getChartData = (data: ChartDataInput) => { ...defaultStyle, data: data.durations, backgroundColor: data.barColor, + borderColor: 'rgba(0, 0, 0, 0.55)', + borderWidth: { + top: 0, + left: 0, + right: 1, + bottom: 0, + }, datalabels: { // Positioning info - https://chartjs-plugin-datalabels.netlify.app/guide/positioning.html color: primaryTextColor, diff --git a/packages/console/src/components/Executions/useExecutionMetrics.tsx b/packages/console/src/components/Executions/useExecutionMetrics.tsx new file mode 100644 index 000000000..4c1cfb93c --- /dev/null +++ b/packages/console/src/components/Executions/useExecutionMetrics.tsx @@ -0,0 +1,37 @@ +import { Admin } from '@flyteorg/flyteidl-types'; +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; +import { useFetchableData } from 'components/hooks/useFetchableData'; +import { WorkflowExecutionIdentifier } from 'models'; + +export const fetchExecutionMetrics = async ( + id: WorkflowExecutionIdentifier, + depth: number, + apiContext: APIContextValue, +) => { + const { getExecutionMetrics } = apiContext; + const metrics = await getExecutionMetrics(id, { + params: { + depth, + }, + }); + return metrics; +}; + +export function useExecutionMetrics( + id: WorkflowExecutionIdentifier, + depth = 0, +) { + const apiContext = useAPIContext(); + + return useFetchableData< + Admin.WorkflowExecutionGetMetricsResponse, + WorkflowExecutionIdentifier + >( + { + debugName: 'ExecutionMetrics', + defaultValue: [] as Admin.WorkflowExecutionGetMetricsResponse, + doFetch: id => fetchExecutionMetrics(id, depth, apiContext), + }, + id, + ); +} diff --git a/packages/console/src/components/data/types.ts b/packages/console/src/components/data/types.ts index d751dca1a..040328487 100644 --- a/packages/console/src/components/data/types.ts +++ b/packages/console/src/components/data/types.ts @@ -4,6 +4,7 @@ import { } from 'react-query'; export enum QueryType { + ExecutionMetrics = 'executionMetrics', NodeExecutionDetails = 'NodeExecutionDetails', DynamicWorkflowFromNodeExecution = 'DynamicWorkflowFromNodeExecution', NodeExecution = 'nodeExecution', diff --git a/packages/console/src/models/Common/constants.ts b/packages/console/src/models/Common/constants.ts index df7b72725..1216444bf 100644 --- a/packages/console/src/models/Common/constants.ts +++ b/packages/console/src/models/Common/constants.ts @@ -6,6 +6,7 @@ export const endpointPrefixes = { namedEntity: '/named_entities', nodeExecution: '/node_executions', dynamicWorkflowExecution: '/data/node_executions', + executionMetrics: '/metrics/executions', project: '/projects', projectAttributes: '/project_attributes', projectDomainAtributes: '/project_domain_attributes', diff --git a/packages/console/src/models/Execution/api.ts b/packages/console/src/models/Execution/api.ts index 9d7839905..58058c29f 100644 --- a/packages/console/src/models/Execution/api.ts +++ b/packages/console/src/models/Execution/api.ts @@ -29,6 +29,7 @@ import { } from './types'; import { executionListTransformer, + makeExecutionMetricsPath, makeExecutionPath, makeNodeExecutionListPath, makeNodeExecutionPath, @@ -427,3 +428,18 @@ export const updateExecution = ( config, ); }; + +export const getExecutionMetrics = ( + id: WorkflowExecutionIdentifier, + config?: RequestConfig, +): Promise => + getAdminEntity< + Admin.WorkflowExecutionGetMetricsResponse, + Admin.WorkflowExecutionGetMetricsResponse + >( + { + path: makeExecutionMetricsPath(id), + messageType: Admin.WorkflowExecutionGetMetricsResponse, + }, + config, + ); diff --git a/packages/console/src/models/Execution/utils.ts b/packages/console/src/models/Execution/utils.ts index 3a7980932..77aeb9c3f 100644 --- a/packages/console/src/models/Execution/utils.ts +++ b/packages/console/src/models/Execution/utils.ts @@ -1,3 +1,4 @@ +import { WorkflowExecutionIdentifier } from 'models/Execution/types'; import { Admin } from '@flyteorg/flyteidl-types'; import { createPaginationTransformer } from 'models/AdminEntity/utils'; import { endpointPrefixes } from 'models/Common/constants'; @@ -9,9 +10,16 @@ import { NodeExecutionIdentifier, TaskExecution, TaskExecutionIdentifier, - WorkflowExecutionIdentifier, } from './types'; +/** Generates the API endpoint for getting execution metrics for a given workflow */ +export const makeExecutionMetricsPath = ({ + project, + domain, + name, +}: WorkflowExecutionIdentifier) => + [endpointPrefixes.executionMetrics, project, domain, name].join('/'); + /** Generates the API endpoint for a given `WorkflowExecutionIdentifier` */ export const makeExecutionPath = ({ project, diff --git a/website/package.json b/website/package.json index 71c65a075..be189ae0d 100644 --- a/website/package.json +++ b/website/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@flyteorg/common": "^0.0.4", - "@flyteorg/console": "^0.0.28", + "@flyteorg/console": "^0.0.29", "long": "^4.0.0", "protobufjs": "~6.11.3", "react-ga4": "^1.4.1", diff --git a/yarn.lock b/yarn.lock index 1359e8180..a87818160 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2020,7 +2020,7 @@ __metadata: resolution: "@flyteconsole/client-app@workspace:website" dependencies: "@flyteorg/common": ^0.0.4 - "@flyteorg/console": ^0.0.28 + "@flyteorg/console": ^0.0.29 "@types/long": ^3.0.32 long: ^4.0.0 protobufjs: ~6.11.3 @@ -2059,7 +2059,7 @@ __metadata: languageName: unknown linkType: soft -"@flyteorg/console@^0.0.28, @flyteorg/console@workspace:packages/console": +"@flyteorg/console@^0.0.29, @flyteorg/console@workspace:packages/console": version: 0.0.0-use.local resolution: "@flyteorg/console@workspace:packages/console" dependencies: @@ -5620,6 +5620,13 @@ __metadata: languageName: node linkType: hard +"@types/traverse@npm:^0.6.32": + version: 0.6.32 + resolution: "@types/traverse@npm:0.6.32" + checksum: 0d27f4758e1da79463c42cdd91de018095048e5c3963ad108b97044d5f43ea48b071da87062fdd4ff46407461802cc47367dd90b8f4a03a3dcf2ff94840d4909 + languageName: node + linkType: hard + "@types/uglify-js@npm:*": version: 3.17.1 resolution: "@types/uglify-js@npm:3.17.1" @@ -11663,6 +11670,7 @@ __metadata: "@types/morgan": ^1.9.4 "@types/react": ^16.14.35 "@types/react-dom": ^16.9.7 + "@types/traverse": ^0.6.32 "@typescript-eslint/eslint-plugin": ^5.48.2 "@typescript-eslint/parser": ^5.48.2 babel-loader: ^8.2.5 @@ -11695,6 +11703,7 @@ __metadata: react-dom: ^16.13.1 serve-static: ^1.12.3 source-map-loader: ^4.0.1 + traverse: ^0.6.7 ts-jest: ^26.3.0 ts-loader: ^9.2.6 ts-node: ^8.0.2 @@ -21258,6 +21267,13 @@ __metadata: languageName: node linkType: hard +"traverse@npm:^0.6.7": + version: 0.6.7 + resolution: "traverse@npm:0.6.7" + checksum: 21018085ab72f717991597e12e2b52446962ed59df591502e4d7e1a709bc0a989f7c3d451aa7d882666ad0634f1546d696c5edecda1f2fc228777df7bb529a1e + languageName: node + linkType: hard + "treeverse@npm:*, treeverse@npm:^3.0.0": version: 3.0.0 resolution: "treeverse@npm:3.0.0"