diff --git a/.prettierrc.yml b/.prettierrc.yml index 52da3342dd..c3a355ff6c 100644 --- a/.prettierrc.yml +++ b/.prettierrc.yml @@ -1,7 +1,9 @@ singleQuote: true tabWidth: 4 +max-line-length: 120 +printWidth: 120 overrides: - - files: "*.json" + - files: ["*.js*", "*.ts*", "*.json"] options: tabWidth: 2 - files: ["*.yml", "*.yaml"] diff --git a/package.json b/package.json index 9a560d3faa..de92c2d2c1 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "babel-polyfill": "^6.26.0", "cache": "^2.1.0", "chalk": "^2.0.1", + "chart.js": "^3.6.2", + "chartjs-plugin-datalabels": "^2.0.0", "cheerio": "^1.0.0-rc.2", "cookie-parser": "^1.4.3", "dagre-d3": "^0.6.4", @@ -57,6 +59,7 @@ "lodash": "^4.17.19", "memory-fs": "^0.4.1", "morgan": "^1.8.2", + "react-chartjs-2": "^4.0.0", "react-flow-renderer": "^9.6.3", "react-ga4": "^1.4.1", "react-helmet": "^5.1.3", diff --git a/src/basics/FeatureFlags/defaultConfig.ts b/src/basics/FeatureFlags/defaultConfig.ts index f7a861320d..a3d0fcbbff 100644 --- a/src/basics/FeatureFlags/defaultConfig.ts +++ b/src/basics/FeatureFlags/defaultConfig.ts @@ -3,21 +3,23 @@ */ export enum FeatureFlag { - // Test flag is created only for unit-tests - TestFlagUndefined = 'test-flag-undefined', - TestFlagTrue = 'test-flag-true', + // Test flag is created only for unit-tests + TestFlagUndefined = 'test-flag-undefined', + TestFlagTrue = 'test-flag-true', - // Production flags - LaunchPlan = 'launch-plan' + // Production flags + LaunchPlan = 'launch-plan', + TimelineView = 'timeline-view' } export type FeatureFlagConfig = { [k: string]: boolean }; export const defaultFlagConfig: FeatureFlagConfig = { - // Test - 'test-flag-true': true, + // Test + 'test-flag-true': true, - // Production - new code should be turned off by default - // If you need to turn it on locally -> update runtimeConfig in ./index.tsx file - 'launch-plan': false + // Production - new code should be turned off by default + // If you need to turn it on locally -> update runtimeConfig in ./index.tsx file + 'launch-plan': false, + 'timeline-view': false }; diff --git a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx index d818f14347..4aa30c787c 100644 --- a/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx +++ b/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx @@ -1,7 +1,9 @@ import * as React from 'react'; import { Tab, Tabs } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; +import { noop } from 'lodash'; +import { FeatureFlag, useFeatureFlag } from 'basics/FeatureFlags'; import { WaitForQuery } from 'components/common/WaitForQuery'; import { DataError } from 'components/Errors/DataError'; import { useTabState } from 'components/hooks/useTabState'; @@ -15,104 +17,97 @@ import { NodeExecutionsTable } from '../Tables/NodeExecutionsTable'; import { tabs } from './constants'; import { ExecutionChildrenLoader } from './ExecutionChildrenLoader'; import { useExecutionNodeViewsState } from './useExecutionNodeViewsState'; +import { ExecutionNodesTimeline } from './Timeline'; const useStyles = makeStyles((theme: Theme) => ({ - filters: { - paddingLeft: theme.spacing(3) - }, - nodesContainer: { - borderTop: `1px solid ${theme.palette.divider}`, - display: 'flex', - flex: '1 1 100%', - flexDirection: 'column', - minHeight: 0 - }, - tabs: { - background: secondaryBackgroundColor, - paddingLeft: theme.spacing(3.5) - } + filters: { + paddingLeft: theme.spacing(3) + }, + nodesContainer: { + borderTop: `1px solid ${theme.palette.divider}`, + display: 'flex', + flex: '1 1 100%', + flexDirection: 'column', + minHeight: 0 + }, + tabs: { + background: secondaryBackgroundColor, + paddingLeft: theme.spacing(3.5) + } })); export interface ExecutionNodeViewsProps { - execution: Execution; + execution: Execution; } /** Contains the available ways to visualize the nodes of a WorkflowExecution */ -export const ExecutionNodeViews: React.FC = ({ - execution -}) => { - const styles = useStyles(); - const filterState = useNodeExecutionFiltersState(); - const tabState = useTabState(tabs, tabs.nodes.id); +export const ExecutionNodeViews: React.FC = ({ execution }) => { + const defaultTab = tabs.nodes.id; + const styles = useStyles(); + const filterState = useNodeExecutionFiltersState(); + const tabState = useTabState(tabs, defaultTab); + const isTimelineEnabled = useFeatureFlag(FeatureFlag.TimelineView); - const { - closure: { abortMetadata } - } = execution; + const { + closure: { abortMetadata } + } = execution; - /* We want to maintain the filter selection when switching away from the Nodes + if (!isTimelineEnabled && tabState.value === tabs.timeline.id) { + tabState.onChange(noop, defaultTab); + } + + /* We want to maintain the filter selection when switching away from the Nodes tab and back, but do not want to filter the nodes when viewing the graph. So, we will only pass filters to the execution state when on the nodes tab. */ - const appliedFilters = - tabState.value === tabs.nodes.id ? filterState.appliedFilters : []; + const appliedFilters = tabState.value === tabs.nodes.id ? filterState.appliedFilters : []; + + const { nodeExecutionsQuery, nodeExecutionsRequestConfig } = useExecutionNodeViewsState(execution, appliedFilters); - const { - nodeExecutionsQuery, - nodeExecutionsRequestConfig - } = useExecutionNodeViewsState(execution, appliedFilters); + const renderNodeExecutionsTable = (nodeExecutions: NodeExecution[]) => ( + + + + ); - const renderNodeExecutionsTable = (nodeExecutions: NodeExecution[]) => ( - - - - ); + const renderExecutionLoader = (nodeExecutions: NodeExecution[]) => { + return ; + }; - const renderExecutionLoader = (nodeExecutions: NodeExecution[]) => { - return ( - - ); - }; + const renderExecutionsTimeline = (nodeExecutions: NodeExecution[]) => ( + + ); - return ( - <> - - - - - -
- {tabState.value === tabs.nodes.id && ( - <> -
- -
- - {renderNodeExecutionsTable} - - - )} - {tabState.value === tabs.graph.id && ( - - {renderExecutionLoader} - - )} -
-
- - ); + return ( + <> + + + + {isTimelineEnabled && } + + +
+ {tabState.value === tabs.nodes.id && ( + <> +
+ +
+ + {renderNodeExecutionsTable} + + + )} + {tabState.value === tabs.graph.id && ( + + {renderExecutionLoader} + + )} + {isTimelineEnabled && tabState.value === tabs.timeline.id && ( + + {renderExecutionsTimeline} + + )} +
+
+ + ); }; diff --git a/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx b/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx new file mode 100644 index 0000000000..be4b922e8b --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx @@ -0,0 +1,217 @@ +import * as React from 'react'; +import * as moment from 'moment-timezone'; +import { Bar } from 'react-chartjs-2'; +import { Chart as ChartJS, registerables } from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { makeStyles, Typography } from '@material-ui/core'; + +import { useNodeExecutionContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { transformerWorkflowToPlainNodes } from 'components/WorkflowGraph/transformerWorkflowToDag'; +import { isEndNode, isStartNode, isExpanded } from 'components/WorkflowGraph/utils'; +import { tableHeaderColor } from 'components/Theme/constants'; +import { NodeExecution } from 'models/Execution/types'; +import { dNode } from 'models/Graph/types'; +import { TaskNames } from './taskNames'; +import { convertToPlainNodes, getBarOptions, TimeZone } from './helpers'; +import { ChartHeader } from './chartHeader'; +import { useChartDurationData } from './chartData'; + +// Register components to be usable by chart.js +ChartJS.register(...registerables, ChartDataLabels); + +interface StyleProps { + chartWidth: number; + durationLength: number; +} + +const useStyles = makeStyles(theme => ({ + chartHeader: (props: StyleProps) => ({ + marginTop: -10, + marginLeft: -15, + width: `${props.chartWidth + 20}px`, + height: `${56 * props.durationLength + 20}px` + }), + taskNames: { + display: 'flex', + flexDirection: 'column', + borderRight: `1px solid ${theme.palette.divider}`, + overflowY: 'auto' + }, + taskNamesHeader: { + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + lineHeight: '16px', + color: tableHeaderColor, + height: 45, + flexBasis: 45, + display: 'flex', + alignItems: 'center', + borderBottom: `4px solid ${theme.palette.divider}`, + paddingLeft: 30 + }, + taskDurations: { + borderLeft: `1px solid ${theme.palette.divider}`, + marginLeft: 4, + flex: 1, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column' + }, + taskDurationsLabelsView: { + overflow: 'hidden', + borderBottom: `4px solid ${theme.palette.divider}` + }, + taskDurationsView: { + flex: 1, + overflowY: 'hidden' + } +})); + +const INTERVAL_LENGTH = 110; + +interface ExProps { + nodeExecutions: NodeExecution[]; + chartTimeInterval: number; + chartTimezone: string; +} + +export const ExecutionTimeline: React.FC = ({ nodeExecutions, chartTimeInterval, chartTimezone }) => { + const [chartWidth, setChartWidth] = React.useState(0); + const [labelInterval, setLabelInterval] = React.useState(INTERVAL_LENGTH); + const durationsRef = React.useRef(null); + const durationsLabelsRef = React.useRef(null); + const taskNamesRef = React.createRef(); + + const [originalNodes, setOriginalNodes] = React.useState([]); + + const { compiledWorkflowClosure } = useNodeExecutionContext(); + + // narusina - we need to propely merge executions with planeNodes instead of original Nodes + React.useEffect(() => { + const { nodes: originalNodes } = transformerWorkflowToPlainNodes(compiledWorkflowClosure!); + setOriginalNodes( + originalNodes.map(node => { + const index = nodeExecutions.findIndex(exe => exe.id.nodeId === node.id && exe.scopedId === node.scopedId); + return { + ...node, + execution: index >= 0 ? nodeExecutions[index] : undefined + }; + }) + ); + }, [compiledWorkflowClosure, nodeExecutions]); + + const nodes = convertToPlainNodes(originalNodes); + + const { startedAt, totalDuration, durationLength, chartData } = useChartDurationData({ nodes: nodes }); + const styles = useStyles({ chartWidth: chartWidth, durationLength: durationLength }); + + React.useEffect(() => { + const calcWidth = Math.ceil(totalDuration / chartTimeInterval) * INTERVAL_LENGTH; + if (!durationsRef.current) { + setChartWidth(calcWidth); + setLabelInterval(INTERVAL_LENGTH); + } else if (calcWidth < durationsRef.current.clientWidth) { + setLabelInterval(durationsRef.current.clientWidth / Math.ceil(totalDuration / chartTimeInterval)); + setChartWidth(durationsRef.current.clientWidth); + } else { + setChartWidth(calcWidth); + setLabelInterval(INTERVAL_LENGTH); + } + }, [totalDuration, chartTimeInterval, durationsRef]); + + React.useEffect(() => { + const durationsView = durationsRef?.current; + const labelsView = durationsLabelsRef?.current; + if (durationsView && labelsView) { + const handleScroll = e => { + durationsView.scrollTo({ + left: durationsView.scrollLeft + e.deltaY, + behavior: 'smooth' + }); + labelsView.scrollTo({ + left: labelsView.scrollLeft + e.deltaY, + behavior: 'smooth' + }); + }; + + durationsView.addEventListener('wheel', handleScroll); + + return () => durationsView.removeEventListener('wheel', handleScroll); + } + + return () => {}; + }, [durationsRef, durationsLabelsRef]); + + React.useEffect(() => { + const el = taskNamesRef.current; + if (el) { + const handleScroll = e => { + const canvasView = durationsRef?.current; + if (canvasView) { + canvasView.scrollTo({ + top: e.srcElement.scrollTop + // behavior: 'smooth' + }); + } + }; + + el.addEventListener('scroll', handleScroll); + + return () => el.removeEventListener('scroll', handleScroll); + } + + return () => {}; + }, [taskNamesRef, durationsRef]); + + const toggleNode = (id: string, scopeId: string) => { + const searchNode = (nodes: dNode[]) => { + if (!nodes || nodes.length === 0) { + return; + } + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (isStartNode(node) || isEndNode(node)) { + continue; + } + if (node.id === id && node.scopedId === scopeId) { + nodes[i].expanded = !nodes[i].expanded; + return; + } + if (node.nodes.length > 0 && isExpanded(node)) { + searchNode(node.nodes); + } + } + }; + searchNode(originalNodes); + setOriginalNodes([...originalNodes]); + }; + + const labels = React.useMemo(() => { + const len = Math.ceil(totalDuration / chartTimeInterval); + const lbs = len > 0 ? new Array(len).fill(0) : []; + return lbs.map((_, idx) => { + const time = moment.utc(new Date(startedAt.getTime() + idx * chartTimeInterval * 1000)); + return chartTimezone === TimeZone.UTC ? time.format('hh:mm:ss A') : time.local().format('hh:mm:ss A'); + }); + }, [chartTimezone, startedAt, chartTimeInterval, totalDuration]); + + return ( + <> +
+ Task Name + +
+
+
+ +
+
+
+ +
+
+
+ + ); +}; diff --git a/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimelineFooter.tsx b/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimelineFooter.tsx new file mode 100644 index 0000000000..bc804dc1dd --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimelineFooter.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { Theme, Radio, RadioGroup, Slider, Tooltip } from '@material-ui/core'; +import { makeStyles, withStyles } from '@material-ui/styles'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { COLOR_SPECTRUM } from 'components/Theme/colorSpectrum'; +import { TimeZone } from './helpers'; + +function valueText(value: number) { + return `${value}s`; +} + +const useStyles = makeStyles((theme: Theme) => ({ + container: { + borderTop: `1px solid ${theme.palette.divider}`, + padding: '20px 24px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' + } +})); + +const CustomSlider = withStyles({ + root: { + color: COLOR_SPECTRUM.indigo60.color, + height: 4, + padding: '15px 0', + width: 360 + }, + active: {}, + valueLabel: { + left: 'calc(-50% + 12px)', + color: COLOR_SPECTRUM.black.color, + top: -22, + '& *': { + background: 'transparent', + color: COLOR_SPECTRUM.black.color + } + }, + track: { + height: 4 + }, + rail: { + height: 4, + opacity: 0.5, + backgroundColor: COLOR_SPECTRUM.gray20.color + }, + mark: { + backgroundColor: COLOR_SPECTRUM.gray20.color, + height: 8, + width: 2, + marginTop: -2 + }, + markLabel: { + top: -6, + fontSize: 12, + color: COLOR_SPECTRUM.gray40.color + }, + markActive: { + opacity: 1, + backgroundColor: 'currentColor' + }, + marked: { + marginBottom: 0 + } +})(Slider); + +const formatSeconds = t => { + const time = Math.floor(t); + if (time < 60) { + return `${time}s`; + } + if (time % 60 === 0) { + return `${Math.floor(time / 60)}m`; + } + return `${Math.floor(time / 60)}m ${time % 60}s`; +}; + +const percentage = [0, 0.1, 0.25, 0.5, 0.75, 1]; + +interface ExecutionTimelineFooterProps { + maxTime: number; + onTimezoneChange?: (timezone: string) => void; + onTimeIntervalChange?: (interval: number) => void; +} + +export const ExecutionTimelineFooter: React.FC = ({ + maxTime, + onTimezoneChange, + onTimeIntervalChange +}) => { + const styles = useStyles(); + const [timezone, setTimezone] = React.useState(TimeZone.Local); + const [timeInterval, setTimeInterval] = React.useState(1); + + const getTitle = React.useCallback( + value => { + return value === 0 ? '1s' : formatSeconds(maxTime * percentage[value]); + }, + [maxTime] + ); + + const marks = React.useMemo( + () => + percentage.map((_, index) => ({ + value: index, + label: getTitle(index) + })), + [getTitle] + ); + + const handleTimezoneChange = (event: React.ChangeEvent) => { + const newTimezone = (event.target as HTMLInputElement).value; + setTimezone(newTimezone); + if (onTimezoneChange) { + onTimezoneChange(newTimezone); + } + }; + + const handleTimeIntervalChange = (event, newValue) => { + setTimeInterval(newValue); + if (onTimeIntervalChange) { + onTimeIntervalChange(newValue === 0 ? 1 : Math.floor(maxTime * percentage[newValue])); + } + }; + + return ( +
+ ( + + {children} + + )} + valueLabelDisplay="on" + getAriaValueText={valueText} + /> + + } label="Local Time" /> + } label="UTC" /> + +
+ ); +}; diff --git a/src/components/Executions/ExecutionDetails/Timeline/NodeExecutionName.tsx b/src/components/Executions/ExecutionDetails/Timeline/NodeExecutionName.tsx new file mode 100644 index 0000000000..205f0591ac --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/NodeExecutionName.tsx @@ -0,0 +1,35 @@ +import { makeStyles, Theme } from '@material-ui/core'; +import Typography from '@material-ui/core/Typography'; +import { useCommonStyles } from 'components/common/styles'; +import { SelectNodeExecutionLink } from 'components/Executions/Tables/SelectNodeExecutionLink'; +import { isEqual } from 'lodash'; +import { NodeExecution } from 'models/Execution/types'; +import * as React from 'react'; +import { NodeExecutionsTimelineContextData } from './context'; + +interface NodeExecutionTimelineNameData { + name: string; + execution: NodeExecution; + state: NodeExecutionsTimelineContextData; +} + +const useStyles = makeStyles((_theme: Theme) => ({ + selectedExecutionName: { + fontWeight: 'bold' + } +})); + +export const NodeExecutionName: React.FC = ({ name, execution, state }) => { + const commonStyles = useCommonStyles(); + const styles = useStyles(); + + const isSelected = state.selectedExecution != null && isEqual(execution.id, state.selectedExecution); + + return isSelected ? ( + + {name} + + ) : ( + + ); +}; diff --git a/src/components/Executions/ExecutionDetails/Timeline/chartData.tsx b/src/components/Executions/ExecutionDetails/Timeline/chartData.tsx new file mode 100644 index 0000000000..242d35a759 --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/chartData.tsx @@ -0,0 +1,126 @@ +import { durationToMilliseconds, timestampToDate } from 'common/utils'; +import { getNodeExecutionPhaseConstants } from 'components/Executions/utils'; +import { NodeExecutionPhase } from 'models/Execution/enums'; +import { dNode } from 'models/Graph/types'; +import * as React from 'react'; + +interface DataProps { + nodes: dNode[]; +} + +export const useChartDurationData = (props: DataProps) => { + const colorData = React.useMemo(() => { + const definedExecutions = props.nodes.map( + ({ execution }) => + getNodeExecutionPhaseConstants(execution?.closure.phase ?? NodeExecutionPhase.UNDEFINED).badgeColor + ); + return definedExecutions; + }, [props.nodes]); + + const startedAt = React.useMemo(() => { + if (props.nodes.length === 0 || !props.nodes[0].execution?.closure.startedAt) { + return new Date(); + } + return timestampToDate(props.nodes[0].execution?.closure.startedAt); + }, [props.nodes]); + + const stackedData = React.useMemo(() => { + let undefinedStart = 0; + for (const node of props.nodes) { + const exec = node.execution; + if (exec?.closure.startedAt) { + const startedTime = timestampToDate(exec?.closure.startedAt).getTime(); + const absoluteDuration = + startedTime - + startedAt.getTime() + + (exec?.closure.duration ? durationToMilliseconds(exec?.closure.duration) : Date.now() - startedTime); + if (absoluteDuration > undefinedStart) { + undefinedStart = absoluteDuration; + } + } + } + undefinedStart = undefinedStart / 1000; + + const definedExecutions = props.nodes.map(({ execution }) => + execution?.closure.startedAt + ? (timestampToDate(execution?.closure.startedAt).getTime() - startedAt.getTime()) / 1000 + : 0 + ); + + return definedExecutions; + }, [props.nodes, startedAt]); + + // Divide by 1000 to calculate all duration data be second based. + const durationData = React.useMemo(() => { + const definedExecutions = props.nodes.map(node => { + const exec = node.execution; + if (!exec) return 0; + if (exec.closure.phase === NodeExecutionPhase.RUNNING) { + if (!exec.closure.startedAt) { + return 0; + } + return (Date.now() - timestampToDate(exec.closure.startedAt).getTime()) / 1000; + } + if (!exec.closure.duration) { + return 0; + } + return durationToMilliseconds(exec.closure.duration) / 1000; + }); + return definedExecutions; + }, [props.nodes]); + + const totalDuration = React.useMemo(() => { + const durations = durationData.map((duration, idx) => duration + stackedData[idx]); + return Math.max(...durations); + }, [durationData, stackedData]); + + const stackedColorData = React.useMemo(() => { + return durationData.map(duration => { + return duration === 0 ? '#4AE3AE40' : 'rgba(0, 0, 0, 0)'; + }); + }, [durationData]); + + const chartData = React.useMemo(() => { + return { + labels: durationData.map(() => ''), + datasets: [ + { + data: stackedData, + backgroundColor: stackedColorData, + barPercentage: 1, + borderWidth: 0, + datalabels: { + labels: { + title: null + } + } + }, + { + data: durationData.map(duration => { + return duration === -1 ? 10 : duration === 0 ? 0.5 : duration; + }), + backgroundColor: colorData, + barPercentage: 1, + borderWidth: 0, + datalabels: { + color: '#292936' as const, + align: 'start' as const, + formatter: function(value, context) { + if (durationData[context.dataIndex] === -1) { + return ''; + } + return Math.round(value) + 's'; + } + } + } + ] + }; + }, [durationData, stackedData, colorData, stackedColorData]); + + return { + startedAt, + totalDuration, + durationLength: durationData.length, + chartData + }; +}; diff --git a/src/components/Executions/ExecutionDetails/Timeline/chartHeader.tsx b/src/components/Executions/ExecutionDetails/Timeline/chartHeader.tsx new file mode 100644 index 0000000000..f06f4e60f7 --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/chartHeader.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import { COLOR_SPECTRUM } from 'components/Theme/colorSpectrum'; + +interface StyleProps { + chartWidth: number; + labelInterval: number; +} + +const useStyles = makeStyles(_theme => ({ + chartHeader: (props: StyleProps) => ({ + height: 41, + display: 'flex', + alignItems: 'center', + width: `${props.chartWidth}px` + }), + taskDurationsLabelItem: (props: StyleProps) => ({ + fontSize: 12, + fontFamily: 'Open Sans', + fontWeight: 'bold', + color: COLOR_SPECTRUM.gray40.color, + paddingLeft: 10, + width: `${props.labelInterval}px` + }) +})); + +interface HeaderProps extends StyleProps { + labels: string[]; +} + +export const ChartHeader = (props: HeaderProps) => { + const styles = useStyles(props); + + return ( +
+ {props.labels.map((label, idx) => { + return ( +
+ {label} +
+ ); + })} +
+ ); +}; diff --git a/src/components/Executions/ExecutionDetails/Timeline/context.ts b/src/components/Executions/ExecutionDetails/Timeline/context.ts new file mode 100644 index 0000000000..e2109b76d5 --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/context.ts @@ -0,0 +1,13 @@ +import { NodeExecutionIdentifier } from 'models/Execution/types'; +import * as React from 'react'; + +export interface NodeExecutionsTimelineContextData { + selectedExecution?: NodeExecutionIdentifier | null; + setSelectedExecution: ( + selectedExecutionId: NodeExecutionIdentifier | null + ) => void; +} + +export const NodeExecutionsTimelineContext = React.createContext< + NodeExecutionsTimelineContextData +>({} as NodeExecutionsTimelineContextData); diff --git a/src/components/Executions/ExecutionDetails/Timeline/helpers.ts b/src/components/Executions/ExecutionDetails/Timeline/helpers.ts new file mode 100644 index 0000000000..cdd52683e7 --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/helpers.ts @@ -0,0 +1,66 @@ +import { isEndNode, isExpanded, isStartNode } from 'components/WorkflowGraph/utils'; +import { dNode } from 'models/Graph/types'; + +export const TimeZone = { + Local: 'local', + UTC: 'utc' +}; + +export function convertToPlainNodes(nodes: dNode[], level = 0): dNode[] { + const result: dNode[] = []; + if (!nodes || nodes.length === 0) { + return result; + } + nodes.forEach(node => { + if (isStartNode(node) || isEndNode(node)) { + return; + } + result.push({ ...node, level }); + if (node.nodes.length > 0 && isExpanded(node)) { + result.push(...convertToPlainNodes(node.nodes, level + 1)); + } + }); + return result; +} + +export const getBarOptions = (chartTimeInterval: number) => { + return { + animation: false as const, + indexAxis: 'y' as const, + elements: { + bar: { + borderWidth: 2 + } + }, + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false + }, + title: { + display: false + }, + tooltip: { + filter: function(tooltipItem) { + return tooltipItem.datasetIndex === 1; + } + } + }, + scales: { + x: { + format: Intl.DateTimeFormat, + position: 'top' as const, + ticks: { + display: false, + autoSkip: false, + stepSize: chartTimeInterval + }, + stacked: true + }, + y: { + stacked: true + } + } + }; +}; diff --git a/src/components/Executions/ExecutionDetails/Timeline/index.tsx b/src/components/Executions/ExecutionDetails/Timeline/index.tsx new file mode 100644 index 0000000000..2a4ac74549 --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/index.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { makeStyles } from '@material-ui/core'; +import { NodeExecution, NodeExecutionIdentifier } from 'models/Execution/types'; +import { WaitForQuery } from 'components/common/WaitForQuery'; +import { NodeExecutionsRequestConfigContext } from 'components/Executions/contexts'; +import { useAllTreeNodeExecutionGroupsQuery } from 'components/Executions/nodeExecutionQueries'; +import { DataError } from 'components/Errors/DataError'; +import { DetailsPanel } from 'components/common/DetailsPanel'; +import { NodeExecutionDetailsPanelContent } from '../NodeExecutionDetailsPanelContent'; +import { NodeExecutionsTimelineContext } from './context'; +import { ExecutionTimelineFooter } from './ExecutionTimelineFooter'; +import { ExecutionTimeline } from './ExecutionTimeline'; +import { TimeZone } from './helpers'; + +const useStyles = makeStyles(() => ({ + wrapper: { + display: 'flex', + flexDirection: 'column', + flex: '1 1 100%' + }, + container: { + display: 'flex', + flex: '1 1 0', + overflowY: 'auto' + } +})); + +interface TimelineProps { + nodeExecutions: NodeExecution[]; +} + +export const ExecutionNodesTimeline = (props: TimelineProps) => { + const styles = useStyles(); + + const [selectedExecution, setSelectedExecution] = React.useState(null); + const [chartTimeInterval, setChartTimeInterval] = React.useState(12); //narusina - should use 1-6 point system instead of real numbers + const [chartTimezone, setChartTimezone] = React.useState(TimeZone.Local); + + const onCloseDetailsPanel = () => setSelectedExecution(null); + const handleTimeIntervalChange = interval => setChartTimeInterval(interval); + const handleTimezoneChange = tz => setChartTimezone(tz); + + const requestConfig = React.useContext(NodeExecutionsRequestConfigContext); + const childGroupsQuery = useAllTreeNodeExecutionGroupsQuery(props.nodeExecutions, requestConfig); + + const timelineContext = React.useMemo(() => ({ selectedExecution, setSelectedExecution }), [ + selectedExecution, + setSelectedExecution + ]); + + const renderExecutionsTimeline = (nodeExecutions: NodeExecution[]) => { + console.log(`!!! NODE: ${nodeExecutions.length}`); + return ( + + ); + }; + + return ( + <> +
+
+ + + {renderExecutionsTimeline} + + +
+ +
+ + {/* Side panel, shows information for specific node */} + + {selectedExecution && ( + + )} + + + ); +}; diff --git a/src/components/Executions/ExecutionDetails/Timeline/taskNames.tsx b/src/components/Executions/ExecutionDetails/Timeline/taskNames.tsx new file mode 100644 index 0000000000..13055dd854 --- /dev/null +++ b/src/components/Executions/ExecutionDetails/Timeline/taskNames.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { makeStyles, Theme, Typography } from '@material-ui/core'; + +import { RowExpander } from 'components/Executions/Tables/RowExpander'; +import { NodeExecutionName } from './NodeExecutionName'; +import { NodeExecutionsTimelineContext } from './context'; +import { getNodeTemplateName } from 'components/WorkflowGraph/utils'; +import { dNode } from 'models/Graph/types'; + +const useStyles = makeStyles((theme: Theme) => ({ + taskNamesList: { + overflowY: 'scroll', + flex: 1 + }, + namesContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'left', + padding: '0 10px', + height: 56, + width: 256, + borderBottom: `1px solid ${theme.palette.divider}`, + whiteSpace: 'nowrap' + }, + namesContainerExpander: { + display: 'flex', + marginTop: 'auto', + marginBottom: 'auto' + }, + namesContainerBody: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + justifyContent: 'center', + whiteSpace: 'nowrap', + height: '100%', + overflow: 'hidden' + }, + displayName: { + marginTop: 4, + textOverflow: 'ellipsis', + width: '100%', + overflow: 'hidden' + }, + leaf: { + width: 30 + } +})); + +interface TaskNamesProps { + nodes: dNode[]; + onToggle: (id: string, scopeId: string) => void; +} + +export const TaskNames = React.forwardRef((props, ref) => { + const state = React.useContext(NodeExecutionsTimelineContext); + const { nodes, onToggle } = props; + const styles = useStyles(); + + return ( +
+ {nodes.map(node => { + const templateName = getNodeTemplateName(node); + return ( +
+
+ {node.nodes?.length ? ( + onToggle(node.id, node.scopedId)} /> + ) : ( +
+ )} +
+ +
+ + + {templateName} + +
+
+ ); + })} +
+ ); +}); diff --git a/src/components/Executions/ExecutionDetails/constants.ts b/src/components/Executions/ExecutionDetails/constants.ts index 2dd30a1240..90df4e6eba 100644 --- a/src/components/Executions/ExecutionDetails/constants.ts +++ b/src/components/Executions/ExecutionDetails/constants.ts @@ -20,6 +20,10 @@ export const tabs = { graph: { id: 'graph', label: 'Graph' + }, + timeline: { + id: 'timeline', + label: 'Timeline' } }; diff --git a/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx b/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx index d0671617a2..d03f6d7384 100644 --- a/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx +++ b/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx @@ -17,6 +17,15 @@ jest.mock('../ExecutionWorkflowGraph.tsx', () => ({ ExecutionWorkflowGraph: () => null })); +jest.mock('chart.js', () => ({ + Chart: { register: () => null }, + registerables: [] +})); + +jest.mock('chartjs-plugin-datalabels', () => ({ + ChartDataLabels: null +})); + // ExecutionNodeViews uses query params for NE list, so we must match them // for the list to be returned properly const baseQueryParams = { diff --git a/src/components/Executions/Tables/SelectNodeExecutionLink.tsx b/src/components/Executions/Tables/SelectNodeExecutionLink.tsx index 6b486fb563..f693387fd7 100644 --- a/src/components/Executions/Tables/SelectNodeExecutionLink.tsx +++ b/src/components/Executions/Tables/SelectNodeExecutionLink.tsx @@ -7,20 +7,15 @@ import { NodeExecutionsTableState } from './types'; * given NodeExecution. */ export const SelectNodeExecutionLink: React.FC<{ - className?: string; - execution: NodeExecution; - linkText: string; - state: NodeExecutionsTableState; + className?: string; + execution: NodeExecution; + linkText: string; + state: NodeExecutionsTableState; }> = ({ className, execution, linkText, state }) => { - const onClick = () => state.setSelectedExecution(execution.id); - return ( - - {linkText} - - ); + const onClick = () => state.setSelectedExecution(execution.id); + return ( + + {linkText} + + ); }; diff --git a/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx b/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx index 37d5b374ad..44642b385e 100644 --- a/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx +++ b/src/components/Executions/Tables/__stories__/NodeExecutionsTable.stories.tsx @@ -8,50 +8,46 @@ import { useQuery, useQueryClient } from 'react-query'; import { NodeExecutionsTable } from '../NodeExecutionsTable'; const useStyles = makeStyles((theme: Theme) => ({ - container: { - borderLeft: `1px solid ${theme.palette.grey[400]}`, - display: 'flex', - height: '100vh', - padding: `${theme.spacing(2)}px 0`, - width: '100vw' - } + container: { + borderLeft: `1px solid ${theme.palette.grey[400]}`, + display: 'flex', + height: '100vh', + padding: `${theme.spacing(2)}px 0`, + width: '100vw' + } })); const fixture = basicPythonWorkflow.generate(); const workflowExecution = fixture.workflowExecutions.top.data; +const workflowId = { ...fixture.workflowExecutions.top.data.id, version: '0.1' }; +const compiledWorkflowClosure = null; const getNodeExecutionDetails = async () => { - return { - displayId: 'node0', - displayName: 'basic.byton.workflow.unique.task_name', - displayType: 'Python-Task' - }; + return { + displayId: 'node0', + displayName: 'basic.byton.workflow.unique.task_name', + displayType: 'Python-Task' + }; }; const stories = storiesOf('Tables/NodeExecutionsTable', module); stories.addDecorator(story => { - return
{story()}
; + return
{story()}
; }); stories.add('Basic', () => { - const query = useQuery( - makeNodeExecutionListQuery(useQueryClient(), workflowExecution.id) - ); - return query.data ? ( - - - - ) : ( -
- ); + const query = useQuery(makeNodeExecutionListQuery(useQueryClient(), workflowExecution.id)); + return query.data ? ( + + + + ) : ( +
+ ); }); stories.add('With no items', () => { - return ( - - - - ); + return ( + + + + ); }); diff --git a/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx b/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx index 29ca604961..02d43fb803 100644 --- a/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx +++ b/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx @@ -7,167 +7,153 @@ import { useQueryClient } from 'react-query'; import { fetchWorkflow } from 'components/Workflow/workflowQueries'; import { NodeExecutionDetails } from '../../types'; import { isIdEqual, UNKNOWN_DETAILS } from './types'; -import { - createExecutionDetails, - CurrentExecutionDetails -} from './createExecutionArray'; +import { createExecutionDetails, CurrentExecutionDetails } from './createExecutionArray'; import { getTaskThroughExecution } from './getTaskThroughExecution'; +import { CompiledWorkflowClosure } from 'models/Workflow/types'; interface NodeExecutionDetailsState { - getNodeExecutionDetails: ( - nodeExecution?: NodeExecution - ) => Promise; + getNodeExecutionDetails: (nodeExecution?: NodeExecution) => Promise; + workflowId: Identifier; + compiledWorkflowClosure: CompiledWorkflowClosure | null; } +const NOT_AVAILABLE = 'NotAvailable'; /** Use this Context to redefine Provider returns in storybooks */ -export const NodeExecutionDetailsContext = createContext< - NodeExecutionDetailsState ->({ - /** Default values used if ContextProvider wasn't initialized. */ - getNodeExecutionDetails: async () => { - console.error( - 'ERROR: No NodeExecutionDetailsContextProvider was found in parent components.' - ); - return UNKNOWN_DETAILS; - } +export const NodeExecutionDetailsContext = createContext({ + /** Default values used if ContextProvider wasn't initialized. */ + getNodeExecutionDetails: async () => { + console.error('ERROR: No NodeExecutionDetailsContextProvider was found in parent components.'); + return UNKNOWN_DETAILS; + }, + workflowId: { + project: NOT_AVAILABLE, + domain: NOT_AVAILABLE, + name: NOT_AVAILABLE, + version: NOT_AVAILABLE + }, + compiledWorkflowClosure: null }); /** Should be used to get NodeExecutionDetails for a specific nodeExecution. */ export const useNodeExecutionDetails = (nodeExecution?: NodeExecution) => - useContext(NodeExecutionDetailsContext).getNodeExecutionDetails( - nodeExecution - ); + useContext(NodeExecutionDetailsContext).getNodeExecutionDetails(nodeExecution); /** Could be used to access the whole NodeExecutionDetailsState */ -export const useNodeExecutionContext = (): NodeExecutionDetailsState => - useContext(NodeExecutionDetailsContext); +export const useNodeExecutionContext = (): NodeExecutionDetailsState => useContext(NodeExecutionDetailsContext); interface ProviderProps { - workflowId: Identifier; - children?: React.ReactNode; + workflowId: Identifier; + children?: React.ReactNode; } /** Should wrap "top level" component in Execution view, will build a nodeExecutions tree for specific workflow*/ export const NodeExecutionDetailsContextProvider = (props: ProviderProps) => { - // workflow Identifier - separated to parameters, to minimize re-render count - // as useEffect doesn't know how to do deep comparison - const { resourceType, project, domain, name, version } = props.workflowId; - - const [ - executionTree, - setExecutionTree - ] = useState(null); - const [parentMap, setParentMap] = useState( - new Map() - ); - const [tasks, setTasks] = useState(new Map()); - - const resetState = () => { - setExecutionTree(null); - setParentMap(new Map()); + // workflow Identifier - separated to parameters, to minimize re-render count + // as useEffect doesn't know how to do deep comparison + const { resourceType, project, domain, name, version } = props.workflowId; + + const [executionTree, setExecutionTree] = useState(null); + const [parentMap, setParentMap] = useState(new Map()); + const [tasks, setTasks] = useState(new Map()); + const [closure, setClosure] = useState(null); + + const resetState = () => { + setExecutionTree(null); + setParentMap(new Map()); + }; + + const queryClient = useQueryClient(); + const isMounted = useRef(false); + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; }; + }, []); + + useEffect(() => { + let isCurrent = true; + async function fetchData() { + const workflowId: Identifier = { + resourceType, + project, + domain, + name, + version + }; + const workflow = await fetchWorkflow(queryClient, workflowId); + if (!workflow) { + resetState(); + return; + } + + const { nodes: tree, map } = createExecutionDetails(workflow); + if (isCurrent) { + setClosure(workflow.closure?.compiledWorkflow ?? null); + setExecutionTree(tree); + setParentMap(map); + } + } - const queryClient = useQueryClient(); - const isMounted = useRef(false); - useEffect(() => { - isMounted.current = true; - return () => { - isMounted.current = false; - }; - }, []); - - useEffect(() => { - let isCurrent = true; - async function fetchData() { - const workflowId: Identifier = { - resourceType, - project, - domain, - name, - version - }; - const workflow = await fetchWorkflow(queryClient, workflowId); - if (!workflow) { - resetState(); - return; - } - - const { nodes: tree, map } = createExecutionDetails(workflow); - if (isCurrent) { - setExecutionTree(tree); - setParentMap(map); - } - } - - fetchData(); - - // This handles the unmount case - return () => { - isCurrent = false; - resetState(); - }; - }, [queryClient, resourceType, project, domain, name, version]); - - const checkForDynamicTasks = async (nodeExecution: NodeExecution) => { - const taskDetails = await getTaskThroughExecution( - queryClient, - nodeExecution - ); - - const tasksMap = tasks; - tasksMap.set(nodeExecution.id.nodeId, taskDetails); - if (isMounted.current) { - setTasks(tasksMap); - } - - return taskDetails; - }; + fetchData(); - const getDetails = async ( - nodeExecution?: NodeExecution - ): Promise => { - if (!executionTree || !nodeExecution) { - return UNKNOWN_DETAILS; - } - - const specId = - nodeExecution.metadata?.specNodeId || nodeExecution.id.nodeId; - const parentId = nodeExecution.parentId; - - let nodeDetail = executionTree.nodes.filter( - n => n.displayId === specId - ); - if (nodeDetail.length > 1) { - // more than one result - we will try to filter by parent info - // if there is no parent_id - we are dealing with the root. - const parentTemplate = parentId - ? parentMap.get(parentId) ?? executionTree.executionId - : executionTree.executionId; - nodeDetail = nodeDetail.filter(n => - isIdEqual(n.parentTemplate, parentTemplate) - ); - } - - if (nodeDetail.length === 0) { - let details = tasks.get(nodeExecution.id.nodeId); - if (details) { - // we already have looked for it and found - return details; - } - - // look for specific task by nodeId in current execution - details = await checkForDynamicTasks(nodeExecution); - return details; - } - - return nodeDetail?.[0] ?? UNKNOWN_DETAILS; + // This handles the unmount case + return () => { + isCurrent = false; + resetState(); }; + }, [queryClient, resourceType, project, domain, name, version]); + + const checkForDynamicTasks = async (nodeExecution: NodeExecution) => { + const taskDetails = await getTaskThroughExecution(queryClient, nodeExecution); + + const tasksMap = tasks; + tasksMap.set(nodeExecution.id.nodeId, taskDetails); + if (isMounted.current) { + setTasks(tasksMap); + } + + return taskDetails; + }; + + const getDetails = async (nodeExecution?: NodeExecution): Promise => { + if (!executionTree || !nodeExecution) { + return UNKNOWN_DETAILS; + } + + const specId = nodeExecution.metadata?.specNodeId || nodeExecution.id.nodeId; + const parentId = nodeExecution.parentId; + + let nodeDetail = executionTree.nodes.filter(n => n.displayId === specId); + if (nodeDetail.length > 1) { + // more than one result - we will try to filter by parent info + // if there is no parent_id - we are dealing with the root. + const parentTemplate = parentId + ? parentMap.get(parentId) ?? executionTree.executionId + : executionTree.executionId; + nodeDetail = nodeDetail.filter(n => isIdEqual(n.parentTemplate, parentTemplate)); + } + + if (nodeDetail.length === 0) { + let details = tasks.get(nodeExecution.id.nodeId); + if (details) { + // we already have looked for it and found + return details; + } + + // look for specific task by nodeId in current execution + details = await checkForDynamicTasks(nodeExecution); + return details; + } + + return nodeDetail?.[0] ?? UNKNOWN_DETAILS; + }; - return ( - - {props.children} - - ); + return ( + + {props.children} + + ); }; diff --git a/src/components/Executions/nodeExecutionQueries.ts b/src/components/Executions/nodeExecutionQueries.ts index c6ce60a209..a4b57c53cb 100644 --- a/src/components/Executions/nodeExecutionQueries.ts +++ b/src/components/Executions/nodeExecutionQueries.ts @@ -1,26 +1,25 @@ +import { compareTimestampsAscending } from 'common/utils'; import { QueryInput, QueryType } from 'components/data/types'; import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; import { isEqual } from 'lodash'; +import { PaginatedEntityResponse, RequestConfig } from 'models/AdminEntity/types'; import { - PaginatedEntityResponse, - RequestConfig -} from 'models/AdminEntity/types'; -import { - getNodeExecution, - listNodeExecutions, - listTaskExecutionChildren, - listTaskExecutions + getNodeExecution, + listNodeExecutions, + listTaskExecutionChildren, + listTaskExecutions } from 'models/Execution/api'; import { nodeExecutionQueryParams } from 'models/Execution/constants'; import { - NodeExecution, - NodeExecutionIdentifier, - TaskExecution, - TaskExecutionIdentifier, - WorkflowExecutionIdentifier + NodeExecution, + NodeExecutionIdentifier, + TaskExecution, + TaskExecutionIdentifier, + WorkflowExecutionIdentifier } from 'models/Execution/types'; import { endNodeId, startNodeId } from 'models/Node/constants'; import { QueryClient, QueryObserverResult, useQueryClient } from 'react-query'; +import { executionRefreshIntervalMs } from './constants'; import { fetchTaskExecutionList } from './taskExecutionQueries'; import { formatRetryAttempt } from './TaskExecutionsList/utils'; import { NodeExecutionGroup } from './types'; @@ -28,294 +27,231 @@ import { isParentNode, nodeExecutionIsTerminal } from './utils'; const ignoredNodeIds = [startNodeId, endNodeId]; function removeSystemNodes(nodeExecutions: NodeExecution[]): NodeExecution[] { - return nodeExecutions.filter(ne => { - if (ignoredNodeIds.includes(ne.id.nodeId)) { - return false; - } - const specId = ne.metadata?.specNodeId; - if (specId != null && ignoredNodeIds.includes(specId)) { - return false; - } - return true; - }); + return nodeExecutions.filter(ne => { + if (ignoredNodeIds.includes(ne.id.nodeId)) { + return false; + } + const specId = ne.metadata?.specNodeId; + if (specId != null && ignoredNodeIds.includes(specId)) { + return false; + } + return true; + }); } /** A query for fetching a single `NodeExecution` by id. */ -export function makeNodeExecutionQuery( - id: NodeExecutionIdentifier -): QueryInput { - return { - queryKey: [QueryType.NodeExecution, id], - queryFn: () => getNodeExecution(id) - }; +export function makeNodeExecutionQuery(id: NodeExecutionIdentifier): QueryInput { + return { + queryKey: [QueryType.NodeExecution, id], + queryFn: () => getNodeExecution(id) + }; } export function makeListTaskExecutionsQuery( - id: NodeExecutionIdentifier + id: NodeExecutionIdentifier ): QueryInput> { - return { - queryKey: [QueryType.TaskExecutionList, id], - queryFn: () => listTaskExecutions(id) - }; + return { + queryKey: [QueryType.TaskExecutionList, id], + queryFn: () => listTaskExecutions(id) + }; } /** Composable fetch function which wraps `makeNodeExecutionQuery` */ -export function fetchNodeExecution( - queryClient: QueryClient, - id: NodeExecutionIdentifier -) { - return queryClient.fetchQuery(makeNodeExecutionQuery(id)); +export function fetchNodeExecution(queryClient: QueryClient, id: NodeExecutionIdentifier) { + return queryClient.fetchQuery(makeNodeExecutionQuery(id)); } // On successful node execution list queries, extract and store all // executions so they are individually fetchable from the cache. -function cacheNodeExecutions( - queryClient: QueryClient, - nodeExecutions: NodeExecution[] -) { - nodeExecutions.forEach(ne => - queryClient.setQueryData([QueryType.NodeExecution, ne.id], ne) - ); +function cacheNodeExecutions(queryClient: QueryClient, nodeExecutions: NodeExecution[]) { + nodeExecutions.forEach(ne => queryClient.setQueryData([QueryType.NodeExecution, ne.id], ne)); } /** A query for fetching a list of `NodeExecution`s which are children of a given * `Execution`. */ export function makeNodeExecutionListQuery( - queryClient: QueryClient, - id: WorkflowExecutionIdentifier, - config?: RequestConfig + queryClient: QueryClient, + id: WorkflowExecutionIdentifier, + config?: RequestConfig ): QueryInput { - return { - queryKey: [QueryType.NodeExecutionList, id, config], - queryFn: async () => { - const nodeExecutions = removeSystemNodes( - (await listNodeExecutions(id, config)).entities - ); - nodeExecutions.map(exe => { - if (exe.metadata) { - return (exe.scopedId = exe.metadata.specNodeId); - } else { - return (exe.scopedId = exe.id.nodeId); - } - }); - cacheNodeExecutions(queryClient, nodeExecutions); - return nodeExecutions; + return { + queryKey: [QueryType.NodeExecutionList, id, config], + queryFn: async () => { + const nodeExecutions = removeSystemNodes((await listNodeExecutions(id, config)).entities); + nodeExecutions.map(exe => { + if (exe.metadata) { + return (exe.scopedId = exe.metadata.specNodeId); + } else { + return (exe.scopedId = exe.id.nodeId); } - }; + }); + cacheNodeExecutions(queryClient, nodeExecutions); + return nodeExecutions; + } + }; } /** Composable fetch function which wraps `makeNodeExecutionListQuery`. */ export function fetchNodeExecutionList( - queryClient: QueryClient, - id: WorkflowExecutionIdentifier, - config?: RequestConfig + queryClient: QueryClient, + id: WorkflowExecutionIdentifier, + config?: RequestConfig ) { - return queryClient.fetchQuery( - makeNodeExecutionListQuery(queryClient, id, config) - ); + return queryClient.fetchQuery(makeNodeExecutionListQuery(queryClient, id, config)); } /** A query for fetching a list of `NodeExecution`s which are children of a given * `TaskExecution`. */ export function makeTaskExecutionChildListQuery( - queryClient: QueryClient, - id: TaskExecutionIdentifier, - config?: RequestConfig + queryClient: QueryClient, + id: TaskExecutionIdentifier, + config?: RequestConfig ): QueryInput { - return { - queryKey: [QueryType.TaskExecutionChildList, id, config], - queryFn: async () => { - const nodeExecutions = removeSystemNodes( - (await listTaskExecutionChildren(id, config)).entities - ); - cacheNodeExecutions(queryClient, nodeExecutions); - return nodeExecutions; - }, - onSuccess: nodeExecutions => { - nodeExecutions.forEach(ne => - queryClient.setQueryData([QueryType.NodeExecution, ne.id], ne) - ); - } - }; + return { + queryKey: [QueryType.TaskExecutionChildList, id, config], + queryFn: async () => { + const nodeExecutions = removeSystemNodes((await listTaskExecutionChildren(id, config)).entities); + cacheNodeExecutions(queryClient, nodeExecutions); + return nodeExecutions; + }, + onSuccess: nodeExecutions => { + nodeExecutions.forEach(ne => queryClient.setQueryData([QueryType.NodeExecution, ne.id], ne)); + } + }; } /** Composable fetch function which wraps `makeTaskExecutionChildListQuery`. */ export function fetchTaskExecutionChildList( - queryClient: QueryClient, - id: TaskExecutionIdentifier, - config?: RequestConfig + queryClient: QueryClient, + id: TaskExecutionIdentifier, + config?: RequestConfig ) { - return queryClient.fetchQuery( - makeTaskExecutionChildListQuery(queryClient, id, config) - ); + return queryClient.fetchQuery(makeTaskExecutionChildListQuery(queryClient, id, config)); } /** --- Queries for fetching children of a NodeExecution --- **/ async function fetchGroupForTaskExecution( - queryClient: QueryClient, - taskExecutionId: TaskExecutionIdentifier, - config: RequestConfig + queryClient: QueryClient, + taskExecutionId: TaskExecutionIdentifier, + config: RequestConfig ): Promise { - return { - // NodeExecutions created by a TaskExecution are grouped - // by the retry attempt of the task. - name: formatRetryAttempt(taskExecutionId.retryAttempt), - nodeExecutions: await fetchTaskExecutionChildList( - queryClient, - taskExecutionId, - config - ) - }; + return { + // NodeExecutions created by a TaskExecution are grouped + // by the retry attempt of the task. + name: formatRetryAttempt(taskExecutionId.retryAttempt), + nodeExecutions: await fetchTaskExecutionChildList(queryClient, taskExecutionId, config) + }; } async function fetchGroupForWorkflowExecution( - queryClient: QueryClient, - executionId: WorkflowExecutionIdentifier, - config: RequestConfig + queryClient: QueryClient, + executionId: WorkflowExecutionIdentifier, + config: RequestConfig ): Promise { - return { - // NodeExecutions created by a workflow execution are grouped - // by the execution id, since workflow executions are not retryable. - name: executionId.name, - nodeExecutions: await fetchNodeExecutionList( - queryClient, - executionId, - config - ) - }; + return { + // NodeExecutions created by a workflow execution are grouped + // by the execution id, since workflow executions are not retryable. + name: executionId.name, + nodeExecutions: await fetchNodeExecutionList(queryClient, executionId, config) + }; } async function fetchGroupsForTaskExecutionNode( - queryClient: QueryClient, - nodeExecution: NodeExecution, - config: RequestConfig + queryClient: QueryClient, + nodeExecution: NodeExecution, + config: RequestConfig ): Promise { - const taskExecutions = await fetchTaskExecutionList( - queryClient, - nodeExecution.id, - config - ); + const taskExecutions = await fetchTaskExecutionList(queryClient, nodeExecution.id, config); - // For TaskExecutions marked as parents, fetch its children and create a group. - // Otherwise, return null and we will filter it out later. - const groups = await Promise.all( - taskExecutions.map(execution => - execution.isParent - ? fetchGroupForTaskExecution(queryClient, execution.id, config) - : Promise.resolve(null) - ) - ); + // For TaskExecutions marked as parents, fetch its children and create a group. + // Otherwise, return null and we will filter it out later. + const groups = await Promise.all( + taskExecutions.map(execution => + execution.isParent ? fetchGroupForTaskExecution(queryClient, execution.id, config) : Promise.resolve(null) + ) + ); - // Remove any empty groups - return groups.filter( - group => group !== null && group.nodeExecutions.length > 0 - ) as NodeExecutionGroup[]; + // Remove any empty groups + return groups.filter(group => group !== null && group.nodeExecutions.length > 0) as NodeExecutionGroup[]; } async function fetchGroupsForWorkflowExecutionNode( - queryClient: QueryClient, - nodeExecution: NodeExecution, - config: RequestConfig + queryClient: QueryClient, + nodeExecution: NodeExecution, + config: RequestConfig ): Promise { - if (!nodeExecution.closure.workflowNodeMetadata) { - throw new Error('Unexpected empty `workflowNodeMetadata`'); - } - const { executionId } = nodeExecution.closure.workflowNodeMetadata; - // We can only have one WorkflowExecution (no retries), so there is only - // one group to return. But calling code expects it as an array. - const group = await fetchGroupForWorkflowExecution( - queryClient, - executionId, - config - ); - return group.nodeExecutions.length > 0 ? [group] : []; + if (!nodeExecution.closure.workflowNodeMetadata) { + throw new Error('Unexpected empty `workflowNodeMetadata`'); + } + const { executionId } = nodeExecution.closure.workflowNodeMetadata; + // We can only have one WorkflowExecution (no retries), so there is only + // one group to return. But calling code expects it as an array. + const group = await fetchGroupForWorkflowExecution(queryClient, executionId, config); + return group.nodeExecutions.length > 0 ? [group] : []; } async function fetchGroupsForParentNodeExecution( - queryClient: QueryClient, - nodeExecution: NodeExecution, - config: RequestConfig + queryClient: QueryClient, + nodeExecution: NodeExecution, + config: RequestConfig ): Promise { - const finalConfig = { - ...config, - params: { - ...config.params, - [nodeExecutionQueryParams.parentNodeId]: nodeExecution.id.nodeId - } - }; - - /** @TODO there is likely a better way to do this; eg, in a previous call */ - if (!nodeExecution.scopedId) { - nodeExecution.scopedId = nodeExecution.metadata?.specNodeId; + const finalConfig = { + ...config, + params: { + ...config.params, + [nodeExecutionQueryParams.parentNodeId]: nodeExecution.id.nodeId } + }; - const children = await fetchNodeExecutionList( - queryClient, - nodeExecution.id.executionId, - finalConfig - ); - const groupsByName = children.reduce>( - (out, child) => { - const retryAttempt = formatRetryAttempt(child.metadata?.retryGroup); - let group = out.get(retryAttempt); - if (!group) { - group = { name: retryAttempt, nodeExecutions: [] }; - out.set(retryAttempt, group); - } - /** - * GraphUX uses workflowClosure which uses scopedId - * This builds a scopedId via parent nodeExecution - * to enable mapping between graph and other components - */ - let scopedId: string | undefined = - nodeExecution.metadata?.specNodeId; - if (scopedId != undefined) { - child['parentId'] = scopedId; - scopedId += `-${child.metadata?.retryGroup}-${child.metadata?.specNodeId}`; - child['scopedId'] = scopedId; - } else { - child['scopedId'] = child.metadata?.specNodeId; - } - group.nodeExecutions.push(child); - return out; - }, - new Map() - ); - return Array.from(groupsByName.values()); -} + /** @TODO there is likely a better way to do this; eg, in a previous call */ + if (!nodeExecution.scopedId) { + nodeExecution.scopedId = nodeExecution.metadata?.specNodeId; + } -function fetchChildNodeExecutionGroups( - queryClient: QueryClient, - nodeExecution: NodeExecution, - config: RequestConfig -) { - const { workflowNodeMetadata } = nodeExecution.closure; - // Newer NodeExecution structures can directly indicate their parent - // status and have their children fetched in bulk. - if (isParentNode(nodeExecution)) { - return fetchGroupsForParentNodeExecution( - queryClient, - nodeExecution, - config - ); + const children = await fetchNodeExecutionList(queryClient, nodeExecution.id.executionId, finalConfig); + const groupsByName = children.reduce>((out, child) => { + const retryAttempt = formatRetryAttempt(child.metadata?.retryGroup); + let group = out.get(retryAttempt); + if (!group) { + group = { name: retryAttempt, nodeExecutions: [] }; + out.set(retryAttempt, group); } - // Otherwise, we need to determine the type of the node and - // recursively fetch NodeExecutions for the corresponding Workflow - // or Task executions. - if ( - workflowNodeMetadata && - !isEqual(workflowNodeMetadata.executionId, nodeExecution.id.executionId) - ) { - return fetchGroupsForWorkflowExecutionNode( - queryClient, - nodeExecution, - config - ); + /** + * GraphUX uses workflowClosure which uses scopedId + * This builds a scopedId via parent nodeExecution + * to enable mapping between graph and other components + */ + let scopedId: string | undefined = nodeExecution.metadata?.specNodeId; + if (scopedId != undefined) { + child['parentId'] = scopedId; + scopedId += `-${child.metadata?.retryGroup}-${child.metadata?.specNodeId}`; + child['scopedId'] = scopedId; + } else { + child['scopedId'] = child.metadata?.specNodeId; } - return fetchGroupsForTaskExecutionNode(queryClient, nodeExecution, config); + group.nodeExecutions.push(child); + return out; + }, new Map()); + return Array.from(groupsByName.values()); +} + +function fetchChildNodeExecutionGroups(queryClient: QueryClient, nodeExecution: NodeExecution, config: RequestConfig) { + const { workflowNodeMetadata } = nodeExecution.closure; + // Newer NodeExecution structures can directly indicate their parent + // status and have their children fetched in bulk. + if (isParentNode(nodeExecution)) { + return fetchGroupsForParentNodeExecution(queryClient, nodeExecution, config); + } + // Otherwise, we need to determine the type of the node and + // recursively fetch NodeExecutions for the corresponding Workflow + // or Task executions. + if (workflowNodeMetadata && !isEqual(workflowNodeMetadata.executionId, nodeExecution.id.executionId)) { + return fetchGroupsForWorkflowExecutionNode(queryClient, nodeExecution, config); + } + return fetchGroupsForTaskExecutionNode(queryClient, nodeExecution, config); } /** @@ -324,17 +260,18 @@ function fetchChildNodeExecutionGroups( * list of nodeExecitions */ async function fetchAllChildNodeExecutions( - queryClient: QueryClient, - nodeExecutions: NodeExecution[], - config: RequestConfig + queryClient: QueryClient, + nodeExecutions: NodeExecution[], + config: RequestConfig ): Promise> { - const executions: Array = await Promise.all( - nodeExecutions.map(exe => { - return fetchChildNodeExecutionGroups(queryClient, exe, config); - }) - ); - return executions; + const executions: Array = await Promise.all( + nodeExecutions.map(exe => { + return fetchChildNodeExecutionGroups(queryClient, exe, config); + }) + ); + return executions; } + /** * * @param nodeExecutions list of parent node executionId's @@ -342,76 +279,124 @@ async function fetchAllChildNodeExecutions( * @returns */ export function useAllChildNodeExecutionGroupsQuery( - nodeExecutions: NodeExecution[], - config: RequestConfig + nodeExecutions: NodeExecution[], + config: RequestConfig ): QueryObserverResult, Error> { - const queryClient = useQueryClient(); - const shouldEnableFn = groups => { - if (nodeExecutions[0] && groups.length > 0) { - if (!nodeExecutionIsTerminal(nodeExecutions[0])) { - return true; - } - return groups.some(group => { - if (group.nodeExecutions?.length > 0) { - return group.nodeExecutions.some(ne => { - return !nodeExecutionIsTerminal(ne); - }); - } else { - return false; - } - }); + const queryClient = useQueryClient(); + const shouldEnableFn = groups => { + if (nodeExecutions[0] && groups.length > 0) { + if (!nodeExecutionIsTerminal(nodeExecutions[0])) { + return true; + } + return groups.some(group => { + if (group.nodeExecutions?.length > 0) { + return group.nodeExecutions.some(ne => { + return !nodeExecutionIsTerminal(ne); + }); } else { - return false; + return false; } - }; + }); + } else { + return false; + } + }; - return useConditionalQuery>( - { - queryKey: [ - QueryType.NodeExecutionChildList, - nodeExecutions[0]?.id, - config - ], - queryFn: () => - fetchAllChildNodeExecutions(queryClient, nodeExecutions, config) - }, - shouldEnableFn - ); + return useConditionalQuery>( + { + queryKey: [QueryType.NodeExecutionChildList, nodeExecutions[0]?.id, config], + queryFn: () => fetchAllChildNodeExecutions(queryClient, nodeExecutions, config) + }, + shouldEnableFn + ); } /** Fetches and groups `NodeExecution`s which are direct children of the given * `NodeExecution`. */ export function useChildNodeExecutionGroupsQuery( - nodeExecution: NodeExecution, - config: RequestConfig + nodeExecution: NodeExecution, + config: RequestConfig ): QueryObserverResult { - const queryClient = useQueryClient(); - // Use cached data if the parent node execution is terminal and all children - // in all groups are terminal - const shouldEnableFn = (groups: NodeExecutionGroup[]) => { - if (!nodeExecutionIsTerminal(nodeExecution)) { - return true; - } - return groups.some(group => - group.nodeExecutions.some(ne => !nodeExecutionIsTerminal(ne)) - ); - }; + const queryClient = useQueryClient(); + // Use cached data if the parent node execution is terminal and all children + // in all groups are terminal + const shouldEnableFn = (groups: NodeExecutionGroup[]) => { + if (!nodeExecutionIsTerminal(nodeExecution)) { + return true; + } + return groups.some(group => group.nodeExecutions.some(ne => !nodeExecutionIsTerminal(ne))); + }; + + return useConditionalQuery( + { + queryKey: [QueryType.NodeExecutionChildList, nodeExecution.id, config], + queryFn: () => fetchChildNodeExecutionGroups(queryClient, nodeExecution, config) + }, + shouldEnableFn + ); +} + +/** + * Query returns all children (not only direct childs) for a list of `nodeExecutions` + */ +async function fetchAllTreeNodeExecutions( + queryClient: QueryClient, + nodeExecutions: NodeExecution[], + config: RequestConfig +): Promise { + const queue: NodeExecution[] = [...nodeExecutions]; + let left = 0; + let right = queue.length; + + while (left < right) { + const top: NodeExecution = queue[left++]; + const executionGroups: NodeExecutionGroup[] = await fetchChildNodeExecutionGroups(queryClient, top, config); + for (let i = 0; i < executionGroups.length; i++) { + for (let j = 0; j < executionGroups[i].nodeExecutions.length; j++) { + queue.push(executionGroups[i].nodeExecutions[j]); + right++; + } + } + } + + const sorted: NodeExecution[] = queue.sort((na: NodeExecution, nb: NodeExecution) => { + if (!na.closure.startedAt) { + return 1; + } + if (!nb.closure.startedAt) { + return -1; + } + return compareTimestampsAscending(na.closure.startedAt, nb.closure.startedAt); + }); + + return sorted; +} + +/** + * + * @param nodeExecutions list of parent node executionId's + * @param config + * @returns + */ +export function useAllTreeNodeExecutionGroupsQuery( + nodeExecutions: NodeExecution[], + config: RequestConfig +): QueryObserverResult { + const queryClient = useQueryClient(); + const shouldEnableFn = groups => { + if (nodeExecutions.some(ne => !nodeExecutionIsTerminal(ne))) { + return true; + } + return groups.some(group => !nodeExecutionIsTerminal(group)); + }; - return useConditionalQuery( - { - queryKey: [ - QueryType.NodeExecutionChildList, - nodeExecution.id, - config - ], - queryFn: () => - fetchChildNodeExecutionGroups( - queryClient, - nodeExecution, - config - ) - }, - shouldEnableFn - ); + return useConditionalQuery( + { + queryKey: [QueryType.NodeExecutionTreeList, nodeExecutions[0]?.id, config], + queryFn: () => fetchAllTreeNodeExecutions(queryClient, nodeExecutions, config), + refetchInterval: executionRefreshIntervalMs + }, + shouldEnableFn + ); } diff --git a/src/components/WorkflowGraph/transformerWorkflowToDag.tsx b/src/components/WorkflowGraph/transformerWorkflowToDag.tsx index 8247bf98ae..a17a6cc72a 100644 --- a/src/components/WorkflowGraph/transformerWorkflowToDag.tsx +++ b/src/components/WorkflowGraph/transformerWorkflowToDag.tsx @@ -33,6 +33,12 @@ export const transformerWorkflowToDag = ( return root; }; +export const transformerWorkflowToPlainNodes = ( + workflow: CompiledWorkflowClosure +): dNode => { + return buildDAG(null, workflow.primary, dTypes.primary, workflow); +}; + const createDNode = ( compiledNode: CompiledNode, parentDNode?: dNode | null, diff --git a/src/components/WorkflowGraph/utils.ts b/src/components/WorkflowGraph/utils.ts index 49b3da310d..592d6f1273 100644 --- a/src/components/WorkflowGraph/utils.ts +++ b/src/components/WorkflowGraph/utils.ts @@ -12,11 +12,15 @@ export const DISPLAY_NAME_START = 'start'; export const DISPLAY_NAME_END = 'end'; export function isStartNode(node: any) { - return node.id === startNodeId; + return node.id === startNodeId; } export function isEndNode(node: any) { - return node.id === endNodeId; + return node.id === endNodeId; +} + +export function isExpanded(node: any) { + return !!node.expanded; } /** @@ -27,12 +31,12 @@ export function isEndNode(node: any) { * @returns boolean */ export const checkIfObjectsAreSame = (a, b) => { - for (const k in a) { - if (a[k] != b[k]) { - return false; - } + for (const k in a) { + if (a[k] != b[k]) { + return false; } - return true; + } + return true; }; /** @@ -41,30 +45,27 @@ export const checkIfObjectsAreSame = (a, b) => { * @returns Display name */ export const getDisplayName = (context: any): string => { - let fullName; - if (context.metadata) { - // Compiled Node with Meta - fullName = context.metadata.name; - } else if (context.id) { - // Compiled Node (start/end) - fullName = context.id; - } else { - // CompiledWorkflow - fullName = context.template.id.name; - } + let fullName; + if (context.metadata) { + // Compiled Node with Meta + fullName = context.metadata.name; + } else if (context.id) { + // Compiled Node (start/end) + fullName = context.id; + } else { + // CompiledWorkflow + fullName = context.template.id.name; + } - if (fullName == startNodeId) { - return DISPLAY_NAME_START; - } else if (fullName == endNodeId) { - return DISPLAY_NAME_END; - } else if (fullName.indexOf('.') > 0) { - return fullName.substr( - fullName.lastIndexOf('.') + 1, - fullName.length - 1 - ); - } else { - return fullName; - } + if (fullName == startNodeId) { + return DISPLAY_NAME_START; + } else if (fullName == endNodeId) { + return DISPLAY_NAME_END; + } else if (fullName.indexOf('.') > 0) { + return fullName.substr(fullName.lastIndexOf('.') + 1, fullName.length - 1); + } else { + return fullName; + } }; /** @@ -73,71 +74,76 @@ export const getDisplayName = (context: any): string => { * @returns id */ export const getWorkflowId = (workflow: CompiledWorkflow): string => { - return workflow.template.id.name; + return workflow.template.id.name; }; export const getNodeTypeFromCompiledNode = (node: CompiledNode): dTypes => { - if (isStartNode(node)) { - return dTypes.start; - } else if (isEndNode(node)) { - return dTypes.end; - } else if (node.branchNode) { - return dTypes.branch; - } else if (node.workflowNode) { - return dTypes.subworkflow; - } else { - return dTypes.task; - } + if (isStartNode(node)) { + return dTypes.start; + } else if (isEndNode(node)) { + return dTypes.end; + } else if (node.branchNode) { + return dTypes.branch; + } else if (node.workflowNode) { + return dTypes.subworkflow; + } else { + return dTypes.task; + } }; export const getSubWorkflowFromId = (id, workflow) => { - const { subWorkflows } = workflow; - /* Find current matching entitity from subWorkflows */ - for (const k in subWorkflows) { - const subWorkflowId = subWorkflows[k].template.id; - if (checkIfObjectsAreSame(subWorkflowId, id)) { - return subWorkflows[k]; - } + const { subWorkflows } = workflow; + /* Find current matching entitity from subWorkflows */ + for (const k in subWorkflows) { + const subWorkflowId = subWorkflows[k].template.id; + if (checkIfObjectsAreSame(subWorkflowId, id)) { + return subWorkflows[k]; } - return false; + } + return false; }; -export const getTaskTypeFromCompiledNode = ( - taskNode: TaskNode, - tasks: CompiledTask[] -) => { - for (let i = 0; i < tasks.length; i++) { - const compiledTask: CompiledTask = tasks[i]; - const taskTemplate: TaskTemplate = compiledTask.template; - const templateId: Identifier = taskTemplate.id; - if (checkIfObjectsAreSame(templateId, taskNode.referenceId)) { - return compiledTask; - } +export const getTaskTypeFromCompiledNode = (taskNode: TaskNode, tasks: CompiledTask[]) => { + for (let i = 0; i < tasks.length; i++) { + const compiledTask: CompiledTask = tasks[i]; + const taskTemplate: TaskTemplate = compiledTask.template; + const templateId: Identifier = taskTemplate.id; + if (checkIfObjectsAreSame(templateId, taskNode.referenceId)) { + return compiledTask; } - return null; + } + return null; }; export const getNodeNameFromDag = (dagData: dNode, nodeId: string) => { - const id = nodeId.slice(nodeId.lastIndexOf('-') + 1); - const value = dagData[id].value; + const id = nodeId.slice(nodeId.lastIndexOf('-') + 1); + const node = dagData[id]; - if (value.taskNode) { - return value.taskNode.referenceId.name; - } else if (value.workflowNode) { - return value.workflowNode.subWorkflowRef.name; - } - return ''; + return getNodeTemplateName(node); +}; + +export const getNodeTemplateName = (node: dNode) => { + const value = node?.value; + if (value?.workflowNode) { + const { launchplanRef, subWorkflowRef } = node.value.workflowNode; + const identifier = (launchplanRef ? launchplanRef : subWorkflowRef) as Identifier; + return identifier.name; + } + + if (value?.taskNode) { + return value.taskNode.referenceId.name; + } + + return ''; }; export const transformWorkflowToKeyedDag = (workflow: Workflow) => { - if (!workflow.closure?.compiledWorkflow) return {}; - - const dagData = transformerWorkflowToDag( - workflow.closure?.compiledWorkflow - ); - const data = {}; - dagData.nodes.forEach(node => { - data[`${node.id}`] = node; - }); - return data; + if (!workflow.closure?.compiledWorkflow) return {}; + + const dagData = transformerWorkflowToDag(workflow.closure?.compiledWorkflow); + const data = {}; + dagData.nodes.forEach(node => { + data[`${node.id}`] = node; + }); + return data; }; diff --git a/src/components/data/types.ts b/src/components/data/types.ts index 2fcd4d60a2..d7dd979744 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -1,38 +1,32 @@ -import { - InfiniteQueryObserverOptions, - QueryObserverOptions -} from 'react-query'; +import { InfiniteQueryObserverOptions, QueryObserverOptions } from 'react-query'; export enum QueryType { - NodeExecutionDetails = 'NodeExecutionDetails', - NodeExecution = 'nodeExecution', - NodeExecutionList = 'nodeExecutionList', - NodeExecutionChildList = 'nodeExecutionChildList', - TaskExecution = 'taskExecution', - TaskExecutionList = 'taskExecutionList', - TaskExecutionChildList = 'taskExecutionChildList', - TaskTemplate = 'taskTemplate', - Workflow = 'workflow', - WorkflowExecution = 'workflowExecution', - WorkflowExecutionList = 'workflowExecutionList' + NodeExecutionDetails = 'NodeExecutionDetails', + NodeExecution = 'nodeExecution', + NodeExecutionList = 'nodeExecutionList', + NodeExecutionChildList = 'nodeExecutionChildList', + NodeExecutionTreeList = 'nodeExecutionTreeList', + TaskExecution = 'taskExecution', + TaskExecutionList = 'taskExecutionList', + TaskExecutionChildList = 'taskExecutionChildList', + TaskTemplate = 'taskTemplate', + Workflow = 'workflow', + WorkflowExecution = 'workflowExecution', + WorkflowExecutionList = 'workflowExecutionList' } type QueryKeyArray = [QueryType, ...unknown[]]; export interface QueryInput extends QueryObserverOptions { - queryKey: QueryKeyArray; - queryFn: QueryObserverOptions['queryFn']; + queryKey: QueryKeyArray; + queryFn: QueryObserverOptions['queryFn']; } -export interface InfiniteQueryInput - extends InfiniteQueryObserverOptions, Error> { - queryKey: QueryKeyArray; - queryFn: InfiniteQueryObserverOptions< - InfiniteQueryPage, - Error - >['queryFn']; +export interface InfiniteQueryInput extends InfiniteQueryObserverOptions, Error> { + queryKey: QueryKeyArray; + queryFn: InfiniteQueryObserverOptions, Error>['queryFn']; } export interface InfiniteQueryPage { - data: T[]; - token?: string; + data: T[]; + token?: string; } diff --git a/src/models/Graph/types.ts b/src/models/Graph/types.ts index ffc8176946..307f6cf6a9 100644 --- a/src/models/Graph/types.ts +++ b/src/models/Graph/types.ts @@ -55,4 +55,7 @@ export interface dNode { value?: any; nodes: Array; edges: Array; + expanded?: boolean; + level?: number; + execution?: NodeExecution; } diff --git a/webpack.config.ts b/webpack.config.ts index 8b15cb2cdf..92878de961 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -15,8 +15,8 @@ const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); import { processEnv as env } from './env'; const packageJson: { - dependencies: { [libName: string]: string }; - devDependencies: { [libName: string]: string }; + dependencies: { [libName: string]: string }; + devDependencies: { [libName: string]: string }; } = require(require.resolve('./package.json')); /** Current service name */ @@ -35,17 +35,13 @@ export const isDev = env.NODE_ENV === 'development'; export const isProd = env.NODE_ENV === 'production'; /** CSS module class name pattern */ -export const localIdentName = isDev - ? '[local]_[hash:base64:3]' - : '[hash:base64:6]'; +export const localIdentName = isDev ? '[local]_[hash:base64:3]' : '[hash:base64:6]'; /** Sourcemap configuration */ const devtool = isDev ? 'cheap-source-map' : undefined; // Report current configuration -console.log( - chalk.cyan('Exporting Webpack config with following configurations:') -); +console.log(chalk.cyan('Exporting Webpack config with following configurations:')); console.log(chalk.blue('Environment:'), chalk.green(env.NODE_ENV)); console.log(chalk.blue('Output directory:'), chalk.green(path.resolve(dist))); console.log(chalk.blue('Public path:'), chalk.green(publicPath)); @@ -55,21 +51,21 @@ export const sourceMapLoader: webpack.Loader = 'source-map-loader'; /** Load static files like images */ export const fileLoader: webpack.Loader = { - loader: 'file-loader', - options: { - publicPath, - name: '[hash:8].[ext]' - } + loader: 'file-loader', + options: { + publicPath, + name: '[hash:8].[ext]' + } }; /** Common webpack resolve options */ export const resolve: webpack.Resolve = { - /** Base directories that Webpack will look to resolve absolutely imported modules */ - modules: ['src', 'node_modules'], - /** Extension that are allowed to be omitted from import statements */ - extensions: ['.ts', '.tsx', '.js', '.jsx'], - /** "main" fields in package.json files to resolve a CommonJS module for */ - mainFields: ['browser', 'module', 'main'] + /** Base directories that Webpack will look to resolve absolutely imported modules */ + modules: ['src', 'node_modules'], + /** Extension that are allowed to be omitted from import statements */ + extensions: ['.ts', '.tsx', '.js', '.jsx'], + /** "main" fields in package.json files to resolve a CommonJS module for */ + mainFields: ['browser', 'module', 'main'] }; /** Get clean version of a version string of package.json entry for a package by @@ -79,122 +75,114 @@ export const resolve: webpack.Resolve = { * Examples: '1', '1.0', '1.2.3', '1.2.3-alpha.0' */ export function absoluteVersion(version: string) { - return version.replace(/[^\d.\-a-z]/g, ''); + return version.replace(/[^\d.\-a-z]/g, ''); } /** Map of libraries that are loaded from CDN and are omitted from emitted JS */ export const clientExternals: webpack.ExternalsElement = { - react: 'React', - 'react-dom': 'ReactDOM' + react: 'React', + 'react-dom': 'ReactDOM' }; /** A map of library names to their CDN values */ export const clientExternalsCDNUrls: { [libName: string]: string } = { - react: `https://unpkg.com/react@${absoluteVersion( - packageJson.devDependencies.react - )}/umd/react.production.min.js`, - 'react-dom': `https://unpkg.com/react-dom@${absoluteVersion( - packageJson.devDependencies['react-dom'] - )}/umd/react-dom.production.min.js` + react: `https://unpkg.com/react@${absoluteVersion(packageJson.devDependencies.react)}/umd/react.production.min.js`, + 'react-dom': `https://unpkg.com/react-dom@${absoluteVersion( + packageJson.devDependencies['react-dom'] + )}/umd/react-dom.production.min.js` }; /** Minification options for HTMLWebpackPlugin */ export const htmlMinifyConfig: HTMLWebpackPlugin.MinifyConfig = { - minifyCSS: true, - minifyJS: false, - removeComments: true, - collapseInlineTagWhitespace: true, - collapseWhitespace: true + minifyCSS: true, + minifyJS: false, + removeComments: true, + collapseInlineTagWhitespace: true, + collapseWhitespace: true }; /** FavIconWebpackPlugin icon options */ export const favIconIconsOptions = { - persistentCache: isDev, - android: false, - appleIcon: false, - appleStartup: false, - coast: false, - favicons: true, - firefox: false, - opengraph: false, - twitter: false, - yandex: false, - windows: false + persistentCache: isDev, + android: false, + appleIcon: false, + appleStartup: false, + coast: false, + favicons: true, + firefox: false, + opengraph: false, + twitter: false, + yandex: false, + windows: false }; /** Adds sourcemap support */ export const sourceMapRule: webpack.Rule = { - test: /\.js$/, - enforce: 'pre', - use: sourceMapLoader + test: /\.js$/, + enforce: 'pre', + use: sourceMapLoader }; /** Rule for images, icons and fonts */ export const imageAndFontsRule: webpack.Rule = { - test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)(\?.*)?$/, - use: fileLoader + test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)(\?.*)?$/, + use: fileLoader }; /** Generates HTML file that includes webpack assets */ export const htmlPlugin = new HTMLWebpackPlugin({ - template: './src/assets/index.html', - inject: 'body', - minify: isProd ? htmlMinifyConfig : false, - hash: false, - showErrors: isDev + template: './src/assets/index.html', + inject: 'body', + minify: isProd ? htmlMinifyConfig : false, + hash: false, + showErrors: isDev }); /** Generate all favorite icons based on one icon file */ export const favIconPlugin = new FavIconWebpackPlugin({ - logo: path.resolve(__dirname, 'src/assets/favicon.png'), - prefix: '[hash:8]/', - persistentCache: false, - icons: favIconIconsOptions + logo: path.resolve(__dirname, 'src/assets/favicon.png'), + prefix: '[hash:8]/', + persistentCache: false, + icons: favIconIconsOptions }); /** Write client stats to a JSON file for production */ export const statsWriterPlugin = new StatsWriterPlugin({ - filename: 'client-stats.json', - fields: [ - 'chunks', - 'publicPath', - 'assets', - 'assetsByChunkName', - 'assetsByChunkId' - ] + filename: 'client-stats.json', + fields: ['chunks', 'publicPath', 'assets', 'assetsByChunkName', 'assetsByChunkId'] }); /** Inject CDN URLs for externals */ export const htmlExternalsPlugin = new HTMLExternalsWebpackPlugin({ - externals: Object.keys(clientExternals).map(libName => ({ - module: libName, - entry: clientExternalsCDNUrls[libName], - global: clientExternals[libName] - })) + externals: Object.keys(clientExternals).map(libName => ({ + module: libName, + entry: clientExternalsCDNUrls[libName], + global: clientExternals[libName] + })) }); /** Gzip assets */ export const compressionPlugin = new CompressionWebpackPlugin({ - algorithm: 'gzip', - test: /\.(js|css|html)$/, - threshold: 10240, - minRatio: 0.8 + algorithm: 'gzip', + test: /\.(js|css|html)$/, + threshold: 10240, + minRatio: 0.8 }); /** Define "process.env" in client app. Only provide things that can be public */ export const getDefinePlugin = (isServer: boolean) => - new webpack.DefinePlugin({ - 'process.env': isServer - ? 'process.env' - : Object.keys(env).reduce( - (result, key: string) => ({ - ...result, - [key]: JSON.stringify((env as any)[key]) - }), - {} - ), - __isServer: isServer - }); + new webpack.DefinePlugin({ + 'process.env': isServer + ? 'process.env' + : Object.keys(env).reduce( + (result, key: string) => ({ + ...result, + [key]: JSON.stringify((env as any)[key]) + }), + {} + ), + __isServer: isServer + }); /** Enables Webpack HMR */ export const hmrPlugin = new webpack.HotModuleReplacementPlugin(); @@ -204,16 +192,13 @@ export const noErrorsPlugin = new webpack.NoEmitOnErrorsPlugin(); /** Limit server chunks to be only one. No need to split code in server */ export const limitChunksPlugin = new webpack.optimize.LimitChunkCountPlugin({ - maxChunks: 1 + maxChunks: 1 }); const typescriptRule = { - test: /\.tsx?$/, - exclude: /node_modules/, - use: [ - 'babel-loader', - { loader: 'ts-loader', options: { transpileOnly: true } } - ] + test: /\.tsx?$/, + exclude: /node_modules/, + use: ['babel-loader', { loader: 'ts-loader', options: { transpileOnly: true } }] }; /** @@ -222,93 +207,90 @@ const typescriptRule = { * Client is compiled into multiple chunks that are result to dynamic imports. */ export const clientConfig: webpack.Configuration = { - devtool, - resolve, - name: 'client', - target: 'web', - get entry() { - const entry = ['babel-polyfill', './src/client']; - - if (isDev) { - return ['webpack-hot-middleware/client', ...entry]; - } + devtool, + resolve, + name: 'client', + target: 'web', + get entry() { + const entry = ['babel-polyfill', './src/client']; + + if (isDev) { + return ['webpack-hot-middleware/client', ...entry]; + } - return entry; - }, - externals: isProd ? clientExternals : undefined, - module: { - rules: [sourceMapRule, typescriptRule, imageAndFontsRule] - }, - optimization: { - noEmitOnErrors: isDev, - minimizer: [ - new UglifyJsPlugin({ - parallel: true, - sourceMap: true, - uglifyOptions: { - compress: { - collapse_vars: false, - warnings: false - }, - output: { comments: false } - } - }) - ], - splitChunks: { - cacheGroups: { - vendor: { - chunks: 'initial', - enforce: true, - name: 'vendor', - priority: 10, - test: /node_modules/ - } - } + return entry; + }, + externals: isProd ? clientExternals : undefined, + module: { + rules: [sourceMapRule, typescriptRule, imageAndFontsRule] + }, + optimization: { + noEmitOnErrors: isDev, + minimizer: [ + new UglifyJsPlugin({ + parallel: true, + sourceMap: true, + uglifyOptions: { + compress: { + collapse_vars: false, + warnings: false + }, + output: { comments: false } } - }, - output: { - publicPath, - path: dist, - filename: '[name]-[hash:8].js', - chunkFilename: '[name]-[chunkhash].chunk.js', - crossOriginLoading: 'anonymous' - }, - get plugins() { - const plugins: webpack.Plugin[] = [ - htmlPlugin, - new ForkTsCheckerWebpackPlugin({ checkSyntacticErrors: true }), - favIconPlugin, - statsWriterPlugin, - getDefinePlugin(false), - new ServePlugin({ - middleware: (app, builtins) => - app.use(async (ctx, next) => { - ctx.setHeader( - 'Content-Type', - 'application/javascript; charset=UTF-8' - ); - await next(); - }), - port: 7777 - }) - ]; - - // Apply production specific configs - if (isProd) { - // plugins.push(uglifyPlugin); - plugins.push(htmlExternalsPlugin); - plugins.push(compressionPlugin); - } - - if (isDev) { - return [ - ...plugins - // namedModulesPlugin, - ]; + }) + ], + splitChunks: { + cacheGroups: { + vendor: { + chunks: 'initial', + enforce: true, + name: 'vendor', + priority: 10, + test: /node_modules/ } + } + } + }, + output: { + publicPath, + path: dist, + filename: '[name]-[hash:8].js', + chunkFilename: '[name]-[chunkhash].chunk.js', + crossOriginLoading: 'anonymous' + }, + get plugins() { + const plugins: webpack.Plugin[] = [ + htmlPlugin, + new ForkTsCheckerWebpackPlugin({ checkSyntacticErrors: true }), + favIconPlugin, + statsWriterPlugin, + getDefinePlugin(false), + new ServePlugin({ + middleware: (app, builtins) => + app.use(async (ctx, next) => { + ctx.setHeader('Content-Type', 'application/javascript; charset=UTF-8'); + await next(); + }), + port: 7777 + }) + ]; + + // Apply production specific configs + if (isProd) { + // plugins.push(uglifyPlugin); + plugins.push(htmlExternalsPlugin); + plugins.push(compressionPlugin); + } - return plugins; + if (isDev) { + return [ + ...plugins + // namedModulesPlugin, + ]; } + + return plugins; + } }; /** @@ -317,26 +299,26 @@ export const clientConfig: webpack.Configuration = { * Server bundle is compiled as a CommonJS package that exports an Express middleware */ export const serverConfig: webpack.Configuration = { - resolve, - name: 'server', - target: 'node', - devtool: isProd ? devtool : undefined, - entry: ['babel-polyfill', './src/server'], - module: { - rules: [sourceMapRule, typescriptRule, imageAndFontsRule] - }, - externals: [nodeExternals({ whitelist: /lyft/ })], - output: { - path: dist, - filename: 'server.js', - libraryTarget: 'commonjs2' - }, - plugins: [ - limitChunksPlugin, - new ForkTsCheckerWebpackPlugin({ checkSyntacticErrors: true }), - getDefinePlugin(true) - // namedModulesPlugin, - ] + resolve, + name: 'server', + target: 'node', + devtool: isProd ? devtool : undefined, + entry: ['babel-polyfill', './src/server'], + module: { + rules: [sourceMapRule, typescriptRule, imageAndFontsRule] + }, + externals: [nodeExternals({ whitelist: /lyft/ })], + output: { + path: dist, + filename: 'server.js', + libraryTarget: 'commonjs2' + }, + plugins: [ + limitChunksPlugin, + new ForkTsCheckerWebpackPlugin({ checkSyntacticErrors: true }), + getDefinePlugin(true) + // namedModulesPlugin, + ] }; export default [clientConfig, serverConfig]; diff --git a/yarn.lock b/yarn.lock index d356325de1..b7fab86a8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7170,6 +7170,16 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +chart.js@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.6.2.tgz#47342c551f688ffdda2cd53b534cb7e461ecec33" + integrity sha512-Xz7f/fgtVltfQYWq0zL1Xbv7N2inpG+B54p3D5FSvpCdy3sM+oZhbqa42eNuYXltaVvajgX5UpKCU2GeeJIgxg== + +chartjs-plugin-datalabels@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.0.0.tgz#caacefb26803d968785071eab012dde8746c5939" + integrity sha512-WBsWihphzM0Y8fmQVm89+iy99mmgejmj5/jcsYqwxSioLRL/zqJ4Scv/eXq5ZqvG3TpojlGzZLeaOaSvDm7fwA== + cheerio@^1.0.0-rc.2: version "1.0.0-rc.3" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.3.tgz#094636d425b2e9c0f4eb91a46c05630c9a1a8bf6" @@ -16553,6 +16563,11 @@ react-base16-styling@^0.5.1: lodash.flow "^3.3.0" pure-color "^1.2.0" +react-chartjs-2@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.0.0.tgz#a79919c9efe5381b8cb5abfd0ac7a56c9736cdb8" + integrity sha512-0kx41EVO6wIoeU6zvdwovX9kKcdrs7O62DGTSNmwAXZeLGJ3U+n4XijO1kxcMmAi4I6PQJWGD5oRwxVixHSp6g== + react-clientside-effect@^1.2.0, react-clientside-effect@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837"