diff --git a/packages/console/package.json b/packages/console/package.json index 8857c1404..28689c279 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@flyteorg/console", - "version": "0.0.20", + "version": "0.0.21", "description": "Flyteconsole main app module", "main": "./dist/index.js", "module": "./lib/index.js", diff --git a/packages/console/src/components/Executions/CacheStatus.tsx b/packages/console/src/components/Executions/CacheStatus.tsx index 2772c7054..2a6a43823 100644 --- a/packages/console/src/components/Executions/CacheStatus.tsx +++ b/packages/console/src/components/Executions/CacheStatus.tsx @@ -82,6 +82,7 @@ export interface CacheStatusProps { variant?: 'normal' | 'iconOnly'; sourceTaskExecutionId?: TaskExecutionIdentifier; iconStyles?: React.CSSProperties; + className?: string; } export const CacheStatus: React.FC = ({ @@ -89,6 +90,7 @@ export const CacheStatus: React.FC = ({ sourceTaskExecutionId, variant = 'normal', iconStyles, + className, }) => { const commonStyles = useCommonStyles(); const styles = useStyles(); @@ -100,11 +102,12 @@ export const CacheStatus: React.FC = ({ const message = cacheStatusMessages[cacheStatus] || unknownCacheStatusString; return variant === 'iconOnly' ? ( - + = ({ ) : ( <> @@ -122,6 +125,7 @@ export const CacheStatus: React.FC = ({ className={classnames( commonStyles.iconSecondary, commonStyles.iconLeft, + className, )} /> {message} @@ -131,6 +135,7 @@ export const CacheStatus: React.FC = ({ className={classnames( commonStyles.primaryLink, styles.sourceExecutionLink, + className, )} to={Routes.ExecutionDetails.makeUrl( sourceTaskExecutionId.nodeExecutionId.executionId, diff --git a/packages/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.ts b/packages/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.ts deleted file mode 100644 index 471fb755f..000000000 --- a/packages/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NodeExecutionIdentifier } from 'models/Execution/types'; -import { createContext } from 'react'; - -export interface DetailsPanelContextData { - selectedExecution?: NodeExecutionIdentifier | null; - setSelectedExecution: ( - selectedExecutionId: NodeExecutionIdentifier | null, - ) => void; -} - -export const DetailsPanelContext = createContext( - {} as DetailsPanelContextData, -); diff --git a/packages/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.tsx b/packages/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.tsx new file mode 100644 index 000000000..25b78182d --- /dev/null +++ b/packages/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.tsx @@ -0,0 +1,126 @@ +import React, { + PropsWithChildren, + useContext, + useEffect, + createContext, + useState, +} from 'react'; +import { NodeExecutionIdentifier } from 'models/Execution/types'; +import { DetailsPanel } from 'components/common/DetailsPanel'; +import { TaskExecutionPhase } from 'models'; +import { Core } from '@flyteorg/flyteidl-types'; +import { isStartOrEndNode } from 'models/Node/utils'; +import { NodeExecutionDetailsPanelContent } from './NodeExecutionDetailsPanelContent'; +import { useNodeExecutionsById } from '../contextProvider/NodeExecutionDetails'; + +export interface DetailsPanelContextData { + selectedExecution?: NodeExecutionIdentifier | null; + setSelectedExecution: ( + selectedExecutionId: NodeExecutionIdentifier | null, + ) => void; + onNodeSelectionChanged: (newSelection: string[]) => void; + selectedPhase: Core.TaskExecution.Phase | undefined; + setSelectedPhase: ( + value: React.SetStateAction, + ) => void; + isDetailsTabClosed: boolean; + setIsDetailsTabClosed: (boolean) => void; +} + +export const DetailsPanelContext = createContext( + {} as DetailsPanelContextData, +); + +export interface DetailsPanelContextProviderProps { + selectedPhase?: TaskExecutionPhase; +} +export const DetailsPanelContextProvider = ({ + children, +}: PropsWithChildren) => { + const [selectedNodes, setSelectedNodes] = useState([]); + const { nodeExecutionsById } = useNodeExecutionsById(); + + const [selectedPhase, setSelectedPhase] = useState< + TaskExecutionPhase | undefined + >(undefined); + + // Note: flytegraph allows multiple selection, but we only support showing + // a single item in the details panel + const [selectedExecution, setSelectedExecution] = + useState( + selectedNodes.length + ? nodeExecutionsById[selectedNodes[0]] + ? nodeExecutionsById[selectedNodes[0]].id + : { + nodeId: selectedNodes[0], + executionId: + nodeExecutionsById[Object.keys(nodeExecutionsById)[0]].id + .executionId, + } + : null, + ); + + const [isDetailsTabClosed, setIsDetailsTabClosed] = useState( + !selectedExecution, + ); + + useEffect(() => { + setIsDetailsTabClosed(!selectedExecution); + }, [selectedExecution]); + + const onNodeSelectionChanged = (newSelection: string[]) => { + const validSelection = newSelection.filter(nodeId => { + if (isStartOrEndNode(nodeId)) { + return false; + } + return true; + }); + setSelectedNodes(validSelection); + const newSelectedExecution = validSelection.length + ? nodeExecutionsById[validSelection[0]] + ? nodeExecutionsById[validSelection[0]].id + : { + nodeId: validSelection[0], + executionId: + nodeExecutionsById[Object.keys(nodeExecutionsById)[0]].id + .executionId, + } + : null; + setSelectedExecution(newSelectedExecution); + }; + + const onCloseDetailsPanel = () => { + setSelectedExecution(null); + setSelectedPhase(undefined); + setSelectedNodes([]); + }; + + return ( + + {children} + + {!isDetailsTabClosed && selectedExecution && ( + + )} + + + ); +}; + +export const useDetailsPanel = () => { + return useContext(DetailsPanelContext); +}; diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx index ab6a570d1..678306280 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetails.tsx @@ -1,3 +1,5 @@ +import * as React from 'react'; +import { useContext } from 'react'; import { Collapse, IconButton } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import ExpandMore from '@material-ui/icons/ExpandMore'; @@ -7,13 +9,16 @@ import { WaitForQuery } from 'components/common/WaitForQuery'; import { withRouteParams } from 'components/common/withRouteParams'; import { DataError } from 'components/Errors/DataError'; import { Execution } from 'models/Execution/types'; -import * as React from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { useQuery, useQueryClient } from 'react-query'; +import { Workflow } from 'models/Workflow/types'; +import { makeWorkflowQuery } from 'components/Workflow/workflowQueries'; import { ExecutionContext } from '../contexts'; import { useWorkflowExecutionQuery } from '../useWorkflowExecution'; import { ExecutionDetailsAppBarContent } from './ExecutionDetailsAppBarContent'; import { ExecutionMetadata } from './ExecutionMetadata'; import { ExecutionNodeViews } from './ExecutionNodeViews'; +import { NodeExecutionDetailsContextProvider } from '../contextProvider/NodeExecutionDetails'; const useStyles = makeStyles((theme: Theme) => ({ expandCollapseButton: { @@ -38,51 +43,60 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); -export interface ExecutionDetailsRouteParams { - domainId: string; - executionId: string; - projectId: string; -} -export type ExecutionDetailsProps = ExecutionDetailsRouteParams; - -interface RenderExecutionDetailsProps { - execution: Execution; -} - -const RenderExecutionDetails: React.FC = ({ - execution, -}) => { +const RenderExecutionContainer: React.FC<{}> = () => { const styles = useStyles(); const [metadataExpanded, setMetadataExpanded] = React.useState(true); const toggleMetadata = () => setMetadataExpanded(!metadataExpanded); - const contextValue = { - execution, - }; + const { execution } = useContext(ExecutionContext); + + const { + closure: { workflowId }, + } = execution; + + const workflowQuery = useQuery( + makeWorkflowQuery(useQueryClient(), workflowId), + ); return ( - - -
- - - -
- - - -
-
- -
+ <> + {/* Fetches the current workflow to build the execution tree inside NodeExecutionDetailsContextProvider */} + + {workflow => ( + <> + {/* Provides a node execution tree for the current workflow */} + + +
+ + + +
+ + + +
+
+ + +
+ + )} +
+ ); }; +export interface ExecutionDetailsRouteParams { + domainId: string; + executionId: string; + projectId: string; +} /** The view component for the Execution Details page */ -export const ExecutionDetailsContainer: React.FC = ({ +export const ExecutionDetailsWrapper: React.FC = ({ executionId, domainId, projectId, @@ -93,21 +107,28 @@ export const ExecutionDetailsContainer: React.FC = ({ name: executionId, }; - const renderExecutionDetails = (execution: Execution) => ( - - ); + const workflowExecutionQuery = useWorkflowExecutionQuery(id); return ( + // get the workflow execution query to get the current workflow id - {renderExecutionDetails} + {(execution: Execution) => ( + + + + )} ); }; export const ExecutionDetails: React.FunctionComponent< RouteComponentProps -> = withRouteParams(ExecutionDetailsContainer); +> = withRouteParams(ExecutionDetailsWrapper); diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx index 1a66af50c..2752c4dbf 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx @@ -1,5 +1,5 @@ +import React, { useEffect, useState } from 'react'; import { Button, Dialog, IconButton } from '@material-ui/core'; -import * as React from 'react'; import { ResourceIdentifier, Identifier } from 'models/Common/types'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { getTask } from 'models/Task/api'; @@ -14,13 +14,14 @@ import { TaskInitialLaunchParameters } from 'components/Launch/LaunchForm/types' import { NodeExecutionPhase } from 'models/Execution/enums'; import { extractCompiledNodes } from 'components/hooks/utils'; import Close from '@material-ui/icons/Close'; -import { useEffect, useState } from 'react'; import classnames from 'classnames'; import { NodeExecutionDetails } from '../types'; import t from './strings'; import { ExecutionNodeDeck } from './ExecutionNodeDeck'; -import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from '../contexts'; +import { + useNodeExecutionContext, + useNodeExecutionsById, +} from '../contextProvider/NodeExecutionDetails'; const useStyles = makeStyles((theme: Theme) => { return { @@ -63,7 +64,6 @@ const useStyles = makeStyles((theme: Theme) => { }, }; }); - interface ExecutionDetailsActionsProps { className?: string; details?: NodeExecutionDetails; @@ -91,12 +91,11 @@ export const ExecutionDetailsActions = ({ const [initialParameters, setInitialParameters] = useState< TaskInitialLaunchParameters | undefined >(undefined); - + const { nodeExecutionsById } = useNodeExecutionsById(); const executionData = useNodeExecutionData(nodeExecutionId); const execution = useNodeExecution(nodeExecutionId); const { compiledWorkflowClosure } = useNodeExecutionContext(); const id = details?.taskTemplate?.id; - const { nodeExecutionsById } = React.useContext(NodeExecutionsByIdContext); const compiledNode = extractCompiledNodes(compiledWorkflowClosure).find( node => diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx index 3e57d4fa8..c5fcc052a 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionDetailsAppBarContent.tsx @@ -8,7 +8,6 @@ import { ButtonCircularProgress } from 'components/common/ButtonCircularProgress import { MoreOptionsMenu } from 'components/common/MoreOptionsMenu'; import { useCommonStyles } from 'components/common/styles'; import { useLocationState } from 'components/hooks/useLocationState'; -import { Execution } from 'models/Execution/types'; import { Link as RouterLink } from 'react-router-dom'; import { history } from 'routes/history'; import { Routes } from 'routes/routes'; @@ -22,6 +21,7 @@ import { backLinkTitle, executionActionStrings } from './constants'; import { RelaunchExecutionForm } from './RelaunchExecutionForm'; import { getExecutionBackLink, getExecutionSourceId } from './utils'; import { useRecoverExecutionState } from './useRecoverExecutionState'; +import { ExecutionContext } from '../contexts'; const useStyles = makeStyles((theme: Theme) => { return { @@ -73,11 +73,12 @@ const useStyles = makeStyles((theme: Theme) => { }); /** Renders information about a given Execution into the NavBar */ -export const ExecutionDetailsAppBarContentInner: React.FC<{ - execution: Execution; -}> = ({ execution }) => { +export const ExecutionDetailsAppBarContentInner: React.FC<{}> = () => { const commonStyles = useCommonStyles(); const styles = useStyles(); + + const { execution } = React.useContext(ExecutionContext); + const [showInputsOutputs, setShowInputsOutputs] = React.useState(false); const [showRelaunchForm, setShowRelaunchForm] = React.useState(false); const { domain, name, project } = execution.id; @@ -231,12 +232,10 @@ export const ExecutionDetailsAppBarContentInner: React.FC<{ ); }; -export const ExecutionDetailsAppBarContent: React.FC<{ - execution: Execution; -}> = ({ execution }) => { +export const ExecutionDetailsAppBarContent: React.FC<{}> = () => { return ( - + ); }; diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx index e83fa0543..a5a4428a8 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadata.tsx @@ -10,6 +10,7 @@ import { secondaryBackgroundColor } from 'components/Theme/constants'; import { Execution } from 'models/Execution/types'; import { Link as RouterLink } from 'react-router-dom'; import { Routes } from 'routes/routes'; +import { ExecutionContext } from '../contexts'; import { ExpandableExecutionError } from '../Tables/ExpandableExecutionError'; import { ExecutionMetadataLabels } from './constants'; import { ExecutionMetadataExtra } from './ExecutionMetadataExtra'; @@ -60,12 +61,12 @@ interface DetailItem { } /** Renders metadata details about a given Execution */ -export const ExecutionMetadata: React.FC<{ - execution: Execution; -}> = ({ execution }) => { +export const ExecutionMetadata: React.FC<{}> = () => { const commonStyles = useCommonStyles(); const styles = useStyles(); + const { execution } = React.useContext(ExecutionContext); + const { domain } = execution.id; const { abortMetadata, duration, error, startedAt, workflowId } = execution.closure; diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadataExtra.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadataExtra.tsx index e59349b0e..e64dc16ef 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadataExtra.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionMetadataExtra.tsx @@ -67,7 +67,8 @@ export const ExecutionMetadataExtra: React.FC<{ label: ExecutionMetadataLabels.rawOutputPrefix, value: rawOutputDataConfig?.outputLocationPrefix || - launchPlanSpec?.rawOutputDataConfig?.outputLocationPrefix, + launchPlanSpec?.rawOutputDataConfig?.outputLocationPrefix || + dashedValueString, }, { label: ExecutionMetadataLabels.parallelism, diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx index dc09b04d6..91c379b59 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx @@ -1,139 +1,32 @@ -import React, { useEffect } from 'react'; -import { Tab, Tabs } from '@material-ui/core'; -import { makeStyles, Theme } from '@material-ui/core/styles'; -import { WaitForQuery } from 'components/common/WaitForQuery'; +import React, { useContext } from 'react'; +import { WaitForQuery } from 'components/common'; import { DataError } from 'components/Errors/DataError'; -import { useTabState } from 'components/hooks/useTabState'; -import { secondaryBackgroundColor } from 'components/Theme/constants'; -import { Execution } from 'models/Execution/types'; -import { clone, keyBy, merge } from 'lodash'; -import { LargeLoadingSpinner } from 'components/common/LoadingSpinner'; -import { FilterOperation } from 'models/AdminEntity/types'; -import { NodeExecutionDetailsContextProvider } from '../contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from '../contexts'; -import { ExecutionFilters } from '../ExecutionFilters'; -import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; -import { tabs } from './constants'; -import { useExecutionNodeViewsState } from './useExecutionNodeViewsState'; -import { ExecutionTab } from './ExecutionTab'; -import { useNodeExecutionsById } from '../useNodeExecutionsById'; - -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), - }, - loading: { - margin: 'auto', - }, -})); - -const isPhaseFilter = (appliedFilters: FilterOperation[]) => { - if (appliedFilters.length === 1 && appliedFilters[0].key === 'phase') { - return true; - } - return false; -}; - -interface ExecutionNodeViewsProps { - execution: Execution; -} +import { LargeLoadingComponent } from 'components/common/LoadingSpinner'; +import { ExecutionContext } from '../contexts'; +import { useExecutionNodeViewsStatePoll } from './useExecutionNodeViewsState'; +import { ExecutionTabView } from './ExecutionTabView'; +import { WorkflowNodeExecutionsProvider } from '../contextProvider/NodeExecutionDetails'; /** Contains the available ways to visualize the nodes of a WorkflowExecution */ -export const ExecutionNodeViews: React.FC = ({ - execution, -}) => { - const defaultTab = tabs.nodes.id; - const styles = useStyles(); - const filterState = useNodeExecutionFiltersState(); - const tabState = useTabState(tabs, defaultTab); - - const { - closure: { workflowId }, - } = execution; +export const ExecutionNodeViews: React.FC<{}> = () => { + const { execution } = useContext(ExecutionContext); // query to get all data to build Graph and Timeline - const { nodeExecutionsQuery } = useExecutionNodeViewsState(execution); - // query to get filtered data to narrow down Table outputs - const { - nodeExecutionsQuery: { data: filteredNodeExecutions }, - } = useExecutionNodeViewsState(execution, filterState.appliedFilters); - - const { nodeExecutionsById, setCurrentNodeExecutionsById } = - useNodeExecutionsById(); - - useEffect(() => { - const currentNodeExecutionsById = keyBy( - nodeExecutionsQuery.data, - 'scopedId', - ); - const prevNodeExecutionsById = clone(nodeExecutionsById); - const newNodeExecutionsById = merge( - prevNodeExecutionsById, - currentNodeExecutionsById, - ); - setCurrentNodeExecutionsById(newNodeExecutionsById); - }, [nodeExecutionsQuery.data]); - - const LoadingComponent = () => { - return ( -
- -
- ); - }; - - const renderTab = tabType => { - return ( - - ); - }; + const { nodeExecutionsQuery } = useExecutionNodeViewsStatePoll(execution); return ( <> - - - - - - - -
- {tabState.value === tabs.nodes.id && ( -
- -
- )} - - {() => renderTab(tabState.value)} - -
-
-
+ + {data => ( + + + + )} + ); }; diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx index 8bac79209..865220744 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx @@ -1,40 +1,43 @@ -import { WaitForQuery } from 'components/common/WaitForQuery'; -import { DataError } from 'components/Errors/DataError'; -import { makeWorkflowQuery } from 'components/Workflow/workflowQueries'; -import { Workflow } from 'models/Workflow/types'; import * as React from 'react'; -import { useQuery, useQueryClient } from 'react-query'; -import { NodeExecution } from 'models/Execution/types'; -import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; +import { WorkflowGraph } from 'components/WorkflowGraph/WorkflowGraph'; +import { Theme, makeStyles } from '@material-ui/core/styles'; +import { tabs } from './constants'; +import { NodeExecutionsTable } from '../Tables/NodeExecutionsTable'; +import { DetailsPanelContextProvider } from './DetailsPanelContext'; import { ScaleProvider } from './Timeline/scaleContext'; -import { ExecutionTabContent } from './ExecutionTabContent'; +import { ExecutionTimelineContainer } from './Timeline/ExecutionTimelineContainer'; +import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; + +const useStyles = makeStyles((theme: Theme) => ({ + nodesContainer: { + borderTop: `1px solid ${theme.palette.divider}`, + display: 'flex', + flex: '1 1 100%', + flexDirection: 'column', + minHeight: 0, + }, +})); interface ExecutionTabProps { tabType: string; - filteredNodeExecutions?: NodeExecution[]; } /** Contains the available ways to visualize the nodes of a WorkflowExecution */ -export const ExecutionTab: React.FC = ({ - tabType, - filteredNodeExecutions, -}) => { - const queryClient = useQueryClient(); - const { workflowId } = useNodeExecutionContext(); - const workflowQuery = useQuery( - makeWorkflowQuery(queryClient, workflowId), - ); +export const ExecutionTab: React.FC = ({ tabType }) => { + const styles = useStyles(); + const filterState = useNodeExecutionFiltersState(); return ( - - {() => ( - - )} - + +
+ {tabType === tabs.nodes.id && ( + + )} + {tabType === tabs.graph.id && } + {tabType === tabs.timeline.id && } +
+
); }; diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx deleted file mode 100644 index 60333ae5b..000000000 --- a/packages/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import { makeStyles } from '@material-ui/core'; -import { DetailsPanel } from 'components/common/DetailsPanel'; -import { makeNodeExecutionDynamicWorkflowQuery } from 'components/Workflow/workflowQueries'; -import { WorkflowGraph } from 'components/WorkflowGraph/WorkflowGraph'; -import { TaskExecutionPhase } from 'models/Execution/enums'; -import { NodeExecution, NodeExecutionIdentifier } from 'models/Execution/types'; -import { startNodeId, endNodeId } from 'models/Node/constants'; -import React, { useContext, useEffect, useMemo, useState } from 'react'; -import { transformerWorkflowToDag } from 'components/WorkflowGraph/transformerWorkflowToDag'; -import { checkForDynamicExecutions } from 'components/common/utils'; -import { dNode } from 'models/Graph/types'; -import { useQuery } from 'react-query'; -import { - FilterOperation, - FilterOperationName, - FilterOperationValueList, -} from 'models/AdminEntity/types'; -import { isEqual } from 'lodash'; -import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from '../contexts'; -import { NodeExecutionsTable } from '../Tables/NodeExecutionsTable'; -import { tabs } from './constants'; -import { NodeExecutionDetailsPanelContent } from './NodeExecutionDetailsPanelContent'; -import { ExecutionTimeline } from './Timeline/ExecutionTimeline'; -import { ExecutionTimelineFooter } from './Timeline/ExecutionTimelineFooter'; -import { convertToPlainNodes, TimeZone } from './Timeline/helpers'; -import { DetailsPanelContext } from './DetailsPanelContext'; -import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; -import { nodeExecutionPhaseConstants } from '../constants'; - -interface ExecutionTabContentProps { - tabType: string; - filteredNodeExecutions?: NodeExecution[]; -} - -const useStyles = makeStyles(() => ({ - wrapper: { - display: 'flex', - flexDirection: 'column', - flex: '1 1 100%', - }, - container: { - display: 'flex', - flex: '1 1 0', - overflowY: 'auto', - }, -})); - -const executionMatchesPhaseFilter = ( - nodeExecution: NodeExecution, - { key, value, operation }: FilterOperation, -) => { - if (key === 'phase' && operation === FilterOperationName.VALUE_IN) { - // default to UNKNOWN phase if the field does not exist on a closure - const itemValue = - nodeExecutionPhaseConstants()[nodeExecution?.closure[key]]?.value ?? - nodeExecutionPhaseConstants()[0].value; - // phase check filters always return values in an array - const valuesArray = value as FilterOperationValueList; - return valuesArray.includes(itemValue); - } - return false; -}; - -export const ExecutionTabContent: React.FC = ({ - tabType, - filteredNodeExecutions, -}) => { - const styles = useStyles(); - const { compiledWorkflowClosure } = useNodeExecutionContext(); - const { appliedFilters } = useNodeExecutionFiltersState(); - const { nodeExecutionsById } = useContext(NodeExecutionsByIdContext); - const { staticExecutionIdsMap } = compiledWorkflowClosure - ? transformerWorkflowToDag(compiledWorkflowClosure) - : { staticExecutionIdsMap: {} }; - const [dynamicParents, setDynamicParents] = useState( - checkForDynamicExecutions(nodeExecutionsById, staticExecutionIdsMap), - ); - const { data: dynamicWorkflows, refetch } = useQuery( - makeNodeExecutionDynamicWorkflowQuery(dynamicParents), - ); - - const [initialNodes, setInitialNodes] = useState([]); - const [initialFilteredNodes, setInitialFilteredNodes] = useState< - dNode[] | undefined - >(undefined); - const [dagError, setDagError] = useState(null); - const [mergedDag, setMergedDag] = useState(null); - const [filters, setFilters] = useState(appliedFilters); - const [isFiltersChanged, setIsFiltersChanged] = useState(false); - const [shouldUpdate, setShouldUpdate] = useState(false); - - useEffect(() => { - if (shouldUpdate) { - const newDynamicParents = checkForDynamicExecutions( - nodeExecutionsById, - staticExecutionIdsMap, - ); - setDynamicParents(newDynamicParents); - refetch(); - setShouldUpdate(false); - } - }, [shouldUpdate]); - - useEffect(() => { - const { dag, staticExecutionIdsMap, error } = compiledWorkflowClosure - ? transformerWorkflowToDag( - compiledWorkflowClosure, - dynamicWorkflows, - nodeExecutionsById, - ) - : { dag: {}, staticExecutionIdsMap: {}, error: null }; - - const nodes = dag.nodes ?? []; - - // we remove start/end node info in the root dNode list during first assignment - const plainNodes = convertToPlainNodes(nodes); - - let newMergedDag = dag; - - for (const dynamicId in dynamicWorkflows) { - if (staticExecutionIdsMap[dynamicId]) { - if (compiledWorkflowClosure) { - const dynamicWorkflow = transformerWorkflowToDag( - compiledWorkflowClosure, - dynamicWorkflows, - nodeExecutionsById, - ); - newMergedDag = dynamicWorkflow.dag; - } - } - } - setDagError(error); - setMergedDag(newMergedDag); - plainNodes.map(node => { - const initialNode = initialNodes.find(n => n.scopedId === node.scopedId); - if (initialNode) { - node.expanded = initialNode.expanded; - } - }); - setInitialNodes(plainNodes); - }, [ - compiledWorkflowClosure, - dynamicWorkflows, - dynamicParents, - nodeExecutionsById, - ]); - - useEffect(() => { - if (!isEqual(filters, appliedFilters)) { - setFilters(appliedFilters); - setIsFiltersChanged(true); - } else { - setIsFiltersChanged(false); - } - }, [appliedFilters]); - - useEffect(() => { - if (appliedFilters.length > 0) { - // if filter was apllied, but filteredNodeExecutions is empty, we only appliied Phase filter, - // and need to clear out items manually - if (!filteredNodeExecutions) { - const filteredNodes = initialNodes.filter(node => - executionMatchesPhaseFilter( - nodeExecutionsById[node.scopedId], - appliedFilters[0], - ), - ); - setInitialFilteredNodes(filteredNodes); - } else { - const filteredNodes = initialNodes.filter((node: dNode) => - filteredNodeExecutions.find( - (execution: NodeExecution) => execution.scopedId === node.scopedId, - ), - ); - setInitialFilteredNodes(filteredNodes); - } - } - }, [initialNodes, filteredNodeExecutions, isFiltersChanged]); - - const [selectedNodes, setSelectedNodes] = useState([]); - - // Note: flytegraph allows multiple selection, but we only support showing - // a single item in the details panel - const [selectedExecution, setSelectedExecution] = - useState( - selectedNodes.length - ? nodeExecutionsById[selectedNodes[0]] - ? nodeExecutionsById[selectedNodes[0]].id - : { - nodeId: selectedNodes[0], - executionId: - nodeExecutionsById[Object.keys(nodeExecutionsById)[0]].id - .executionId, - } - : null, - ); - - const [selectedPhase, setSelectedPhase] = useState< - TaskExecutionPhase | undefined - >(undefined); - const [isDetailsTabClosed, setIsDetailsTabClosed] = useState( - !selectedExecution, - ); - - useEffect(() => { - setIsDetailsTabClosed(!selectedExecution); - }, [selectedExecution]); - - const onCloseDetailsPanel = () => { - setSelectedExecution(null); - setSelectedPhase(undefined); - setSelectedNodes([]); - }; - - const [chartTimezone, setChartTimezone] = useState(TimeZone.Local); - - const handleTimezoneChange = tz => setChartTimezone(tz); - - const detailsPanelContext = useMemo( - () => ({ selectedExecution, setSelectedExecution }), - [selectedExecution, setSelectedExecution], - ); - - const onNodeSelectionChanged = (newSelection: string[]) => { - const validSelection = newSelection.filter(nodeId => { - if (nodeId === startNodeId || nodeId === endNodeId) { - return false; - } - return true; - }); - setSelectedNodes(validSelection); - const newSelectedExecution = validSelection.length - ? nodeExecutionsById[validSelection[0]] - ? nodeExecutionsById[validSelection[0]].id - : { - nodeId: validSelection[0], - executionId: - nodeExecutionsById[Object.keys(nodeExecutionsById)[0]].id - .executionId, - } - : null; - setSelectedExecution(newSelectedExecution); - }; - - const renderContent = () => { - switch (tabType) { - case tabs.nodes.id: - return ( - - ); - case tabs.graph.id: - return ( - - ); - case tabs.timeline.id: - return ( -
-
- -
- -
- ); - default: - return null; - } - }; - - return ( - <> - - {renderContent()} - - {/* Side panel, shows information for specific node */} - - {!isDetailsTabClosed && selectedExecution && ( - - )} - - - ); -}; diff --git a/packages/console/src/components/Executions/ExecutionDetails/ExecutionTabView.tsx b/packages/console/src/components/Executions/ExecutionDetails/ExecutionTabView.tsx new file mode 100644 index 000000000..408802537 --- /dev/null +++ b/packages/console/src/components/Executions/ExecutionDetails/ExecutionTabView.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Tab, Tabs } from '@material-ui/core'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import { useTabState } from 'components/hooks/useTabState'; +import { secondaryBackgroundColor } from 'components/Theme/constants'; +import { tabs } from './constants'; +import { ExecutionTab } from './ExecutionTab'; + +const useStyles = makeStyles((theme: Theme) => ({ + tabs: { + background: secondaryBackgroundColor, + paddingLeft: theme.spacing(3.5), + }, +})); + +const DEFAULT_TAB = tabs.nodes.id; + +/** Contains the available ways to visualize the nodes of a WorkflowExecution */ +export const ExecutionTabView: React.FC<{}> = () => { + const styles = useStyles(); + const tabState = useTabState(tabs, DEFAULT_TAB); + + return ( + <> + + + + + + + + + ); +}; diff --git a/packages/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx b/packages/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx index 93a0d0048..d8fe5d3b1 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx @@ -54,7 +54,7 @@ import { ExecutionDetailsActions } from './ExecutionDetailsActions'; import { getNodeFrontendPhase, isNodeGateNode } from '../utils'; import { fetchTaskExecutionList } from '../taskExecutionQueries'; import { getGroupedLogs } from '../TaskExecutionsList/utils'; -import { NodeExecutionsByIdContext } from '../contexts'; +import { WorkflowNodeExecutionsContext } from '../contexts'; const useStyles = makeStyles((theme: Theme) => { const paddingVertical = `${theme.spacing(2)}px`; @@ -262,7 +262,7 @@ export const NodeExecutionDetailsPanelContent: React.FC< const { getNodeExecutionDetails, compiledWorkflowClosure } = useNodeExecutionContext(); const { nodeExecutionsById, setCurrentNodeExecutionsById } = useContext( - NodeExecutionsByIdContext, + WorkflowNodeExecutionsContext, ); const isGateNode = isNodeGateNode( extractCompiledNodes(compiledWorkflowClosure), @@ -325,7 +325,7 @@ export const NodeExecutionDetailsPanelContent: React.FC< if (nodeExecution) { if ( nodeExecution.scopedId && - !nodeExecutionsById[nodeExecution.scopedId].tasksFetched + !nodeExecutionsById?.[nodeExecution.scopedId]?.tasksFetched ) fetchTasksData(nodeExecution, queryClient); } else { diff --git a/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx b/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx index 620d58a6e..d796b38bf 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNode.tsx @@ -1,10 +1,10 @@ +import React from 'react'; +import { useNodeExecutionsById } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { getNodeExecutionPhaseConstants } from 'components/Executions/utils'; import { NodeRendererProps, Point } from 'components/flytegraph/types'; import { TaskNodeRenderer } from 'components/WorkflowGraph/TaskNodeRenderer'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { DAGNode } from 'models/Graph/types'; -import React, { useContext } from 'react'; -import { NodeExecutionsByIdContext } from '../../contexts'; import { StatusIndicator } from './StatusIndicator'; /** Renders DAGNodes with colors based on their node type, as well as dots to @@ -14,7 +14,7 @@ export const TaskExecutionNode: React.FC< NodeRendererProps > = props => { const { node, config, selected } = props; - const { nodeExecutionsById } = useContext(NodeExecutionsByIdContext); + const { nodeExecutionsById } = useNodeExecutionsById(); const nodeExecution = nodeExecutionsById[node.id]; const phase = nodeExecution diff --git a/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNodeRenderer.tsx b/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNodeRenderer.tsx index 53e4bc9dd..50ae08531 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNodeRenderer.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/TaskExecutionNodeRenderer/TaskExecutionNodeRenderer.tsx @@ -1,6 +1,6 @@ import { NodeRendererProps } from 'components/flytegraph/types'; import { TaskNodeRenderer } from 'components/WorkflowGraph/TaskNodeRenderer'; -import { isEndNode, isStartNode } from 'components/WorkflowGraph/utils'; +import { isEndNode, isStartNode } from 'models/Node/utils'; import { DAGNode } from 'models/Graph/types'; import * as React from 'react'; import { TaskExecutionNode } from './TaskExecutionNode'; diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx b/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx index 2d8e98206..c7423da39 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx @@ -1,21 +1,15 @@ -import React, { - createRef, - useContext, - useEffect, - useRef, - useState, -} from 'react'; +import React, { createRef, useEffect, useRef, useState } from 'react'; import { makeStyles, Typography } from '@material-ui/core'; import { tableHeaderColor } from 'components/Theme/constants'; import { timestampToDate } from 'common/utils'; import { dNode } from 'models/Graph/types'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; import { fetchChildrenExecutions, searchNode, } from 'components/Executions/utils'; import { useQueryClient } from 'react-query'; import { eq, merge } from 'lodash'; +import { useNodeExecutionsById } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { convertToPlainNodes } from './helpers'; import { ChartHeader } from './ChartHeader'; import { useScaleContext } from './scaleContext'; @@ -78,13 +72,11 @@ const INTERVAL_LENGTH = 110; interface ExProps { chartTimezone: string; initialNodes: dNode[]; - setShouldUpdate: (val: boolean) => void; } export const ExecutionTimeline: React.FC = ({ chartTimezone, initialNodes, - setShouldUpdate, }) => { const [chartWidth, setChartWidth] = useState(0); const [labelInterval, setLabelInterval] = useState(INTERVAL_LENGTH); @@ -96,9 +88,8 @@ export const ExecutionTimeline: React.FC = ({ const [showNodes, setShowNodes] = useState([]); const [startedAt, setStartedAt] = useState(new Date()); const queryClient = useQueryClient(); - const { nodeExecutionsById, setCurrentNodeExecutionsById } = useContext( - NodeExecutionsByIdContext, - ); + const { nodeExecutionsById, setCurrentNodeExecutionsById } = + useNodeExecutionsById(); const { chartInterval: chartTimeInterval } = useScaleContext(); useEffect(() => { @@ -182,7 +173,6 @@ export const ExecutionTimeline: React.FC = ({ scopedId, nodeExecutionsById, setCurrentNodeExecutionsById, - setShouldUpdate, ); searchNode(originalNodes, 0, id, scopedId, level); setOriginalNodes([...originalNodes]); diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimelineContainer.tsx b/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimelineContainer.tsx new file mode 100644 index 000000000..efcd92e00 --- /dev/null +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimelineContainer.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import { useState } from 'react'; +import { useNodeExecutionsById } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { ExecutionTimeline } from './ExecutionTimeline'; +import { ExecutionTimelineFooter } from './ExecutionTimelineFooter'; +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', + }, +})); + +export const ExecutionTimelineContainer: React.FC<{}> = () => { + const styles = useStyles(); + const [chartTimezone, setChartTimezone] = useState(TimeZone.Local); + const handleTimezoneChange = tz => setChartTimezone(tz); + + const { initialDNodes: initialNodes } = useNodeExecutionsById(); + return ( +
+
+ +
+ +
+ ); +}; diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/NodeExecutionName.tsx b/packages/console/src/components/Executions/ExecutionDetails/Timeline/NodeExecutionName.tsx index 4db08a68a..475870913 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/NodeExecutionName.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/NodeExecutionName.tsx @@ -1,5 +1,6 @@ import { makeStyles, Theme } from '@material-ui/core'; import Typography from '@material-ui/core/Typography'; +import classNames from 'classnames'; import { useCommonStyles } from 'components/common/styles'; import { useNodeExecutionContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { SelectNodeExecutionLink } from 'components/Executions/Tables/SelectNodeExecutionLink'; @@ -7,12 +8,13 @@ import { isEqual } from 'lodash'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { NodeExecution } from 'models/Execution/types'; import React, { useContext, useEffect, useState } from 'react'; -import { DetailsPanelContext } from '../DetailsPanelContext'; +import { DetailsPanelContext, useDetailsPanel } from '../DetailsPanelContext'; interface NodeExecutionTimelineNameData { name: string; templateName?: string; execution?: NodeExecution; + className?: string; } const useStyles = makeStyles((_theme: Theme) => ({ @@ -31,13 +33,13 @@ export const NodeExecutionName: React.FC = ({ name, templateName, execution, + className, }) => { const commonStyles = useCommonStyles(); const styles = useStyles(); const { getNodeExecutionDetails } = useNodeExecutionContext(); - const { selectedExecution, setSelectedExecution } = - useContext(DetailsPanelContext); + const { selectedExecution, setSelectedExecution } = useDetailsPanel(); const [displayName, setDisplayName] = useState(); useEffect(() => { @@ -67,12 +69,15 @@ export const NodeExecutionName: React.FC = ({ <> {isSelected || execution.closure.phase === NodeExecutionPhase.UNDEFINED ? ( - + {truncatedName} ) : ( = ({ {templateName} diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx index dcb94c60a..32040a6f8 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx @@ -1,14 +1,12 @@ -import React, { useContext } from 'react'; +import React from 'react'; import { IconButton, makeStyles, Theme, Tooltip } from '@material-ui/core'; import { RowExpander } from 'components/Executions/Tables/RowExpander'; -import { - getNodeTemplateName, - isExpanded, -} from 'components/WorkflowGraph/utils'; +import { getNodeTemplateName } from 'components/WorkflowGraph/utils'; import { dNode } from 'models/Graph/types'; import { PlayCircleOutline } from '@material-ui/icons'; import { isParentNode } from 'components/Executions/utils'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { useNodeExecutionsById } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { isExpanded } from 'models/Node/utils'; import { NodeExecutionName } from './NodeExecutionName'; import t from '../strings'; @@ -57,7 +55,7 @@ interface TaskNamesProps { export const TaskNames = React.forwardRef( ({ nodes, onScroll, onToggle, onAction }, ref) => { const styles = useStyles(); - const { nodeExecutionsById } = useContext(NodeExecutionsByIdContext); + const { nodeExecutionsById } = useNodeExecutionsById(); const expanderRef = React.useRef(); diff --git a/packages/console/src/components/Executions/ExecutionDetails/Timeline/helpers.ts b/packages/console/src/components/Executions/ExecutionDetails/Timeline/helpers.ts index 08de0a61b..53bbb6c97 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/Timeline/helpers.ts +++ b/packages/console/src/components/Executions/ExecutionDetails/Timeline/helpers.ts @@ -1,6 +1,6 @@ -import { endNodeId, startNodeId } from 'models/Node/constants'; -import { isExpanded } from 'components/WorkflowGraph/utils'; +import { ignoredNodeIds } from 'models/Node/constants'; import { dNode } from 'models/Graph/types'; +import { isExpanded } from 'models/Node/utils'; export const TimeZone = { Local: 'local', @@ -8,8 +8,8 @@ export const TimeZone = { }; export function isTransitionNode(node: dNode) { - // In case of bracnhNode childs, start and end nodes could be present as 'n0-start-node' etc. - return node.id.includes(startNodeId) || node.id.includes(endNodeId); + // In case of branchNode childs, start and end nodes could be present as 'n0-start-node' etc. + return ignoredNodeIds.includes(node?.id); } export function convertToPlainNodes(nodes: dNode[], level = 0): dNode[] { @@ -17,12 +17,12 @@ export function convertToPlainNodes(nodes: dNode[], level = 0): dNode[] { if (!nodes || nodes.length === 0) { return result; } - nodes.forEach(node => { + nodes?.forEach(node => { if (isTransitionNode(node)) { return; } result.push({ ...node, level }); - if (node.nodes.length > 0 && isExpanded(node)) { + if (node?.nodes?.length > 0 && isExpanded(node)) { result.push(...convertToPlainNodes(node.nodes, level + 1)); } }); diff --git a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionDetailsAppBarContent.test.tsx b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionDetailsAppBarContent.test.tsx index 9577f43b5..39646f3d7 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionDetailsAppBarContent.test.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionDetailsAppBarContent.test.tsx @@ -47,7 +47,7 @@ describe('ExecutionDetailsAppBarContent', () => { - + , diff --git a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx index 1e67802ba..ead3b180d 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionMetadata.test.tsx @@ -6,6 +6,7 @@ import { createMockExecution } from 'models/__mocks__/executionsData'; import * as React from 'react'; import { MemoryRouter } from 'react-router'; import { Routes } from 'routes/routes'; +import { ExecutionContext } from 'components/Executions/contexts'; import { ExecutionMetadataLabels } from '../constants'; import { ExecutionMetadata } from '../ExecutionMetadata'; @@ -28,7 +29,13 @@ describe('ExecutionMetadata', () => { const renderMetadata = () => render( - + + + , ); diff --git a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx index 84a88f982..fbd47a964 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx @@ -1,20 +1,26 @@ -import { fireEvent, render, waitFor } from '@testing-library/react'; +import * as React from 'react'; +import { fireEvent, render, waitFor, screen } from '@testing-library/react'; import { filterLabels } from 'components/Executions/filters/constants'; import { nodeExecutionStatusFilters } from 'components/Executions/filters/statusFilters'; import { oneFailedTaskWorkflow } from 'mocks/data/fixtures/oneFailedTaskWorkflow'; import { insertFixture } from 'mocks/data/insertFixture'; import { mockServer } from 'mocks/server'; import { Execution } from 'models/Execution/types'; -import * as React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { createTestQueryClient } from 'test/utils'; -import { tabs } from '../constants'; +import { ExecutionContext } from 'components/Executions/contexts'; +import { listNodeExecutions, listTaskExecutions } from 'models/Execution/api'; +import { NodeExecutionPhase } from 'models'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { NodeExecutionDetailsContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { transformerWorkflowToDag } from 'components/WorkflowGraph/transformerWorkflowToDag'; import { ExecutionNodeViews } from '../ExecutionNodeViews'; +import { tabs } from '../constants'; jest.mock('components/Executions/Tables/NodeExecutionRow', () => ({ - NodeExecutionRow: jest.fn(({ nodeExecution }) => ( + NodeExecutionRow: jest.fn(({ node }) => (
- {nodeExecution?.id?.nodeId} + {node?.execution?.id?.nodeId}
)), })); @@ -39,6 +45,14 @@ jest.mock( NodeExecutionName: jest.fn(({ name }) =>
{name}
), }), ); +jest.mock('models/Execution/api', () => ({ + listNodeExecutions: jest.fn(), + listTaskExecutions: jest.fn(), +})); + +jest.mock('components/WorkflowGraph/transformerWorkflowToDag', () => ({ + transformerWorkflowToDag: jest.fn(), +})); // ExecutionNodeViews uses query params for NE list, so we must match them // for the list to be returned properly @@ -52,33 +66,104 @@ describe('ExecutionNodeViews', () => { let queryClient: QueryClient; let execution: Execution; let fixture: ReturnType; - beforeEach(() => { fixture = oneFailedTaskWorkflow.generate(); execution = fixture.workflowExecutions.top.data; insertFixture(mockServer, fixture); const nodeExecutions = fixture.workflowExecutions.top.nodeExecutions; - - mockServer.insertNodeExecutionList( - execution.id, - Object.values(nodeExecutions).map(({ data }) => data), - baseQueryParams, - ); - mockServer.insertNodeExecutionList( - execution.id, - [nodeExecutions.failedNode.data], - { - ...baseQueryParams, - filters: 'value_in(phase,FAILED)', - }, + const nodeExecutionsArray = Object.values(nodeExecutions).map( + ({ data }) => data, ); + transformerWorkflowToDag.mockImplementation(_ => { + return { + dag: { + id: 'start-node', + scopedId: 'start-node', + value: { + id: 'start-node', + }, + type: 4, + name: 'start', + nodes: [ + { + id: 'start-node', + scopedId: 'start-node', + value: { + inputs: [], + upstreamNodeIds: [], + outputAliases: [], + id: 'start-node', + }, + type: 4, + name: 'start', + nodes: [], + edges: [], + }, + { + id: 'end-node', + scopedId: 'end-node', + value: { + inputs: [], + upstreamNodeIds: [], + outputAliases: [], + id: 'end-node', + }, + type: 5, + name: 'end', + nodes: [], + edges: [], + }, + ...nodeExecutionsArray.map(n => ({ + id: n.id.nodeId, + scopedId: n.scopedId, + execution: n, + // type: dTypes.gateNode, + name: n.id.nodeId, + type: 3, + nodes: [], + edges: [], + })), + ], + }, + staticExecutionIdsMap: {}, + }; + }); + listNodeExecutions.mockImplementation((_, filters) => { + let finalNodes = nodeExecutionsArray; + if (filters?.filter?.length) { + const phases = filters?.filter + ?.filter(f => f.key === 'phase')?.[0] + .value?.map(f => NodeExecutionPhase[f]); + finalNodes = finalNodes.filter(n => { + return phases.includes(n.closure.phase); + }); + } + return Promise.resolve({ + entities: Object.values(finalNodes), + }); + }); + listTaskExecutions.mockImplementation(() => { + return Promise.resolve({ + entities: [], + }); + }); queryClient = createTestQueryClient(); }); const renderViews = () => render( - + + + + + , ); @@ -87,7 +172,7 @@ describe('ExecutionNodeViews', () => { const failedNodeName = nodeExecutions.failedNode.data.id.nodeId; const succeededNodeName = nodeExecutions.pythonNode.data.id.nodeId; - const { getByText, queryByText, getByLabelText } = renderViews(); + const { getByText, queryByText, queryAllByTestId } = renderViews(); await waitFor(() => getByText(tabs.nodes.label)); @@ -96,21 +181,31 @@ describe('ExecutionNodeViews', () => { // Ensure we are on Nodes tab await fireEvent.click(nodesTab); + await waitFor(() => { + const nodes = queryAllByTestId('node-execution-row'); + return nodes?.length === 2; + }); + await waitFor(() => queryByText(succeededNodeName)); const statusButton = await waitFor(() => getByText(filterLabels.status)); // Apply 'Failed' filter and wait for list to include only the failed item await fireEvent.click(statusButton); + const failedFilter = await waitFor(() => - getByLabelText(nodeExecutionStatusFilters.failed.label), + screen.getByLabelText(nodeExecutionStatusFilters.failed.label), ); // Wait for succeeded task to disappear and ensure failed task remains await fireEvent.click(failedFilter); - await waitFor(() => queryByText(failedNodeName)); + await waitFor(() => { + const nodes = queryAllByTestId('node-execution-row'); + return nodes?.length === 1; + }); expect(queryByText(succeededNodeName)).not.toBeInTheDocument(); + expect(queryByText(failedNodeName)).toBeInTheDocument(); // Switch to the Graph tab @@ -118,7 +213,9 @@ describe('ExecutionNodeViews', () => { await fireEvent.click(timelineTab); await waitFor(() => queryByText(succeededNodeName)); + // expect all initital nodes to be rendered expect(queryByText(succeededNodeName)).toBeInTheDocument(); + expect(queryByText(failedNodeName)).toBeInTheDocument(); // Switch back to Nodes Tab and verify filter still applied await fireEvent.click(nodesTab); diff --git a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx index a6d8c73fd..63ddae854 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx @@ -1,6 +1,6 @@ import { render, waitFor } from '@testing-library/react'; import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { WorkflowNodeExecutionsContext } from 'components/Executions/contexts'; import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; import { mockWorkflowId } from 'mocks/data/fixtures/types'; import { insertFixture } from 'mocks/data/insertFixture'; @@ -8,7 +8,7 @@ import { mockServer } from 'mocks/server'; import * as React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { createTestQueryClient } from 'test/utils'; -import { ExecutionTabContent } from '../ExecutionTabContent'; +import { ExecutionTab } from '../ExecutionTab'; import { tabs } from '../constants'; jest.mock('components/Workflow/workflowQueries'); @@ -64,12 +64,9 @@ describe('Executions > ExecutionDetails > ExecutionTabContent', () => { return render( - - - + + + , ); diff --git a/packages/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx b/packages/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx index 6f414fdaf..4d41a184b 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx @@ -1,3 +1,4 @@ +import * as React from 'react'; import { render, waitFor } from '@testing-library/react'; import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; @@ -6,12 +7,19 @@ import { insertFixture } from 'mocks/data/insertFixture'; import { mockServer } from 'mocks/server'; import { TaskExecutionPhase } from 'models/Execution/enums'; import { NodeExecution } from 'models/Execution/types'; -import * as React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router'; import { createTestQueryClient } from 'test/utils'; import { NodeExecutionDetailsPanelContent } from '../NodeExecutionDetailsPanelContent'; +jest.mock( + 'components/Executions/ExecutionDetails/ExecutionDetailsActions', + () => ({ + ExecutionDetailsActions: jest.fn(() => ( +
+ )), + }), +); jest.mock('components/Workflow/workflowQueries'); const { fetchWorkflow } = require('components/Workflow/workflowQueries'); diff --git a/packages/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx b/packages/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx index 7a8807b39..0bc2d0f16 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx +++ b/packages/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx @@ -3,11 +3,10 @@ import { render } from '@testing-library/react'; import { dTypes } from 'models/Graph/types'; import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { QueryClient, QueryClientProvider } from 'react-query'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { WorkflowNodeExecutionsContext } from 'components/Executions/contexts'; import { mockWorkflowId } from 'mocks/data/fixtures/types'; import { createTestQueryClient } from 'test/utils'; import { dateToTimestamp } from 'common/utils'; -import { createMockWorkflow } from 'models/__mocks__/workflowData'; import { TaskNames } from '../Timeline/TaskNames'; const onToggle = jest.fn(); @@ -74,14 +73,21 @@ describe('ExecutionDetails > Timeline > TaskNames', () => { return render( - {}, + setShouldUpdate: () => {}, + shouldUpdate: false, }} > - + , ); diff --git a/packages/console/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts b/packages/console/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts index 3e1b45963..b913e1936 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts +++ b/packages/console/src/components/Executions/ExecutionDetails/useExecutionNodeViewsState.ts @@ -3,16 +3,13 @@ import { limits } from 'models/AdminEntity/constants'; import { FilterOperation, SortDirection } from 'models/AdminEntity/types'; import { executionSortFields } from 'models/Execution/constants'; import { Execution, NodeExecution } from 'models/Execution/types'; -import { useQueryClient } from 'react-query'; +import { useQueryClient, UseQueryResult } from 'react-query'; import { executionRefreshIntervalMs } from '../constants'; import { makeNodeExecutionListQuery } from '../nodeExecutionQueries'; import { executionIsTerminal, nodeExecutionIsTerminal } from '../utils'; -export function useExecutionNodeViewsState( - execution: Execution, - filter: FilterOperation[] = [], -): { - nodeExecutionsQuery: any; +export interface UseExecutionNodeViewsState { + nodeExecutionsQuery: UseQueryResult; nodeExecutionsRequestConfig: { filter: FilterOperation[]; sort: { @@ -21,20 +18,28 @@ export function useExecutionNodeViewsState( }; limit: number; }; -} { - const sort = { - key: executionSortFields.createdAt, - direction: SortDirection.ASCENDING, - }; +} +const sort = { + key: executionSortFields.createdAt, + direction: SortDirection.ASCENDING, +}; + +export function useExecutionNodeViewsStatePoll( + execution: Execution, + filter: FilterOperation[] = [], +): UseExecutionNodeViewsState { const nodeExecutionsRequestConfig = { filter, sort, limit: limits.NONE, }; - const shouldEnableQuery = (executions: NodeExecution[]) => - !executionIsTerminal(execution) || - executions.some(ne => !nodeExecutionIsTerminal(ne)); + const shouldEnableQuery = (executions: NodeExecution[]) => { + const shouldEnable = + !executionIsTerminal(execution) || + executions.some(ne => !nodeExecutionIsTerminal(ne)); + return shouldEnable; + }; const nodeExecutionsQuery = useConditionalQuery( { diff --git a/packages/console/src/components/Executions/ExecutionDetails/useNodeExecutionRow.ts b/packages/console/src/components/Executions/ExecutionDetails/useNodeExecutionRow.ts new file mode 100644 index 000000000..b714131ff --- /dev/null +++ b/packages/console/src/components/Executions/ExecutionDetails/useNodeExecutionRow.ts @@ -0,0 +1,24 @@ +import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; +import { NodeExecution } from 'models/Execution/types'; + +import { QueryClient, UseQueryResult } from 'react-query'; +import { nodeExecutionRefreshIntervalMs } from '../constants'; +import { makeNodeExecutionQueryEnhanced } from '../nodeExecutionQueries'; + +export const useNodeExecutionRow = ( + queryClient: QueryClient, + execution: NodeExecution, + shouldEnableQuery: (data: NodeExecution[]) => boolean, +): { + nodeExecutionRowQuery: UseQueryResult; +} => { + const nodeExecutionRowQuery = useConditionalQuery( + { + ...makeNodeExecutionQueryEnhanced(execution, queryClient), + refetchInterval: nodeExecutionRefreshIntervalMs, + }, + shouldEnableQuery, + ); + + return { nodeExecutionRowQuery }; +}; diff --git a/packages/console/src/components/Executions/ExecutionDetails/utils.ts b/packages/console/src/components/Executions/ExecutionDetails/utils.ts index 7d6c84c7a..61c3ca507 100644 --- a/packages/console/src/components/Executions/ExecutionDetails/utils.ts +++ b/packages/console/src/components/Executions/ExecutionDetails/utils.ts @@ -38,9 +38,7 @@ export function isChildGroupsFetched( scopedId: string, nodeExecutionsById: Dictionary, ): boolean { - return Object.values(nodeExecutionsById).find( - exe => exe?.fromUniqueParentId === scopedId, - ) - ? true - : false; + return Object.values(nodeExecutionsById).some( + v => v?.fromUniqueParentId === scopedId, + ); } diff --git a/packages/console/src/components/Executions/ExecutionFilters.tsx b/packages/console/src/components/Executions/ExecutionFilters.tsx index b9e08894b..a2ae877fe 100644 --- a/packages/console/src/components/Executions/ExecutionFilters.tsx +++ b/packages/console/src/components/Executions/ExecutionFilters.tsx @@ -116,7 +116,6 @@ export const ExecutionFilters: React.FC = ({ /> ); } - const renderContent = () => ; return ( = ({ onReset={filter.onReset} buttonText={filter.label} className={styles.filterButton} - renderContent={renderContent} + renderContent={() => } /> ); })} diff --git a/packages/console/src/components/Executions/NodeExecutionCacheStatus.tsx b/packages/console/src/components/Executions/NodeExecutionCacheStatus.tsx index 37ca5ca8f..319bb0c8d 100644 --- a/packages/console/src/components/Executions/NodeExecutionCacheStatus.tsx +++ b/packages/console/src/components/Executions/NodeExecutionCacheStatus.tsx @@ -13,6 +13,7 @@ interface NodeExecutionCacheStatusProps { * `iconOnly` will render just the icon with the description as a tooltip */ variant?: 'normal' | 'iconOnly'; + className?: string; } /** For a given `NodeExecution.closure.taskNodeMetadata` object, will render * the cache status with a descriptive message. For `Core.CacheCatalogStatus.CACHE_HIT`, @@ -24,7 +25,7 @@ interface NodeExecutionCacheStatusProps { */ export const NodeExecutionCacheStatus: React.FC< NodeExecutionCacheStatusProps -> = ({ execution, variant = 'normal' }) => { +> = ({ execution, variant = 'normal', className }) => { const taskNodeMetadata = execution.closure?.taskNodeMetadata; const { getNodeExecutionDetails } = useNodeExecutionContext(); const [nodeDetails, setNodeDetails] = useState< @@ -49,6 +50,7 @@ export const NodeExecutionCacheStatus: React.FC< ); } @@ -64,6 +66,7 @@ export const NodeExecutionCacheStatus: React.FC< cacheStatus={taskNodeMetadata.cacheStatus} sourceTaskExecutionId={taskNodeMetadata.catalogKey?.sourceTaskExecution} variant={variant} + className={className} /> ); }; diff --git a/packages/console/src/components/Executions/Tables/NodeExecutionActions.tsx b/packages/console/src/components/Executions/Tables/NodeExecutionActions.tsx index defeeca92..79cfd5967 100644 --- a/packages/console/src/components/Executions/Tables/NodeExecutionActions.tsx +++ b/packages/console/src/components/Executions/Tables/NodeExecutionActions.tsx @@ -10,25 +10,27 @@ import { getTask } from 'models/Task/api'; import { useNodeExecutionData } from 'components/hooks/useNodeExecution'; import { TaskInitialLaunchParameters } from 'components/Launch/LaunchForm/types'; import { literalsToLiteralValueMap } from 'components/Launch/LaunchForm/utils'; -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { extractCompiledNodes } from 'components/hooks/utils'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; import { NodeExecutionDetails } from '../types'; import t from './strings'; import { getNodeFrontendPhase, isNodeGateNode } from '../utils'; -import { DetailsPanelContext } from '../ExecutionDetails/DetailsPanelContext'; +import { useDetailsPanel } from '../ExecutionDetails/DetailsPanelContext'; interface NodeExecutionActionsProps { execution: NodeExecution; + className?: string; } export const NodeExecutionActions = ({ execution, + className, }: NodeExecutionActionsProps): JSX.Element => { const { compiledWorkflowClosure, getNodeExecutionDetails } = useNodeExecutionContext(); - const { setSelectedExecution } = useContext(DetailsPanelContext); + const { setSelectedExecution } = useDetailsPanel(); const [showLaunchForm, setShowLaunchForm] = useState(false); const [showResumeForm, setShowResumeForm] = useState(false); @@ -48,7 +50,7 @@ export const NodeExecutionActions = ({ ); const phase = getNodeFrontendPhase(execution.closure.phase, isGateNode); - const compiledNode = extractCompiledNodes(compiledWorkflowClosure).find( + const compiledNode = extractCompiledNodes(compiledWorkflowClosure)?.find( node => node.id === execution.metadata?.specNodeId || node.id === execution.id.nodeId, @@ -111,20 +113,20 @@ export const NodeExecutionActions = ({ return (
{phase === NodeExecutionPhase.PAUSED && ( - + )} - + {id && initialParameters ? ( <> - + diff --git a/packages/console/src/components/Executions/Tables/NodeExecutionRow.tsx b/packages/console/src/components/Executions/Tables/NodeExecutionRow.tsx index 9d0165c7e..03be656a6 100644 --- a/packages/console/src/components/Executions/Tables/NodeExecutionRow.tsx +++ b/packages/console/src/components/Executions/Tables/NodeExecutionRow.tsx @@ -1,20 +1,28 @@ +import React from 'react'; import classnames from 'classnames'; -import { NodeExecution } from 'models/Execution/types'; import { dNode } from 'models/Graph/types'; import { NodeExecutionPhase } from 'models/Execution/enums'; -import React, { useContext } from 'react'; -import { isExpanded } from 'components/WorkflowGraph/utils'; import { isEqual } from 'lodash'; import { useTheme } from 'components/Theme/useTheme'; import { makeStyles } from '@material-ui/core'; -import { selectedClassName, useExecutionTableStyles } from './styles'; +import { isExpanded } from 'models/Node/utils'; +import { dateToTimestamp } from 'common/utils'; +import { + grayedClassName, + selectedClassName, + useExecutionTableStyles, +} from './styles'; import { NodeExecutionColumnDefinition } from './types'; -import { DetailsPanelContext } from '../ExecutionDetails/DetailsPanelContext'; +import { useDetailsPanel } from '../ExecutionDetails/DetailsPanelContext'; import { RowExpander } from './RowExpander'; import { calculateNodeExecutionRowLeftSpacing } from './utils'; import { isParentNode } from '../utils'; +import { useNodeExecutionDynamicContext } from '../contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider'; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ + [`${grayedClassName}`]: { + color: `${theme.palette.grey[300]} !important`, + }, namesContainerExpander: { display: 'flex', marginTop: 'auto', @@ -27,7 +35,6 @@ const useStyles = makeStyles(() => ({ interface NodeExecutionRowProps { columns: NodeExecutionColumnDefinition[]; - nodeExecution: NodeExecution; level?: number; style?: React.CSSProperties; node: dNode; @@ -37,19 +44,14 @@ interface NodeExecutionRowProps { /** Renders a NodeExecution as a row inside a `NodeExecutionsTable` */ export const NodeExecutionRow: React.FC = ({ columns, - nodeExecution, node, style, onToggle, }) => { const styles = useStyles(); const theme = useTheme(); - const expanderRef = React.useRef(); - const tableStyles = useExecutionTableStyles(); - const { selectedExecution, setSelectedExecution } = - useContext(DetailsPanelContext); - + const { childCount, componentProps } = useNodeExecutionDynamicContext(); const nodeLevel = node?.level ?? 0; // For the first level, we want the borders to span the entire table, @@ -63,29 +65,37 @@ export const NodeExecutionRow: React.FC = ({ )}px`, }; + const expanderRef = React.useRef(); + + const { selectedExecution, setSelectedExecution } = useDetailsPanel(); + const selected = selectedExecution - ? isEqual(selectedExecution, nodeExecution) + ? isEqual(selectedExecution, node.execution?.id) : false; const expanderContent = React.useMemo(() => { - return isParentNode(nodeExecution) ? ( + const isParent = node?.execution ? isParentNode(node.execution) : false; + const isExpandedVal = isExpanded(node); + + return isParent ? ( } - expanded={isExpanded(node)} + expanded={isExpandedVal} onClick={() => { onToggle(node.id, node.scopedId, nodeLevel); }} + disabled={!childCount} /> ) : (
); - }, [node, nodeLevel]); + }, [node, nodeLevel, node.execution, childCount]); // open the side panel for selected execution's detail // use null in case if there is no execution provided - when it is null, will close side panel const onClickRow = () => - nodeExecution.closure.phase !== NodeExecutionPhase.UNDEFINED && - setSelectedExecution(nodeExecution?.id ?? null); + node?.execution?.closure.phase !== NodeExecutionPhase.UNDEFINED && + setSelectedExecution(node.execution?.id ?? null); return (
= ({ })} style={style} onClick={onClickRow} + {...componentProps} + key={node.scopedId} >
{expanderContent} @@ -108,11 +124,32 @@ export const NodeExecutionRow: React.FC = ({ {columns.map(({ className, key: columnKey, cellRenderer }) => (
{cellRenderer({ node, - execution: nodeExecution, + execution: node.execution || { + closure: { + createdAt: dateToTimestamp(new Date()), + outputUri: '', + phase: NodeExecutionPhase.UNDEFINED, + }, + id: { + executionId: { + domain: node.value?.taskNode?.referenceId?.domain, + name: node.value?.taskNode?.referenceId?.name, + project: node.value?.taskNode?.referenceId?.project, + }, + nodeId: node.id, + }, + inputUri: '', + scopedId: node.scopedId, + }, + className: node.grayedOut ? tableStyles.grayed : '', })}
))} diff --git a/packages/console/src/components/Executions/Tables/NodeExecutionsTable.tsx b/packages/console/src/components/Executions/Tables/NodeExecutionsTable.tsx index af3d4cf08..7dcea752c 100644 --- a/packages/console/src/components/Executions/Tables/NodeExecutionsTable.tsx +++ b/packages/console/src/components/Executions/Tables/NodeExecutionsTable.tsx @@ -1,87 +1,197 @@ +import React, { useMemo, useEffect, useState, useContext } from 'react'; import classnames from 'classnames'; -import { getCacheKey } from 'components/Cache/utils'; import { useCommonStyles } from 'components/common/styles'; import scrollbarSize from 'dom-helpers/scrollbarSize'; -import { NodeExecution } from 'models/Execution/types'; -import { dNode } from 'models/Graph/types'; -import { NodeExecutionPhase } from 'models/Execution/enums'; -import { dateToTimestamp } from 'common/utils'; -import React, { useMemo, useEffect, useState, useContext } from 'react'; -import { useQueryClient } from 'react-query'; -import { merge, eq } from 'lodash'; +import { NodeExecution, NodeExecutionsById } from 'models/Execution/types'; +import { merge, isEqual, cloneDeep } from 'lodash'; import { extractCompiledNodes } from 'components/hooks/utils'; +import { + FilterOperation, + FilterOperationName, + FilterOperationValueList, +} from 'models'; +import { dNode } from 'models/Graph/types'; import { ExecutionsTableHeader } from './ExecutionsTableHeader'; import { generateColumns } from './nodeExecutionColumns'; import { NoExecutionsContent } from './NoExecutionsContent'; import { useColumnStyles, useExecutionTableStyles } from './styles'; -import { NodeExecutionsByIdContext } from '../contexts'; import { convertToPlainNodes } from '../ExecutionDetails/Timeline/helpers'; -import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; +import { + useNodeExecutionContext, + useNodeExecutionsById, +} from '../contextProvider/NodeExecutionDetails'; import { NodeExecutionRow } from './NodeExecutionRow'; -import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; -import { fetchChildrenExecutions, searchNode } from '../utils'; - -interface NodeExecutionsTableProps { - initialNodes: dNode[]; - filteredNodes?: dNode[]; - setShouldUpdate: (val: boolean) => void; -} +import { ExecutionFiltersState } from '../filters/useExecutionFiltersState'; +import { searchNode } from '../utils'; +import { nodeExecutionPhaseConstants } from '../constants'; +import { NodeExecutionDynamicProvider } from '../contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider'; +import { ExecutionFilters } from '../ExecutionFilters'; +import { ExecutionContext, FilteredNodeExecutions } from '../contexts'; +import { useExecutionNodeViewsStatePoll } from '../ExecutionDetails/useExecutionNodeViewsState'; +import { stringifyIsEqual } from '../contextProvider/NodeExecutionDetails/utils'; const scrollbarPadding = scrollbarSize(); -/** - * TODO - * Refactor to avoid code duplication here and in ExecutionTimeline, ie toggleNode, the insides of the effect - */ +const mergeOriginIntoNodes = (target: dNode[], origin: dNode[]) => { + if (!target?.length) { + return target; + } + const originClone = cloneDeep(origin); + const newTarget = cloneDeep(target); + newTarget?.forEach(value => { + const originalNode = originClone.find( + og => og.id === value.id && og.scopedId === value.scopedId, + ); + const newNodes = mergeOriginIntoNodes( + value.nodes, + originalNode?.nodes || [], + ); + + value = merge(value, originalNode); + value.nodes = newNodes; + return value; + }); + + return newTarget; +}; + +const executionMatchesPhaseFilter = ( + nodeExecution: NodeExecution, + { key, value, operation }: FilterOperation, +) => { + if (key === 'phase' && operation === FilterOperationName.VALUE_IN) { + // default to UNKNOWN phase if the field does not exist on a closure + const itemValue = + nodeExecutionPhaseConstants()[nodeExecution?.closure[key]]?.value ?? + nodeExecutionPhaseConstants()[0].value; + // phase check filters always return values in an array + const valuesArray = value as FilterOperationValueList; + return valuesArray.includes(itemValue); + } + return false; +}; + +const filterNodes = ( + initialNodes: dNode[], + nodeExecutionsById: NodeExecutionsById, + appliedFilters: FilterOperation[], +) => { + if (!initialNodes?.length) { + return []; + } + + let initialClone = cloneDeep(initialNodes); + + for (const n of initialClone) { + n.nodes = filterNodes(n.nodes, nodeExecutionsById, appliedFilters); + } + + initialClone = initialClone.filter(node => { + const hasFilteredChildren = !!node.nodes?.length; + const shouldBeIncluded = executionMatchesPhaseFilter( + nodeExecutionsById[node.scopedId], + appliedFilters[0], + ); + const result = hasFilteredChildren || shouldBeIncluded; + + if (hasFilteredChildren && !shouldBeIncluded) { + node.grayedOut = true; + } + + return result; + }); + + return initialClone; +}; + +const isPhaseFilter = (appliedFilters: FilterOperation[] = []) => { + if (appliedFilters.length === 1 && appliedFilters[0].key === 'phase') { + return true; + } + return false; +}; /** Renders a table of NodeExecution records. Executions with errors will * have an expanadable container rendered as part of the table row. * NodeExecutions are expandable and will potentially render a list of child * TaskExecutions */ -export const NodeExecutionsTable: React.FC = ({ - initialNodes, - filteredNodes, - setShouldUpdate, -}) => { +export const NodeExecutionsTable: React.FC<{ + filterState: ExecutionFiltersState; +}> = ({ filterState }) => { const commonStyles = useCommonStyles(); const tableStyles = useExecutionTableStyles(); - const queryClient = useQueryClient(); - const { nodeExecutionsById, setCurrentNodeExecutionsById } = useContext( - NodeExecutionsByIdContext, - ); - const { appliedFilters } = useNodeExecutionFiltersState(); - const [originalNodes, setOriginalNodes] = useState( - appliedFilters.length > 0 && filteredNodes ? filteredNodes : initialNodes, - ); - const [showNodes, setShowNodes] = useState([]); + const columnStyles = useColumnStyles(); + + const { execution } = useContext(ExecutionContext); + + const { appliedFilters } = filterState; + const [filteredNodeExecutions, setFilteredNodeExecutions] = + useState(); + const { nodeExecutionsById, initialDNodes: initialNodes } = + useNodeExecutionsById(); + + const [filters, setFilters] = useState([]); + const [originalNodes, setOriginalNodes] = useState([]); + + // query to get filtered data to narrow down Table outputs + const { nodeExecutionsQuery: filteredNodeExecutionsQuery } = + useExecutionNodeViewsStatePoll(execution, filters); + const { compiledWorkflowClosure } = useNodeExecutionContext(); + const [showNodes, setShowNodes] = useState([]); - const columnStyles = useColumnStyles(); - // Memoizing columns so they won't be re-generated unless the styles change - const compiledNodes = extractCompiledNodes(compiledWorkflowClosure); - const columns = useMemo( - () => generateColumns(columnStyles, compiledNodes), - [columnStyles, compiledNodes], - ); + const [initialFilteredNodes, setInitialFilteredNodes] = useState< + dNode[] | undefined + >(undefined); useEffect(() => { - setOriginalNodes(ogn => { - const newNodes = - appliedFilters.length > 0 && filteredNodes - ? filteredNodes - : merge(initialNodes, ogn); - - if (!eq(newNodes, ogn)) { - return newNodes; + // keep original nodes as a record of the nodes' toggle status + setOriginalNodes(prev => { + const newOgNodes = merge(initialNodes, prev); + if (stringifyIsEqual(prev, newOgNodes)) { + return prev; } + return newOgNodes; + }); + }, [initialNodes]); - return ogn; + // wait for changes to filtered node executions + useEffect(() => { + if (filteredNodeExecutionsQuery.isFetching) { + return; + } + + const newFilteredNodeExecutions = isPhaseFilter(filters) + ? undefined + : filteredNodeExecutionsQuery.data; + + setFilteredNodeExecutions(prev => { + if (isEqual(prev, newFilteredNodeExecutions)) { + return prev; + } + + return newFilteredNodeExecutions; }); + }, [filteredNodeExecutionsQuery]); + + useEffect(() => { + const newShownNodes = + filters.length > 0 && initialFilteredNodes + ? // if there are filtered nodes, merge original ones into them to preserve toggle status + mergeOriginIntoNodes( + cloneDeep(initialFilteredNodes), + cloneDeep(originalNodes), + ) + : // else, merge originalNodes into initialNodes to preserve toggle status + mergeOriginIntoNodes( + cloneDeep(initialNodes), + cloneDeep(originalNodes), + ); - const plainNodes = convertToPlainNodes(originalNodes); + const plainNodes = convertToPlainNodes(newShownNodes || []); const updatedShownNodesMap = plainNodes.map(node => { - const execution = nodeExecutionsById[node.scopedId]; + const execution = nodeExecutionsById?.[node?.scopedId]; return { ...node, startedAt: execution?.closure.startedAt, @@ -89,67 +199,94 @@ export const NodeExecutionsTable: React.FC = ({ }; }); setShowNodes(updatedShownNodesMap); - }, [initialNodes, filteredNodes, originalNodes, nodeExecutionsById]); + }, [ + initialNodes, + initialFilteredNodes, + originalNodes, + nodeExecutionsById, + filters, + ]); + + useEffect(() => { + setFilters(prev => { + if (isEqual(prev, appliedFilters)) { + return prev; + } + return JSON.parse(JSON.stringify(appliedFilters)); + }); + }, [appliedFilters]); + + // Memoizing columns so they won't be re-generated unless the styles change + const compiledNodes = extractCompiledNodes(compiledWorkflowClosure); + const columns = useMemo( + () => generateColumns(columnStyles, compiledNodes), + [columnStyles, compiledNodes], + ); + + useEffect(() => { + if (filters.length > 0) { + // if filter was apllied, but filteredNodeExecutions is empty, we only appliied Phase filter, + // and need to clear out items manually + if (!filteredNodeExecutions) { + // top level + const filteredNodes = filterNodes( + initialNodes, + nodeExecutionsById, + filters, + ); + + setInitialFilteredNodes(filteredNodes); + } else { + const filteredNodes = initialNodes.filter((node: dNode) => + filteredNodeExecutions.find( + (execution: NodeExecution) => execution.scopedId === node.scopedId, + ), + ); + setInitialFilteredNodes(filteredNodes); + } + } + }, [initialNodes, filteredNodeExecutions, filters]); const toggleNode = async (id: string, scopedId: string, level: number) => { - await fetchChildrenExecutions( - queryClient, - scopedId, - nodeExecutionsById, - setCurrentNodeExecutionsById, - setShouldUpdate, - ); searchNode(originalNodes, 0, id, scopedId, level); setOriginalNodes([...originalNodes]); }; return ( -
- -
- {showNodes.length > 0 ? ( - showNodes.map(node => { - let nodeExecution: NodeExecution; - if (nodeExecutionsById[node.scopedId]) { - nodeExecution = nodeExecutionsById[node.scopedId]; - } else { - nodeExecution = { - closure: { - createdAt: dateToTimestamp(new Date()), - outputUri: '', - phase: NodeExecutionPhase.UNDEFINED, - }, - id: { - executionId: { - domain: node.value?.taskNode?.referenceId?.domain, - name: node.value?.taskNode?.referenceId?.name, - project: node.value?.taskNode?.referenceId?.project, - }, - nodeId: node.id, - }, - inputUri: '', - scopedId: node.scopedId, - }; - } - return ( - - ); - }) - ) : ( - + <> +
+ +
+
+ +
+ {showNodes.length > 0 ? ( + showNodes.map(node => { + return ( + + + + ); + }) + ) : ( + + )} +
-
+ ); }; diff --git a/packages/console/src/components/Executions/Tables/RowExpander.tsx b/packages/console/src/components/Executions/Tables/RowExpander.tsx index b9d4f3642..42f8cdb70 100644 --- a/packages/console/src/components/Executions/Tables/RowExpander.tsx +++ b/packages/console/src/components/Executions/Tables/RowExpander.tsx @@ -6,6 +6,7 @@ import t from './strings'; interface RowExpanderProps { expanded: boolean; + disabled?: boolean; key?: string; onClick: () => void; } @@ -13,7 +14,7 @@ interface RowExpanderProps { export const RowExpander = React.forwardRef< HTMLButtonElement, RowExpanderProps ->(({ expanded, key, onClick }, ref) => { +>(({ disabled, expanded, key, onClick }, ref) => { return ( {expanded ? : } diff --git a/packages/console/src/components/Executions/Tables/nodeExecutionColumns.tsx b/packages/console/src/components/Executions/Tables/nodeExecutionColumns.tsx index d41108801..23a1795c7 100644 --- a/packages/console/src/components/Executions/Tables/nodeExecutionColumns.tsx +++ b/packages/console/src/components/Executions/Tables/nodeExecutionColumns.tsx @@ -11,6 +11,7 @@ import { useEffect, useState } from 'react'; import { CompiledNode } from 'models/Node/types'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { getNodeTemplateName } from 'components/WorkflowGraph/utils'; +import classnames from 'classnames'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; import { ExecutionStatusBadge } from '../ExecutionStatusBadge'; import { NodeExecutionCacheStatus } from '../NodeExecutionCacheStatus'; @@ -28,7 +29,10 @@ import { import t from '../strings'; import { NodeExecutionName } from '../ExecutionDetails/Timeline/NodeExecutionName'; -const DisplayId: React.FC = ({ execution }) => { +const DisplayId: React.FC = ({ + execution, + className, +}) => { const commonStyles = useCommonStyles(); const { getNodeExecutionDetails } = useNodeExecutionContext(); const [displayId, setDisplayId] = useState(); @@ -48,13 +52,16 @@ const DisplayId: React.FC = ({ execution }) => { const nodeId = displayId ?? execution.id.nodeId; return ( -
{nodeId}
+
+ {nodeId} +
); }; const DisplayType: React.FC = ({ execution, + className, }) => { const { getNodeExecutionDetails } = useNodeExecutionContext(); const [type, setType] = useState(); @@ -71,7 +78,11 @@ const DisplayType: React.FC = ({ }; }); - return {type}; + return ( + + {type} + + ); }; export function generateColumns( @@ -80,11 +91,12 @@ export function generateColumns( ): NodeExecutionColumnDefinition[] { return [ { - cellRenderer: ({ node }) => ( + cellRenderer: ({ node, className }) => ( ), className: styles.columnName, @@ -104,7 +116,7 @@ export function generateColumns( label: t('typeLabel'), }, { - cellRenderer: ({ execution }) => { + cellRenderer: ({ execution, className }) => { const isGateNode = isNodeGateNode( nodes, execution.metadata?.specNodeId || execution.id.nodeId, @@ -117,10 +129,15 @@ export function generateColumns( return ( <> - + ); @@ -130,7 +147,7 @@ export function generateColumns( label: t('phaseLabel'), }, { - cellRenderer: ({ execution: { closure } }) => { + cellRenderer: ({ execution: { closure }, className }) => { const { startedAt } = closure; if (!startedAt) { return ''; @@ -138,10 +155,14 @@ export function generateColumns( const startedAtDate = timestampToDate(startedAt); return ( <> - + {formatDateUTC(startedAtDate)} - + {formatDateLocalTimezone(startedAtDate)} @@ -152,14 +173,14 @@ export function generateColumns( label: t('startedAtLabel'), }, { - cellRenderer: ({ execution }) => { + cellRenderer: ({ execution, className }) => { const timing = getNodeExecutionTimingMS(execution); if (timing === null) { return ''; } return ( <> - + {millisecondsToHMS(timing.duration)} @@ -179,9 +200,9 @@ export function generateColumns( ), }, { - cellRenderer: ({ execution }) => + cellRenderer: ({ execution, className }) => execution.closure.phase === NodeExecutionPhase.UNDEFINED ? null : ( - + ), className: styles.columnLogs, key: 'actions', diff --git a/packages/console/src/components/Executions/Tables/styles.ts b/packages/console/src/components/Executions/Tables/styles.ts index a4b969905..19543adbd 100644 --- a/packages/console/src/components/Executions/Tables/styles.ts +++ b/packages/console/src/components/Executions/Tables/styles.ts @@ -12,12 +12,19 @@ import { } from './constants'; export const selectedClassName = 'selected'; +export const grayedClassName = 'grayed'; // NOTE: The order of these `makeStyles` calls is important, as it determines // specificity in the browser. The execution table styles are overridden by // the columns styles in some cases. So the column styles should be defined // last. export const useExecutionTableStyles = makeStyles((theme: Theme) => ({ + filters: { + paddingLeft: theme.spacing(3), + }, + [grayedClassName]: { + color: theme.palette.grey[300], + }, borderBottom: { borderBottom: `1px solid ${theme.palette.divider}`, }, @@ -67,6 +74,9 @@ export const useExecutionTableStyles = makeStyles((theme: Theme) => ({ [`&.${selectedClassName}`]: { backgroundColor: listhoverColor, }, + [`&.${grayedClassName}`]: { + color: theme.palette.grey[300], + }, }, clickableRow: { cursor: 'pointer', @@ -97,6 +107,9 @@ export const useExecutionTableStyles = makeStyles((theme: Theme) => ({ export const nameColumnLeftMarginGridWidth = 6; export const useColumnStyles = makeStyles((theme: Theme) => ({ + [`&.${grayedClassName}`]: { + color: theme.palette.grey[400], + }, columnName: { flexGrow: 1, // We want this to fluidly expand into whatever available space, @@ -106,13 +119,22 @@ export const useColumnStyles = makeStyles((theme: Theme) => ({ '&:first-of-type': { marginLeft: theme.spacing(nameColumnLeftMarginGridWidth), }, + [`&.${grayedClassName}`]: { + color: theme.palette.grey[400], + }, }, columnNodeId: { flexBasis: nodeExecutionsTableColumnWidths.nodeId, + [`&.${grayedClassName}`]: { + color: theme.palette.grey[400], + }, }, columnType: { flexBasis: nodeExecutionsTableColumnWidths.type, textTransform: 'capitalize', + [`&.${grayedClassName}`]: { + color: theme.palette.grey[400], + }, }, columnStatus: { display: 'flex', @@ -120,15 +142,24 @@ export const useColumnStyles = makeStyles((theme: Theme) => ({ }, columnStartedAt: { flexBasis: nodeExecutionsTableColumnWidths.startedAt, + [`&.${grayedClassName}`]: { + color: theme.palette.grey[400], + }, }, columnDuration: { flexBasis: nodeExecutionsTableColumnWidths.duration, textAlign: 'right', + [`&.${grayedClassName}`]: { + color: theme.palette.grey[400], + }, }, columnLogs: { flexBasis: nodeExecutionsTableColumnWidths.logs, marginLeft: theme.spacing(4), marginRight: theme.spacing(2), + [`&.${grayedClassName}`]: { + color: theme.palette.grey[400], + }, }, selectedExecutionName: { fontWeight: 'bold', diff --git a/packages/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx b/packages/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx index 60b2c9e8b..71f501b37 100644 --- a/packages/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx +++ b/packages/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx @@ -8,6 +8,8 @@ import { insertFixture } from 'mocks/data/insertFixture'; import { mockServer } from 'mocks/server'; import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; import { NodeExecution } from 'models/Execution/types'; +import { cloneDeep } from 'lodash'; +import { DetailsPanelContextProvider } from 'components/Executions/ExecutionDetails/DetailsPanelContext'; import { NodeExecutionActions } from '../NodeExecutionActions'; jest.mock('components/Workflow/workflowQueries'); @@ -28,7 +30,9 @@ describe('Executions > Tables > NodeExecutionActions', () => { beforeEach(() => { fixture = basicPythonWorkflow.generate(); - execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + execution = cloneDeep( + fixture.workflowExecutions.top.nodeExecutions.pythonNode.data, + ); queryClient = createTestQueryClient(); insertFixture(mockServer, fixture); fetchWorkflow.mockImplementation(() => @@ -40,7 +44,9 @@ describe('Executions > Tables > NodeExecutionActions', () => { render( - + + + , ); diff --git a/packages/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx b/packages/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx index fe1419661..a6ebf92bc 100644 --- a/packages/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx +++ b/packages/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx @@ -8,14 +8,16 @@ import { insertFixture } from 'mocks/data/insertFixture'; import { mockServer } from 'mocks/server'; import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; import { NodeExecution } from 'models/Execution/types'; -import { dTypes } from 'models/Graph/types'; +import { dNode, dTypes } from 'models/Graph/types'; +import { NodeExecutionDynamicContext } from 'components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider'; +import { cloneDeep } from 'lodash'; import { NodeExecutionRow } from '../NodeExecutionRow'; jest.mock('components/Workflow/workflowQueries'); const { fetchWorkflow } = require('components/Workflow/workflowQueries'); const columns = []; -const node = { +const node: dNode = { id: 'n1', scopedId: 'n1', type: dTypes.start, @@ -33,6 +35,7 @@ describe('Executions > Tables > NodeExecutionRow', () => { beforeEach(() => { fixture = basicPythonWorkflow.generate(); execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + node.execution = cloneDeep(execution); queryClient = createTestQueryClient(); insertFixture(mockServer, fixture); fetchWorkflow.mockImplementation(() => @@ -41,10 +44,23 @@ describe('Executions > Tables > NodeExecutionRow', () => { }); const renderComponent = props => { + const { node } = props; return render( - + n.execution), + componentProps: { + ref: null, + }, + inView: false, + }} + > + + , ); @@ -53,7 +69,6 @@ describe('Executions > Tables > NodeExecutionRow', () => { const { queryByRole, queryByTestId } = renderComponent({ columns, node, - nodeExecution: execution, onToggle, }); await waitFor(() => queryByRole('listitem')); @@ -63,13 +78,12 @@ describe('Executions > Tables > NodeExecutionRow', () => { }); it('should render expander if node contains list of nodes', async () => { + node.execution!.metadata!.isParentNode = true; const mockNode = { ...node, nodes: [node, node], }; - (execution.metadata as any).isParentNode = true; - const { queryByRole, queryByTitle } = renderComponent({ columns, node: mockNode, diff --git a/packages/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx b/packages/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx index 8460dc5e1..5652401bd 100644 --- a/packages/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx +++ b/packages/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx @@ -1,6 +1,9 @@ import { render, waitFor } from '@testing-library/react'; import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { + ExecutionContext, + WorkflowNodeExecutionsContext, +} from 'components/Executions/contexts'; import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; import { noExecutionsFoundString } from 'common/constants'; import { mockWorkflowId } from 'mocks/data/fixtures/types'; @@ -13,6 +16,8 @@ import { QueryClient, QueryClientProvider } from 'react-query'; import { createTestQueryClient } from 'test/utils'; import { dNode } from 'models/Graph/types'; import { useNodeExecutionFiltersState } from 'components/Executions/filters/useExecutionFiltersState'; +import { Execution, NodeExecution } from 'models'; +import { listNodeExecutions, listTaskExecutions } from 'models/Execution/api'; import { NodeExecutionsTable } from '../NodeExecutionsTable'; jest.mock('components/Workflow/workflowQueries'); @@ -27,16 +32,23 @@ mockUseNodeExecutionFiltersState.mockReturnValue({ }); jest.mock('components/Executions/Tables/NodeExecutionRow', () => ({ - NodeExecutionRow: jest.fn(({ nodeExecution }) => ( + NodeExecutionRow: jest.fn(({ node }) => (
-
{nodeExecution?.id?.nodeId}
+
+ {node?.execution?.id?.nodeId} +
- {nodeExecution?.closure?.phase} + {node?.execution?.closure?.phase}
)), })); +jest.mock('models/Execution/api', () => ({ + listNodeExecutions: jest.fn(), + listTaskExecutions: jest.fn(), +})); + const mockNodes = (n: number): dNode[] => { const nodes: dNode[] = []; for (let i = 1; i <= n; i++) { @@ -51,6 +63,7 @@ const mockNodes = (n: number): dNode[] => { } return nodes; }; +const executionId = { domain: 'domain', name: 'name', project: 'project' }; const mockExecutionsById = (n: number, phases: NodeExecutionPhase[]) => { const nodeExecutionsById = {}; @@ -63,7 +76,7 @@ const mockExecutionsById = (n: number, phases: NodeExecutionPhase[]) => { phase: phases[i - 1], }, id: { - executionId: { domain: 'domain', name: 'name', project: 'project' }, + executionId, nodeId: `node${i}`, }, inputUri: '', @@ -85,32 +98,59 @@ describe('NodeExecutionsTableExecutions > Tables > NodeExecutionsTable', () => { fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top), ); + + listNodeExecutions.mockImplementation(() => { + return Promise.resolve({ + entities: Object.values([]), + }); + }); + listTaskExecutions.mockImplementation(() => { + return Promise.resolve({ + entities: Object.values([]), + }); + }); }); - const renderTable = ({ nodeExecutionsById, initialNodes, filteredNodes }) => + const renderTable = ({ nodeExecutionsById, initialNodes, filterState }) => render( - - {}, - }} - > - - - + + + {}, + setShouldUpdate: () => {}, + shouldUpdate: false, + }} + > + + + + , ); it('renders empty content when there are no nodes', async () => { + const filterState = { + filters: [], + appliedFilters: [], + }; const { queryByText, queryByTestId } = renderTable({ initialNodes: [], nodeExecutionsById: {}, - filteredNodes: [], + filterState, }); await waitFor(() => queryByText(noExecutionsFoundString)); @@ -122,18 +162,21 @@ describe('NodeExecutionsTableExecutions > Tables > NodeExecutionsTable', () => { it('renders NodeExecutionRows with initialNodes when no filteredNodes were provided', async () => { const phases = [NodeExecutionPhase.FAILED, NodeExecutionPhase.SUCCEEDED]; const nodeExecutionsById = mockExecutionsById(2, phases); - + const filterState = { + filters: [], + appliedFilters: [], + }; const { queryAllByTestId } = renderTable({ initialNodes, nodeExecutionsById, - filteredNodes: undefined, + filterState, }); - await waitFor(() => queryAllByTestId('node-execution-row')); - - expect(queryAllByTestId('node-execution-row')).toHaveLength( - initialNodes.length, - ); + await waitFor(() => { + const nodes = queryAllByTestId('node-execution-row'); + expect(nodes).toHaveLength(initialNodes.length); + return nodes; + }); const ids = queryAllByTestId('node-execution-col-id'); expect(ids).toHaveLength(initialNodes.length); const renderedPhases = queryAllByTestId('node-execution-col-phase'); @@ -147,14 +190,31 @@ describe('NodeExecutionsTableExecutions > Tables > NodeExecutionsTable', () => { it('renders NodeExecutionRows with initialNodes even when filterNodes were provided, if appliedFilters is empty', async () => { const phases = [NodeExecutionPhase.FAILED, NodeExecutionPhase.SUCCEEDED]; const nodeExecutionsById = mockExecutionsById(2, phases); - const filteredNodes = mockNodes(1); + const filteredNodeExecutions = nodeExecutionsById['n1']; + const filterState = { + filters: [], + appliedFilters: [], + }; + listNodeExecutions.mockImplementation(() => { + return Promise.resolve({ + entities: [filteredNodeExecutions], + }); + }); const { queryAllByTestId } = renderTable({ initialNodes, nodeExecutionsById, - filteredNodes, + filterState, }); + await waitFor(() => + expect(listNodeExecutions).toHaveBeenCalledWith( + expect.objectContaining(executionId), + expect.objectContaining({ + filter: [], + }), + ), + ); await waitFor(() => queryAllByTestId('node-execution-row')); expect(queryAllByTestId('node-execution-row')).toHaveLength( @@ -170,35 +230,106 @@ describe('NodeExecutionsTableExecutions > Tables > NodeExecutionsTable', () => { } }); - it('renders NodeExecutionRows with filterNodes if appliedFilters is not empty', async () => { - mockUseNodeExecutionFiltersState.mockReturnValueOnce({ + it('renders NodeExecutionRows with filterNodes if appliedFilters are less than original filters', async () => { + const appliedFilters = [ + { key: 'phase', operation: 'value_in', value: ['FAILED'] }, + ]; + const filterState = { filters: [], - appliedFilters: [ - { key: 'phase', operation: 'value_in', value: ['FAILED', 'SUCCEEDED'] }, - ], + appliedFilters, + }; + const phases = [NodeExecutionPhase.FAILED, NodeExecutionPhase.SUCCEEDED]; + const nodeExecutionsById = mockExecutionsById(2, phases); + const filteredNodeExecutions = [nodeExecutionsById['n1']]; + listNodeExecutions.mockImplementation(() => { + return Promise.resolve({ + entities: filteredNodeExecutions, + }); + }); + + const { queryAllByTestId, debug, container } = renderTable({ + initialNodes, + nodeExecutionsById, + filterState, }); + await waitFor(() => + expect(listNodeExecutions).toHaveBeenCalledWith( + expect.objectContaining(executionId), + expect.objectContaining({ + filter: appliedFilters, + }), + ), + ); + + debug(container); + + await waitFor(() => { + const rows = queryAllByTestId('node-execution-row'); + expect(rows).toHaveLength(filteredNodeExecutions.length); + }); + + const ids = queryAllByTestId('node-execution-col-id'); + expect(ids).toHaveLength(filteredNodeExecutions.length); + const renderedPhases = queryAllByTestId('node-execution-col-phase'); + expect(renderedPhases).toHaveLength(filteredNodeExecutions.length); + + for (const i in filteredNodeExecutions) { + expect(ids[i]).toHaveTextContent(filteredNodeExecutions[i].id?.nodeId); + expect(renderedPhases[i]).toHaveTextContent(phases[i].toString()); + } + }); + + it('renders NodeExecutionRows with filterNodes if appliedFilters are the same as original filters', async () => { const phases = [NodeExecutionPhase.FAILED, NodeExecutionPhase.SUCCEEDED]; + const appliedFilters = [ + { key: 'phase', operation: 'value_in', value: ['FAILED', 'SUCCEEDED'] }, + ]; + const filterState = { + filters: [], + appliedFilters, + }; + const nodeExecutionsById = mockExecutionsById(2, phases); - const filteredNodes = mockNodes(1); + const filteredNodeExecutions: NodeExecution[] = + Object.values(nodeExecutionsById); + listNodeExecutions.mockImplementation(() => { + return Promise.resolve({ + entities: filteredNodeExecutions, + }); + }); const { queryAllByTestId } = renderTable({ initialNodes, nodeExecutionsById, - filteredNodes, + filterState, }); - await waitFor(() => queryAllByTestId('node-execution-row')); + await waitFor(() => + expect(listNodeExecutions).toHaveBeenCalledWith( + expect.objectContaining(executionId), + expect.objectContaining({ + filter: appliedFilters, + }), + ), + ); + + await waitFor(() => { + const rows = queryAllByTestId('node-execution-row'); + return rows.length === filteredNodeExecutions.length; + }); expect(queryAllByTestId('node-execution-row')).toHaveLength( - filteredNodes.length, + filteredNodeExecutions.length, ); + const ids = queryAllByTestId('node-execution-col-id'); - expect(ids).toHaveLength(filteredNodes.length); + expect(ids).toHaveLength(filteredNodeExecutions.length); const renderedPhases = queryAllByTestId('node-execution-col-phase'); - expect(renderedPhases).toHaveLength(filteredNodes.length); - for (const i in filteredNodes) { - expect(ids[i]).toHaveTextContent(filteredNodes[i].id); + expect(renderedPhases).toHaveLength(filteredNodeExecutions.length); + + for (const i in filteredNodeExecutions) { + expect(ids[i]).toHaveTextContent(filteredNodeExecutions[i].id?.nodeId); expect(renderedPhases[i]).toHaveTextContent(phases[i].toString()); } }); diff --git a/packages/console/src/components/Executions/Tables/types.ts b/packages/console/src/components/Executions/Tables/types.ts index c16488c50..ffa7eb4e1 100644 --- a/packages/console/src/components/Executions/Tables/types.ts +++ b/packages/console/src/components/Executions/Tables/types.ts @@ -28,6 +28,7 @@ export interface ColumnDefinition { export interface NodeExecutionCellRendererData { execution: NodeExecution; node: dNode; + className: string; } export type NodeExecutionColumnDefinition = ColumnDefinition; diff --git a/packages/console/src/components/Executions/TerminateExecution/TerminateExecutionForm.tsx b/packages/console/src/components/Executions/TerminateExecution/TerminateExecutionForm.tsx index e5a8732f0..9474de719 100644 --- a/packages/console/src/components/Executions/TerminateExecution/TerminateExecutionForm.tsx +++ b/packages/console/src/components/Executions/TerminateExecution/TerminateExecutionForm.tsx @@ -67,8 +67,8 @@ export const TerminateExecutionForm: React.FC<{ multiline={true} onChange={onChange} placeholder={placeholderString} - rowsMax={4} - rows={4} + maxRows={4} + minRows={4} type="text" value={cause} /> diff --git a/packages/console/src/components/Executions/constants.ts b/packages/console/src/components/Executions/constants.ts index 8e14be5d6..e586872e7 100644 --- a/packages/console/src/components/Executions/constants.ts +++ b/packages/console/src/components/Executions/constants.ts @@ -16,6 +16,7 @@ import t from './strings'; import { ExecutionPhaseConstants, NodeExecutionDisplayType } from './types'; export const executionRefreshIntervalMs = 10000; +export const nodeExecutionRefreshIntervalMs = 3000; export const noLogsFoundString = t('noLogsFoundString'); /** Shared values for color/text/etc for each execution phase */ @@ -261,6 +262,7 @@ export const taskTypeToNodeExecutionDisplayType: { [TaskType.MPI]: NodeExecutionDisplayType.MpiTask, [TaskType.ARRAY_AWS]: NodeExecutionDisplayType.ARRAY_AWS, [TaskType.ARRAY_K8S]: NodeExecutionDisplayType.ARRAY_K8S, + [TaskType.BRANCH]: NodeExecutionDisplayType.BranchNode, }; export const cacheStatusMessages: { [k in CatalogCacheStatus]: string } = { diff --git a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDetailsContextProvider.tsx b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDetailsContextProvider.tsx new file mode 100644 index 000000000..0721cd434 --- /dev/null +++ b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDetailsContextProvider.tsx @@ -0,0 +1,189 @@ +import React, { + PropsWithChildren, + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { log } from 'common/log'; +import { Identifier } from 'models/Common/types'; +import { NodeExecution } from 'models/Execution/types'; +import { CompiledWorkflowClosure, Workflow } from 'models/Workflow/types'; +import { useQueryClient } from 'react-query'; +import { fetchWorkflow } from 'components/Workflow/workflowQueries'; +import { NodeExecutionDetails } from '../../types'; +import { UNKNOWN_DETAILS } from './types'; +import { + createExecutionDetails, + CurrentExecutionDetails, +} from './createExecutionArray'; +import { getTaskThroughExecution } from './getTaskThroughExecution'; + +interface NodeExecutionDetailsState { + 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({ + /** Default values used if ContextProvider wasn't initialized. */ + getNodeExecutionDetails: async () => { + log.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, + ); + +/** Could be used to access the whole NodeExecutionDetailsState */ +export const useNodeExecutionContext = (): NodeExecutionDetailsState => + useContext(NodeExecutionDetailsContext); + +export type ProviderProps = PropsWithChildren<{ + workflowId: Identifier; +}>; + +/** Should wrap "top level" component in Execution view, will build a nodeExecutions tree for specific workflow */ +export const NodeExecutionDetailsContextProvider = ({ + workflowId, + children, +}: 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 } = workflowId; + + const [executionTree, setExecutionTree] = useState( + {} as CurrentExecutionDetails, + ); + const [tasks, setTasks] = useState(new Map()); + const [closure, setClosure] = useState( + {} as CompiledWorkflowClosure, + ); + + const resetState = () => { + setExecutionTree({} as CurrentExecutionDetails); + setClosure({} as CompiledWorkflowClosure); + }; + + 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 result = await fetchWorkflow(queryClient, workflowId); + if (!result) { + resetState(); + return; + } + const fetchedWorkflow = JSON.parse(JSON.stringify(result)); + const tree = createExecutionDetails(fetchedWorkflow); + if (isCurrent) { + setClosure(fetchedWorkflow.closure?.compiledWorkflow ?? null); + setExecutionTree(tree); + } + } + + fetchData(); + + // This handles the unmount case + return () => { + isCurrent = false; + resetState(); + }; + }, [queryClient, resourceType, project, domain, name, version]); + + const getDynamicTasks = 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.scopedId || + nodeExecution.metadata?.specNodeId || + nodeExecution.id.nodeId; + const nodeDetail = executionTree.nodes?.filter(n => n.scopedId === specId); + 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 + if (nodeExecution.metadata?.isDynamic) { + details = await getDynamicTasks(nodeExecution); + } + return details; + } + + return nodeDetail?.[0] ?? UNKNOWN_DETAILS; + }; + + return ( + + {children} + + ); +}; diff --git a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider.tsx b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider.tsx new file mode 100644 index 000000000..a17861e7f --- /dev/null +++ b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider.tsx @@ -0,0 +1,177 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + useEffect, + useMemo, + Ref, + useState, +} from 'react'; +import { WorkflowNodeExecution } from 'components/Executions/contexts'; +import { useNodeExecutionRow } from 'components/Executions/ExecutionDetails/useNodeExecutionRow'; +import { + isParentNode, + nodeExecutionIsTerminal, +} from 'components/Executions/utils'; +import { keyBy, values } from 'lodash'; +import { NodeExecution } from 'models'; +import { dNode } from 'models/Graph/types'; +import { useInView } from 'react-intersection-observer'; +import { useQueryClient } from 'react-query'; +import { useNodeExecutionsById } from './WorkflowNodeExecutionsProvider'; + +export type RefType = Ref; +export interface INodeExecutionDynamicContext { + node: dNode; + childExecutions: WorkflowNodeExecution[]; + childCount: number; + inView: boolean; + componentProps: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >; + // setSkipChildList: (childList: NodeExecution[]) => void; +} + +export const NodeExecutionDynamicContext = + createContext({ + node: {} as dNode, + childExecutions: [], + childCount: 0, + inView: false, + componentProps: { + ref: null, + }, + }); + +const checkEnableChildQuery = ( + childExecutions: NodeExecution[], + nodeExecution: WorkflowNodeExecution, + inView: boolean, +) => { + // check that we fetched all children otherwise force fetch + const missingChildren = + isParentNode(nodeExecution) && !childExecutions.length; + + const childrenStillRunning = childExecutions?.some( + c => !nodeExecutionIsTerminal(c), + ); + + const executionRunning = !nodeExecutionIsTerminal(nodeExecution); + + const tasksFetched = nodeExecution.tasksFetched; + + const forceRefetch = + inView && + (!tasksFetched || + missingChildren || + childrenStillRunning || + executionRunning); + + // force fetch: + // if parent's children haven't been fetched + // if parent is still running or + // if any childExecutions are still running + return forceRefetch; +}; + +export type NodeExecutionDynamicProviderProps = PropsWithChildren<{ + node: dNode; + overrideInViewValue?: boolean; +}>; +/** Should wrap "top level" component in Execution view, will build a nodeExecutions tree for specific workflow */ +export const NodeExecutionDynamicProvider = ({ + node, + overrideInViewValue, + children, +}: NodeExecutionDynamicProviderProps) => { + const queryClient = useQueryClient(); + const { ref, inView } = useInView(); + const [overloadedInView, setOverloadedInView] = useState(false); + const [fetchedChildCount, setFetchedChildCount] = useState(0); + + useEffect(() => { + setOverloadedInView(prev => { + const newVal = + overrideInViewValue === undefined ? inView : overrideInViewValue; + if (newVal === prev) { + return prev; + } + + return newVal; + }); + }, [inView, overrideInViewValue]); + // get running data + const { setCurrentNodeExecutionsById, nodeExecutionsById } = + useNodeExecutionsById(); + + // get the node execution + const nodeExecution = node?.execution; // useMemo(() => node.execution, [node.execution]); + + const childExecutions = useMemo(() => { + const children = values(nodeExecutionsById).filter(execution => { + return execution.fromUniqueParentId === node?.scopedId; + }); + + return children; + }, [nodeExecutionsById]); + + const { nodeExecutionRowQuery } = useNodeExecutionRow( + queryClient, + nodeExecution!, + () => { + const shouldRun = checkEnableChildQuery( + childExecutions, + nodeExecution!, + !!overloadedInView, + ); + + return shouldRun; + }, + ); + + useEffect(() => { + // don't update if still fetching + if (nodeExecutionRowQuery.isFetching || !nodeExecutionRowQuery.data) { + return; + } + + const parentAndChildren = nodeExecutionRowQuery.data; + + // update parent context with tnew executions data + const parentAndChildrenById = keyBy(parentAndChildren, 'scopedId'); + setCurrentNodeExecutionsById(parentAndChildrenById, true); + + const newChildCount = (parentAndChildren?.length || 1) - 1; + + // update known children count + setFetchedChildCount(prev => { + if (prev === newChildCount) { + return prev; + } + return newChildCount; + }); + }, [nodeExecutionRowQuery]); + + return ( + + {children} + + ); +}; + +export const useNodeExecutionDynamicContext = + (): INodeExecutionDynamicContext => { + return useContext(NodeExecutionDynamicContext); + }; diff --git a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/WorkflowNodeExecutionsProvider.tsx b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/WorkflowNodeExecutionsProvider.tsx new file mode 100644 index 000000000..859f5e65a --- /dev/null +++ b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/WorkflowNodeExecutionsProvider.tsx @@ -0,0 +1,231 @@ +import React, { + PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { NodeExecution, NodeExecutionsById } from 'models/Execution/types'; +import { + IWorkflowNodeExecutionsContext, + WorkflowNodeExecutionsContext, +} from 'components/Executions/contexts'; +import { isEqual, keyBy, merge, mergeWith } from 'lodash'; +import { dNode } from 'models/Graph/types'; +import { + NodeExecutionDynamicWorkflowQueryResult, + makeNodeExecutionDynamicWorkflowQuery, +} from 'components/Workflow/workflowQueries'; +import { transformerWorkflowToDag } from 'components/WorkflowGraph/transformerWorkflowToDag'; +import { checkForDynamicExecutions } from 'components/common/utils'; +import { useQuery } from 'react-query'; +import { convertToPlainNodes } from 'components/Executions/ExecutionDetails/Timeline/helpers'; +import { useNodeExecutionContext } from './NodeExecutionDetailsContextProvider'; +import { + mapStringifyReplacer, + mergeNodeExecutions, + stringifyIsEqual, +} from './utils'; + +export type WorkflowNodeExecutionsProviderProps = PropsWithChildren<{ + initialNodeExecutions?: NodeExecution[]; +}>; + +/** Should wrap "top level" component in Execution view, will build a nodeExecutions tree for specific workflow */ +export const WorkflowNodeExecutionsProvider = ({ + initialNodeExecutions, + children, +}: WorkflowNodeExecutionsProviderProps) => { + const [shouldUpdate, setShouldUpdate] = useState(false); + const { compiledWorkflowClosure } = useNodeExecutionContext(); + + const [nodeExecutionsById, setNodeExecutionsById] = + useState({}); + + const [dagError, setDagError] = useState(); + const [mergedDag, setMergedDag] = useState({}); + const [initialDNodes, setInitialDNodes] = useState([]); + + const [dynamicWorkflows, setDynamicWorkflows] = + useState({}); + const [staticExecutionIdsMap, setstaticExecutionIdsMap] = useState({}); + + const [dynamicParents, setDynamicParents] = useState({}); + + const nodeExecutionDynamicWorkflowQuery = useQuery( + makeNodeExecutionDynamicWorkflowQuery(dynamicParents), + ); + + useEffect(() => { + const initialNodeExecutionsById = keyBy(initialNodeExecutions, 'scopedId'); + + setCurrentNodeExecutionsById(initialNodeExecutionsById, true); + }, [initialNodeExecutions]); + + useEffect(() => { + const { staticExecutionIdsMap: newstaticExecutionIdsMap } = + compiledWorkflowClosure + ? transformerWorkflowToDag(compiledWorkflowClosure) + : { staticExecutionIdsMap: {} }; + + setstaticExecutionIdsMap(prev => { + if (isEqual(prev, newstaticExecutionIdsMap)) { + return prev; + } + + return newstaticExecutionIdsMap; + }); + }, [compiledWorkflowClosure]); + + useEffect(() => { + const newdynamicParents = checkForDynamicExecutions( + nodeExecutionsById, + staticExecutionIdsMap, + ); + setDynamicParents(prev => { + if (isEqual(prev, newdynamicParents)) { + return prev; + } + + return newdynamicParents; + }); + }, [nodeExecutionsById]); + + useEffect(() => { + const dagData = compiledWorkflowClosure + ? transformerWorkflowToDag( + compiledWorkflowClosure, + dynamicWorkflows, + nodeExecutionsById, + ) + : { dag: {} as dNode, staticExecutionIdsMap: {}, error: undefined }; + const { dag, staticExecutionIdsMap, error } = dagData; + const nodes = dag?.nodes ?? []; + + let newMergedDag = dag; + + for (const dynamicId in dynamicWorkflows) { + if (staticExecutionIdsMap[dynamicId]) { + if (compiledWorkflowClosure) { + const dynamicWorkflow = transformerWorkflowToDag( + compiledWorkflowClosure, + dynamicWorkflows, + nodeExecutionsById, + ); + newMergedDag = dynamicWorkflow.dag; + } + } + } + setDagError(error); + setMergedDag(prev => { + if (stringifyIsEqual(prev, newMergedDag)) { + return prev; + } + return newMergedDag; + }); + + // we remove start/end node info in the root dNode list during first assignment + const plainNodes = convertToPlainNodes(nodes); + plainNodes.map(node => { + const initialNode = initialDNodes.find(n => n.scopedId === node.scopedId); + if (initialNode) { + node.expanded = initialNode.expanded; + } + }); + setInitialDNodes(prev => { + if (stringifyIsEqual(prev, plainNodes)) { + return prev; + } + return plainNodes; + }); + }, [ + compiledWorkflowClosure, + dynamicWorkflows, + dynamicParents, + nodeExecutionsById, + ]); + + useEffect(() => { + if (nodeExecutionDynamicWorkflowQuery.isFetching) { + return; + } + setDynamicWorkflows(prev => { + const newDynamicWorkflows = merge( + { ...(prev || {}) }, + nodeExecutionDynamicWorkflowQuery.data, + ); + if (isEqual(prev, newDynamicWorkflows)) { + return prev; + } + + return newDynamicWorkflows; + }); + }, [nodeExecutionDynamicWorkflowQuery]); + + useEffect(() => { + if (shouldUpdate) { + const newDynamicParents = checkForDynamicExecutions( + nodeExecutionsById, + staticExecutionIdsMap, + ); + setDynamicParents(prev => { + if (isEqual(prev, newDynamicParents)) { + return prev; + } + + return newDynamicParents; + }); + setShouldUpdate(false); + } + }, [shouldUpdate]); + + const setCurrentNodeExecutionsById = useCallback( + ( + newNodeExecutionsById: NodeExecutionsById, + checkForDynamicParents?: boolean, + ): void => { + setNodeExecutionsById(prev => { + const newNodes = mergeWith( + { ...prev }, + { ...newNodeExecutionsById }, + mergeNodeExecutions, + ); + if ( + JSON.stringify(prev, mapStringifyReplacer) === + JSON.stringify(newNodes, mapStringifyReplacer) + ) { + return prev; + } + + if (checkForDynamicParents) { + setShouldUpdate(true); + } + + return newNodes; + }); + }, + [], + ); + + return ( + + {children} + + ); +}; + +export const useNodeExecutionsById = (): IWorkflowNodeExecutionsContext => { + return useContext(WorkflowNodeExecutionsContext); +}; diff --git a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx index 649d65968..751b07864 100644 --- a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx +++ b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx @@ -8,7 +8,7 @@ import { Workflow } from 'models/Workflow/types'; import { Identifier } from 'models/Common/types'; import { CompiledTask } from 'models/Task/types'; import { dNode } from 'models/Graph/types'; -import { isEndNode, isStartNode } from 'components/WorkflowGraph/utils'; +import { isEndNode, isStartNode } from 'models/Node/utils'; import { UNKNOWN_DETAILS } from './types'; interface NodeExecutionInfo extends NodeExecutionDetails { diff --git a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx index 63497f8c1..fc899112f 100644 --- a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx +++ b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/index.tsx @@ -1,176 +1,2 @@ -import React, { - createContext, - useContext, - useEffect, - useRef, - useState, -} from 'react'; -import { log } from 'common/log'; -import { Identifier } from 'models/Common/types'; -import { NodeExecution } from 'models/Execution/types'; -import { CompiledWorkflowClosure } from 'models/Workflow/types'; -import { useQueryClient } from 'react-query'; -import { fetchWorkflow } from 'components/Workflow/workflowQueries'; -import { NodeExecutionDetails } from '../../types'; -import { UNKNOWN_DETAILS } from './types'; -import { - createExecutionDetails, - CurrentExecutionDetails, -} from './createExecutionArray'; -import { getTaskThroughExecution } from './getTaskThroughExecution'; - -interface NodeExecutionDetailsState { - 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({ - /** Default values used if ContextProvider wasn't initialized. */ - getNodeExecutionDetails: async () => { - log.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, - ); - -/** Could be used to access the whole NodeExecutionDetailsState */ -export const useNodeExecutionContext = (): NodeExecutionDetailsState => - useContext(NodeExecutionDetailsContext); - -interface ProviderProps { - 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 [tasks, setTasks] = useState(new Map()); - const [closure, setClosure] = useState(null); - - const resetState = () => { - setExecutionTree(null); - }; - - 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 result = await fetchWorkflow(queryClient, workflowId); - if (!result) { - resetState(); - return; - } - const workflow = JSON.parse(JSON.stringify(result)); - const tree = createExecutionDetails(workflow); - if (isCurrent) { - setClosure(workflow.closure?.compiledWorkflow ?? null); - setExecutionTree(tree); - } - } - - fetchData(); - - // This handles the unmount case - return () => { - isCurrent = false; - resetState(); - }; - }, [queryClient, resourceType, project, domain, name, version]); - - const getDynamicTasks = 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.scopedId || - nodeExecution.metadata?.specNodeId || - nodeExecution.id.nodeId; - const nodeDetail = executionTree.nodes.filter(n => n.scopedId === specId); - 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 - if (nodeExecution.metadata?.isDynamic) { - details = await getDynamicTasks(nodeExecution); - } - return details; - } - - return nodeDetail?.[0] ?? UNKNOWN_DETAILS; - }; - - return ( - - {props.children} - - ); -}; +export * from './NodeExecutionDetailsContextProvider'; +export * from './WorkflowNodeExecutionsProvider'; diff --git a/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/utils.ts b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/utils.ts new file mode 100644 index 000000000..7f71deba4 --- /dev/null +++ b/packages/console/src/components/Executions/contextProvider/NodeExecutionDetails/utils.ts @@ -0,0 +1,31 @@ +import { merge, mergeWith } from 'lodash'; + +export const mapStringifyReplacer = (key: string, value: any) => { + if (value instanceof Map) { + return { + dataType: 'Map', + value: Array.from(value.entries()), // or with spread: value: [...value] + }; + } else { + return value; + } +}; + +export const stringifyIsEqual = (a: any, b: any) => { + return ( + JSON.stringify(a, mapStringifyReplacer) === + JSON.stringify(b, mapStringifyReplacer) + ); +}; + +export const mergeNodeExecutions = (val, srcVal, _key) => { + const retVal = mergeWith(val, srcVal, (val, srcVal, _key) => { + if (srcVal instanceof Map) { + return srcVal; + } + const finaVal = + typeof srcVal === 'object' ? merge({ ...val }, { ...srcVal }) : srcVal; + return finaVal; + }); + return retVal; +}; diff --git a/packages/console/src/components/Executions/contexts.ts b/packages/console/src/components/Executions/contexts.ts index 0f592e9cf..25d9a639f 100644 --- a/packages/console/src/components/Executions/contexts.ts +++ b/packages/console/src/components/Executions/contexts.ts @@ -1,4 +1,5 @@ import { Execution, LogsByPhase, NodeExecution } from 'models/Execution/types'; +import { dNode } from 'models/Graph/types'; import { createContext } from 'react'; export interface ExecutionContextData { @@ -14,15 +15,39 @@ export const ExecutionContext = createContext( {} as ExecutionContextData, ); -export interface INodeExecutionsByIdContext { - nodeExecutionsById: Dictionary; - setCurrentNodeExecutionsById: ( - currentNodeExecutionsById: Dictionary, - ) => void; +export type NodeExecutionsById = Dictionary; +export type FilteredNodeExecutions = WorkflowNodeExecution[] | undefined; +export type SetCurrentNodeExecutionsById = ( + currentNodeExecutionsById: Dictionary, + checkForDynamicParents?: boolean, +) => void; + +export interface IWorkflowNodeExecutionsContext { + nodeExecutionsById: NodeExecutionsById; + setCurrentNodeExecutionsById: SetCurrentNodeExecutionsById; + shouldUpdate: boolean; + setShouldUpdate: (val: boolean) => void; + // Tabs + initialDNodes: dNode[]; + dagData: { + mergedDag: any; + dagError: any; + }; } -export const NodeExecutionsByIdContext = - createContext({ +export const WorkflowNodeExecutionsContext = + createContext({ nodeExecutionsById: {}, - setCurrentNodeExecutionsById: () => {}, + setCurrentNodeExecutionsById: () => { + throw new Error('Must use NodeExecutionsByIdContextProvider'); + }, + shouldUpdate: false, + setShouldUpdate: _val => { + throw new Error('Must use NodeExecutionsByIdContextProvider'); + }, + initialDNodes: [], + dagData: { + mergedDag: {}, + dagError: null, + }, }); diff --git a/packages/console/src/components/Executions/nodeExecutionQueries.ts b/packages/console/src/components/Executions/nodeExecutionQueries.ts index f7ef95932..1fd2ce457 100644 --- a/packages/console/src/components/Executions/nodeExecutionQueries.ts +++ b/packages/console/src/components/Executions/nodeExecutionQueries.ts @@ -13,20 +13,23 @@ import { } from 'models/Execution/api'; import { nodeExecutionQueryParams } from 'models/Execution/constants'; import { + ExternalResource, + LogsByPhase, NodeExecution, NodeExecutionIdentifier, TaskExecution, TaskExecutionIdentifier, WorkflowExecutionIdentifier, } from 'models/Execution/types'; -import { endNodeId, startNodeId } from 'models/Node/constants'; +import { ignoredNodeIds } from 'models/Node/constants'; +import { isMapTaskV1 } from 'models/Task/utils'; import { QueryClient } from 'react-query'; +import { WorkflowNodeExecution } from './contexts'; import { fetchTaskExecutionList } from './taskExecutionQueries'; -import { formatRetryAttempt } from './TaskExecutionsList/utils'; +import { formatRetryAttempt, getGroupedLogs } from './TaskExecutionsList/utils'; import { NodeExecutionGroup } from './types'; -import { isParentNode } from './utils'; +import { isDynamicNode, isParentNode, nodeExecutionIsTerminal } from './utils'; -const ignoredNodeIds = [startNodeId, endNodeId]; function removeSystemNodes(nodeExecutions: NodeExecution[]): NodeExecution[] { return nodeExecutions.filter(ne => { if (ignoredNodeIds.includes(ne.id.nodeId)) { @@ -50,6 +53,112 @@ export function makeNodeExecutionQuery( }; } +export const getTaskExecutions = async ( + nodeExecution: WorkflowNodeExecution, + queryClient, +) => { + const isTerminal = nodeExecutionIsTerminal(nodeExecution); + const tasksFetched = !!nodeExecution.tasksFetched; + const isDynamic = isDynamicNode(nodeExecution); + if (!isDynamic && tasksFetched) { + // return null to signal no change + return; + } + return await fetchTaskExecutionList( + queryClient, + nodeExecution.id as any, + ).then(taskExecutions => { + const useNewMapTaskView = taskExecutions.every(taskExecution => { + const { + closure: { taskType, metadata, eventVersion = 0 }, + } = taskExecution; + return isMapTaskV1( + eventVersion, + metadata?.externalResources?.length ?? 0, + taskType ?? undefined, + ); + }); + + const externalResources: ExternalResource[] = taskExecutions + .map(taskExecution => taskExecution.closure.metadata?.externalResources) + .flat() + .filter((resource): resource is ExternalResource => !!resource); + + const logsByPhase: LogsByPhase = getGroupedLogs(externalResources); + + const appendTasksFetched = !isDynamic || (isDynamic && isTerminal); + + const { closure: _, metadata: __, ...nodeExecutionLight } = nodeExecution; + return { + // to avoid overwiring data from queries that handle status updates + ...nodeExecutionLight, + taskExecutions, + ...(useNewMapTaskView && logsByPhase.size > 0 && { logsByPhase }), + ...((appendTasksFetched && { tasksFetched: true }) || {}), + }; + }); +}; + +/** A query for fetching a single `NodeExecution` by id. */ +export function makeNodeExecutionQueryEnhanced( + nodeExecution: WorkflowNodeExecution, + queryClient: QueryClient, +): QueryInput { + const { id } = nodeExecution || {}; + + return { + enabled: !!nodeExecution, + queryKey: [QueryType.NodeExecutionAndChildList, id], + queryFn: async () => { + // complexity: + // +1 for parent node tasks + // +1 for node execution list + // +n= executionList.length + const isParent = isParentNode(nodeExecution); + const parentNodeID = nodeExecution.id.nodeId; + const parentScopeId = + nodeExecution.scopedId ?? nodeExecution.metadata?.specNodeId; + nodeExecution.scopedId = parentScopeId; + + // if the node is a parent, force refetch its children + // called by NodeExecutionDynamicProvider + const parentNodeExecutions = isParent + ? () => + fetchNodeExecutionList( + // requests listNodeExecutions + queryClient, + id.executionId, + { + params: { + [nodeExecutionQueryParams.parentNodeId]: parentNodeID, + }, + }, + ).then(childExecutions => { + const children = childExecutions.map(e => { + const scopedId = e.metadata?.specNodeId + ? retriesToZero(e?.metadata?.specNodeId) + : retriesToZero(e?.id?.nodeId); + e['scopedId'] = `${parentScopeId}-0-${scopedId}`; + e['fromUniqueParentId'] = parentNodeID; + + return e; + }); + return children; + }) + : () => Promise.resolve([]); + + const parentNodeAndTaskExecutions = await Promise.all([ + getTaskExecutions(nodeExecution, queryClient), + parentNodeExecutions(), + ]).then(([parent, children]) => { + return [parent, ...children].filter(n => !!n); + }); + + return parentNodeAndTaskExecutions as NodeExecution[]; + }, + }; +} + export function makeListTaskExecutionsQuery( id: NodeExecutionIdentifier, ): QueryInput> { @@ -97,9 +206,8 @@ export function makeNodeExecutionListQuery( return { queryKey: [QueryType.NodeExecutionList, id, config], queryFn: async () => { - const nodeExecutions = removeSystemNodes( - (await listNodeExecutions(id, config)).entities, - ); + const promise = (await listNodeExecutions(id, config)).entities; + const nodeExecutions = removeSystemNodes(promise); nodeExecutions.map(exe => { if (exe.metadata?.specNodeId) { return (exe.scopedId = retriesToZero(exe.metadata.specNodeId)); @@ -108,6 +216,7 @@ export function makeNodeExecutionListQuery( } }); cacheNodeExecutions(queryClient, nodeExecutions); + return nodeExecutions; }, }; diff --git a/packages/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx b/packages/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx index 0c44a7d93..6800d5ddd 100644 --- a/packages/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx +++ b/packages/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx @@ -11,6 +11,7 @@ import * as React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { MemoryRouter } from 'react-router'; import { createTestQueryClient } from 'test/utils'; +import { cloneDeep } from 'lodash'; import { NodeExecutionCacheStatus } from '../NodeExecutionCacheStatus'; jest.mock('models/Task/utils'); @@ -45,7 +46,8 @@ describe('Executions > NodeExecutionCacheStatus', () => { ); it('should not render anything, if cacheStatus is undefined', async () => { - const { container } = renderComponent({ execution }); + const ex = cloneDeep(execution); + const { container } = renderComponent({ execution: ex }); await waitFor(() => container); expect(container).toBeEmptyDOMElement(); diff --git a/packages/console/src/components/Executions/types.ts b/packages/console/src/components/Executions/types.ts index 3bed86f03..fa61b44f9 100644 --- a/packages/console/src/components/Executions/types.ts +++ b/packages/console/src/components/Executions/types.ts @@ -40,6 +40,12 @@ export interface ParentNodeExecution extends NodeExecution { }; } +export interface DynamicNodeExecution extends NodeExecution { + metadata: NodeExecutionMetadata & { + isDynamic: true; + }; +} + export interface WorkflowNodeExecutionClosure extends NodeExecutionClosure { workflowNodeMetadata: WorkflowNodeMetadata; } diff --git a/packages/console/src/components/Executions/useNodeExecutionsById.ts b/packages/console/src/components/Executions/useNodeExecutionsById.ts deleted file mode 100644 index 0ebec211a..000000000 --- a/packages/console/src/components/Executions/useNodeExecutionsById.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { NodeExecution } from 'models/Execution/types'; -import { useCallback, useState } from 'react'; -import { INodeExecutionsByIdContext } from './contexts'; - -export const useNodeExecutionsById = ( - initialNodeExecutionsById?: Dictionary, -): INodeExecutionsByIdContext => { - const [nodeExecutionsById, setNodeExecutionsById] = useState( - initialNodeExecutionsById ?? {}, - ); - - const setCurrentNodeExecutionsById = useCallback( - (currentNodeExecutionsById: Dictionary): void => { - setNodeExecutionsById(currentNodeExecutionsById); - }, - [], - ); - - return { - nodeExecutionsById, - setCurrentNodeExecutionsById, - }; -}; diff --git a/packages/console/src/components/Executions/useWorkflowExecution.ts b/packages/console/src/components/Executions/useWorkflowExecution.ts index 84b54baed..0bf9b26c5 100644 --- a/packages/console/src/components/Executions/useWorkflowExecution.ts +++ b/packages/console/src/components/Executions/useWorkflowExecution.ts @@ -24,7 +24,10 @@ export function makeWorkflowExecutionQuery( ): QueryInput { return { queryKey: [QueryType.WorkflowExecution, id], - queryFn: () => getExecution(id), + queryFn: async () => { + const result = await getExecution(id); + return result; + }, }; } diff --git a/packages/console/src/components/Executions/utils.ts b/packages/console/src/components/Executions/utils.ts index c86f4d48e..9bcd9cca6 100644 --- a/packages/console/src/components/Executions/utils.ts +++ b/packages/console/src/components/Executions/utils.ts @@ -1,10 +1,5 @@ import { durationToMilliseconds, timestampToDate } from 'common/utils'; -import { - isEndNode, - isExpanded, - isStartNode, -} from 'components/WorkflowGraph/utils'; -import { clone, isEqual, keyBy, merge } from 'lodash'; +import { cloneDeep, keyBy, merge } from 'lodash'; import { runningExecutionStates, terminalExecutionStates, @@ -21,11 +16,11 @@ import { BaseExecutionClosure, Execution, NodeExecution, - NodeExecutionIdentifier, TaskExecution, } from 'models/Execution/types'; import { dNode } from 'models/Graph/types'; import { CompiledNode } from 'models/Node/types'; +import { isEndNode, isExpanded, isStartNode } from 'models/Node/utils'; import { QueryClient } from 'react-query'; import { nodeExecutionPhaseConstants, @@ -33,10 +28,14 @@ import { taskTypeToNodeExecutionDisplayType, workflowExecutionPhaseConstants, } from './constants'; -import { WorkflowNodeExecution } from './contexts'; +import { + SetCurrentNodeExecutionsById, + WorkflowNodeExecution, +} from './contexts'; import { isChildGroupsFetched } from './ExecutionDetails/utils'; import { fetchChildNodeExecutionGroups } from './nodeExecutionQueries'; import { + DynamicNodeExecution, ExecutionPhaseConstants, NodeExecutionDisplayType, ParentNodeExecution, @@ -154,9 +153,14 @@ function getExecutionTimingMS({ export function isParentNode( nodeExecution: NodeExecution, ): nodeExecution is ParentNodeExecution { - return ( - nodeExecution.metadata != null && !!nodeExecution.metadata.isParentNode - ); + return !!nodeExecution?.metadata?.isParentNode; +} + +/** Indicates if a NodeExecution is explicitly marked as a parent node. */ +export function isDynamicNode( + nodeExecution: NodeExecution, +): nodeExecution is DynamicNodeExecution { + return !!nodeExecution?.metadata?.isDynamic; } export function flattenBranchNodes(node: CompiledNode): CompiledNode[] { @@ -204,7 +208,7 @@ export function isExecutionArchived(execution: Execution): boolean { /** Returns true if current node (by nodeId) has 'gateNode' field in the list of nodes on compiledWorkflowClosure */ export function isNodeGateNode(nodes: CompiledNode[], id: string): boolean { - const node = nodes.find(n => n.id === id); + const node = nodes?.find(n => n.id === id); return !!node?.gateNode; } @@ -247,15 +251,15 @@ export async function fetchChildrenExecutions( queryClient: QueryClient, scopedId: string, nodeExecutionsById: Dictionary, - setCurrentNodeExecutionsById: ( - currentNodeExecutionsById: Dictionary, - ) => void, - setShouldUpdate?: (val: boolean) => void, + setCurrentNodeExecutionsById: SetCurrentNodeExecutionsById, + skipCache = false, ) { - if (!isChildGroupsFetched(scopedId, nodeExecutionsById)) { + const cachedParentNode = nodeExecutionsById[scopedId]; + const nodeExecutionsByIdAdapted = skipCache ? {} : nodeExecutionsById; + if (!isChildGroupsFetched(scopedId, nodeExecutionsByIdAdapted)) { const childGroups = await fetchChildNodeExecutionGroups( queryClient, - nodeExecutionsById[scopedId], + cachedParentNode, {}, ); @@ -267,19 +271,12 @@ export async function fetchChildrenExecutions( ); }); if (childGroupsExecutionsById) { - const prevNodeExecutionsById = clone(nodeExecutionsById); const currentNodeExecutionsById = merge( - nodeExecutionsById, - childGroupsExecutionsById, + cloneDeep(nodeExecutionsByIdAdapted), + cloneDeep(childGroupsExecutionsById), ); - if ( - setShouldUpdate && - !isEqual(prevNodeExecutionsById, currentNodeExecutionsById) - ) { - setShouldUpdate(true); - } - setCurrentNodeExecutionsById(currentNodeExecutionsById); + setCurrentNodeExecutionsById(currentNodeExecutionsById, true); } } } diff --git a/packages/console/src/components/Launch/LaunchForm/CollectionInput.tsx b/packages/console/src/components/Launch/LaunchForm/CollectionInput.tsx index d3427dc82..34303f574 100644 --- a/packages/console/src/components/Launch/LaunchForm/CollectionInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/CollectionInput.tsx @@ -46,7 +46,7 @@ export const CollectionInput: React.FC = props => { label={label} multiline={true} onChange={makeStringChangeHandler(onChange)} - rowsMax={8} + maxRows={8} value={value} variant="outlined" /> diff --git a/packages/console/src/components/Launch/LaunchForm/LaunchFormActions.tsx b/packages/console/src/components/Launch/LaunchForm/LaunchFormActions.tsx index d71728dc3..d79b177c9 100644 --- a/packages/console/src/components/Launch/LaunchForm/LaunchFormActions.tsx +++ b/packages/console/src/components/Launch/LaunchForm/LaunchFormActions.tsx @@ -4,6 +4,7 @@ import * as React from 'react'; import { useEffect } from 'react'; import { history } from 'routes/history'; import { Routes } from 'routes/routes'; +import { useDetailsPanel } from 'components/Executions/ExecutionDetails/DetailsPanelContext'; import t from './strings'; import { LaunchState, TaskResumeContext } from './launchMachine'; import { useStyles } from './styles'; @@ -28,6 +29,7 @@ export const LaunchFormActions: React.FC = ({ }) => { const styles = useStyles(); const submissionInFlight = state.matches(LaunchState.SUBMITTING); + const { setIsDetailsTabClosed } = useDetailsPanel(); const canSubmit = [ LaunchState.ENTER_INPUTS, LaunchState.VALIDATING_INPUTS, @@ -59,9 +61,11 @@ export const LaunchFormActions: React.FC = ({ // if (state.matches({ submit: 'succeeded' })) { if (newState.matches(LaunchState.SUBMIT_SUCCEEDED)) { if (newState.context.resultExecutionId) { - history.push( - Routes.ExecutionDetails.makeUrl(newState.context.resultExecutionId), - ); + onClose(); + setIsDetailsTabClosed && setIsDetailsTabClosed(true); + // history.push( + // Routes.ExecutionDetails.makeUrl(newState.context.resultExecutionId), + // ); } const context = newState.context as TaskResumeContext; if (context.compiledNode) { diff --git a/packages/console/src/components/Launch/LaunchForm/MapInput.tsx b/packages/console/src/components/Launch/LaunchForm/MapInput.tsx index 3febaba75..7b2156564 100644 --- a/packages/console/src/components/Launch/LaunchForm/MapInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/MapInput.tsx @@ -166,8 +166,6 @@ export const MapInput = (props: InputProps) => { } = props; const classes = useStyles(); - console.log('MY FILTER: org error: ', error); - const [data, setData] = React.useState( parseMappedTypeValue(value), ); diff --git a/packages/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx b/packages/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx index f4523904f..cc784ec89 100644 --- a/packages/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx +++ b/packages/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx @@ -1,14 +1,14 @@ import { DialogContent, Typography } from '@material-ui/core'; import { getCacheKey } from 'components/Cache/utils'; import * as React from 'react'; -import { useState, useContext, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { NodeExecution, NodeExecutionIdentifier } from 'models/Execution/types'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; import { useNodeExecutionData } from 'components/hooks/useNodeExecution'; import { LiteralMapViewer } from 'components/Literals/LiteralMapViewer'; import { WaitForData } from 'components/common/WaitForData'; import t from 'components/common/strings'; import { CompiledNode } from 'models/Node/types'; +import { useNodeExecutionsById } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { useStyles } from './styles'; import { BaseInterpretedLaunchState, @@ -39,7 +39,7 @@ export const ResumeSignalForm: React.FC = ({ nodeExecutionId, onClose, }); - const { nodeExecutionsById } = useContext(NodeExecutionsByIdContext); + const { nodeExecutionsById } = useNodeExecutionsById(); const [nodeExecution, setNodeExecution] = useState( nodeExecutionsById[nodeExecutionId.nodeId], ); diff --git a/packages/console/src/components/Launch/LaunchForm/StructInput.tsx b/packages/console/src/components/Launch/LaunchForm/StructInput.tsx index 8777c97cf..c2dfe6434 100644 --- a/packages/console/src/components/Launch/LaunchForm/StructInput.tsx +++ b/packages/console/src/components/Launch/LaunchForm/StructInput.tsx @@ -120,7 +120,7 @@ export const StructInput: React.FC = props => { label={label} multiline={true} onChange={makeStringChangeHandler(onChange)} - rowsMax={8} + maxRows={8} value={value} variant="outlined" /> diff --git a/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx b/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx index f21a350bb..91b8f9354 100644 --- a/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx +++ b/packages/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx @@ -11,7 +11,7 @@ import { getMuiTheme } from 'components/Theme/muiTheme'; import { SimpleType } from 'models/Common/types'; import { resumeSignalNode } from 'models/Execution/api'; import * as React from 'react'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { WorkflowNodeExecutionsContext } from 'components/Executions/contexts'; import { dateToTimestamp } from 'common/utils'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { createTestQueryClient } from 'test/utils'; @@ -111,7 +111,7 @@ describe('ResumeSignalForm', () => { compiledWorkflowClosure: mockCompiledWorkflowClosure, }} > - { compiledNode={mockCompiledNode} nodeExecutionId={mockNodeExecutionId} /> - + diff --git a/packages/console/src/components/Theme/constants.ts b/packages/console/src/components/Theme/constants.ts index 7d464dee2..8837031d5 100644 --- a/packages/console/src/components/Theme/constants.ts +++ b/packages/console/src/components/Theme/constants.ts @@ -97,6 +97,7 @@ export const taskColors: TaskColorMap = { // plugins [TaskType.ARRAY_AWS]: '#E1E8ED', [TaskType.ARRAY_K8S]: '#E1E8ED', + [TaskType.BRANCH]: '#E1E8ED', }; export const bodyFontSize = '0.875rem'; diff --git a/packages/console/src/components/Workflow/workflowQueries.ts b/packages/console/src/components/Workflow/workflowQueries.ts index 1fff7b10d..8545b5f75 100644 --- a/packages/console/src/components/Workflow/workflowQueries.ts +++ b/packages/console/src/components/Workflow/workflowQueries.ts @@ -1,6 +1,7 @@ import { log } from 'common/log'; import { QueryInput, QueryType } from 'components/data/types'; import { extractTaskTemplates } from 'components/hooks/utils'; +import { ExecutionData } from 'models'; import { getNodeExecutionData } from 'models/Execution/api'; import { getWorkflow } from 'models/Workflow/api'; import { Workflow, WorkflowId } from 'models/Workflow/types'; @@ -29,14 +30,20 @@ export function makeWorkflowQuery( }; } +export interface NodeExecutionDynamicWorkflowQueryResult { + [key: string]: ExecutionData; +} export function makeNodeExecutionDynamicWorkflowQuery( parentsToFetch, -): QueryInput<{ [key: string]: any }> { +): QueryInput { + const parentsIds = Object.keys(parentsToFetch); return { queryKey: [QueryType.DynamicWorkflowFromNodeExecution, parentsToFetch], + // don't make any requests as long as there are no dynamic node executions to fetch + enabled: !!parentsIds?.length, queryFn: async () => { return await Promise.all( - Object.keys(parentsToFetch) + parentsIds .filter(id => parentsToFetch[id]) .map(id => { const executionId = parentsToFetch[id]; @@ -45,10 +52,9 @@ export function makeNodeExecutionDynamicWorkflowQuery( // when Branch node support would be added log.error(`Graph missing info for ${id}`); } - const data = getNodeExecutionData(executionId.id).then(value => { + return getNodeExecutionData(executionId.id).then(value => { return { key: id, value: value }; }); - return data; }), ).then(values => { const output: { [key: string]: any } = {}; diff --git a/packages/console/src/components/WorkflowGraph/TaskNodeRenderer.tsx b/packages/console/src/components/WorkflowGraph/TaskNodeRenderer.tsx index bcdc5053b..461a47e21 100644 --- a/packages/console/src/components/WorkflowGraph/TaskNodeRenderer.tsx +++ b/packages/console/src/components/WorkflowGraph/TaskNodeRenderer.tsx @@ -1,11 +1,11 @@ +import * as React from 'react'; import { Node } from 'components/flytegraph/Node'; import { NodeRendererProps } from 'components/flytegraph/types'; import { taskColors } from 'components/Theme/constants'; import { DAGNode } from 'models/Graph/types'; +import { isEndNode, isStartNode } from 'models/Node/utils'; import { TaskType } from 'models/Task/constants'; -import * as React from 'react'; import { InputOutputNodeRenderer } from './InputOutputNodeRenderer'; -import { isEndNode, isStartNode } from './utils'; const TaskNode: React.FC> = props => { const { node, config } = props; diff --git a/packages/console/src/components/WorkflowGraph/WorkflowGraph.tsx b/packages/console/src/components/WorkflowGraph/WorkflowGraph.tsx index dfbc8da5c..2c46e75ab 100644 --- a/packages/console/src/components/WorkflowGraph/WorkflowGraph.tsx +++ b/packages/console/src/components/WorkflowGraph/WorkflowGraph.tsx @@ -1,44 +1,28 @@ import React from 'react'; import { ReactFlowGraphComponent } from 'components/flytegraph/ReactFlow/ReactFlowGraphComponent'; -import { Error } from 'models/Common/types'; import { NonIdealState } from 'components/common/NonIdealState'; import { CompiledNode } from 'models/Node/types'; -import { TaskExecutionPhase } from 'models/Execution/enums'; -import { dNode } from 'models/Graph/types'; +import { ReactFlowBreadCrumbProvider } from 'components/flytegraph/ReactFlow/ReactFlowBreadCrumbProvider'; +import { useNodeExecutionsById } from 'components/Executions/contextProvider/NodeExecutionDetails'; import t from './strings'; -export interface WorkflowGraphProps { - onNodeSelectionChanged: (selectedNodes: string[]) => void; - onPhaseSelectionChanged: (phase: TaskExecutionPhase) => void; - selectedPhase?: TaskExecutionPhase; - isDetailsTabClosed: boolean; - mergedDag: any; - error: Error | null; - dynamicWorkflows: any; - initialNodes: dNode[]; - shouldUpdate: boolean; - setShouldUpdate: (val: boolean) => void; -} export interface DynamicWorkflowMapping { rootGraphNodeId: CompiledNode; dynamicWorkflow: any; dynamicExecutions: any[]; } -export const WorkflowGraph: React.FC = ({ - onNodeSelectionChanged, - onPhaseSelectionChanged, - selectedPhase, - isDetailsTabClosed, - mergedDag, - error, - dynamicWorkflows, - initialNodes, - shouldUpdate, - setShouldUpdate, -}) => { - if (error) { + +export const WorkflowGraph: React.FC<{}> = () => { + const { + dagData: { mergedDag, dagError }, + } = useNodeExecutionsById(); + + if (dagError) { return ( - + ); } @@ -53,15 +37,8 @@ export const WorkflowGraph: React.FC = ({ } return ( - + + + ); }; diff --git a/packages/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx b/packages/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx index 45437261b..0e6da9161 100644 --- a/packages/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx +++ b/packages/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx @@ -2,8 +2,16 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; import { createTestQueryClient } from 'test/utils'; import { QueryClient, QueryClientProvider } from 'react-query'; +import { WorkflowNodeExecutionsContext } from 'components/Executions/contexts'; import { WorkflowGraph } from '../WorkflowGraph'; +jest.mock('../../flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx', () => ({ + ConvertFlyteDagToReactFlows: jest.fn(() => ({ + nodes: [], + edges: [], + })), +})); + jest.mock('../../flytegraph/ReactFlow/ReactFlowWrapper.tsx', () => ({ ReactFlowWrapper: jest.fn(({ children }) => (
{children}
@@ -21,24 +29,30 @@ describe('WorkflowGraph', () => { await act(() => { render( - {}, + setShouldUpdate: () => {}, + shouldUpdate: false, + dagData: { + mergedDag: { + edges: [], + id: 'node', + name: 'node', + nodes: [], + type: 4, + value: { + id: 'name', + }, + }, + dagError: undefined, }, }} - error={null} - dynamicWorkflows={[]} - initialNodes={[]} - /> + > + + , ); }); diff --git a/packages/console/src/components/WorkflowGraph/test/utils.test.ts b/packages/console/src/components/WorkflowGraph/test/utils.test.ts index 6debf085b..99c2cd3cf 100644 --- a/packages/console/src/components/WorkflowGraph/test/utils.test.ts +++ b/packages/console/src/components/WorkflowGraph/test/utils.test.ts @@ -5,13 +5,12 @@ import { mockCompiledTaskNode, } from 'models/__mocks__/graphWorkflowData'; import { dTypes } from 'models/Graph/types'; +import { isEndNode, isStartNode } from 'models/Node/utils'; import { DISPLAY_NAME_START, DISPLAY_NAME_END, getDisplayName, getNodeTypeFromCompiledNode, - isStartNode, - isEndNode, getNodeTemplateName, } from '../utils'; diff --git a/packages/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx b/packages/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx index bd173aacc..d5a202ae7 100644 --- a/packages/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx +++ b/packages/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx @@ -12,8 +12,9 @@ import { CompiledWorkflowClosure, } from 'models/Workflow/types'; import { isParentNode } from 'components/Executions/utils'; +import { isStartOrEndNode } from 'models/Node/utils'; +import { NodeExecutionsById } from 'models'; import { - isStartOrEndNode, getDisplayName, getSubWorkflowFromId, getNodeTypeFromCompiledNode, @@ -26,367 +27,511 @@ export interface staticNodeExecutionIds { const debug = createDebugLogger('@transformerWorkflowToDag'); -/** - * Returns a DAG from Flyte workflow request data - * @param context input can be either CompiledWorkflow or CompiledNode - * @returns Display name - */ -export const transformerWorkflowToDag = ( - workflow: CompiledWorkflowClosure, - dynamicToMerge: any | null = null, - nodeExecutionsById = {}, -): any => { - const { primary } = workflow; - const staticExecutionIdsMap = {}; - - interface CreateDEdgeProps { - sourceId: string; - targetId: string; - } - const createDEdge = ({ sourceId, targetId }: CreateDEdgeProps): dEdge => { - const id = `${sourceId}->${targetId}`; - const edge: dEdge = { - sourceId: sourceId, - targetId: targetId, - id: id, - }; - return edge; - }; +interface CreateDNodeProps { + compiledNode: CompiledNode; + parentDNode?: dNode; + taskTemplate?: CompiledTask; + typeOverride?: dTypes; + nodeExecutionsById?: NodeExecutionsById; + staticExecutionIdsMap?: any; +} +const createDNode = ({ + compiledNode, + parentDNode, + taskTemplate, + typeOverride, + nodeExecutionsById, + staticExecutionIdsMap, +}: CreateDNodeProps): dNode => { + const nodeValue = + taskTemplate == null ? compiledNode : { ...compiledNode, ...taskTemplate }; - interface CreateDNodeProps { - compiledNode: CompiledNode; - parentDNode?: dNode; - taskTemplate?: CompiledTask; - typeOverride?: dTypes; - } - const createDNode = ({ - compiledNode, - parentDNode, - taskTemplate, - typeOverride, - }: CreateDNodeProps): dNode => { - const nodeValue = - taskTemplate == null - ? compiledNode - : { ...compiledNode, ...taskTemplate }; - - /** - * Note on scopedId: - * We need to be able to map nodeExecution's to their corresponding nodes. The problem is that nodeExecutions come - * back with a scoped id's (eg, {parentId}-{retry}-{childId}) while nodes are contextual (eg, 'n3' vs 'n0-0-n1-0-n3'). - * Further, even if we try to construct these values here we cannot know the actual retry value until run-time. - * - * To mitigate this we've added a new property on NodeExecutions that is the same as an executions scopedId but - * assuming '0' for each retry. We then construct that same scopedId here with the same solution of '0' for retries - * which allows us to map them regardless of what the actual retry value is. - */ - let scopedId = ''; + /** + * Note on scopedId: + * We need to be able to map nodeExecution's to their corresponding nodes. The problem is that nodeExecutions come + * back with a scoped id's (eg, {parentId}-{retry}-{childId}) while nodes are contextual (eg, 'n3' vs 'n0-0-n1-0-n3'). + * Further, even if we try to construct these values here we cannot know the actual retry value until run-time. + * + * To mitigate this we've added a new property on NodeExecutions that is the same as an executions scopedId but + * assuming '0' for each retry. We then construct that same scopedId here with the same solution of '0' for retries + * which allows us to map them regardless of what the actual retry value is. + */ + let scopedId = ''; + if ( + isStartOrEndNode(compiledNode) && + parentDNode && + !isStartOrEndNode(parentDNode) + ) { + scopedId = `${parentDNode.scopedId}-${compiledNode.id}`; + } else if (parentDNode && parentDNode.type !== dTypes.start) { if ( - isStartOrEndNode(compiledNode) && - parentDNode && - !isStartOrEndNode(parentDNode) + parentDNode.type === dTypes.branch || + parentDNode.type === dTypes.subworkflow ) { - scopedId = `${parentDNode.scopedId}-${compiledNode.id}`; - } else if (parentDNode && parentDNode.type !== dTypes.start) { - if ( - parentDNode.type === dTypes.branch || - parentDNode.type === dTypes.subworkflow - ) { - scopedId = `${parentDNode.scopedId}-0-${compiledNode.id}`; - } else { - scopedId = `${parentDNode.scopedId}-${compiledNode.id}`; - } + scopedId = `${parentDNode.scopedId}-0-${compiledNode.id}`; } else { - /* Case: primary workflow nodes won't have parents */ - scopedId = compiledNode.id; + scopedId = `${parentDNode.scopedId}-${compiledNode.id}`; } - const type = - typeOverride == null - ? getNodeTypeFromCompiledNode(compiledNode) - : typeOverride; - - const nodeExecution = nodeExecutionsById[scopedId]; - const isParent = nodeExecution && isParentNode(nodeExecution); - - const output = { - id: compiledNode.id, - scopedId: scopedId, - value: nodeValue, - type: type, - name: getDisplayName(compiledNode), - nodes: [], - edges: [], - gateNode: compiledNode.gateNode, - isParentNode: isParent, - } as dNode; - - staticExecutionIdsMap[output.scopedId] = compiledNode; - return output; - }; + } else { + /* Case: primary workflow nodes won't have parents */ + scopedId = compiledNode.id; + } + const type = + typeOverride == null + ? getNodeTypeFromCompiledNode(compiledNode) + : typeOverride; + + const nodeExecution = nodeExecutionsById?.[scopedId]; + const isParent = nodeExecution && isParentNode(nodeExecution); + + const output = { + id: compiledNode.id, + scopedId: scopedId, + value: nodeValue, + type: type, + name: getDisplayName(compiledNode), + nodes: [], + edges: [], + gateNode: compiledNode.gateNode, + isParentNode: isParent, + ...(nodeExecution ? { execution: nodeExecution } : {}), + } as dNode; + + staticExecutionIdsMap[output.scopedId] = compiledNode; + return output; +}; - const buildBranchStartEndNodes = (root: dNode) => { - const startNode = createDNode({ - compiledNode: { - id: `${root.id}-${startNodeId}`, - metadata: { - name: DISPLAY_NAME_START, - }, - } as CompiledNode, - typeOverride: dTypes.nestedStart, - }); +const buildBranchStartEndNodes = ( + root: dNode, + nodeExecutionsById: NodeExecutionsById = {}, + staticExecutionIdsMap: any = {}, +) => { + const startNode = createDNode({ + compiledNode: { + id: `${root.id}-${startNodeId}`, + metadata: { + name: DISPLAY_NAME_START, + }, + } as CompiledNode, + typeOverride: dTypes.nestedStart, + nodeExecutionsById, + staticExecutionIdsMap, + }); - const endNode = createDNode({ - compiledNode: { - id: `${root.id}-${endNodeId}`, - metadata: { - name: DISPLAY_NAME_END, - }, - } as CompiledNode, - typeOverride: dTypes.nestedEnd, - }); + const endNode = createDNode({ + compiledNode: { + id: `${root.id}-${endNodeId}`, + metadata: { + name: DISPLAY_NAME_END, + }, + } as CompiledNode, + typeOverride: dTypes.nestedEnd, + nodeExecutionsById, + staticExecutionIdsMap, + }); - return { - startNode, - endNode, - }; + return { + startNode, + endNode, }; +}; - const buildWorkflowEdges = ( - root, - context: ConnectionSet, - ingress, - nodeMap, - ) => { - const list = context.downstream[ingress].ids; - - for (let i = 0; i < list.length; i++) { - const source = nodeMap[ingress]?.dNode.scopedId; - const target = nodeMap[list[i]]?.dNode.scopedId; - if (source && target) { - const edge: dEdge = createDEdge({ - sourceId: source, - targetId: target, - }); - root.edges.push(edge); - if (context.downstream[list[i]]) { - buildWorkflowEdges(root, context, list[i], nodeMap); - } +interface CreateDEdgeProps { + sourceId: string; + targetId: string; +} +const createDEdge = ({ sourceId, targetId }: CreateDEdgeProps): dEdge => { + const id = `${sourceId}->${targetId}`; + const edge: dEdge = { + sourceId: sourceId, + targetId: targetId, + id: id, + }; + return edge; +}; + +const buildWorkflowEdges = (root, context: ConnectionSet, ingress, nodeMap) => { + const downstreamIds = context.downstream[ingress].ids; + + for (let i = 0; i < downstreamIds.length; i++) { + const source = nodeMap[ingress]?.dNode.scopedId; + const target = nodeMap[downstreamIds[i]]?.dNode.scopedId; + if (source && target) { + const edge: dEdge = createDEdge({ + sourceId: source, + targetId: target, + }); + root.edges.push(edge); + if (context.downstream[downstreamIds[i]]) { + buildWorkflowEdges(root, context, downstreamIds[i], nodeMap); } } - }; + } +}; +/** + * Handles parsing CompiledNode + * + * @param node CompiledNode to parse + * @param root Root node for the graph that will be rendered + * @param workflow Main/root workflow + */ +interface ParseNodeProps { + node: CompiledNode; + root?: dNode; + nodeExecutionsById: NodeExecutionsById; + staticExecutionIdsMap: any; + dynamicToMerge: any | null; + workflow: any; +} +const parseNode = ({ + node, + root, + nodeExecutionsById, + staticExecutionIdsMap, + dynamicToMerge, + workflow, +}: ParseNodeProps) => { + let dNode; /** - * Handles parsing CompiledNode - * - * @param node CompiledNode to parse - * @param root Root node for the graph that will be rendered - * @param workflow Main/root workflow + * Note: if node is dynamic we must add dynamicWorkflow + * as a subworkflow on the root workflow. We also need to check + * if the dynamic workflow has any subworkflows and add them too. */ - interface ParseNodeProps { - node: CompiledNode; - root?: dNode; - } - const parseNode = ({ node, root }: ParseNodeProps) => { - let dNode; - /** - * Note: if node is dynamic we must add dynamicWorkflow - * as a subworkflow on the root workflow. We also need to check - * if the dynamic workflow has any subworkflows and add them too. - */ - if (dynamicToMerge) { - const scopedId = `${root?.scopedId}-0-${node.id}`; - const id = dynamicToMerge[scopedId] != null ? scopedId : node.id; - if (dynamicToMerge[id]) { - const dynamicWorkflow = dynamicToMerge[id].dynamicWorkflow; - - if (dynamicWorkflow) { - const dWorkflowId = - dynamicWorkflow.metadata?.specNodeId || dynamicWorkflow.id; - const dPrimaryWorkflow = dynamicWorkflow.compiledWorkflow.primary; - - node['workflowNode'] = { - subWorkflowRef: dWorkflowId, - }; - - /* 1. Add primary workflow as subworkflow on root */ - if (getSubWorkflowFromId(dWorkflowId, workflow) === false) { - workflow.subWorkflows?.push(dPrimaryWorkflow); - } + if (dynamicToMerge) { + const scopedId = `${root?.scopedId}-0-${node.id}`; + const id = dynamicToMerge[scopedId] != null ? scopedId : node.id; + if (dynamicToMerge[id]) { + const dynamicWorkflow = dynamicToMerge[id].dynamicWorkflow; + + if (dynamicWorkflow) { + const dWorkflowId = + dynamicWorkflow.metadata?.specNodeId || dynamicWorkflow.id; + const dPrimaryWorkflow = dynamicWorkflow.compiledWorkflow.primary; + + node['workflowNode'] = { + subWorkflowRef: dWorkflowId, + }; + + /* 1. Add primary workflow as subworkflow on root */ + if (getSubWorkflowFromId(dWorkflowId, workflow) === false) { + workflow.subWorkflows?.push(dPrimaryWorkflow); + } - /* 2. Add subworkflows as subworkflows on root */ - const dSubWorkflows = dynamicWorkflow.compiledWorkflow.subWorkflows; + /* 2. Add subworkflows as subworkflows on root */ + const dSubWorkflows = dynamicWorkflow.compiledWorkflow.subWorkflows; - for (let i = 0; i < dSubWorkflows.length; i++) { - const subworkflow = dSubWorkflows[i]; - const subId = subworkflow.template.id; - if (getSubWorkflowFromId(subId, workflow) === false) { - workflow.subWorkflows?.push(subworkflow); - } + for (let i = 0; i < dSubWorkflows.length; i++) { + const subworkflow = dSubWorkflows[i]; + const subId = subworkflow.template.id; + if (getSubWorkflowFromId(subId, workflow) === false) { + workflow.subWorkflows?.push(subworkflow); } } - /* Remove entry when done to prevent infinite loop */ - delete dynamicToMerge[node.id]; } + /* Remove entry when done to prevent infinite loop */ + delete dynamicToMerge[node.id]; } + } - if (node.branchNode) { + if (node.branchNode) { + dNode = createDNode({ + compiledNode: node, + parentDNode: root, + nodeExecutionsById, + staticExecutionIdsMap, + }); + buildDAG( + dNode, + node, + dTypes.branch, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + ); + } else if (node.workflowNode) { + if (node.workflowNode.launchplanRef) { dNode = createDNode({ compiledNode: node, parentDNode: root, - }); - buildDAG(dNode, node, dTypes.branch); - } else if (node.workflowNode) { - if (node.workflowNode.launchplanRef) { - dNode = createDNode({ - compiledNode: node, - parentDNode: root, - }); - } else { - const id = node.workflowNode.subWorkflowRef; - const subworkflow = getSubWorkflowFromId(id, workflow); - dNode = createDNode({ - compiledNode: node, - parentDNode: root, - }); - buildDAG(dNode, subworkflow, dTypes.subworkflow); - } - } else if (node.taskNode) { - const taskNode = node.taskNode as TaskNode; - const taskType: CompiledTask = getTaskTypeFromCompiledNode( - taskNode, - workflow.tasks, - ) as CompiledTask; - dNode = createDNode({ - compiledNode: node as CompiledNode, - parentDNode: root, - taskTemplate: taskType, + nodeExecutionsById, + staticExecutionIdsMap, }); } else { + const id = node.workflowNode.subWorkflowRef; + const subworkflow = getSubWorkflowFromId(id, workflow); dNode = createDNode({ compiledNode: node, parentDNode: root, + nodeExecutionsById, + staticExecutionIdsMap, }); + buildDAG( + dNode, + subworkflow, + dTypes.subworkflow, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + ); } - root?.nodes.push(dNode); - }; - - /** - * Handles parsing branch from CompiledNode - * - * @param root Root node for the branch that will be rendered - * @param context Current branch node being parsed - */ - interface ParseBranchProps { - root: dNode; - context: CompiledNode; + } else if (node.taskNode) { + const taskNode = node.taskNode as TaskNode; + const taskType: CompiledTask = getTaskTypeFromCompiledNode( + taskNode, + workflow.tasks, + ) as CompiledTask; + dNode = createDNode({ + compiledNode: node as CompiledNode, + parentDNode: root, + taskTemplate: taskType, + nodeExecutionsById, + staticExecutionIdsMap, + }); + } else { + dNode = createDNode({ + compiledNode: node, + parentDNode: root, + nodeExecutionsById, + staticExecutionIdsMap, + }); } - const parseBranch = ({ root, context }: ParseBranchProps) => { - const otherNode = context.branchNode?.ifElse?.other; - const thenNode = context.branchNode?.ifElse?.case?.thenNode as CompiledNode; - const elseNode = context.branchNode?.ifElse?.elseNode as CompiledNode; - - /* Check: then (if) case */ - if (thenNode) { - parseNode({ node: thenNode, root: root }); - } - - /* Check: else case */ - if (elseNode) { - parseNode({ node: elseNode, root: root }); - } + root?.nodes.push(dNode); +}; - /* Check: other (else-if) case */ - if (otherNode) { - otherNode.map(otherItem => { - const otherCompiledNode: CompiledNode = - otherItem.thenNode as CompiledNode; - parseNode({ - node: otherCompiledNode, - root: root, - }); +/** + * Recursively renders DAG of given context. + * + * @param root Root node of DAG (note: will mutate root) + * @param graphType DAG type (eg, branch, workflow) + * @param context Pointer to current context of response + */ +const buildDAG = ( + root: dNode, + context: any, + graphType: dTypes, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, +) => { + switch (graphType) { + case dTypes.branch: + parseBranch({ + root, + context, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, }); + break; + case dTypes.subworkflow: + parseWorkflow( + root, + context, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + ); + break; + case dTypes.primary: + return parseWorkflow( + root, + context, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + ); + default: { + throw new Error('unhandled case'); } + } +}; - /* Add edges and add start/end nodes */ - const { startNode, endNode } = buildBranchStartEndNodes(root); - for (let i = 0; i < root.nodes.length; i++) { - const startEdge: dEdge = createDEdge({ - sourceId: startNode.id, - targetId: root.nodes[i].scopedId, - }); - const endEdge: dEdge = createDEdge({ - sourceId: root.nodes[i].scopedId, - targetId: endNode.id, - }); - root.edges.push(startEdge); - root.edges.push(endEdge); - } - root.nodes.push(startNode); - root.nodes.push(endNode); - }; +/** + * Handles parsing branch from CompiledNode + * + * @param root Root node for the branch that will be rendered + * @param context Current branch node being parsed + */ +interface ParseBranchProps { + root: dNode; + context: CompiledNode; + nodeExecutionsById: NodeExecutionsById; + staticExecutionIdsMap: any; + dynamicToMerge: any | null; + workflow: any; +} +const parseBranch = ({ + root, + context, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, +}: ParseBranchProps) => { + const otherNode = context.branchNode?.ifElse?.other; + const thenNode = context.branchNode?.ifElse?.case?.thenNode as CompiledNode; + const elseNode = context.branchNode?.ifElse?.elseNode as CompiledNode; + + /* Check: then (if) case */ + if (thenNode) { + parseNode({ + node: thenNode, + root: root, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + }); + } - /** - * Handles parsing CompiledWorkflow - * - * @param root Root node for the graph that will be rendered - * @param context The current workflow being parsed - */ - const parseWorkflow = (root, context: CompiledWorkflow) => { - if (!context?.template?.nodes) { - return root; - } + /* Check: else case */ + if (elseNode) { + parseNode({ + node: elseNode, + root: root, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + }); + } - /* Build Nodes from template */ - for (let i = 0; i < context.template.nodes.length; i++) { - const compiledNode: CompiledNode = context.template.nodes[i]; + /* Check: other (else-if) case */ + if (otherNode) { + otherNode.map(otherItem => { + const otherCompiledNode: CompiledNode = + otherItem.thenNode as CompiledNode; parseNode({ - node: compiledNode, + node: otherCompiledNode, root: root, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, }); - } - - const nodesList = context.template.nodes; - const nodeMap = {}; + }); + } - /* Create mapping of CompiledNode.id => dNode.id to build edges */ - for (let i = 0; i < root.nodes.length; i++) { - const dNode = root.nodes[i]; - nodeMap[dNode.id] = { - dNode: dNode, - compiledNode: nodesList[i], - }; - } + /* Add edges and add start/end nodes */ + const { startNode, endNode } = buildBranchStartEndNodes( + root, + nodeExecutionsById, + staticExecutionIdsMap, + ); + for (let i = 0; i < root.nodes.length; i++) { + const startEdge: dEdge = createDEdge({ + sourceId: startNode.id, + targetId: root.nodes[i].scopedId, + }); + const endEdge: dEdge = createDEdge({ + sourceId: root.nodes[i].scopedId, + targetId: endNode.id, + }); + root.edges.push(startEdge); + root.edges.push(endEdge); + } + root.nodes.push(startNode); + root.nodes.push(endNode); +}; - /* Build Edges */ - buildWorkflowEdges(root, context.connections, startNodeId, nodeMap); +/** + * Handles parsing CompiledWorkflow + * + * @param root Root node for the graph that will be rendered + * @param context The current workflow being parsed + */ +const parseWorkflow = ( + root, + context: CompiledWorkflow, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, +) => { + if (!context?.template?.nodes) { return root; - }; + } + + const templateNodeList = context.template.nodes; + + /* Build Nodes from template */ + for (let i = 0; i < templateNodeList.length; i++) { + const compiledNode: CompiledNode = templateNodeList[i]; + parseNode({ + node: compiledNode, + root: root, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + }); + } + + const nodeMap = {}; + + /* Create mapping of CompiledNode.id => dNode.id to build edges */ + for (let i = 0; i < root.nodes.length; i++) { + const dNode = root.nodes[i]; + nodeMap[dNode.id] = { + dNode: dNode, + compiledNode: templateNodeList[i], + }; + } + + /* Build Edges */ + buildWorkflowEdges(root, context.connections, startNodeId, nodeMap); + return root; +}; + +/** + * Returns a DAG from Flyte workflow request data + * @param context input can be either CompiledWorkflow or CompiledNode + * @returns Display name + */ +export interface TransformerWorkflowToDag { + dag: dNode; + staticExecutionIdsMap: {}; + error?: Error; +} +export const transformerWorkflowToDag = ( + workflow: CompiledWorkflowClosure, + dynamicToMerge: any | null = null, + nodeExecutionsById = {}, +): TransformerWorkflowToDag => { + const { primary } = workflow; + const staticExecutionIdsMap = {}; - /** - * Recursively renders DAG of given context. - * - * @param root Root node of DAG (note: will mutate root) - * @param graphType DAG type (eg, branch, workflow) - * @param context Pointer to current context of response - */ - const buildDAG = (root: dNode, context: any, graphType: dTypes) => { - switch (graphType) { - case dTypes.branch: - parseBranch({ root, context }); - break; - case dTypes.subworkflow: - parseWorkflow(root, context); - break; - case dTypes.primary: - return parseWorkflow(root, context); - } - }; const primaryWorkflowRoot = createDNode({ compiledNode: { id: startNodeId, } as CompiledNode, + nodeExecutionsById, + staticExecutionIdsMap, }); - const dag: dNode = buildDAG(primaryWorkflowRoot, primary, dTypes.primary); - debug('output:', dag); - return { dag, staticExecutionIdsMap }; + let dag: dNode; + + try { + dag = buildDAG( + primaryWorkflowRoot, + primary, + dTypes.primary, + dynamicToMerge, + nodeExecutionsById, + staticExecutionIdsMap, + workflow, + ); + debug('output:', dag); + return { dag, staticExecutionIdsMap }; + } catch (error) { + dag = {} as any; + debug('output:', dag); + return { dag, staticExecutionIdsMap, error }; + } }; diff --git a/packages/console/src/components/WorkflowGraph/utils.ts b/packages/console/src/components/WorkflowGraph/utils.ts index b789731af..a21274cb3 100644 --- a/packages/console/src/components/WorkflowGraph/utils.ts +++ b/packages/console/src/components/WorkflowGraph/utils.ts @@ -5,6 +5,7 @@ import { CompiledNode, TaskNode } from 'models/Node/types'; import { CompiledTask, TaskTemplate } from 'models/Task/types'; import { dTypes, dNode } from 'models/Graph/types'; import _ from 'lodash'; +import { isEndNode, isStartNode } from 'models/Node/utils'; import { transformerWorkflowToDag } from './transformerWorkflowToDag'; /** * TODO FC#393: these are dupes for testing, remove once tests fixed @@ -12,22 +13,6 @@ import { transformerWorkflowToDag } from './transformerWorkflowToDag'; export const DISPLAY_NAME_START = 'start'; export const DISPLAY_NAME_END = 'end'; -export const isStartOrEndNode = (node: any) => { - return node.id === startNodeId || node.id === endNodeId; -}; - -export function isStartNode(node: any) { - return node.id === startNodeId; -} - -export function isEndNode(node: any) { - return node.id === endNodeId; -} - -export function isExpanded(node: any) { - return !!node.expanded; -} - /** * Returns a display name from either workflows or nodes * @param context input can be either CompiledWorkflow or CompiledNode diff --git a/packages/console/src/components/common/DetailsPanel.tsx b/packages/console/src/components/common/DetailsPanel.tsx index 144da77e6..81d15e17f 100644 --- a/packages/console/src/components/common/DetailsPanel.tsx +++ b/packages/console/src/components/common/DetailsPanel.tsx @@ -49,6 +49,7 @@ export const DetailsPanel: React.FC = ({ }} onClose={onClose} open={open} + key="detailsPanel" >
diff --git a/packages/console/src/components/common/LoadingSpinner.tsx b/packages/console/src/components/common/LoadingSpinner.tsx index fc9f298df..7abc91df8 100644 --- a/packages/console/src/components/common/LoadingSpinner.tsx +++ b/packages/console/src/components/common/LoadingSpinner.tsx @@ -55,3 +55,11 @@ export const MediumLoadingSpinner: React.FC = () => ( export const LargeLoadingSpinner: React.FC = () => ( ); + +export const LargeLoadingComponent = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx b/packages/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx index c68acee91..7b378a20a 100644 --- a/packages/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx +++ b/packages/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx @@ -6,6 +6,7 @@ import { getTaskLogName } from 'components/Executions/TaskExecutionsList/utils'; import { MapTaskExecution, TaskExecution } from 'models/Execution/types'; import { noLogsFoundString } from 'components/Executions/constants'; import { CacheStatus } from 'components/Executions/CacheStatus'; +import classnames from 'classnames'; import { useCommonStyles } from '../styles'; const useStyles = makeStyles((_theme: Theme) => ({ @@ -27,12 +28,14 @@ interface TaskNameListProps { taskExecution: TaskExecution; logs: Core.ITaskLog[]; onTaskSelected: (val: MapTaskExecution) => void; + className?: string; } export const TaskNameList = ({ taskExecution, logs, onTaskSelected, + className, }: TaskNameListProps) => { const commonStyles = useCommonStyles(); const styles = useStyles(); @@ -76,12 +79,19 @@ export const TaskNameList = ({ variant="body1" color={log.uri ? 'primary' : 'textPrimary'} onClick={log.uri ? handleClick : undefined} - className={log.uri ? styles.taskTitleLink : styles.taskTitle} + className={classnames( + log.uri ? styles.taskTitleLink : styles.taskTitle, + className, + )} data-testid="map-task-log" > {taskLogName} - +
); })} diff --git a/packages/console/src/components/common/utils.ts b/packages/console/src/components/common/utils.ts index e4ab7f3e7..46bd4828b 100644 --- a/packages/console/src/components/common/utils.ts +++ b/packages/console/src/components/common/utils.ts @@ -30,7 +30,7 @@ export const checkForDynamicExecutions = (allExecutions, staticExecutions) => { const executionsByNodeId = {}; for (const executionId in allExecutions) { const execution = allExecutions[executionId]; - executionsByNodeId[execution?.id.nodeId] = execution; + executionsByNodeId[execution?.id?.nodeId] = execution; if (!staticExecutions[executionId]) { if (execution) { const dynamicExecutionId = diff --git a/packages/console/src/components/data/types.ts b/packages/console/src/components/data/types.ts index b402ab734..1cb3d3aec 100644 --- a/packages/console/src/components/data/types.ts +++ b/packages/console/src/components/data/types.ts @@ -8,6 +8,7 @@ export enum QueryType { DynamicWorkflowFromNodeExecution = 'DynamicWorkflowFromNodeExecution', NodeExecution = 'nodeExecution', NodeExecutionList = 'nodeExecutionList', + NodeExecutionAndChildList = 'nodeExecutionAndChildList', NodeExecutionChildList = 'nodeExecutionChildList', NodeExecutionTreeList = 'nodeExecutionTreeList', TaskExecution = 'taskExecution', diff --git a/packages/console/src/components/flytegraph/ReactFlow/BreadCrumb.tsx b/packages/console/src/components/flytegraph/ReactFlow/BreadCrumb.tsx new file mode 100644 index 000000000..3b6a64e31 --- /dev/null +++ b/packages/console/src/components/flytegraph/ReactFlow/BreadCrumb.tsx @@ -0,0 +1,163 @@ +import React, { PropsWithChildren } from 'react'; +import { + NodeExecutionDynamicProvider, + useNodeExecutionDynamicContext, +} from 'components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider'; +import { COLOR_SPECTRUM } from 'components/Theme/colorSpectrum'; +import { dNode } from 'models/Graph/types'; +import { NodeExecutionPhase } from 'models'; +import { findNodeInDag, getNestedContainerStyle } from './utils'; +import { RFCustomData } from './types'; + +const BREAD_FONT_SIZE = '9px'; +const BREAD_COLOR_ACTIVE = COLOR_SPECTRUM.purple60.color; +const BREAD_COLOR_INACTIVE = COLOR_SPECTRUM.black.color; + +export const BreadElement = ({ + nestedView, + index, + currentNestedDepth, + scopedId, + onClick, +}) => { + const liStyles: React.CSSProperties = { + cursor: 'pointer', + fontSize: BREAD_FONT_SIZE, + color: BREAD_COLOR_ACTIVE, + }; + + const liStyleInactive: React.CSSProperties = { ...liStyles }; + liStyleInactive['color'] = BREAD_COLOR_INACTIVE; + + const beforeStyle: React.CSSProperties = { + cursor: 'pointer', + color: BREAD_COLOR_ACTIVE, + padding: '0 .2rem', + fontSize: BREAD_FONT_SIZE, + }; + + return ( +
  • + {index === 0 ? {'>'} : null} + {nestedView} + {index < currentNestedDepth - 1 ? ( + {'>'} + ) : null} +
  • + ); +}; + +const BorderElement = ({ + node, + initialNodeExecutionStatus, + children, +}: PropsWithChildren<{ + node: dNode; + initialNodeExecutionStatus: NodeExecutionPhase; +}>) => { + const { componentProps } = useNodeExecutionDynamicContext(); + + const nodeExecutionStatus = + node?.execution?.closure.phase || initialNodeExecutionStatus; + const borderStyle = getNestedContainerStyle(nodeExecutionStatus); + + return ( +
    + {children} +
    + ); +}; + +export const BorderContainer = ({ + data, + children, +}: PropsWithChildren<{ + data: RFCustomData; +}>) => { + const { node, currentNestedView, nodeExecutionStatus } = data; + + let contextNode = node; + let borders = ( + + {children} + + ); + for (const view of currentNestedView || []) { + contextNode = findNodeInDag(view, contextNode); + + borders = contextNode ? ( + + + {borders} + + + ) : ( + + {borders} + + ); + } + return borders; +}; + +const breadContainerStyle: React.CSSProperties = { + position: 'absolute', + display: 'flex', + width: '100%', + marginTop: '-1rem', +}; +const olStyles: React.CSSProperties = { + margin: 0, + padding: 0, + display: 'flex', + listStyle: 'none', + listStyleImage: 'none', + minWidth: '1rem', +}; +const headerStyle: React.CSSProperties = { + color: BREAD_COLOR_ACTIVE, + fontSize: BREAD_FONT_SIZE, + margin: 0, + padding: 0, +}; + +export const BreadCrumbContainer = ({ + text, + currentNestedDepth, + handleRootClick, + children, +}: PropsWithChildren<{ + text: string; + currentNestedDepth: number; + handleRootClick: () => void; +}>) => { + const rootClick = currentNestedDepth > 0 ? handleRootClick : undefined; + return ( +
    +
    { + e.stopPropagation(); + rootClick?.(); + }} + > + {text} +
    +
      {children}
    +
    + ); +}; diff --git a/packages/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx b/packages/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx index 60b8258b6..9a4c64a26 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx +++ b/packages/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx @@ -1,16 +1,18 @@ import * as React from 'react'; -import { useState, useContext } from 'react'; +import { useState } from 'react'; import { Badge, Button, withStyles } from '@material-ui/core'; import { TaskNames } from 'components/Executions/ExecutionDetails/Timeline/TaskNames'; import { dNode } from 'models/Graph/types'; -import { isExpanded } from 'components/WorkflowGraph/utils'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { COLOR_SPECTRUM } from 'components/Theme/colorSpectrum'; import { nodeExecutionPhaseConstants } from 'components/Executions/constants'; import { LaunchFormDialog } from 'components/Launch/LaunchForm/LaunchFormDialog'; -import { useNodeExecutionContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { + useNodeExecutionContext, + useNodeExecutionsById, +} from 'components/Executions/contextProvider/NodeExecutionDetails'; import { extractCompiledNodes } from 'components/hooks/utils'; +import { isExpanded } from 'models/Node/utils'; import { graphButtonContainer, graphButtonStyle, @@ -36,7 +38,7 @@ export const PausedTasksComponent: React.FC = ({ pausedNodes, initialIsVisible = false, }) => { - const { nodeExecutionsById } = useContext(NodeExecutionsByIdContext); + const { nodeExecutionsById } = useNodeExecutionsById(); const { compiledWorkflowClosure } = useNodeExecutionContext(); const [isVisible, setIsVisible] = useState(initialIsVisible); const [showResumeForm, setShowResumeForm] = useState(false); diff --git a/packages/console/src/components/flytegraph/ReactFlow/ReactFlowBreadCrumbProvider.tsx b/packages/console/src/components/flytegraph/ReactFlow/ReactFlowBreadCrumbProvider.tsx new file mode 100644 index 000000000..26e7a6633 --- /dev/null +++ b/packages/console/src/components/flytegraph/ReactFlow/ReactFlowBreadCrumbProvider.tsx @@ -0,0 +1,80 @@ +import React, { + createContext, + PropsWithChildren, + useContext, + Ref, + useState, +} from 'react'; + +export type RefType = Ref; +export interface IReactFlowBreadCrumbContext { + currentNestedDepth: number; + currentNestedView: BreadCrumbViews; + setCurrentNestedView: (newLevels: BreadCrumbViews) => void; + onAddNestedView: (view: any, sourceNode?: any) => Promise; + onRemoveNestedView: (viewParent: any, viewIndex: any) => void; +} + +export const ReactFlowBreadCrumbContext = + createContext({ + currentNestedDepth: 0, + currentNestedView: {}, + setCurrentNestedView: () => {}, + onAddNestedView: () => { + throw new Error('please use NodeExecutionDynamicProvider'); + }, + onRemoveNestedView: () => { + throw new Error('please use NodeExecutionDynamicProvider'); + }, + }); + +export interface BreadCrumbViews { + [key: string]: string[]; +} +/** Should wrap "top level" component in Execution view, will build a nodeExecutions tree for specific workflow */ +export const ReactFlowBreadCrumbProvider = ({ + children, +}: PropsWithChildren<{}>) => { + const [currentNestedView, setCurrentNestedView] = useState( + {}, + ); + const currentNestedDepth = (currentNestedView?.length || 0) as any as number; + + const onAddNestedView = async view => { + const currentView = currentNestedView[view.parent] || []; + const newView = { + [view.parent]: [...currentView, view.view], + }; + setCurrentNestedView(newView); + }; + + const onRemoveNestedView = (viewParent, viewIndex) => { + const newcurrentNestedView: any = { ...currentNestedView }; + newcurrentNestedView[viewParent] = newcurrentNestedView[viewParent]?.filter( + (_item, i) => i <= viewIndex, + ); + if (newcurrentNestedView[viewParent]?.length < 1) { + delete newcurrentNestedView[viewParent]; + } + setCurrentNestedView(newcurrentNestedView); + }; + + return ( + + {children} + + ); +}; + +export const useReactFlowBreadCrumbContext = + (): IReactFlowBreadCrumbContext => { + return useContext(ReactFlowBreadCrumbContext); + }; diff --git a/packages/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx b/packages/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx index e54e9de98..ee2f64a7b 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx +++ b/packages/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx @@ -1,179 +1,73 @@ -import React, { useState, useEffect, useContext, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { ConvertFlyteDagToReactFlows } from 'components/flytegraph/ReactFlow/transformDAGToReactFlowV2'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; -import { useNodeExecutionContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { NodeExecutionPhase } from 'models/Execution/enums'; import { - fetchChildrenExecutions, - isNodeGateNode, -} from 'components/Executions/utils'; + useNodeExecutionContext, + useNodeExecutionsById, +} from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { NodeExecutionPhase } from 'models/Execution/enums'; +import { isNodeGateNode } from 'components/Executions/utils'; import { dNode } from 'models/Graph/types'; -import { useQueryClient } from 'react-query'; -import { fetchTaskExecutionList } from 'components/Executions/taskExecutionQueries'; -import { isMapTaskV1 } from 'models/Task/utils'; import { extractCompiledNodes } from 'components/hooks/utils'; -import { ExternalResource, LogsByPhase } from 'models/Execution/types'; -import { getGroupedLogs } from 'components/Executions/TaskExecutionsList/utils'; -import { LargeLoadingSpinner } from 'components/common/LoadingSpinner'; -import { keyBy, merge } from 'lodash'; -import { RFWrapperProps, RFGraphTypes, ConvertDagProps } from './types'; -import { getRFBackground, isUnFetchedDynamicNode } from './utils'; +import { useDetailsPanel } from 'components/Executions/ExecutionDetails/DetailsPanelContext'; +import { RFGraphTypes, ConvertDagProps } from './types'; +import { getRFBackground } from './utils'; import { ReactFlowWrapper } from './ReactFlowWrapper'; import { Legend } from './NodeStatusLegend'; import { PausedTasksComponent } from './PausedTasksComponent'; +import { useReactFlowBreadCrumbContext } from './ReactFlowBreadCrumbProvider'; + +const containerStyle: React.CSSProperties = { + display: 'flex', + flex: `1 1 100%`, + flexDirection: 'column', + minHeight: '100px', + minWidth: '200px', + height: '100%', +}; -export const ReactFlowGraphComponent = ({ - data, - onNodeSelectionChanged, - onPhaseSelectionChanged, - selectedPhase, - isDetailsTabClosed, - initialNodes, - shouldUpdate, - setShouldUpdate, -}) => { - const queryClient = useQueryClient(); - const { nodeExecutionsById, setCurrentNodeExecutionsById } = useContext( - NodeExecutionsByIdContext, - ); +export const ReactFlowGraphComponent = () => { + const { + selectedPhase, + isDetailsTabClosed, + onNodeSelectionChanged, + setSelectedPhase: onPhaseSelectionChanged, + } = useDetailsPanel(); + const { + nodeExecutionsById, + initialDNodes, + dagData: { mergedDag }, + } = useNodeExecutionsById(); const { compiledWorkflowClosure } = useNodeExecutionContext(); - const [loading, setLoading] = useState(true); const [pausedNodes, setPausedNodes] = useState([]); - const [currentNestedView, setcurrentNestedView] = useState({}); + const { currentNestedView } = useReactFlowBreadCrumbContext(); - const onAddNestedView = async (view, sourceNode: any = null) => { - if (sourceNode && isUnFetchedDynamicNode(sourceNode)) { - await fetchChildrenExecutions( - queryClient, - sourceNode.scopedId, - nodeExecutionsById, - setCurrentNodeExecutionsById, - setShouldUpdate, - ); - } + const [rfGraphJson, setrfGraphJson] = useState(); - const currentView = currentNestedView[view.parent] || []; - const newView = { - [view.parent]: [...currentView, view.view], - }; - setcurrentNestedView(newView); - }; - - const onRemoveNestedView = (viewParent, viewIndex) => { - const newcurrentNestedView: any = { ...currentNestedView }; - newcurrentNestedView[viewParent] = newcurrentNestedView[viewParent]?.filter( - (_item, i) => i <= viewIndex, - ); - if (newcurrentNestedView[viewParent]?.length < 1) { - delete newcurrentNestedView[viewParent]; - } - setcurrentNestedView(newcurrentNestedView); - }; - - const rfGraphJson = useMemo(() => { - return ConvertFlyteDagToReactFlows({ - root: data, - nodeExecutionsById, + useEffect(() => { + const newrfGraphJson = ConvertFlyteDagToReactFlows({ + root: mergedDag, onNodeSelectionChanged, onPhaseSelectionChanged, selectedPhase, - onAddNestedView, - onRemoveNestedView, - currentNestedView, maxRenderDepth: 1, + currentNestedView, } as ConvertDagProps); + setrfGraphJson(newrfGraphJson); }, [ - data, + initialDNodes, + mergedDag, isDetailsTabClosed, - shouldUpdate, - nodeExecutionsById, onNodeSelectionChanged, onPhaseSelectionChanged, selectedPhase, - onAddNestedView, - onRemoveNestedView, currentNestedView, ]); - useEffect(() => { - // fetch map tasks data for all available node executions to display graph nodes properly - let isCurrent = true; - - async function fetchData(baseNodeExecutions, queryClient) { - setLoading(true); - const nodeExecutionsWithResources = await Promise.all( - baseNodeExecutions.map(async baseNodeExecution => { - if ( - !baseNodeExecution || - nodeExecutionsById[baseNodeExecution.scopedId].tasksFetched - ) { - return; - } - const taskExecutions = await fetchTaskExecutionList( - queryClient, - baseNodeExecution.id, - ); - - const useNewMapTaskView = taskExecutions.every(taskExecution => { - const { - closure: { taskType, metadata, eventVersion = 0 }, - } = taskExecution; - return isMapTaskV1( - eventVersion, - metadata?.externalResources?.length ?? 0, - taskType ?? undefined, - ); - }); - const externalResources: ExternalResource[] = taskExecutions - .map( - taskExecution => - taskExecution.closure.metadata?.externalResources, - ) - .flat() - .filter((resource): resource is ExternalResource => !!resource); - - const logsByPhase: LogsByPhase = getGroupedLogs(externalResources); - - return { - ...baseNodeExecution, - ...(useNewMapTaskView && logsByPhase.size > 0 && { logsByPhase }), - tasksFetched: true, - }; - }), - ); - - if (isCurrent) { - const nodeExecutionsWithResourcesMap = keyBy( - nodeExecutionsWithResources, - 'scopedId', - ); - const newNodeExecutionsById = merge( - nodeExecutionsById, - nodeExecutionsWithResourcesMap, - ); - setCurrentNodeExecutionsById(newNodeExecutionsById); - setLoading(false); - } - } - - const nodeExecutions = Object.values(nodeExecutionsById); - if (nodeExecutions.length > 0) { - fetchData(nodeExecutions, queryClient); - } else { - if (isCurrent) { - setLoading(false); - } - } - return () => { - isCurrent = false; - }; - }, [initialNodes]); - const backgroundStyle = getRFBackground().nested; useEffect(() => { - const updatedPausedNodes: dNode[] = initialNodes.filter(node => { + const updatedPausedNodes: dNode[] = initialDNodes.filter(node => { const nodeExecution = nodeExecutionsById[node.id]; if (nodeExecution) { const phase = nodeExecution?.closure.phase; @@ -194,44 +88,22 @@ export const ReactFlowGraphComponent = ({ }; }); setPausedNodes(nodesWithExecutions); - }, [initialNodes]); - - if (loading) { - return ( -
    - -
    - ); - } - - const containerStyle: React.CSSProperties = { - display: 'flex', - flex: `1 1 100%`, - flexDirection: 'column', - minHeight: '100px', - minWidth: '200px', - height: '100%', - }; - - const renderGraph = () => { - const ReactFlowProps: RFWrapperProps = { - backgroundStyle, - rfGraphJson, - type: RFGraphTypes.main, - nodeExecutionsById, - currentNestedView: currentNestedView, - setShouldUpdate, - }; - return ( -
    - {pausedNodes && pausedNodes.length > 0 && ( - - )} - - -
    - ); - }; - - return rfGraphJson ? renderGraph() : <>; + }, [initialDNodes]); + + return rfGraphJson ? ( +
    + {pausedNodes && pausedNodes.length > 0 && ( + + )} + + +
    + ) : ( + <> + ); }; diff --git a/packages/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx b/packages/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx index b304c1b35..97fe0461a 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx +++ b/packages/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx @@ -1,8 +1,6 @@ -import React, { useState, useEffect, useCallback, useContext } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import ReactFlow, { Background } from 'react-flow-renderer'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; -import { useQueryClient } from 'react-query'; -import { fetchChildrenExecutions } from 'components/Executions/utils'; +import { stringifyIsEqual } from 'components/Executions/contextProvider/NodeExecutionDetails/utils'; import { getPositionedNodes, ReactFlowIdHash } from './utils'; import { ReactFlowCustomEndNode, @@ -26,13 +24,21 @@ const CustomNodeTypes = { FlyteNode_start: ReactFlowCustomStartNode, FlyteNode_end: ReactFlowCustomEndNode, FlyteNode_nestedStart: ReactFlowCustomNestedPoint, + FlyteNode_nestedEnd: ReactFlowCustomNestedPoint, FlyteNode_nestedMaxDepth: ReactFlowCustomMaxNested, + FlyteNode_staticNode: ReactFlowStaticNode, FlyteNode_staticNestedNode: ReactFlowStaticNested, FlyteNode_gateNode: ReactFlowGateNode, }; +const reactFlowStyle: React.CSSProperties = { + display: 'flex', + flex: `1 1 100%`, + flexDirection: 'column', +}; + export const ReactFlowWrapper: React.FC = ({ rfGraphJson, backgroundStyle, @@ -48,17 +54,31 @@ export const ReactFlowWrapper: React.FC = ({ needFitView: false, }); + const setStateDeduped = (newState: typeof state) => { + setState(prevState => { + if (stringifyIsEqual(prevState, newState)) { + return prevState; + } + return newState; + }); + }; useEffect(() => { - setState(state => ({ + if (!rfGraphJson) { + return; + } + setStateDeduped({ ...state, shouldUpdate: true, nodes: rfGraphJson?.nodes, - edges: rfGraphJson?.edges?.map(edge => ({ ...edge, zIndex: 0 })), - })); + edges: rfGraphJson?.edges?.map(edge => ({ + ...edge, + zIndex: 0, + })), + }); }, [rfGraphJson]); const onLoad = (rf: any) => { - setState({ ...state, needFitView: true, reactFlowInstance: rf }); + setStateDeduped({ ...state, needFitView: true, reactFlowInstance: rf }); }; const onNodesChange = useCallback( @@ -82,12 +102,12 @@ export const ReactFlowWrapper: React.FC = ({ state.edges, ); - setState(state => ({ + setStateDeduped({ ...state, shouldUpdate: false, nodes: hashGraph, edges: hashEdges, - })); + }); } if ( changes.length === state.nodes.length && @@ -100,14 +120,8 @@ export const ReactFlowWrapper: React.FC = ({ [state.shouldUpdate, state.reactFlowInstance, state.needFitView], ); - const reactFlowStyle: React.CSSProperties = { - display: 'flex', - flex: `1 1 100%`, - flexDirection: 'column', - }; - const onNodeClick = async _event => { - setState(state => ({ ...state, needFitView: false })); + setStateDeduped({ ...state, needFitView: false }); }; return ( diff --git a/packages/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx b/packages/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx index 8154aaea5..7e0b38be3 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx +++ b/packages/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx @@ -1,27 +1,34 @@ -import React, { useState, useEffect, useContext } from 'react'; -import { Handle, Position } from 'react-flow-renderer'; +import React, { useState, useEffect, useMemo } from 'react'; +import { Handle, Position, ReactFlowProps } from 'react-flow-renderer'; import { dTypes } from 'models/Graph/types'; import { NodeExecutionPhase, TaskExecutionPhase } from 'models/Execution/enums'; import { RENDER_ORDER } from 'components/Executions/TaskExecutionsList/constants'; import { whiteColor } from 'components/Theme/constants'; import { PlayCircleOutline } from '@material-ui/icons'; import { Tooltip } from '@material-ui/core'; -import { COLOR_SPECTRUM } from 'components/Theme/colorSpectrum'; import { getNodeFrontendPhase } from 'components/Executions/utils'; import { CacheStatus } from 'components/Executions/CacheStatus'; import { LaunchFormDialog } from 'components/Launch/LaunchForm/LaunchFormDialog'; import { useNodeExecutionContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; import { extractCompiledNodes } from 'components/hooks/utils'; +import { + NodeExecutionDynamicProvider, + useNodeExecutionDynamicContext, +} from 'components/Executions/contextProvider/NodeExecutionDetails/NodeExecutionDynamicProvider'; import { COLOR_GRAPH_BACKGROUND, getGraphHandleStyle, getGraphNodeStyle, - getNestedContainerStyle, getStatusColor, } from './utils'; import { RFHandleProps, RFNode } from './types'; import t from './strings'; +import { + BorderContainer, + BreadCrumbContainer, + BreadElement, +} from './BreadCrumb'; +import { useReactFlowBreadCrumbContext } from './ReactFlowBreadCrumbProvider'; const taskContainerStyle: React.CSSProperties = { position: 'absolute', @@ -54,9 +61,13 @@ const renderBasicNode = ( scopedId: string, styles: React.CSSProperties, onClick?: () => void, + componentProps?: React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement + >, ) => { return ( -
    +
    {renderTaskType(taskType)}
    {text}
    {renderDefaultHandles( @@ -142,25 +153,58 @@ export const ReactFlowCustomNestedPoint = ({ data }: RFNode) => { * denoted by solid color. * @param props.data data property of ReactFlowGraphNodeData */ - -export const ReactFlowCustomMaxNested = ({ data }: RFNode) => { - const { text, taskType, scopedId, onAddNestedView } = data; +export const ReactFlowCustomMaxNested = (props: ReactFlowNodeProps) => { + return ( + + + + ); +}; +const ReactFlowCustomMaxNestedInner = ({ data }: RFNode) => { + const { text, taskType, scopedId, isParentNode, parentScopedId, node } = data; const styles = getGraphNodeStyle(dTypes.nestedMaxDepth); + const { onAddNestedView } = useReactFlowBreadCrumbContext(); + const { componentProps } = useNodeExecutionDynamicContext(); - const onClick = () => { - onAddNestedView(); - }; - - return renderBasicNode(taskType, text, scopedId, styles, onClick); + return renderBasicNode( + taskType, + text, + scopedId, + styles, + () => { + onAddNestedView( + { + parent: isParentNode ? parentScopedId : scopedId, + view: scopedId, + }, + node, + ); + }, + componentProps, + ); }; -export const ReactFlowStaticNested = ({ data }: RFNode) => { +export const ReactFlowStaticNested = (props: ReactFlowNodeProps) => { + return ( + + + + ); +}; +const ReactFlowStaticNestedInner = ({ data }: RFNode) => { const { text, taskType, scopedId } = data; const styles = getGraphNodeStyle(dTypes.staticNestedNode); return renderBasicNode(taskType, text, scopedId, styles); }; -export const ReactFlowStaticNode = ({ data }: RFNode) => { +export const ReactFlowStaticNode = (props: ReactFlowNodeProps) => { + return ( + + + + ); +}; +const ReactFlowStaticNodeInner = ({ data }: RFNode) => { const { text, taskType, scopedId } = data; const styles = getGraphNodeStyle(dTypes.staticNode); return renderBasicNode(taskType, text, scopedId, styles); @@ -220,25 +264,36 @@ const TaskPhaseItem = ({ * and any edge handles. * @param props.data data property of ReactFlowGraphNodeData */ - -export const ReactFlowGateNode = ({ data }: RFNode) => { +export const ReactFlowGateNode = (props: ReactFlowNodeProps) => { + return ( + + + + ); +}; +const ReactFlowGateNodeInner = ({ data }: RFNode) => { const { compiledWorkflowClosure } = useNodeExecutionContext(); - const { nodeExecutionsById } = useContext(NodeExecutionsByIdContext); const { + node, nodeType, nodeExecutionStatus, text, scopedId, onNodeSelectionChanged, } = data; - const phase = getNodeFrontendPhase(nodeExecutionStatus, true); + const { componentProps } = useNodeExecutionDynamicContext(); + const nodeExecution = node.execution; + const phase = getNodeFrontendPhase( + nodeExecution?.closure?.phase || nodeExecutionStatus, + true, + ); const styles = getGraphNodeStyle(nodeType, phase); const [showResumeForm, setShowResumeForm] = useState(false); const compiledNode = extractCompiledNodes(compiledWorkflowClosure).find( node => - node.id === nodeExecutionsById[scopedId]?.metadata?.specNodeId || - node.id === nodeExecutionsById[scopedId]?.id?.nodeId, + node.id === nodeExecution?.metadata?.specNodeId || + node.id === nodeExecution?.id?.nodeId, ); const iconStyles: React.CSSProperties = { @@ -250,7 +305,8 @@ export const ReactFlowGateNode = ({ data }: RFNode) => { cursor: 'pointer', }; - const handleNodeClick = () => { + const handleNodeClick = e => { + e.stopPropagation(); onNodeSelectionChanged(true); }; @@ -260,7 +316,7 @@ export const ReactFlowGateNode = ({ data }: RFNode) => { }; return ( -
    +
    {text} {phase === NodeExecutionPhase.PAUSED && ( @@ -278,7 +334,7 @@ export const ReactFlowGateNode = ({ data }: RFNode) => { @@ -292,11 +348,20 @@ export const ReactFlowGateNode = ({ data }: RFNode) => { * and any edge handles. * @param props.data data property of ReactFlowGraphNodeData */ - -export const ReactFlowCustomTaskNode = ({ data }: RFNode) => { +export type ReactFlowNodeProps = ReactFlowProps & RFNode; +export const ReactFlowCustomTaskNode = (props: ReactFlowNodeProps) => { + return ( + + + + ); +}; +const ReactFlowCustomTaskNodeInner = (props: ReactFlowNodeProps) => { + const { data } = props; const { + node, nodeType, - nodeExecutionStatus, + nodeExecutionStatus: initialNodeExecutionStatus, selectedPhase: initialPhase, taskType, text, @@ -306,25 +371,29 @@ export const ReactFlowCustomTaskNode = ({ data }: RFNode) => { onNodeSelectionChanged, onPhaseSelectionChanged, } = data; - const styles = getGraphNodeStyle(nodeType, nodeExecutionStatus); + const [selectedNode, setSelectedNode] = useState(false); const [selectedPhase, setSelectedPhase] = useState< TaskExecutionPhase | undefined >(initialPhase); + const { componentProps } = useNodeExecutionDynamicContext(); + const nodeExecution = node.execution; + const nodeExecutionStatus = + nodeExecution?.closure?.phase || initialNodeExecutionStatus; + const styles = getGraphNodeStyle(nodeType, nodeExecutionStatus); useEffect(() => { - if (selectedNode === true) { + // if the node execution isn't there + // (like in the case of a dynamic flow when the node execution isn't available yet) + // bringing up the context pane will result in an infinite loop + // checking if the node execution is present prevents that from happening + if (selectedNode === true && nodeExecution) { onNodeSelectionChanged(selectedNode); setSelectedNode(false); onPhaseSelectionChanged(selectedPhase); setSelectedPhase(selectedPhase); } - }, [ - selectedNode, - onNodeSelectionChanged, - selectedPhase, - onPhaseSelectionChanged, - ]); + }, [selectedNode, selectedPhase]); const mapTaskContainerStyle: React.CSSProperties = { position: 'absolute', @@ -351,7 +420,9 @@ export const ReactFlowCustomTaskNode = ({ data }: RFNode) => { display: 'flex', }; - const handleNodeClick = _e => { + const handleNodeClick = e => { + e.stopPropagation(); + if (nodeExecutionStatus === NodeExecutionPhase.SKIPPED) { return; } @@ -397,7 +468,7 @@ export const ReactFlowCustomTaskNode = ({ data }: RFNode) => { }; return ( -
    +
    {nodeLogsByPhase ? renderTaskName() : renderTaskType(taskType)}
    {nodeLogsByPhase ? renderTaskPhases(nodeLogsByPhase) : text} @@ -421,125 +492,50 @@ export const ReactFlowCustomTaskNode = ({ data }: RFNode) => { * and any edge handles. * @param props.data data property of ReactFlowGraphNodeData */ -export const ReactFlowSubWorkflowContainer = ({ data }: RFNode) => { - const { - nodeExecutionStatus, - text, - scopedId, - currentNestedView, - onRemoveNestedView, - } = data; - const BREAD_FONT_SIZE = '9px'; - const BREAD_COLOR_ACTIVE = COLOR_SPECTRUM.purple60.color; - const BREAD_COLOR_INACTIVE = COLOR_SPECTRUM.black.color; - const borderStyle = getNestedContainerStyle(nodeExecutionStatus); - - const handleNestedViewClick = e => { - const index = e.target.id.substr( - e.target.id.indexOf('_') + 1, - e.target.id.length, - ); - onRemoveNestedView(scopedId, index); - }; +export const ReactFlowSubWorkflowContainer = (props: ReactFlowNodeProps) => { + return ( + + + + ); +}; +export const ReactFlowSubWorkflowContainerInner = ({ data }: RFNode) => { + const { text, scopedId, currentNestedView } = data; + + const { onRemoveNestedView } = useReactFlowBreadCrumbContext(); const handleRootClick = () => { onRemoveNestedView(scopedId, -1); }; const currentNestedDepth = currentNestedView?.length || 0; - - const BreadElement = ({ nestedView, index }) => { - const liStyles: React.CSSProperties = { - cursor: 'pointer', - fontSize: BREAD_FONT_SIZE, - color: BREAD_COLOR_ACTIVE, - }; - - const liStyleInactive: React.CSSProperties = { ...liStyles }; - liStyleInactive['color'] = BREAD_COLOR_INACTIVE; - - const beforeStyle: React.CSSProperties = { - cursor: 'pointer', - color: BREAD_COLOR_ACTIVE, - padding: '0 .2rem', - fontSize: BREAD_FONT_SIZE, - }; - const onClick = - currentNestedDepth > index + 1 ? handleNestedViewClick : undefined; - return ( -
  • - {index === 0 ? {'>'} : null} - {nestedView} - {index < currentNestedDepth - 1 ? ( - {'>'} - ) : null} -
  • - ); - }; - - const BorderElement = props => { - return
    {props.children}
    ; - }; - - const BorderContainer = props => { - let output = BorderElement(props); - for (let i = 0; i < currentNestedDepth; i++) { - output = {output}; - } - return output; - }; - - const renderBreadCrumb = () => { - const breadContainerStyle: React.CSSProperties = { - position: 'absolute', - display: 'flex', - width: '100%', - marginTop: '-1rem', - }; - const olStyles: React.CSSProperties = { - margin: 0, - padding: 0, - display: 'flex', - listStyle: 'none', - listStyleImage: 'none', - minWidth: '1rem', - }; - const headerStyle: React.CSSProperties = { - color: BREAD_COLOR_ACTIVE, - fontSize: BREAD_FONT_SIZE, - margin: 0, - padding: 0, - }; - - const rootClick = currentNestedDepth > 0 ? handleRootClick : undefined; - return ( -
    -
    - {text} -
    -
      - {currentNestedView?.map((nestedView, i) => { - return ( - - ); - })} -
    -
    - ); - }; - return ( <> - {renderBreadCrumb()} - + + {currentNestedView?.map((nestedView, viewIndex) => { + return ( + { + e.stopPropagation(); + onRemoveNestedView(scopedId, viewIndex); + }} + /> + ); + })} + + {renderDefaultHandles( scopedId, getGraphHandleStyle('source'), diff --git a/packages/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx b/packages/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx index 7b1e6d0d1..6514f8424 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx +++ b/packages/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render } from '@testing-library/react'; import { NodeExecutionDetailsContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { mockWorkflowId } from 'mocks/data/fixtures/types'; import { dTypes } from 'models/Graph/types'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { WorkflowNodeExecutionsContext } from 'components/Executions/contexts'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { dateToTimestamp } from 'common/utils'; import { PausedTasksComponent } from '../PausedTasksComponent'; @@ -96,11 +96,21 @@ describe('flytegraph > ReactFlow > PausedTasksComponent', () => { compiledWorkflowClosure, }} > - {} }} + {}, + setShouldUpdate: () => {}, + shouldUpdate: false, + }} > - + , ); diff --git a/packages/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx b/packages/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx index c7becd4c2..cb5fe808c 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx +++ b/packages/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx @@ -49,23 +49,17 @@ export const isStartOrEndEdge = edge => { interface BuildDataProps { node: dNode; - nodeExecutionsById: any; onNodeSelectionChanged: any; onPhaseSelectionChanged: (phase: TaskExecutionPhase) => void; selectedPhase: TaskExecutionPhase; - onAddNestedView: any; - onRemoveNestedView: any; rootParentNode: dNode; currentNestedView: string[]; } const buildReactFlowDataProps = ({ node, - nodeExecutionsById, onNodeSelectionChanged, onPhaseSelectionChanged, selectedPhase, - onAddNestedView, - onRemoveNestedView, rootParentNode, currentNestedView, }: BuildDataProps) => { @@ -75,24 +69,23 @@ const buildReactFlowDataProps = ({ scopedId, type: nodeType, isParentNode, + execution, } = node; const taskType = nodeValue?.template?.type ?? null; const mapNodeExecutionStatus = () => { - if (nodeExecutionsById) { - if (nodeExecutionsById[scopedId]) { - return nodeExecutionsById[scopedId].closure.phase as NodeExecutionPhase; - } else { - return NodeExecutionPhase.SKIPPED; - } + if (execution) { + return ( + (execution.closure.phase as NodeExecutionPhase) || + NodeExecutionPhase.SKIPPED + ); } else { return NodeExecutionPhase.UNDEFINED; } }; const nodeExecutionStatus = mapNodeExecutionStatus(); - const nodeLogsByPhase: LogsByPhase = - nodeExecutionsById?.[node.scopedId]?.logsByPhase; + const nodeLogsByPhase: LogsByPhase = (execution as any)?.logsByPhase; // get the cache status for mapped task const isMapCache = @@ -100,9 +93,10 @@ const buildReactFlowDataProps = ({ const cacheStatus: CatalogCacheStatus = isMapCache ? CatalogCacheStatus.MAP_CACHE - : nodeExecutionsById?.[scopedId]?.closure.taskNodeMetadata?.cacheStatus; + : (execution?.closure.taskNodeMetadata?.cacheStatus as CatalogCacheStatus); const dataProps = { + node, nodeExecutionStatus, text: displayName, handles: [], @@ -111,6 +105,7 @@ const buildReactFlowDataProps = ({ taskType, nodeLogsByPhase, isParentNode, + parentScopedId: rootParentNode ? rootParentNode.scopedId : scopedId, cacheStatus, selectedPhase, onNodeSelectionChanged: () => { @@ -123,16 +118,6 @@ const buildReactFlowDataProps = ({ onPhaseSelectionChanged(phase); } }, - onAddNestedView: () => { - onAddNestedView( - { - parent: rootParentNode ? rootParentNode.scopedId : scopedId, - view: scopedId, - }, - node, - ); - }, - onRemoveNestedView, }; for (const rootParentId in currentNestedView) { @@ -220,22 +205,16 @@ export const nodesToArray = nodes => { export const buildGraphMapping = (props): ReactFlowGraphMapping => { const dag: dNode = props.root; const { - nodeExecutionsById, onNodeSelectionChanged, onPhaseSelectionChanged, selectedPhase, - onAddNestedView, - onRemoveNestedView, currentNestedView, isStaticGraph, } = props; const nodeDataProps = { - nodeExecutionsById, onNodeSelectionChanged, onPhaseSelectionChanged, selectedPhase, - onAddNestedView, - onRemoveNestedView, currentNestedView, }; const root: ReactFlowGraph = { diff --git a/packages/console/src/components/flytegraph/ReactFlow/types.ts b/packages/console/src/components/flytegraph/ReactFlow/types.ts index 68e71d025..0ebf50ab5 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/types.ts +++ b/packages/console/src/components/flytegraph/ReactFlow/types.ts @@ -48,18 +48,13 @@ export interface BuildRFNodeProps { nodeExecutionsById: any; typeOverride: dTypes | null; onNodeSelectionChanged: any; - onAddNestedView?: any; - onRemoveNestedView?: any; currentNestedView?: any; isStaticGraph: boolean; } export interface ConvertDagProps { root: dNode; - nodeExecutionsById: any; onNodeSelectionChanged: any; - onRemoveNestedView?: any; - onAddNestedView?: any; currentNestedView?: any; maxRenderDepth: number; isStaticGraph?: boolean; @@ -70,7 +65,8 @@ export interface DagToReactFlowProps extends ConvertDagProps { parents: any; } -interface RFCustomData { +export interface RFCustomData { + node: dNode; nodeExecutionStatus: NodeExecutionPhase; text: string; handles: []; @@ -79,14 +75,13 @@ interface RFCustomData { dag: any; taskType: dTypes; cacheStatus: CatalogCacheStatus; + parentScopedId: string; isParentNode: boolean; nodeLogsByPhase: LogsByPhase; selectedPhase: TaskExecutionPhase; currentNestedView: string[]; onNodeSelectionChanged: (n: boolean) => void; onPhaseSelectionChanged: (p?: TaskExecutionPhase) => void; - onAddNestedView: () => void; - onRemoveNestedView: (scopedId: string, index: number) => void; } export interface RFNode { diff --git a/packages/console/src/components/flytegraph/ReactFlow/utils.tsx b/packages/console/src/components/flytegraph/ReactFlow/utils.tsx index 9fa902b3f..b5e34de70 100644 --- a/packages/console/src/components/flytegraph/ReactFlow/utils.tsx +++ b/packages/console/src/components/flytegraph/ReactFlow/utils.tsx @@ -1,6 +1,6 @@ import React, { CSSProperties } from 'react'; import { NodeExecutionPhase, TaskExecutionPhase } from 'models/Execution/enums'; -import { dTypes } from 'models/Graph/types'; +import { dNode, dTypes } from 'models/Graph/types'; import { graphStatusColors } from 'components/Theme/constants'; import { nodeExecutionPhaseConstants } from 'components/Executions/constants'; import dagre from 'dagre'; @@ -151,6 +151,19 @@ export const getNestedGraphContainerStyle = overwrite => { return output; }; +export const findNodeInDag = (scopedId: string, root: dNode) => { + if (root.scopedId === scopedId) { + return root; + } + + for (const node of root.nodes) { + const tmp = findNodeInDag(scopedId, node); + if (tmp) { + return tmp; + } + } +}; + export const getNestedContainerStyle = nodeExecutionStatus => { const style = { border: `1px dashed ${getStatusColor(nodeExecutionStatus)}`, diff --git a/packages/console/src/components/flytegraph/RenderedGraph.tsx b/packages/console/src/components/flytegraph/RenderedGraph.tsx index 8ff399ade..fc64c9a6e 100644 --- a/packages/console/src/components/flytegraph/RenderedGraph.tsx +++ b/packages/console/src/components/flytegraph/RenderedGraph.tsx @@ -33,10 +33,14 @@ export class RenderedGraph extends React.Component< private clearSelection = () => { // Don't need to trigger a re-render if selection is already empty - if (!this.props.selectedNodes || this.props.selectedNodes.length === 0) { + if ( + !this.props.selectedNodes || + this.props.selectedNodes.length === 0 || + !this.props.onNodeSelectionChanged + ) { return; } - this.props.onNodeSelectionChanged && this.props.onNodeSelectionChanged([]); + this.props.onNodeSelectionChanged([]); }; private onNodeEnter = (id: string) => { diff --git a/packages/console/src/components/hooks/useNodeExecution.ts b/packages/console/src/components/hooks/useNodeExecution.ts index b1394c4a3..21e33470e 100644 --- a/packages/console/src/components/hooks/useNodeExecution.ts +++ b/packages/console/src/components/hooks/useNodeExecution.ts @@ -31,7 +31,10 @@ export function useNodeExecutionData( { debugName: 'NodeExecutionData', defaultValue: {} as ExecutionData, - doFetch: id => getNodeExecutionData(id), + doFetch: id => + getNodeExecutionData(id).catch(e => { + return {} as ExecutionData; + }), }, id, ); diff --git a/packages/console/src/components/hooks/utils.ts b/packages/console/src/components/hooks/utils.ts index 8769842cb..b4551a321 100644 --- a/packages/console/src/components/hooks/utils.ts +++ b/packages/console/src/components/hooks/utils.ts @@ -1,18 +1,22 @@ import { CompiledNode, GloballyUniqueNode } from 'models/Node/types'; import { TaskTemplate } from 'models/Task/types'; -import { CompiledWorkflowClosure, Workflow } from 'models/Workflow/types'; +import { + CompiledWorkflow, + CompiledWorkflowClosure, + Workflow, +} from 'models/Workflow/types'; export function extractCompiledNodes( compiledWorkflowClosure: CompiledWorkflowClosure | null, ): CompiledNode[] { if (!compiledWorkflowClosure) return []; - const { primary, subWorkflows = [] } = compiledWorkflowClosure; + const { primary = {} as CompiledWorkflow, subWorkflows = [] } = + compiledWorkflowClosure; - return subWorkflows.reduce( - (out, subWorkflow) => [...out, ...subWorkflow.template.nodes], - primary.template.nodes, - ); + return subWorkflows.reduce((out, subWorkflow) => { + return [...out, ...subWorkflow.template.nodes]; + }, primary?.template?.nodes); } export function extractTaskTemplates(workflow: Workflow): TaskTemplate[] { diff --git a/packages/console/src/models/Graph/types.ts b/packages/console/src/models/Graph/types.ts index d53a76d63..464eb72df 100644 --- a/packages/console/src/models/Graph/types.ts +++ b/packages/console/src/models/Graph/types.ts @@ -1,3 +1,4 @@ +import { WorkflowNodeExecution } from 'components/Executions/contexts'; import { NodeExecution } from 'models/Execution/types'; import { TaskTemplate } from 'models/Task/types'; @@ -58,7 +59,8 @@ export interface dNode { nodes: Array; edges: Array; expanded?: boolean; + grayedOut?: boolean; level?: number; - execution?: NodeExecution; + execution?: WorkflowNodeExecution; isParentNode?: boolean; } diff --git a/packages/console/src/models/Node/constants.ts b/packages/console/src/models/Node/constants.ts index 2d591ffc8..cbb46983a 100644 --- a/packages/console/src/models/Node/constants.ts +++ b/packages/console/src/models/Node/constants.ts @@ -1,2 +1,4 @@ export const startNodeId = 'start-node'; export const endNodeId = 'end-node'; + +export const ignoredNodeIds = [startNodeId, endNodeId]; diff --git a/packages/console/src/models/Node/utils.ts b/packages/console/src/models/Node/utils.ts new file mode 100644 index 000000000..adc92390b --- /dev/null +++ b/packages/console/src/models/Node/utils.ts @@ -0,0 +1,17 @@ +import { endNodeId, ignoredNodeIds, startNodeId } from './constants'; + +export const isStartOrEndNode = (node: any) => { + return ignoredNodeIds.includes(node.id); +}; + +export function isStartNode(node: any) { + return node.id === startNodeId; +} + +export function isEndNode(node: any) { + return node.id === endNodeId; +} + +export function isExpanded(node: any) { + return !!node.expanded; +} diff --git a/packages/console/src/models/Task/constants.ts b/packages/console/src/models/Task/constants.ts index 110dfe54c..1f362f014 100644 --- a/packages/console/src/models/Task/constants.ts +++ b/packages/console/src/models/Task/constants.ts @@ -5,6 +5,7 @@ export enum TaskType { ARRAY = 'container_array', BATCH_HIVE = 'batch_hive', DYNAMIC = 'dynamic-task', + BRANCH = 'branch-node', HIVE = 'hive', PYTHON = 'python-task', SIDECAR = 'sidecar', diff --git a/website/package.json b/website/package.json index 3bfdcd2d6..960939e2e 100644 --- a/website/package.json +++ b/website/package.json @@ -37,7 +37,7 @@ }, "dependencies": { "@flyteorg/common": "^0.0.4", - "@flyteorg/console": "^0.0.20", + "@flyteorg/console": "^0.0.21", "long": "^4.0.0", "protobufjs": "~6.11.3", "react-ga4": "^1.4.1", diff --git a/yarn.lock b/yarn.lock index 80aa10f3f..528b45899 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1995,7 +1995,7 @@ __metadata: resolution: "@flyteconsole/client-app@workspace:website" dependencies: "@flyteorg/common": ^0.0.4 - "@flyteorg/console": ^0.0.20 + "@flyteorg/console": ^0.0.21 "@types/long": ^3.0.32 long: ^4.0.0 protobufjs: ~6.11.3 @@ -2034,7 +2034,7 @@ __metadata: languageName: unknown linkType: soft -"@flyteorg/console@^0.0.20, @flyteorg/console@workspace:packages/console": +"@flyteorg/console@^0.0.21, @flyteorg/console@workspace:packages/console": version: 0.0.0-use.local resolution: "@flyteorg/console@workspace:packages/console" dependencies: