From 870a91a56942f5ba9a05bcf82977e14c176cbfa0 Mon Sep 17 00:00:00 2001 From: Olga Nad Date: Wed, 2 Nov 2022 11:54:41 -0500 Subject: [PATCH 1/2] fix: merge devmain into master Signed-off-by: Olga Nad --- README.md | 2 +- packages/zapp/console/package.json | 2 +- .../components/Entities/EntityExecutions.tsx | 2 + .../src/components/Entities/EntityInputs.tsx | 37 +- .../src/components/Executions/CacheStatus.tsx | 116 ++++ .../ExecutionDetails/DetailsPanelContext.ts | 11 + .../ExecutionDetailsActions.tsx | 75 ++- .../ExecutionDetails/ExecutionNodeViews.tsx | 114 ++-- .../ExecutionDetails/ExecutionTab.tsx | 130 +--- .../ExecutionDetails/ExecutionTabContent.tsx | 247 ++++++++ .../NodeExecutionDetailsPanelContent.tsx | 75 ++- .../Timeline/ExecutionTimeline.tsx | 41 +- .../Timeline/NodeExecutionName.tsx | 73 ++- .../ExecutionDetails/Timeline/TaskNames.tsx | 111 ++-- .../ExecutionDetails/Timeline/context.ts | 11 - .../Executions/ExecutionDetails/strings.tsx | 2 + .../test/ExecutionNodeViews.test.tsx | 54 +- .../test/ExecutionTabContent.test.tsx | 92 +++ .../test/NodeExecutionDetails.test.tsx | 96 --- .../NodeExecutionDetailsPanelContent.test.tsx | 50 ++ .../test/NodeExecutionName.test.tsx | 66 ++ .../ExecutionDetails/test/TaskNames.test.tsx | 55 ++ .../Executions/ExecutionFilters.tsx | 8 +- .../Executions/NodeExecutionCacheStatus.tsx | 138 +---- .../Tables/NodeExecutionActions.tsx | 102 ++-- .../Tables/NodeExecutionChildren.tsx | 107 ---- .../Executions/Tables/NodeExecutionRow.tsx | 145 ++--- .../Executions/Tables/NodeExecutionsTable.tsx | 160 +++-- .../Executions/Tables/RowExpander.tsx | 4 +- .../components/Executions/Tables/constants.ts | 8 +- .../components/Executions/Tables/contexts.ts | 11 - .../Tables/nodeExecutionColumns.tsx | 119 ++-- .../components/Executions/Tables/strings.tsx | 12 +- .../components/Executions/Tables/styles.ts | 34 -- .../Tables/test/NodeExecutionActions.test.tsx | 89 +++ .../Tables/test/NodeExecutionRow.test.tsx | 80 +++ .../Tables/test/NodeExecutionsTable.test.tsx | 563 +++++------------- .../src/components/Executions/Tables/types.ts | 3 +- .../TaskExecutionsList/test/utils.spec.ts | 15 +- .../Executions/TaskExecutionsList/utils.ts | 23 +- .../src/components/Executions/constants.ts | 124 +++- .../createExecutionArray.tsx | 13 + .../src/components/Executions/contexts.ts | 11 +- .../Executions/filters/startTimeFilters.ts | 4 +- .../Executions/filters/statusFilters.ts | 38 +- .../Executions/nodeExecutionQueries.ts | 37 +- .../src/components/Executions/strings.ts | 1 + .../Executions/test/CacheStatus.test.tsx | 89 +++ .../test/NodeExecutionCacheStatus.test.tsx | 71 +++ .../components/Executions/test/utils.test.ts | 53 ++ .../src/components/Executions/types.ts | 5 +- .../src/components/Executions/utils.ts | 18 + .../Launch/LaunchForm/BlobInput.tsx | 6 +- .../Launch/LaunchForm/LaunchFormActions.tsx | 20 +- .../Launch/LaunchForm/LaunchFormDialog.tsx | 38 +- .../Launch/LaunchForm/LaunchFormHeader.tsx | 10 +- .../Launch/LaunchForm/LaunchFormInputs.tsx | 8 +- .../LaunchForm/LaunchInterruptibleInput.tsx | 15 +- .../Launch/LaunchForm/LaunchTaskForm.tsx | 9 +- .../Launch/LaunchForm/LaunchWorkflowForm.tsx | 13 +- .../components/Launch/LaunchForm/MapInput.tsx | 6 +- .../Launch/LaunchForm/NoInputsNeeded.tsx | 5 +- .../Launch/LaunchForm/NoneInput.tsx | 4 +- .../Launch/LaunchForm/ResumeForm.tsx | 23 + .../Launch/LaunchForm/ResumeSignalForm.tsx | 85 +++ .../UnsupportedRequiredInputsError.tsx | 14 +- .../components/Launch/LaunchForm/constants.ts | 28 +- .../components/Launch/LaunchForm/getInputs.ts | 6 +- .../Launch/LaunchForm/launchMachine.ts | 25 + .../components/Launch/LaunchForm/services.ts | 4 +- .../components/Launch/LaunchForm/strings.ts | 32 + .../components/Launch/LaunchForm/styles.ts | 3 + .../LaunchForm/test/LaunchTaskForm.test.tsx | 78 ++- .../test/LaunchWorkflowForm.test.tsx | 99 ++- .../LaunchForm/test/ResumeSignalForm.test.tsx | 195 ++++++ .../Launch/LaunchForm/test/constants.ts | 1 + .../src/components/Launch/LaunchForm/types.ts | 8 + .../LaunchForm/useLaunchTaskFormState.ts | 4 +- .../Launch/LaunchForm/useResumeFormState.ts | 116 ++++ .../src/components/Tables/DataList.tsx | 27 +- .../console/src/components/Theme/constants.ts | 12 + .../WorkflowGraph/WorkflowGraph.tsx | 124 ++-- .../src/components/WorkflowGraph/strings.ts | 9 + .../WorkflowGraph/test/WorkflowGraph.test.tsx | 15 +- .../WorkflowGraph/test/utils.test.ts | 51 +- .../transformerWorkflowToDag.tsx | 5 +- .../src/components/WorkflowGraph/utils.ts | 6 +- .../MapTaskExecutionsList/TaskNameList.tsx | 4 +- .../src/components/common/constants.ts | 3 +- .../console/src/components/common/strings.ts | 1 + .../flytegraph/ReactFlow/NodeStatusLegend.tsx | 88 +-- .../ReactFlow/PausedTasksComponent.tsx | 109 ++++ .../ReactFlow/ReactFlowGraphComponent.tsx | 54 +- .../flytegraph/ReactFlow/ReactFlowWrapper.tsx | 2 + .../flytegraph/ReactFlow/commonStyles.ts | 39 ++ .../ReactFlow/customNodeComponents.tsx | 350 ++++++----- .../flytegraph/ReactFlow/strings.ts | 10 + .../ReactFlow/test/NodeStatusLegend.test.tsx | 51 ++ .../test/PausedTasksComponent.test.tsx | 137 +++++ .../flytegraph/ReactFlow/test/utils.test.ts | 23 + .../ReactFlow/transformDAGToReactFlowV2.tsx | 5 + .../components/flytegraph/ReactFlow/types.ts | 26 +- .../components/flytegraph/ReactFlow/utils.tsx | 31 +- .../console/src/models/Common/constants.ts | 1 + .../zapp/console/src/models/Execution/api.ts | 17 + .../console/src/models/Execution/enums.ts | 7 +- .../console/src/models/Execution/types.ts | 9 +- .../zapp/console/src/models/Graph/types.ts | 1 + .../src/models/Node/__mocks__/mockNodeData.ts | 5 + .../zapp/console/src/models/Node/types.ts | 1 + yarn.lock | 8 +- 111 files changed, 3474 insertions(+), 2159 deletions(-) create mode 100644 packages/zapp/console/src/components/Executions/CacheStatus.tsx create mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.ts create mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx delete mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/context.ts create mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx delete mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx create mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx create mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionName.test.tsx create mode 100644 packages/zapp/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx delete mode 100644 packages/zapp/console/src/components/Executions/Tables/NodeExecutionChildren.tsx delete mode 100644 packages/zapp/console/src/components/Executions/Tables/contexts.ts create mode 100644 packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx create mode 100644 packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx create mode 100644 packages/zapp/console/src/components/Executions/test/CacheStatus.test.tsx create mode 100644 packages/zapp/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx create mode 100644 packages/zapp/console/src/components/Launch/LaunchForm/ResumeForm.tsx create mode 100644 packages/zapp/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx create mode 100644 packages/zapp/console/src/components/Launch/LaunchForm/strings.ts create mode 100644 packages/zapp/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx create mode 100644 packages/zapp/console/src/components/Launch/LaunchForm/useResumeFormState.ts create mode 100644 packages/zapp/console/src/components/WorkflowGraph/strings.ts create mode 100644 packages/zapp/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx create mode 100644 packages/zapp/console/src/components/flytegraph/ReactFlow/commonStyles.ts create mode 100644 packages/zapp/console/src/components/flytegraph/ReactFlow/strings.ts create mode 100644 packages/zapp/console/src/components/flytegraph/ReactFlow/test/NodeStatusLegend.test.tsx create mode 100644 packages/zapp/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx create mode 100644 packages/zapp/console/src/components/flytegraph/ReactFlow/test/utils.test.ts diff --git a/README.md b/README.md index cd4e94306..f24a757b2 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ For help with installing dependencies look into ``` 2. Now, export the following env variables: - ` export ADMIN_API_URL=http://localhost:30080 export DISABLE_AUTH=1 ` + `export ADMIN_API_URL=http://localhost:30080 export DISABLE_AUTH=1` > You can persist these environment variables either in the current shell or in a `.env` file at the root > of the repository. A `.env` file will persist the settings across multiple terminal diff --git a/packages/zapp/console/package.json b/packages/zapp/console/package.json index fe1a5ecfb..b6903cd04 100644 --- a/packages/zapp/console/package.json +++ b/packages/zapp/console/package.json @@ -57,7 +57,7 @@ "@commitlint/cli": "^8.3.5", "@commitlint/config-conventional": "^8.3.4", "@date-io/moment": "1.3.9", - "@flyteorg/flyteidl": "1.1.4", + "@flyteorg/flyteidl": "1.2.1", "@material-ui/core": "^4.0.0", "@material-ui/icons": "^4.0.0", "@material-ui/pickers": "^3.2.2", diff --git a/packages/zapp/console/src/components/Entities/EntityExecutions.tsx b/packages/zapp/console/src/components/Entities/EntityExecutions.tsx index b59bfce88..56ae01473 100644 --- a/packages/zapp/console/src/components/Entities/EntityExecutions.tsx +++ b/packages/zapp/console/src/components/Entities/EntityExecutions.tsx @@ -14,6 +14,8 @@ import { executionSortFields } from 'models/Execution/constants'; import { compact } from 'lodash'; import { useOnlyMyExecutionsFilterState } from 'components/Executions/filters/useOnlyMyExecutionsFilterState'; import { executionFilterGenerator } from './generators'; +import { entityStrings } from './constants'; +import t, { patternKey } from './strings'; const useStyles = makeStyles((theme: Theme) => ({ filtersContainer: { diff --git a/packages/zapp/console/src/components/Entities/EntityInputs.tsx b/packages/zapp/console/src/components/Entities/EntityInputs.tsx index ede82f6dd..4b85a2047 100644 --- a/packages/zapp/console/src/components/Entities/EntityInputs.tsx +++ b/packages/zapp/console/src/components/Entities/EntityInputs.tsx @@ -16,6 +16,7 @@ import { FilterOperationName } from 'models/AdminEntity/types'; import { ResourceIdentifier } from 'models/Common/types'; import { LaunchPlanClosure, LaunchPlanSpec } from 'models/Launch/types'; import * as React from 'react'; +import { useMemo } from 'react'; import t from './strings'; import { transformLiterals } from '../Literals/helpers'; @@ -104,8 +105,8 @@ export const EntityInputs: React.FC<{ ? launchPlanState.value[0].spec : ({} as LaunchPlanSpec); - const expectedInputs = React.useMemo(() => { - const results = [] as Input[]; + const expectedInputs = useMemo(() => { + const results: Input[] = []; Object.keys(closure?.expectedInputs?.parameters ?? {}).forEach((name) => { const parameter = closure?.expectedInputs.parameters[name]; if (parameter?.var?.type) { @@ -121,25 +122,11 @@ export const EntityInputs: React.FC<{ return results; }, [closure]); - const fixedInputs = React.useMemo(() => { + const fixedInputs = useMemo(() => { const inputsMap = transformLiterals(spec?.fixedInputs?.literals ?? {}); return Object.keys(inputsMap).map((name) => ({ name, defaultValue: inputsMap[name] })); }, [spec]); - const configs = React.useMemo( - () => [ - { name: t('configType'), value: 'single (csv)' }, - { - name: t('configUrl'), - value: - 'https://raw.githubusercontent.com/jbrownlee/Datasets/master/pima-indians-diabetes.data.csv', - }, - { name: t('configSeed'), value: '7' }, - { name: t('configTestSplitRatio'), value: '0.33' }, - ], - [], - ); - return ( <> @@ -232,22 +219,6 @@ export const EntityInputs: React.FC<{ )} - {/*
-
- - {t('configuration')} - -
    - {configs.map(({ name, value }) => ( -
  • - {name}: - {value} -
  • - ))} -
-
-
-
*/} ); }; diff --git a/packages/zapp/console/src/components/Executions/CacheStatus.tsx b/packages/zapp/console/src/components/Executions/CacheStatus.tsx new file mode 100644 index 000000000..c188aa234 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/CacheStatus.tsx @@ -0,0 +1,116 @@ +import { SvgIconProps, Tooltip, Typography } from '@material-ui/core'; +import { makeStyles, Theme } from '@material-ui/core/styles'; +import CachedOutlined from '@material-ui/icons/CachedOutlined'; +import ErrorOutlined from '@material-ui/icons/ErrorOutlined'; +import InfoOutlined from '@material-ui/icons/InfoOutlined'; +import classnames from 'classnames'; +import { assertNever } from 'common/utils'; +import { PublishedWithChangesOutlined } from 'components/common/PublishedWithChanges'; +import { useCommonStyles } from 'components/common/styles'; +import { CatalogCacheStatus } from 'models/Execution/enums'; +import { TaskExecutionIdentifier } from 'models/Execution/types'; +import { MapCacheIcon } from '@flyteconsole/ui-atoms'; +import * as React from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { Routes } from 'routes/routes'; +import { + cacheStatusMessages, + unknownCacheStatusString, + viewSourceExecutionString, +} from './constants'; + +const useStyles = makeStyles((theme: Theme) => ({ + cacheStatus: { + alignItems: 'center', + display: 'flex', + marginTop: theme.spacing(1), + }, + sourceExecutionLink: { + fontWeight: 'normal', + }, +})); + +/** Renders the appropriate icon for a given CatalogCacheStatus */ +const NodeExecutionCacheStatusIcon: React.FC< + SvgIconProps & { + status: CatalogCacheStatus; + } +> = React.forwardRef(({ status, ...props }, ref) => { + switch (status) { + case CatalogCacheStatus.CACHE_DISABLED: + case CatalogCacheStatus.CACHE_MISS: { + return ; + } + case CatalogCacheStatus.CACHE_HIT: { + return ; + } + case CatalogCacheStatus.CACHE_POPULATED: { + return ; + } + case CatalogCacheStatus.CACHE_LOOKUP_FAILURE: + case CatalogCacheStatus.CACHE_PUT_FAILURE: { + return ; + } + case CatalogCacheStatus.MAP_CACHE: { + return ; + } + default: { + assertNever(status); + return null; + } + } +}); + +export interface CacheStatusProps { + cacheStatus: CatalogCacheStatus | null | undefined; + /** `normal` will render an icon with description message beside it + * `iconOnly` will render just the icon with the description as a tooltip + */ + variant?: 'normal' | 'iconOnly'; + sourceTaskExecutionId?: TaskExecutionIdentifier; + iconStyles?: React.CSSProperties; +} + +export const CacheStatus: React.FC = ({ + cacheStatus, + sourceTaskExecutionId, + variant = 'normal', + iconStyles, +}) => { + const commonStyles = useCommonStyles(); + const styles = useStyles(); + + if (cacheStatus == null) { + return null; + } + + const message = cacheStatusMessages[cacheStatus] || unknownCacheStatusString; + + return variant === 'iconOnly' ? ( + + + + ) : ( + <> + + + {message} + + {sourceTaskExecutionId && ( + + {viewSourceExecutionString} + + )} + + ); +}; diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.ts b/packages/zapp/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.ts new file mode 100644 index 000000000..c0c27650d --- /dev/null +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/DetailsPanelContext.ts @@ -0,0 +1,11 @@ +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/zapp/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx index a4b39da2d..94ca5bcd5 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx @@ -10,9 +10,11 @@ import { literalsToLiteralValueMap } from 'components/Launch/LaunchForm/utils'; import { TaskInitialLaunchParameters } from 'components/Launch/LaunchForm/types'; import { NodeExecutionPhase } from 'models/Execution/enums'; import Close from '@material-ui/icons/Close'; +import { useEffect, useState } from 'react'; import { NodeExecutionDetails } from '../types'; import t from './strings'; import { ExecutionNodeDeck } from './ExecutionNodeDeck'; +import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; const useStyles = makeStyles((theme: Theme) => { return { @@ -57,26 +59,34 @@ const useStyles = makeStyles((theme: Theme) => { }); interface ExecutionDetailsActionsProps { - details: NodeExecutionDetails; + details?: NodeExecutionDetails; nodeExecutionId: NodeExecutionIdentifier; - phase?: NodeExecutionPhase; + phase: NodeExecutionPhase; } -export const ExecutionDetailsActions = (props: ExecutionDetailsActionsProps): JSX.Element => { - const { details, nodeExecutionId, phase } = props; +export const ExecutionDetailsActions = ({ + details, + nodeExecutionId, + phase, +}: ExecutionDetailsActionsProps): JSX.Element => { const styles = useStyles(); - const [showLaunchForm, setShowLaunchForm] = React.useState(false); + const [showLaunchForm, setShowLaunchForm] = useState(false); + const [showResumeForm, setShowResumeForm] = useState(false); - const [initialParameters, setInitialParameters] = React.useState< + const [initialParameters, setInitialParameters] = useState< TaskInitialLaunchParameters | undefined >(undefined); const executionData = useNodeExecutionData(nodeExecutionId); const execution = useNodeExecution(nodeExecutionId); - const id = details.taskTemplate?.id; + const { compiledWorkflowClosure } = useNodeExecutionContext(); + const id = details?.taskTemplate?.id; + const compiledNode = (compiledWorkflowClosure?.primary.template.nodes ?? []).find( + (node) => node.id === nodeExecutionId.nodeId, + ); - React.useEffect(() => { + useEffect(() => { if (!id) { return; } @@ -99,15 +109,16 @@ export const ExecutionDetailsActions = (props: ExecutionDetailsActionsProps): JS const [showDeck, setShowDeck] = React.useState(false); const onCloseDeck = () => setShowDeck(false); - if (!id || !initialParameters) { - return <>; - } - const rerunOnClick = (e: React.MouseEvent) => { e.stopPropagation(); setShowLaunchForm(true); }; + const onResumeClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setShowResumeForm(true); + }; + return ( <>
@@ -121,17 +132,35 @@ export const ExecutionDetailsActions = (props: ExecutionDetailsActionsProps): JS {t('flyteDeck')} )} - + {id && initialParameters && details && ( + + )} + {phase === NodeExecutionPhase.PAUSED && ( + + )}
- - {execution?.value?.closure?.deckUri ? ( + {id && initialParameters && ( + + )} + {compiledNode && ( + + )} + {execution?.value?.closure?.deckUri && (

{t('flyteDeck')}

@@ -141,7 +170,7 @@ export const ExecutionDetailsActions = (props: ExecutionDetailsActionsProps): JS
- ) : null} + )} ); }; diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx index 6c6784607..5b9c2f963 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionNodeViews.tsx @@ -6,16 +6,16 @@ import { DataError } from 'components/Errors/DataError'; import { useTabState } from 'components/hooks/useTabState'; import { secondaryBackgroundColor } from 'components/Theme/constants'; import { Execution, ExternalResource, LogsByPhase, NodeExecution } from 'models/Execution/types'; -import { useContext, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { keyBy } from 'lodash'; import { isMapTaskV1 } from 'models/Task/utils'; import { useQueryClient } from 'react-query'; import { LargeLoadingSpinner } from 'components/common/LoadingSpinner'; +import { FilterOperation } from 'models/AdminEntity/types'; import { NodeExecutionDetailsContextProvider } from '../contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext, NodeExecutionsRequestConfigContext } from '../contexts'; +import { NodeExecutionsByIdContext } from '../contexts'; import { ExecutionFilters } from '../ExecutionFilters'; import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; -import { NodeExecutionsTable } from '../Tables/NodeExecutionsTable'; import { tabs } from './constants'; import { useExecutionNodeViewsState } from './useExecutionNodeViewsState'; import { fetchTaskExecutionList } from '../taskExecutionQueries'; @@ -43,11 +43,18 @@ const useStyles = makeStyles((theme: Theme) => ({ }, })); +const isPhaseFilter = (appliedFilters: FilterOperation[]) => { + if (appliedFilters.length === 1 && appliedFilters[0].key === 'phase') { + return true; + } + return false; +}; + interface WorkflowNodeExecution extends NodeExecution { logsByPhase?: LogsByPhase; } -export interface ExecutionNodeViewsProps { +interface ExecutionNodeViewsProps { execution: Execution; } @@ -58,10 +65,10 @@ export const ExecutionNodeViews: React.FC = ({ executio const filterState = useNodeExecutionFiltersState(); const tabState = useTabState(tabs, defaultTab); const queryClient = useQueryClient(); - const requestConfig = useContext(NodeExecutionsRequestConfigContext); + const [nodeExecutionsLoading, setNodeExecutionsLoading] = useState(true); const { - closure: { abortMetadata, workflowId }, + closure: { workflowId }, } = execution; const [nodeExecutions, setNodeExecutions] = useState([]); @@ -73,19 +80,18 @@ export const ExecutionNodeViews: React.FC = ({ executio return keyBy(nodeExecutionsWithResources, 'scopedId'); }, [nodeExecutionsWithResources]); - /* We want to maintain the filter selection when switching away from the Nodes - tab and back, but do not want to filter the nodes when viewing the graph. So, - we will only pass filters to the execution state when on the nodes tab. */ - const appliedFilters = tabState.value === tabs.nodes.id ? filterState.appliedFilters : []; - - const { nodeExecutionsQuery, nodeExecutionsRequestConfig } = useExecutionNodeViewsState( - execution, - appliedFilters, - ); + // 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); useEffect(() => { let isCurrent = true; + async function fetchData(baseNodeExecutions, queryClient) { + setNodeExecutionsLoading(true); const newValue = await Promise.all( baseNodeExecutions.map(async (baseNodeExecution) => { const taskExecutions = await fetchTaskExecutionList(queryClient, baseNodeExecution.id); @@ -116,21 +122,23 @@ export const ExecutionNodeViews: React.FC = ({ executio if (isCurrent) { setNodeExecutionsWithResources(newValue); + setNodeExecutionsLoading(false); } } if (nodeExecutions.length > 0) { fetchData(nodeExecutions, queryClient); + } else { + if (isCurrent) { + setNodeExecutionsLoading(false); + } } return () => { isCurrent = false; }; }, [nodeExecutions]); - const childGroupsQuery = useAllTreeNodeExecutionGroupsQuery( - nodeExecutionsQuery.data ?? [], - requestConfig, - ); + const childGroupsQuery = useAllTreeNodeExecutionGroupsQuery(nodeExecutionsQuery.data ?? [], {}); useEffect(() => { if (!childGroupsQuery.isLoading && childGroupsQuery.data) { @@ -138,18 +146,7 @@ export const ExecutionNodeViews: React.FC = ({ executio } }, [childGroupsQuery.data]); - const renderNodeExecutionsTable = (nodeExecutions: NodeExecution[]) => ( - - - - ); - - const renderTab = (tabType) => ; - - const TimelineLoading = () => { + const LoadingComponent = () => { return (
@@ -157,6 +154,29 @@ export const ExecutionNodeViews: React.FC = ({ executio ); }; + const renderTab = (tabType) => { + if (nodeExecutionsLoading) { + return ; + } + return ( + + {() => ( + + )} + + ); + }; + return ( <> @@ -166,24 +186,20 @@ export const ExecutionNodeViews: React.FC = ({ executio - {nodeExecutions.length > 0 ? ( -
- {tabState.value === tabs.nodes.id ? ( - <> -
- -
- - {renderNodeExecutionsTable} - - - ) : ( - - {() => renderTab(tabState.value)} - - )} -
- ) : null} +
+ {tabState.value === tabs.nodes.id && ( +
+ +
+ )} + + {() => renderTab(tabState.value)} + +
diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx index 32d8dda32..1616ab663 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTab.tsx @@ -1,144 +1,32 @@ -import { makeStyles } from '@material-ui/core'; -import { DetailsPanel } from 'components/common/DetailsPanel'; import { WaitForQuery } from 'components/common/WaitForQuery'; import { DataError } from 'components/Errors/DataError'; import { makeWorkflowQuery } from 'components/Workflow/workflowQueries'; -import { WorkflowGraph } from 'components/WorkflowGraph/WorkflowGraph'; -import { TaskExecutionPhase } from 'models/Execution/enums'; -import { NodeExecutionIdentifier } from 'models/Execution/types'; -import { startNodeId, endNodeId } from 'models/Node/constants'; import { Workflow } from 'models/Workflow/types'; import * as React from 'react'; -import { useContext, useEffect, useMemo, useState } from 'react'; import { useQuery, useQueryClient } from 'react-query'; +import { NodeExecution } from 'models/Execution/types'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; -import { NodeExecutionsByIdContext } from '../contexts'; -import { tabs } from './constants'; -import { NodeExecutionDetailsPanelContent } from './NodeExecutionDetailsPanelContent'; -import { NodeExecutionsTimelineContext } from './Timeline/context'; -import { ExecutionTimeline } from './Timeline/ExecutionTimeline'; -import { ExecutionTimelineFooter } from './Timeline/ExecutionTimelineFooter'; -import { TimeZone } from './Timeline/helpers'; import { ScaleProvider } from './Timeline/scaleContext'; +import { ExecutionTabContent } from './ExecutionTabContent'; -export interface ExecutionTabProps { +interface ExecutionTabProps { 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', - }, -})); - /** Contains the available ways to visualize the nodes of a WorkflowExecution */ -export const ExecutionTab: React.FC = ({ tabType }) => { - const styles = useStyles(); +export const ExecutionTab: React.FC = ({ tabType, filteredNodeExecutions }) => { const queryClient = useQueryClient(); const { workflowId } = useNodeExecutionContext(); const workflowQuery = useQuery(makeWorkflowQuery(queryClient, workflowId)); - const [selectedNodes, setSelectedNodes] = useState([]); - const nodeExecutionsById = useContext(NodeExecutionsByIdContext); - - // 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(undefined); - const [isDetailsTabClosed, setIsDetailsTabClosed] = useState(!selectedExecution); - - useEffect(() => { - setIsDetailsTabClosed(!selectedExecution); - }, [selectedExecution]); - - 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 onCloseDetailsPanel = () => { - setSelectedExecution(null); - setSelectedPhase(undefined); - setSelectedNodes([]); - }; - - const [chartTimezone, setChartTimezone] = useState(TimeZone.Local); - - const handleTimezoneChange = (tz) => setChartTimezone(tz); - - const timelineContext = useMemo( - () => ({ selectedExecution, setSelectedExecution }), - [selectedExecution, setSelectedExecution], - ); - - const renderGraph = (workflow: Workflow) => ( - - ); - return ( - {tabType === tabs.timeline.id && ( -
-
- - ; - -
- -
- )} - {tabType === tabs.graph.id && ( - - {renderGraph} - - )} - {/* Side panel, shows information for specific node */} - - {!isDetailsTabClosed && selectedExecution && ( - + + {() => ( + )} - +
); }; diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx new file mode 100644 index 000000000..21d418583 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx @@ -0,0 +1,247 @@ +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 * as React from 'react'; +import { transformerWorkflowToDag } from 'components/WorkflowGraph/transformerWorkflowToDag'; +import { checkForDynamicExecutions } from 'components/common/utils'; +import { dNode } from 'models/Graph/types'; +import { useContext, useEffect, useMemo, useState } from 'react'; +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 { dag, staticExecutionIdsMap, error } = compiledWorkflowClosure + ? transformerWorkflowToDag(compiledWorkflowClosure) + : { dag: {}, staticExecutionIdsMap: {}, error: null }; + const dynamicParents = checkForDynamicExecutions(nodeExecutionsById, staticExecutionIdsMap); + const { data: dynamicWorkflows } = useQuery( + makeNodeExecutionDynamicWorkflowQuery(dynamicParents), + ); + const [initialNodes, setInitialNodes] = useState([]); + const [initialFilteredNodes, setInitialFilteredNodes] = useState(undefined); + const [mergedDag, setMergedDag] = useState(null); + const [filters, setFilters] = useState(appliedFilters); + const [isFiltersChanged, setIsFiltersChanged] = useState(false); + + useEffect(() => { + const nodes: dNode[] = compiledWorkflowClosure + ? transformerWorkflowToDag(compiledWorkflowClosure, dynamicWorkflows).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, + ); + newMergedDag = dynamicWorkflow.dag; + } + } + } + setMergedDag(newMergedDag); + setInitialNodes(plainNodes); + }, [compiledWorkflowClosure, dynamicWorkflows]); + + 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(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/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx index dbe5db314..c47747f78 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx @@ -42,6 +42,7 @@ import { ExpandableMonospaceText } from '../../common/ExpandableMonospaceText'; import { fetchWorkflowExecution } from '../useWorkflowExecution'; import { NodeExecutionTabs } from './NodeExecutionTabs'; import { ExecutionDetailsActions } from './ExecutionDetailsActions'; +import { getNodeFrontendPhase, isNodeGateNode } from '../utils'; const useStyles = makeStyles((theme: Theme) => { const paddingVertical = `${theme.spacing(2)}px`; @@ -94,6 +95,11 @@ const useStyles = makeStyles((theme: Theme) => { marginTop: theme.spacing(2), paddingTop: theme.spacing(2), }, + actionsContainer: { + borderTop: `1px solid ${theme.palette.divider}`, + marginTop: theme.spacing(2), + paddingTop: theme.spacing(2), + }, nodeTypeContent: { minWidth: theme.spacing(9), }, @@ -135,7 +141,7 @@ const tabIds = { interface NodeExecutionDetailsProps { nodeExecutionId: NodeExecutionIdentifier; - phase?: TaskExecutionPhase; + taskPhase: TaskExecutionPhase; onClose?: () => void; } @@ -228,18 +234,35 @@ const WorkflowTabs: React.FC<{ */ export const NodeExecutionDetailsPanelContent: React.FC = ({ nodeExecutionId, - phase, + taskPhase, onClose, }) => { const commonStyles = useCommonStyles(); const styles = useStyles(); const queryClient = useQueryClient(); - const detailsContext = useNodeExecutionContext(); + const { getNodeExecutionDetails } = useNodeExecutionContext(); + const { compiledWorkflowClosure } = useNodeExecutionContext(); + const isGateNode = isNodeGateNode( + compiledWorkflowClosure?.primary.template.nodes ?? [], + nodeExecutionId, + ); + + const nodeExecutionQuery = useQuery({ + ...makeNodeExecutionQuery(nodeExecutionId), + // The selected NodeExecution has been fetched at this point, we don't want to + // issue an additional fetch. + staleTime: Infinity, + }); + + const nodeExecution = nodeExecutionQuery.data; const [isReasonsVisible, setReasonsVisible] = useState(false); const [dag, setDag] = useState(null); const [details, setDetails] = useState(); const [selectedTaskExecution, setSelectedTaskExecution] = useState(null); + const [nodePhase, setNodePhase] = useState( + nodeExecution?.closure.phase ?? NodeExecutionPhase.UNDEFINED, + ); const isMounted = useRef(false); useEffect(() => { @@ -249,16 +272,9 @@ export const NodeExecutionDetailsPanelContent: React.FC({ - ...makeNodeExecutionQuery(nodeExecutionId), - // The selected NodeExecution has been fetched at this point, we don't want to - // issue an additional fetch. - staleTime: Infinity, - }); - useEffect(() => { let isCurrent = true; - detailsContext.getNodeExecutionDetails(nodeExecution).then((res) => { + getNodeExecutionDetails(nodeExecution).then((res) => { if (isCurrent) { setDetails(res); } @@ -271,13 +287,12 @@ export const NodeExecutionDetailsPanelContent: React.FC { setReasonsVisible(false); + setNodePhase(nodeExecution?.closure.phase ?? NodeExecutionPhase.UNDEFINED); }, [nodeExecutionId]); useEffect(() => { setSelectedTaskExecution(null); - }, [nodeExecutionId, phase]); - - const nodeExecution = nodeExecutionQuery.data; + }, [nodeExecutionId, taskPhase]); const getWorkflowDag = async () => { const workflowExecution = await fetchWorkflowExecution( @@ -331,12 +346,13 @@ export const NodeExecutionDetailsPanelContent: React.FC { - return ( - nodeExecution?.closure.phase === NodeExecutionPhase.QUEUED || - nodeExecution?.closure.phase === NodeExecutionPhase.RUNNING - ); - }, [nodeExecution]); + const frontendPhase = useMemo(() => getNodeFrontendPhase(nodePhase, isGateNode), [nodePhase]); + + const isRunningPhase = useMemo( + () => + frontendPhase === NodeExecutionPhase.QUEUED || frontendPhase === NodeExecutionPhase.RUNNING, + [nodePhase], + ); const handleReasonsVisibility = () => { setReasonsVisible(!isReasonsVisible); @@ -345,7 +361,7 @@ export const NodeExecutionDetailsPanelContent: React.FC
- + {isRunningPhase && ( )} @@ -374,13 +390,14 @@ export const NodeExecutionDetailsPanelContent: React.FC ) : null; - const displayName = details?.displayName ?? ; + const emptyName = isGateNode ? <> : ; + const displayName = details?.displayName ?? emptyName; return (
@@ -396,13 +413,11 @@ export const NodeExecutionDetailsPanelContent: React.FC {statusContent} {!dag && detailsContent} - {details && ( - - )} +
{dag ? : tabsContent} diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx index caf430e7a..b7d72ab85 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/ExecutionTimeline.tsx @@ -1,23 +1,18 @@ import * as React from 'react'; import { makeStyles, Typography } from '@material-ui/core'; - -import { useNodeExecutionContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { transformerWorkflowToDag } from 'components/WorkflowGraph/transformerWorkflowToDag'; import { isEndNode, isStartNode, isExpanded } from 'components/WorkflowGraph/utils'; import { tableHeaderColor } from 'components/Theme/constants'; import { timestampToDate } from 'common/utils'; import { dNode } from 'models/Graph/types'; -import { makeNodeExecutionDynamicWorkflowQuery } from 'components/Workflow/workflowQueries'; -import { useQuery } from 'react-query'; import { createRef, useContext, useEffect, useRef, useState } from 'react'; import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; -import { checkForDynamicExecutions } from 'components/common/utils'; import { convertToPlainNodes } from './helpers'; import { ChartHeader } from './ChartHeader'; import { useScaleContext } from './scaleContext'; import { TaskNames } from './TaskNames'; import { getChartDurationData } from './TimelineChart/chartData'; import { TimelineChart } from './TimelineChart'; +import t from '../strings'; interface StyleProps { chartWidth: number; @@ -72,45 +67,29 @@ const INTERVAL_LENGTH = 110; interface ExProps { chartTimezone: string; + initialNodes: dNode[]; } -export const ExecutionTimeline: React.FC = ({ chartTimezone }) => { +export const ExecutionTimeline: React.FC = ({ chartTimezone, initialNodes }) => { const [chartWidth, setChartWidth] = useState(0); const [labelInterval, setLabelInterval] = useState(INTERVAL_LENGTH); const durationsRef = useRef(null); const durationsLabelsRef = useRef(null); const taskNamesRef = createRef(); - const [originalNodes, setOriginalNodes] = useState([]); + const [originalNodes, setOriginalNodes] = useState(initialNodes); const [showNodes, setShowNodes] = useState([]); const [startedAt, setStartedAt] = useState(new Date()); - - const { compiledWorkflowClosure } = useNodeExecutionContext(); - const { chartInterval: chartTimeInterval } = useScaleContext(); - const { staticExecutionIdsMap } = compiledWorkflowClosure - ? transformerWorkflowToDag(compiledWorkflowClosure) - : []; - const nodeExecutionsById = useContext(NodeExecutionsByIdContext); - - const dynamicParents = checkForDynamicExecutions(nodeExecutionsById, staticExecutionIdsMap); - - const { data: dynamicWorkflows } = useQuery( - makeNodeExecutionDynamicWorkflowQuery(dynamicParents), - ); + const { chartInterval: chartTimeInterval } = useScaleContext(); useEffect(() => { - const nodes: dNode[] = compiledWorkflowClosure - ? transformerWorkflowToDag(compiledWorkflowClosure, dynamicWorkflows).dag.nodes - : []; - // we remove start/end node info in the root dNode list during first assignment - const initializeNodes = convertToPlainNodes(nodes); - setOriginalNodes(initializeNodes); - }, [dynamicWorkflows, compiledWorkflowClosure]); + setOriginalNodes(initialNodes); + }, [initialNodes]); useEffect(() => { - const initializeNodes = convertToPlainNodes(originalNodes); - const updatedShownNodesMap = initializeNodes.map((node) => { + const plainNodes = convertToPlainNodes(originalNodes); + const updatedShownNodesMap = plainNodes.map((node) => { const execution = nodeExecutionsById[node.scopedId]; return { ...node, @@ -191,7 +170,7 @@ export const ExecutionTimeline: React.FC = ({ chartTimezone }) => { return ( <>
- Task Name + {t('taskNameColumnHeader')} ({ selectedExecutionName: { fontWeight: 'bold', }, + displayName: { + marginTop: 4, + textOverflow: 'ellipsis', + width: '100%', + overflow: 'hidden', + }, })); -export const NodeExecutionName: React.FC = ({ name, execution }) => { +export const NodeExecutionName: React.FC = ({ + name, + templateName, + execution, +}) => { const commonStyles = useCommonStyles(); const styles = useStyles(); - const { selectedExecution, setSelectedExecution } = useContext(NodeExecutionsTimelineContext); + const { getNodeExecutionDetails } = useNodeExecutionContext(); + const { selectedExecution, setSelectedExecution } = useContext(DetailsPanelContext); + const [displayName, setDisplayName] = useState(); + + useEffect(() => { + let isCurrent = true; + getNodeExecutionDetails(execution).then((res) => { + if (isCurrent) { + setDisplayName(res.displayName); + } + }); + return () => { + isCurrent = false; + }; + }); if (!execution) { // to avoid crash - disable items which do not have associated execution. // as we won't be able to provide task info for them anyway. return {name}; } - const isSelected = selectedExecution != null && isEqual(execution.id, selectedExecution); - return isSelected ? ( - - {name} - - ) : ( - + + const defaultName = displayName ?? name; + const truncatedName = defaultName?.split('.').pop() || defaultName; + + return ( + <> + {isSelected || execution.closure.phase === NodeExecutionPhase.UNDEFINED ? ( + + {truncatedName} + + ) : ( + + )} + {templateName && ( + + {templateName} + + )} + ); }; diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx index f2d9f4533..d57b09ea9 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/TaskNames.tsx @@ -1,10 +1,12 @@ import * as React from 'react'; -import { makeStyles, Theme, Typography } from '@material-ui/core'; +import { IconButton, makeStyles, Theme, Tooltip } from '@material-ui/core'; import { RowExpander } from 'components/Executions/Tables/RowExpander'; import { getNodeTemplateName } from 'components/WorkflowGraph/utils'; import { dNode } from 'models/Graph/types'; +import PlayCircleOutlineIcon from '@material-ui/icons/PlayCircleOutline'; import { NodeExecutionName } from './NodeExecutionName'; +import t from '../strings'; const useStyles = makeStyles((theme: Theme) => ({ taskNamesList: { @@ -36,12 +38,6 @@ const useStyles = makeStyles((theme: Theme) => ({ height: '100%', overflow: 'hidden', }, - displayName: { - marginTop: 4, - textOverflow: 'ellipsis', - width: '100%', - overflow: 'hidden', - }, leaf: { width: 30, }, @@ -49,48 +45,71 @@ const useStyles = makeStyles((theme: Theme) => ({ interface TaskNamesProps { nodes: dNode[]; - onScroll: () => void; onToggle: (id: string, scopeId: string, level: number) => void; + onAction?: (id: string) => void; + onScroll?: () => void; } -export const TaskNames = React.forwardRef((props, ref) => { - const { nodes, onScroll, onToggle } = props; - const styles = useStyles(); +export const TaskNames = React.forwardRef( + ({ nodes, onScroll, onToggle, onAction }, ref) => { + const styles = useStyles(); - return ( -
- {nodes.map((node) => { - const templateName = getNodeTemplateName(node); - const nodeLevel = node?.level ?? 0; - return ( -
-
- {node.nodes?.length ? ( - onToggle(node.id, node.scopedId, nodeLevel)} - /> - ) : ( -
- )} -
+ return ( +
+ {nodes.map((node) => { + const nodeLevel = node?.level ?? 0; + return ( +
+
+
+ {node.nodes?.length ? ( + onToggle(node.id, node.scopedId, nodeLevel)} + /> + ) : ( +
+ )} +
-
- - - {templateName} - +
+ +
+
+ {onAction && ( + + onAction(node.id)} + data-testid={`resume-gate-node-${node.id}`} + > + + + + )}
-
- ); - })} -
- ); -}); + ); + })} +
+ ); + }, +); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/context.ts b/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/context.ts deleted file mode 100644 index f60b97e0f..000000000 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/Timeline/context.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NodeExecutionIdentifier } from 'models/Execution/types'; -import * as React from 'react'; - -export interface NodeExecutionsTimelineContextData { - selectedExecution?: NodeExecutionIdentifier | null; - setSelectedExecution: (selectedExecutionId: NodeExecutionIdentifier | null) => void; -} - -export const NodeExecutionsTimelineContext = React.createContext( - {} as NodeExecutionsTimelineContextData, -); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx index b94e31dcc..be34cde38 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx @@ -3,6 +3,8 @@ import { createLocalizedString } from '@flyteconsole/locale'; const str = { rerun: 'RERUN', flyteDeck: 'Flyte Deck', + resume: 'Resume', + taskNameColumnHeader: 'Task Name', }; export { patternKey } from '@flyteconsole/locale'; diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx index fd42e9a50..76c24e3d9 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionNodeViews.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } 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'; @@ -11,14 +11,24 @@ import { createTestQueryClient } from 'test/utils'; import { tabs } from '../constants'; import { ExecutionNodeViews } from '../ExecutionNodeViews'; -jest.mock('chart.js', () => ({ - Chart: { register: () => null }, - Tooltip: { positioners: { cursor: () => null } }, - registerables: [], +jest.mock('components/Executions/Tables/NodeExecutionRow', () => ({ + NodeExecutionRow: jest.fn(({ nodeExecution }) => ( +
+ {nodeExecution?.id?.nodeId} +
+ )), })); -jest.mock('chartjs-plugin-datalabels', () => ({ - ChartDataLabels: null, +jest.mock('components/Executions/ExecutionDetails/Timeline/ExecutionTimelineFooter', () => ({ + ExecutionTimelineFooter: jest.fn(() =>
), +})); + +jest.mock('components/Executions/ExecutionDetails/Timeline/TimelineChart/index', () => ({ + TimelineChart: jest.fn(() =>
), +})); + +jest.mock('components/Executions/ExecutionDetails/Timeline/NodeExecutionName', () => ({ + NodeExecutionName: jest.fn(({ name }) =>
{name}
), })); // ExecutionNodeViews uses query params for NE list, so we must match them @@ -64,35 +74,43 @@ describe('ExecutionNodeViews', () => { const failedNodeName = nodeExecutions.failedNode.data.id.nodeId; const succeededNodeName = nodeExecutions.pythonNode.data.id.nodeId; - const { getByText, queryByText } = renderViews(); - const nodesTab = await waitFor(() => getByText(tabs.nodes.label)); - const graphTab = await waitFor(() => getByText(tabs.graph.label)); + const { getByText, queryByText, getByLabelText } = renderViews(); + + await waitFor(() => getByText(tabs.nodes.label)); + + const nodesTab = getByText(tabs.nodes.label); + const timelineTab = getByText(tabs.timeline.label); // Ensure we are on Nodes tab fireEvent.click(nodesTab); - await waitFor(() => getByText(succeededNodeName)); + await waitFor(() => queryByText(succeededNodeName)); const statusButton = await waitFor(() => getByText(filterLabels.status)); // Apply 'Failed' filter and wait for list to include only the failed item fireEvent.click(statusButton); const failedFilter = await waitFor(() => - screen.getByLabelText(nodeExecutionStatusFilters.failed.label), + getByLabelText(nodeExecutionStatusFilters.failed.label), ); // Wait for succeeded task to disappear and ensure failed task remains fireEvent.click(failedFilter); - await waitFor(() => queryByText(succeededNodeName) == null); - await waitFor(() => expect(getByText(failedNodeName)).toBeInTheDocument()); + await waitFor(() => queryByText(failedNodeName)); + + expect(queryByText(succeededNodeName)).not.toBeInTheDocument(); + expect(queryByText(failedNodeName)).toBeInTheDocument(); // Switch to the Graph tab fireEvent.click(statusButton); - fireEvent.click(graphTab); - await waitFor(() => queryByText(failedNodeName) == null); + fireEvent.click(timelineTab); + await waitFor(() => queryByText(succeededNodeName)); + + expect(queryByText(succeededNodeName)).toBeInTheDocument(); // Switch back to Nodes Tab and verify filter still applied fireEvent.click(nodesTab); - await waitFor(() => getByText(failedNodeName)); - expect(queryByText(succeededNodeName)).toBeNull(); + await waitFor(() => queryByText(failedNodeName)); + expect(queryByText(succeededNodeName)).not.toBeInTheDocument(); + expect(queryByText(failedNodeName)).toBeInTheDocument(); }); }); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx new file mode 100644 index 000000000..36e257776 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/ExecutionTabContent.test.tsx @@ -0,0 +1,92 @@ +import { render, waitFor } from '@testing-library/react'; +import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; +import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { insertFixture } from 'mocks/data/insertFixture'; +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 { tabs } from '../constants'; + +jest.mock('components/Workflow/workflowQueries'); +const { fetchWorkflow } = require('components/Workflow/workflowQueries'); + +jest.mock('components/common/DetailsPanel', () => ({ + DetailsPanel: jest.fn(({ children }) =>
{children}
), +})); + +jest.mock('components/Executions/Tables/NodeExecutionsTable', () => ({ + NodeExecutionsTable: jest.fn(({ children }) => ( +
{children}
+ )), +})); +jest.mock('components/Executions/ExecutionDetails/Timeline/ExecutionTimeline', () => ({ + ExecutionTimeline: jest.fn(({ children }) => ( +
{children}
+ )), +})); +jest.mock('components/Executions/ExecutionDetails/Timeline/ExecutionTimelineFooter', () => ({ + ExecutionTimelineFooter: jest.fn(({ children }) => ( +
{children}
+ )), +})); +jest.mock('components/WorkflowGraph/WorkflowGraph', () => ({ + WorkflowGraph: jest.fn(({ children }) =>
{children}
), +})); + +describe('Executions > ExecutionDetails > ExecutionTabContent', () => { + let queryClient: QueryClient; + let fixture: ReturnType; + + beforeEach(() => { + queryClient = createTestQueryClient(); + fixture = basicPythonWorkflow.generate(); + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); + }); + + const renderTabContent = ({ tabType, nodeExecutionsById }) => { + return render( + + + + + + + , + ); + }; + + it('renders NodeExecutionsTable when the Nodes tab is selected', async () => { + const { queryByTestId } = renderTabContent({ + tabType: tabs.nodes.id, + nodeExecutionsById: {}, + }); + + await waitFor(() => queryByTestId('node-executions-table')); + expect(queryByTestId('node-executions-table')).toBeInTheDocument(); + }); + + it('renders WorkflowGraph when the Graph tab is selected', async () => { + const { queryByTestId } = renderTabContent({ + tabType: tabs.graph.id, + nodeExecutionsById: {}, + }); + + await waitFor(() => queryByTestId('workflow-graph')); + expect(queryByTestId('workflow-graph')).toBeInTheDocument(); + }); + + it('renders ExecutionTimeline when the Timeline tab is selected', async () => { + const { queryByTestId } = renderTabContent({ + tabType: tabs.timeline.id, + nodeExecutionsById: {}, + }); + + await waitFor(() => queryByTestId('execution-timeline')); + expect(queryByTestId('execution-timeline')).toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx deleted file mode 100644 index 02570b12a..000000000 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetails.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { render, waitFor } from '@testing-library/react'; -import { cacheStatusMessages, viewSourceExecutionString } from 'components/Executions/constants'; -import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; -import { mockWorkflowId } from 'mocks/data/fixtures/types'; -import { insertFixture } from 'mocks/data/insertFixture'; -import { mockServer } from 'mocks/server'; -import { ResourceType } from 'models/Common/types'; -import { CatalogCacheStatus } from 'models/Execution/enums'; -import { NodeExecution, TaskNodeMetadata } from 'models/Execution/types'; -import { mockExecution as mockTaskExecution } from 'models/Execution/__mocks__/mockTaskExecutionsData'; -import * as React from 'react'; -import { QueryClient, QueryClientProvider } from 'react-query'; -import { MemoryRouter } from 'react-router'; -import { Routes } from 'routes/routes'; -import { makeIdentifier } from 'test/modelUtils'; -import { createTestQueryClient } from 'test/utils'; -import { NodeExecutionDetailsPanelContent } from '../NodeExecutionDetailsPanelContent'; - -jest.mock('components/Workflow/workflowQueries'); -const { fetchWorkflow } = require('components/Workflow/workflowQueries'); - -describe('NodeExecutionDetails', () => { - let fixture: ReturnType; - let execution: NodeExecution; - let queryClient: QueryClient; - - beforeEach(() => { - fixture = basicPythonWorkflow.generate(); - execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - queryClient = createTestQueryClient(); - }); - - const renderComponent = () => - render( - - - - - - - , - ); - - it('renders name for task nodes', async () => { - const { name } = fixture.tasks.python.id; - const { getByText } = renderComponent(); - await waitFor(() => expect(getByText(name))); - }); - - describe('with cache information', () => { - let taskNodeMetadata: TaskNodeMetadata; - beforeEach(() => { - taskNodeMetadata = { - cacheStatus: CatalogCacheStatus.CACHE_MISS, - catalogKey: { - datasetId: makeIdentifier({ - resourceType: ResourceType.DATASET, - }), - sourceTaskExecution: { ...mockTaskExecution.id }, - }, - }; - execution.closure.taskNodeMetadata = taskNodeMetadata; - mockServer.insertNodeExecution(execution); - }); - - [ - CatalogCacheStatus.CACHE_DISABLED, - CatalogCacheStatus.CACHE_HIT, - CatalogCacheStatus.CACHE_LOOKUP_FAILURE, - CatalogCacheStatus.CACHE_MISS, - CatalogCacheStatus.CACHE_POPULATED, - CatalogCacheStatus.CACHE_PUT_FAILURE, - ].forEach((cacheStatusValue) => - it(`renders correct status for ${CatalogCacheStatus[cacheStatusValue]}`, async () => { - taskNodeMetadata.cacheStatus = cacheStatusValue; - mockServer.insertNodeExecution(execution); - const { getByText } = renderComponent(); - await waitFor(() => expect(getByText(cacheStatusMessages[cacheStatusValue]))); - }), - ); - - it('renders source execution link for cache hits', async () => { - taskNodeMetadata.cacheStatus = CatalogCacheStatus.CACHE_HIT; - const sourceWorkflowExecutionId = - taskNodeMetadata.catalogKey!.sourceTaskExecution.nodeExecutionId.executionId; - const { getByText } = renderComponent(); - const linkEl = await waitFor(() => getByText(viewSourceExecutionString)); - expect(linkEl.getAttribute('href')).toBe( - Routes.ExecutionDetails.makeUrl(sourceWorkflowExecutionId), - ); - }); - }); -}); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx new file mode 100644 index 000000000..4dc9eeec5 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionDetailsPanelContent.test.tsx @@ -0,0 +1,50 @@ +import { render, waitFor } from '@testing-library/react'; +import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +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/Workflow/workflowQueries'); +const { fetchWorkflow } = require('components/Workflow/workflowQueries'); + +describe('NodeExecutionDetailsPanelContent', () => { + let fixture: ReturnType; + let execution: NodeExecution; + let queryClient: QueryClient; + + beforeEach(() => { + fixture = basicPythonWorkflow.generate(); + execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); + queryClient = createTestQueryClient(); + }); + + const renderComponent = () => + render( + + + + + + + , + ); + + it('renders name for task nodes', async () => { + const { name } = fixture.tasks.python.id; + const { getByText } = renderComponent(); + await waitFor(() => expect(getByText(name)).toBeInTheDocument()); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionName.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionName.test.tsx new file mode 100644 index 000000000..4dc161d0a --- /dev/null +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/NodeExecutionName.test.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { createTestQueryClient } from 'test/utils'; +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 { NodeExecutionName } from '../Timeline/NodeExecutionName'; + +jest.mock('components/Workflow/workflowQueries'); +const { fetchWorkflow } = require('components/Workflow/workflowQueries'); + +const name = 'Test'; +const templateName = 'TemplateTest'; + +describe('Executions > ExecutionDetails > NodeExecutionName', () => { + let queryClient: QueryClient; + let fixture: ReturnType; + let execution: NodeExecution; + + beforeEach(() => { + fixture = basicPythonWorkflow.generate(); + execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + queryClient = createTestQueryClient(); + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); + }); + + const renderComponent = (props) => + render( + + + + + , + ); + + it('should only display title if execution is not provided', async () => { + const { queryByText } = renderComponent({ name, templateName }); + await waitFor(() => queryByText(name)); + + expect(queryByText(name)).toBeInTheDocument(); + expect(queryByText(templateName)).not.toBeInTheDocument(); + }); + + it('should only display title if template name is not provided', async () => { + const resultName = 'PythonTask'; + const { queryByText } = renderComponent({ name, execution }); + await waitFor(() => queryByText(resultName)); + + expect(queryByText(resultName)).toBeInTheDocument(); + expect(queryByText(templateName)).not.toBeInTheDocument(); + }); + + it('should display title and subtitle if template name is provided', async () => { + const resultName = 'PythonTask'; + const { queryByText } = renderComponent({ name, templateName, execution }); + await waitFor(() => queryByText(resultName)); + + expect(queryByText(resultName)).toBeInTheDocument(); + expect(queryByText(templateName)).toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx new file mode 100644 index 000000000..51da24a06 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/test/TaskNames.test.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { dTypes } from 'models/Graph/types'; +import { TaskNames } from '../Timeline/TaskNames'; + +const onToggle = jest.fn(); +const onAction = jest.fn(); + +const node1 = { + id: 'n1', + scopedId: 'n1', + type: dTypes.staticNode, + name: 'node1', + nodes: [], + edges: [], +}; + +const node2 = { + id: 'n2', + scopedId: 'n2', + type: dTypes.gateNode, + name: 'node2', + nodes: [], + edges: [], +}; + +describe('ExecutionDetails > Timeline > TaskNames', () => { + const renderComponent = (props) => render(); + + it('should render task names list', () => { + const nodes = [node1, node2]; + const { getAllByTestId } = renderComponent({ nodes, onToggle }); + expect(getAllByTestId('task-name-item').length).toEqual(nodes.length); + }); + + it('should render task names list with resume buttons if onAction prop is passed', () => { + const nodes = [node1, node2]; + const { getAllByTestId, getAllByTitle } = renderComponent({ nodes, onToggle, onAction }); + expect(getAllByTestId('task-name-item').length).toEqual(nodes.length); + expect(getAllByTitle('Resume').length).toEqual(nodes.length); + }); + + it('should render task names list with expanders if nodes contain nested nodes list', () => { + const nestedNodes = [ + { id: 't1', scopedId: 'n1', type: dTypes.task, name: 'task1', nodes: [], edges: [] }, + ]; + const nodes = [ + { ...node1, nodes: nestedNodes }, + { ...node2, nodes: nestedNodes }, + ]; + const { getAllByTestId, getAllByTitle } = renderComponent({ nodes, onToggle }); + expect(getAllByTestId('task-name-item').length).toEqual(nodes.length); + expect(getAllByTitle('Expand row').length).toEqual(nodes.length); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/ExecutionFilters.tsx b/packages/zapp/console/src/components/Executions/ExecutionFilters.tsx index 2b4ce8d5c..a93ad844b 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionFilters.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionFilters.tsx @@ -79,8 +79,12 @@ export const ExecutionFilters: React.FC = ({ filters = filters.map((filter) => { const onChangeFunc = filter.onChange; filter.onChange = (value) => { - if (clearCharts) clearCharts(); - if (onChangeFunc) onChangeFunc(value); + if (clearCharts) { + clearCharts(); + } + if (onChangeFunc) { + onChangeFunc(value); + } }; return filter; }); diff --git a/packages/zapp/console/src/components/Executions/NodeExecutionCacheStatus.tsx b/packages/zapp/console/src/components/Executions/NodeExecutionCacheStatus.tsx index a16960dec..1345ba684 100644 --- a/packages/zapp/console/src/components/Executions/NodeExecutionCacheStatus.tsx +++ b/packages/zapp/console/src/components/Executions/NodeExecutionCacheStatus.tsx @@ -1,70 +1,13 @@ -import { SvgIconProps, Tooltip, Typography } from '@material-ui/core'; -import { makeStyles, Theme } from '@material-ui/core/styles'; -import CachedOutlined from '@material-ui/icons/CachedOutlined'; -import ErrorOutlined from '@material-ui/icons/ErrorOutlined'; -import InfoOutlined from '@material-ui/icons/InfoOutlined'; -import classnames from 'classnames'; -import { assertNever } from 'common/utils'; -import { PublishedWithChangesOutlined } from 'components/common/PublishedWithChanges'; -import { useCommonStyles } from 'components/common/styles'; import { NodeExecutionDetails } from 'components/Executions/types'; import { useNodeExecutionContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; import { CatalogCacheStatus } from 'models/Execution/enums'; -import { NodeExecution, TaskExecutionIdentifier } from 'models/Execution/types'; -import { MapCacheIcon } from '@flyteconsole/ui-atoms'; +import { NodeExecution } from 'models/Execution/types'; import * as React from 'react'; import { isMapTaskType } from 'models/Task/utils'; -import { Link as RouterLink } from 'react-router-dom'; -import { Routes } from 'routes/routes'; -import { - cacheStatusMessages, - unknownCacheStatusString, - viewSourceExecutionString, -} from './constants'; +import { useEffect, useState } from 'react'; +import { CacheStatus } from './CacheStatus'; -const useStyles = makeStyles((theme: Theme) => ({ - cacheStatus: { - alignItems: 'center', - display: 'flex', - marginTop: theme.spacing(1), - }, - sourceExecutionLink: { - fontWeight: 'normal', - }, -})); - -/** Renders the appropriate icon for a given CatalogCacheStatus */ -export const NodeExecutionCacheStatusIcon: React.FC< - SvgIconProps & { - status: CatalogCacheStatus; - } -> = React.forwardRef(({ status, ...props }, ref) => { - switch (status) { - case CatalogCacheStatus.CACHE_DISABLED: - case CatalogCacheStatus.CACHE_MISS: { - return ; - } - case CatalogCacheStatus.CACHE_HIT: { - return ; - } - case CatalogCacheStatus.CACHE_POPULATED: { - return ; - } - case CatalogCacheStatus.CACHE_LOOKUP_FAILURE: - case CatalogCacheStatus.CACHE_PUT_FAILURE: { - return ; - } - case CatalogCacheStatus.MAP_CACHE: { - return ; - } - default: { - assertNever(status); - return null; - } - } -}); - -export interface NodeExecutionCacheStatusProps { +interface NodeExecutionCacheStatusProps { execution: NodeExecution; /** `normal` will render an icon with description message beside it * `iconOnly` will render just the icon with the description as a tooltip @@ -84,12 +27,12 @@ export const NodeExecutionCacheStatus: React.FC = variant = 'normal', }) => { const taskNodeMetadata = execution.closure?.taskNodeMetadata; - const detailsContext = useNodeExecutionContext(); - const [nodeDetails, setNodeDetails] = React.useState(); + const { getNodeExecutionDetails } = useNodeExecutionContext(); + const [nodeDetails, setNodeDetails] = useState(); - React.useEffect(() => { + useEffect(() => { let isCurrent = true; - detailsContext.getNodeExecutionDetails(execution).then((res) => { + getNodeExecutionDetails(execution).then((res) => { if (isCurrent) { setNodeDetails(res); } @@ -110,74 +53,11 @@ export const NodeExecutionCacheStatus: React.FC = return null; } - const sourceTaskExecution = taskNodeMetadata.catalogKey?.sourceTaskExecution; - return ( ); }; - -export interface CacheStatusProps { - cacheStatus: CatalogCacheStatus | null | undefined; - /** `normal` will render an icon with description message beside it - * `iconOnly` will render just the icon with the description as a tooltip - */ - variant?: 'normal' | 'iconOnly'; - sourceTaskExecution?: TaskExecutionIdentifier; - iconStyles?: React.CSSProperties; -} - -export const CacheStatus: React.FC = ({ - cacheStatus, - sourceTaskExecution, - variant = 'normal', - iconStyles, -}) => { - const commonStyles = useCommonStyles(); - const styles = useStyles(); - - if (cacheStatus == null) { - return null; - } - - const message = cacheStatusMessages[cacheStatus] || unknownCacheStatusString; - - const sourceExecutionId = sourceTaskExecution; - const sourceExecutionLink = sourceExecutionId ? ( - - {viewSourceExecutionString} - - ) : null; - - return variant === 'iconOnly' ? ( - - - - ) : ( -
- - - {message} - - {sourceExecutionLink} -
- ); -}; diff --git a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionActions.tsx b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionActions.tsx index 430f82704..5fbb68fda 100644 --- a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionActions.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionActions.tsx @@ -2,6 +2,7 @@ import { IconButton, Tooltip } from '@material-ui/core'; import { NodeExecution } from 'models/Execution/types'; import * as React from 'react'; import InputsAndOutputsIcon from '@material-ui/icons/Tv'; +import PlayCircleOutlineIcon from '@material-ui/icons/PlayCircleOutline'; import { RerunIcon } from '@flyteconsole/ui-atoms'; import { Identifier, ResourceIdentifier } from 'models/Common/types'; import { LaunchFormDialog } from 'components/Launch/LaunchForm/LaunchFormDialog'; @@ -9,38 +10,56 @@ 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 { NodeExecutionsTableState } from './types'; +import { useContext, useEffect, useState } from 'react'; +import { NodeExecutionPhase } from 'models/Execution/enums'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; import { NodeExecutionDetails } from '../types'; import t from './strings'; +import { getNodeFrontendPhase, isNodeGateNode } from '../utils'; +import { DetailsPanelContext } from '../ExecutionDetails/DetailsPanelContext'; interface NodeExecutionActionsProps { execution: NodeExecution; - state: NodeExecutionsTableState; } -export const NodeExecutionActions = (props: NodeExecutionActionsProps): JSX.Element => { - const { execution, state } = props; +export const NodeExecutionActions = ({ execution }: NodeExecutionActionsProps): JSX.Element => { + const { compiledWorkflowClosure, getNodeExecutionDetails } = useNodeExecutionContext(); + const { setSelectedExecution } = useContext(DetailsPanelContext); - const detailsContext = useNodeExecutionContext(); - const [showLaunchForm, setShowLaunchForm] = React.useState(false); - const [nodeExecutionDetails, setNodeExecutionDetails] = React.useState< + const [showLaunchForm, setShowLaunchForm] = useState(false); + const [showResumeForm, setShowResumeForm] = useState(false); + const [nodeExecutionDetails, setNodeExecutionDetails] = useState< NodeExecutionDetails | undefined - >(); - const [initialParameters, setInitialParameters] = React.useState< + >(undefined); + const [initialParameters, setInitialParameters] = useState< TaskInitialLaunchParameters | undefined >(undefined); const executionData = useNodeExecutionData(execution.id); const id = nodeExecutionDetails?.taskTemplate?.id; - React.useEffect(() => { - detailsContext.getNodeExecutionDetails(execution).then((res) => { - setNodeExecutionDetails(res); + const isGateNode = isNodeGateNode( + compiledWorkflowClosure?.primary.template.nodes ?? [], + execution.id, + ); + const phase = getNodeFrontendPhase(execution.closure.phase, isGateNode); + const compiledNode = (compiledWorkflowClosure?.primary.template.nodes ?? []).find( + (node) => node.id === execution.id.nodeId, + ); + + useEffect(() => { + let isCurrent = true; + getNodeExecutionDetails(execution).then((res) => { + if (isCurrent) { + setNodeExecutionDetails(res); + } }); + return () => { + isCurrent = false; + }; }); - React.useEffect(() => { + useEffect(() => { if (!id) { return; } @@ -65,7 +84,7 @@ export const NodeExecutionActions = (props: NodeExecutionActionsProps): JSX.Elem // prevent the parent row body onClick event trigger e.stopPropagation(); // use null in case if there is no execution provided - when it is null will close panel - state.setSelectedExecution(execution?.id ?? null); + setSelectedExecution(execution?.id ?? null); }; const rerunIconOnClick = (e: React.MouseEvent) => { @@ -73,36 +92,49 @@ export const NodeExecutionActions = (props: NodeExecutionActionsProps): JSX.Elem setShowLaunchForm(true); }; - const renderRerunAction = () => { - if (!id || !initialParameters) { - return <>; - } - - return ( - <> - - - - - - - - ); + const onResumeClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setShowResumeForm(true); }; return (
+ {phase === NodeExecutionPhase.PAUSED && ( + + + + + + )} - {renderRerunAction()} + {id && initialParameters ? ( + <> + + + + + + + + ) : null} + {compiledNode && ( + + )}
); }; diff --git a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionChildren.tsx b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionChildren.tsx deleted file mode 100644 index 53e2a1b6e..000000000 --- a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionChildren.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { Button, Typography } from '@material-ui/core'; -import { Theme, makeStyles } from '@material-ui/core/styles'; -import classnames from 'classnames'; -import { getCacheKey } from 'components/Cache/utils'; -import { useTheme } from 'components/Theme/useTheme'; -import { Admin } from 'flyteidl'; -import * as React from 'react'; -import { NodeExecutionGroup } from '../types'; -import { NodeExecutionRow } from './NodeExecutionRow'; -import { useExecutionTableStyles } from './styles'; -import { calculateNodeExecutionRowLeftSpacing } from './utils'; - -export interface NodeExecutionChildrenProps { - abortMetadata?: Admin.IAbortMetadata; - childGroups: NodeExecutionGroup[]; - level: number; -} - -const PAGE_SIZE = 50; - -const useStyles = makeStyles((theme: Theme) => ({ - loadMoreContainer: { - display: 'flex', - justifyContent: 'center', - marginTop: theme.spacing(1), - marginBottom: theme.spacing(1), - }, -})); - -/** Renders a nested list of row items for children of a NodeExecution */ -export const NodeExecutionChildren: React.FC = ({ - abortMetadata, - childGroups, - level, -}) => { - const styles = useStyles(); - const showNames = childGroups.length > 1; - const tableStyles = useExecutionTableStyles(); - const theme = useTheme(); - const childGroupLabelStyle = { - // The label is aligned with the parent above, so remove one level of spacing - marginLeft: `${calculateNodeExecutionRowLeftSpacing(level - 1, theme.spacing)}px`, - }; - const [loadedNodes, setLoadedNodes] = React.useState( - new Array(childGroups.length).fill(PAGE_SIZE), - ); - - const loadMoreRows = React.useCallback( - (which: number) => () => { - const newLoadedNodes = [...loadedNodes]; - newLoadedNodes[which] += PAGE_SIZE; - setLoadedNodes(newLoadedNodes); - }, - [loadedNodes], - ); - - const loadMoreButton = (which: number) => ( -
- -
- ); - - return ( - <> - {childGroups.map(({ name, nodeExecutions }, groupIndex) => { - const rows = nodeExecutions - .slice(0, loadedNodes[groupIndex]) - .map((nodeExecution, index) => ( - - )); - const key = `group-${name}`; - return showNames ? ( -
-
0 }, - tableStyles.borderBottom, - tableStyles.childGroupLabel, - )} - title={name} - style={childGroupLabelStyle} - > - - {name} - -
-
{rows}
- {loadMoreButton(groupIndex)} -
- ) : ( -
- {rows} - {nodeExecutions.length > loadedNodes[groupIndex] && loadMoreButton(groupIndex)} -
- ); - })} - - ); -}; diff --git a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx index e1d75f2db..14d18d0f2 100644 --- a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx @@ -1,114 +1,83 @@ -import { CircularProgress, IconButton } from '@material-ui/core'; -import { Admin } from 'flyteidl'; -import ErrorOutline from '@material-ui/icons/ErrorOutline'; import classnames from 'classnames'; -import { useTheme } from 'components/Theme/useTheme'; -import { isEqual } from 'lodash'; import { NodeExecution } from 'models/Execution/types'; +import { dNode } from 'models/Graph/types'; +import { NodeExecutionPhase } from 'models/Execution/enums'; import * as React from 'react'; -import { NodeExecutionsRequestConfigContext } from '../contexts'; -import { useChildNodeExecutionGroupsQuery } from '../nodeExecutionQueries'; -import { titleStrings } from './constants'; -import { NodeExecutionsTableContext } from './contexts'; -import { ExpandableExecutionError } from './ExpandableExecutionError'; -import { NodeExecutionChildren } from './NodeExecutionChildren'; -import { RowExpander } from './RowExpander'; +import { 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 { NodeExecutionColumnDefinition } from './types'; +import { DetailsPanelContext } from '../ExecutionDetails/DetailsPanelContext'; +import { RowExpander } from './RowExpander'; import { calculateNodeExecutionRowLeftSpacing } from './utils'; +const useStyles = makeStyles(() => ({ + namesContainerExpander: { + display: 'flex', + marginTop: 'auto', + marginBottom: 'auto', + }, + leaf: { + width: 30, + }, +})); + interface NodeExecutionRowProps { - abortMetadata?: Admin.IAbortMetadata; - index: number; - execution: NodeExecution; + columns: NodeExecutionColumnDefinition[]; + nodeExecution: NodeExecution; level?: number; style?: React.CSSProperties; + node: dNode; + onToggle: (id: string, scopeId: string, level: number) => void; } -const ChildFetchErrorIcon: React.FC<{ - query: ReturnType; -}> = ({ query }) => { - return query.isFetching ? ( - - ) : ( - ) => { - // prevent the parent row body onClick event trigger - e.stopPropagation(); - query.refetch(); - }} - > - - - ); -}; - /** Renders a NodeExecution as a row inside a `NodeExecutionsTable` */ export const NodeExecutionRow: React.FC = ({ - abortMetadata, - execution: nodeExecution, - index, - level = 0, + columns, + nodeExecution, + node, style, + onToggle, }) => { + const styles = useStyles(); const theme = useTheme(); - const { columns, state } = React.useContext(NodeExecutionsTableContext); - const requestConfig = React.useContext(NodeExecutionsRequestConfigContext); + const tableStyles = useExecutionTableStyles(); + const { selectedExecution, setSelectedExecution } = useContext(DetailsPanelContext); - const [expanded, setExpanded] = React.useState(false); - const toggleExpanded = () => { - setExpanded(!expanded); - }; + const nodeLevel = node?.level ?? 0; + const expanded = isExpanded(node); // For the first level, we want the borders to span the entire table, // so we'll use padding to space the content. For nested rows, we want the // border to start where the content does, so we'll use margin. - const spacingProp = level === 0 ? 'paddingLeft' : 'marginLeft'; + const spacingProp = nodeLevel === 0 ? 'paddingLeft' : 'marginLeft'; const rowContentStyle = { - [spacingProp]: `${calculateNodeExecutionRowLeftSpacing(level, theme.spacing)}px`, + [spacingProp]: `${calculateNodeExecutionRowLeftSpacing(nodeLevel, theme.spacing)}px`, }; - const childGroupsQuery = useChildNodeExecutionGroupsQuery(nodeExecution, requestConfig); - const { data: childGroups = [] } = childGroupsQuery; - - const isExpandable = childGroups.length > 0; - const tableStyles = useExecutionTableStyles(); - - const selected = state.selectedExecution - ? isEqual(state.selectedExecution, nodeExecution) - : false; - const { error } = nodeExecution.closure; - - const expanderContent = childGroupsQuery.error ? ( - - ) : isExpandable ? ( - - ) : null; + const selected = selectedExecution ? isEqual(selectedExecution, nodeExecution) : false; - const errorContent = error ? ( - - ) : null; - - const extraContent = expanded ? ( -
- + const expanderContent = ( +
+ {node.nodes?.length ? ( + onToggle(node.id, node.scopedId, nodeLevel)} + /> + ) : ( +
+ )}
- ) : null; + ); // 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 = () => state.setSelectedExecution(nodeExecution?.id ?? null); + const onClickRow = () => + nodeExecution.closure.phase !== NodeExecutionPhase.UNDEFINED && + setSelectedExecution(nodeExecution?.id ?? null); return (
= ({ style={style} onClick={onClickRow} > -
0 && expanded), - [tableStyles.borderTop]: level > 0 && index > 0, - })} - style={rowContentStyle} - > +
{expanderContent} @@ -133,15 +96,13 @@ export const NodeExecutionRow: React.FC = ({ {columns.map(({ className, key: columnKey, cellRenderer }) => (
{cellRenderer({ - state, + node, execution: nodeExecution, })}
))}
- {errorContent}
- {extraContent}
); }; diff --git a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx index 91ec5d212..0fbe35edf 100644 --- a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx @@ -1,95 +1,141 @@ import classnames from 'classnames'; -import { Admin } from 'flyteidl'; import { getCacheKey } from 'components/Cache/utils'; -import { DetailsPanel } from 'components/common/DetailsPanel'; import { useCommonStyles } from 'components/common/styles'; import * as scrollbarSize from 'dom-helpers/util/scrollbarSize'; -import { NodeExecution, NodeExecutionIdentifier } from 'models/Execution/types'; +import { NodeExecution } from 'models/Execution/types'; +import { dNode } from 'models/Graph/types'; +import { NodeExecutionPhase } from 'models/Execution/enums'; +import { dateToTimestamp } from 'common/utils'; import * as React from 'react'; -import { NodeExecutionDetailsPanelContent } from '../ExecutionDetails/NodeExecutionDetailsPanelContent'; -import { NodeExecutionsTableContext } from './contexts'; +import { useMemo, useEffect, useState, useContext } from 'react'; +import { isEndNode, isExpanded, isStartNode } from 'components/WorkflowGraph/utils'; import { ExecutionsTableHeader } from './ExecutionsTableHeader'; import { generateColumns } from './nodeExecutionColumns'; -import { NodeExecutionRow } from './NodeExecutionRow'; 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 { NodeExecutionRow } from './NodeExecutionRow'; +import { useNodeExecutionFiltersState } from '../filters/useExecutionFiltersState'; -export interface NodeExecutionsTableProps { - abortMetadata?: Admin.IAbortMetadata; - nodeExecutions: NodeExecution[]; +interface NodeExecutionsTableProps { + initialNodes: dNode[]; + filteredNodes?: dNode[]; } const scrollbarPadding = scrollbarSize(); +/** + * TODO + * Refactor to avoid code duplication here and in ExecutionTimeline, ie toggleNode, the insides of the effect + */ + /** 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 = ({ - abortMetadata, - nodeExecutions, + initialNodes, + filteredNodes, }) => { - const [selectedExecution, setSelectedExecution] = React.useState( - null, - ); const commonStyles = useCommonStyles(); const tableStyles = useExecutionTableStyles(); - - const executionsWithKeys = React.useMemo( - () => - nodeExecutions.map((nodeExecution) => ({ - nodeExecution, - cacheKey: getCacheKey(nodeExecution.id), - })), - [nodeExecutions], + const nodeExecutionsById = useContext(NodeExecutionsByIdContext); + const { appliedFilters } = useNodeExecutionFiltersState(); + const [originalNodes, setOriginalNodes] = useState( + appliedFilters.length > 0 && filteredNodes ? filteredNodes : initialNodes, ); + const [showNodes, setShowNodes] = useState([]); + const { compiledWorkflowClosure } = useNodeExecutionContext(); const columnStyles = useColumnStyles(); // Memoizing columns so they won't be re-generated unless the styles change - const columns = React.useMemo(() => generateColumns(columnStyles), [columnStyles]); - const tableContext = React.useMemo( - () => ({ columns, state: { selectedExecution, setSelectedExecution } }), - [columns, selectedExecution, setSelectedExecution], + const columns = useMemo( + () => generateColumns(columnStyles, compiledWorkflowClosure?.primary.template.nodes ?? []), + [columnStyles], ); - const onCloseDetailsPanel = () => setSelectedExecution(null); + useEffect(() => { + setOriginalNodes(appliedFilters.length > 0 && filteredNodes ? filteredNodes : initialNodes); + const plainNodes = convertToPlainNodes(originalNodes); + const updatedShownNodesMap = plainNodes.map((node) => { + const execution = nodeExecutionsById[node.scopedId]; + return { + ...node, + startedAt: execution?.closure.startedAt, + execution, + }; + }); + setShowNodes(updatedShownNodesMap); + }, [initialNodes, filteredNodes, originalNodes, nodeExecutionsById]); - const rowProps = { - selectedExecution, - setSelectedExecution, + const toggleNode = (id: string, scopeId: string, level: number) => { + const searchNode = (nodes: dNode[], nodeLevel: number) => { + if (!nodes || nodes.length === 0) { + return; + } + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (isStartNode(node) || isEndNode(node)) { + continue; + } + if (node.id === id && node.scopedId === scopeId && nodeLevel === level) { + nodes[i].expanded = !nodes[i].expanded; + return; + } + if (node.nodes.length > 0 && isExpanded(node)) { + searchNode(node.nodes, nodeLevel + 1); + } + } + }; + searchNode(originalNodes, 0); + setOriginalNodes([...originalNodes]); }; - const content = - executionsWithKeys.length > 0 ? ( - executionsWithKeys.map(({ nodeExecution, cacheKey }, index) => { - return ( - - ); - }) - ) : ( - - ); return (
- -
{content}
-
- - {selectedExecution != null ? ( - - ) : null} - +
+ {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 ( + + ); + }) + ) : ( + + )} +
); }; diff --git a/packages/zapp/console/src/components/Executions/Tables/RowExpander.tsx b/packages/zapp/console/src/components/Executions/Tables/RowExpander.tsx index e85a68349..cca089ddb 100644 --- a/packages/zapp/console/src/components/Executions/Tables/RowExpander.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/RowExpander.tsx @@ -2,7 +2,7 @@ import { IconButton } from '@material-ui/core'; import ChevronRight from '@material-ui/icons/ChevronRight'; import ExpandMore from '@material-ui/icons/ExpandMore'; import * as React from 'react'; -import { titleStrings } from './constants'; +import t from './strings'; /** A simple expand/collapse arrow to be rendered next to row items. */ export const RowExpander: React.FC<{ @@ -13,7 +13,7 @@ export const RowExpander: React.FC<{ disableRipple={true} disableTouchRipple={true} size="small" - title={titleStrings.expandRow} + title={t('expanderTitle', expanded)} onClick={(e: React.MouseEvent) => { // prevent the parent row body onClick event trigger e.stopPropagation(); diff --git a/packages/zapp/console/src/components/Executions/Tables/constants.ts b/packages/zapp/console/src/components/Executions/Tables/constants.ts index 8e7238444..1a182a334 100644 --- a/packages/zapp/console/src/components/Executions/Tables/constants.ts +++ b/packages/zapp/console/src/components/Executions/Tables/constants.ts @@ -9,7 +9,7 @@ export const workflowExecutionsTableColumnWidths = { export const nodeExecutionsTableColumnWidths = { duration: 100, - logs: 100, + logs: 138, type: 144, nodeId: 144, name: 380, @@ -17,12 +17,6 @@ export const nodeExecutionsTableColumnWidths = { startedAt: 200, }; -export const titleStrings = { - childGroupFetchFailed: 'Failed to fetch children. Click to retry.', - expandRow: 'Expand row', - groupName: 'Group name', -}; - export const workflowVersionsTableColumnWidths = { radio: 40, name: 380, diff --git a/packages/zapp/console/src/components/Executions/Tables/contexts.ts b/packages/zapp/console/src/components/Executions/Tables/contexts.ts deleted file mode 100644 index 1f8a8ca90..000000000 --- a/packages/zapp/console/src/components/Executions/Tables/contexts.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from 'react'; -import { NodeExecutionColumnDefinition, NodeExecutionsTableState } from './types'; - -export interface NodeExecutionsTableContextData { - columns: NodeExecutionColumnDefinition[]; - state: NodeExecutionsTableState; -} - -export const NodeExecutionsTableContext = React.createContext( - {} as NodeExecutionsTableContextData, -); diff --git a/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx b/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx index 1160df913..67521079b 100644 --- a/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx @@ -2,75 +2,29 @@ import { Tooltip, Typography } from '@material-ui/core'; import { formatDateLocalTimezone, formatDateUTC, millisecondsToHMS } from 'common/formatters'; import { timestampToDate } from 'common/utils'; import { useCommonStyles } from 'components/common/styles'; -import { isEqual } from 'lodash'; -import { NodeExecutionPhase } from 'models/Execution/enums'; import * as React from 'react'; import { useEffect, useState } from 'react'; +import { CompiledNode } from 'models/Node/types'; +import { NodeExecutionPhase } from 'models/Execution/enums'; +import { getNodeTemplateName } from 'components/WorkflowGraph/utils'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; import { ExecutionStatusBadge } from '../ExecutionStatusBadge'; import { NodeExecutionCacheStatus } from '../NodeExecutionCacheStatus'; -import { getNodeExecutionTimingMS } from '../utils'; +import { getNodeExecutionTimingMS, getNodeFrontendPhase, isNodeGateNode } from '../utils'; import { NodeExecutionActions } from './NodeExecutionActions'; -import { SelectNodeExecutionLink } from './SelectNodeExecutionLink'; import { useColumnStyles } from './styles'; import { NodeExecutionCellRendererData, NodeExecutionColumnDefinition } from './types'; - -const ExecutionName: React.FC = ({ execution, state }) => { - const detailsContext = useNodeExecutionContext(); - const [displayName, setDisplayName] = useState(); - - useEffect(() => { - let isCurrent = true; - detailsContext.getNodeExecutionDetails(execution).then((res) => { - if (isCurrent) { - setDisplayName(res.displayName); - } - }); - return () => { - isCurrent = false; - }; - }); - - const commonStyles = useCommonStyles(); - const styles = useColumnStyles(); - const { selectedExecution, setSelectedExecution } = state; - - const isSelected = state.selectedExecution != null && isEqual(execution.id, selectedExecution); - - const name = displayName ?? execution.id.nodeId; - const truncatedName = name?.split('.').pop() || name; - - const readableName = isSelected ? ( - - {truncatedName} - - ) : ( - - ); - - return ( - <> - {readableName} - - {displayName} - - - ); -}; +import t from '../strings'; +import { NodeExecutionName } from '../ExecutionDetails/Timeline/NodeExecutionName'; const DisplayId: React.FC = ({ execution }) => { const commonStyles = useCommonStyles(); - const detailsContext = useNodeExecutionContext(); + const { getNodeExecutionDetails } = useNodeExecutionContext(); const [displayId, setDisplayId] = useState(); useEffect(() => { let isCurrent = true; - detailsContext.getNodeExecutionDetails(execution).then((res) => { + getNodeExecutionDetails(execution).then((res) => { if (isCurrent) { setDisplayId(res.displayId); } @@ -89,12 +43,12 @@ const DisplayId: React.FC = ({ execution }) => { }; const DisplayType: React.FC = ({ execution }) => { - const detailsContext = useNodeExecutionContext(); + const { getNodeExecutionDetails } = useNodeExecutionContext(); const [type, setType] = useState(); useEffect(() => { let isCurrent = true; - detailsContext.getNodeExecutionDetails(execution).then((res) => { + getNodeExecutionDetails(execution).then((res) => { if (isCurrent) { setType(res.displayType); } @@ -109,39 +63,51 @@ const DisplayType: React.FC = ({ execution }) => export function generateColumns( styles: ReturnType, + nodes: CompiledNode[], ): NodeExecutionColumnDefinition[] { return [ { - cellRenderer: (props) => , + cellRenderer: ({ node }) => ( + + ), className: styles.columnName, key: 'name', - label: 'task name', + label: t('nameLabel'), }, { cellRenderer: (props) => , className: styles.columnNodeId, key: 'nodeId', - label: 'node id', + label: t('nodeIdLabel'), }, { cellRenderer: (props) => , className: styles.columnType, key: 'type', - label: 'type', + label: t('typeLabel'), }, { - cellRenderer: ({ execution }) => ( - <> - - - - ), + cellRenderer: ({ execution }) => { + const isGateNode = isNodeGateNode(nodes, execution.id); + const phase = getNodeFrontendPhase( + execution.closure?.phase ?? NodeExecutionPhase.UNDEFINED, + isGateNode, + ); + + return ( + <> + + + + ); + }, className: styles.columnStatus, key: 'phase', - label: 'status', + label: t('phaseLabel'), }, { cellRenderer: ({ execution: { closure } }) => { @@ -161,7 +127,7 @@ export function generateColumns( }, className: styles.columnStartedAt, key: 'startedAt', - label: 'start time', + label: t('startedAtLabel'), }, { cellRenderer: ({ execution }) => { @@ -180,18 +146,19 @@ export function generateColumns( label: () => ( <> - duration + {t('durationLabel')} - Queued Time + {t('queuedTimeLabel')} ), }, { - cellRenderer: ({ execution, state }) => ( - - ), + cellRenderer: ({ execution }) => + execution.closure.phase === NodeExecutionPhase.UNDEFINED ? null : ( + + ), className: styles.columnLogs, key: 'actions', label: '', diff --git a/packages/zapp/console/src/components/Executions/Tables/strings.tsx b/packages/zapp/console/src/components/Executions/Tables/strings.tsx index 402c312c7..923b2c693 100644 --- a/packages/zapp/console/src/components/Executions/Tables/strings.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/strings.tsx @@ -1,8 +1,18 @@ import { createLocalizedString } from '@flyteconsole/locale'; const str = { - inputsAndOutputsTooltip: 'View Inputs & Outpus', + durationLabel: 'duration', + inputsAndOutputsTooltip: 'View Inputs & Outputs', + nameLabel: 'task name', + nodeIdLabel: 'node id', + phaseLabel: 'status', + queuedTimeLabel: 'queued time', rerunTooltip: 'Rerun', + resumeTooltip: 'Resume', + startedAtLabel: 'start time', + typeLabel: 'type', + loadMoreButton: 'Load More', + expanderTitle: (expanded: boolean) => (expanded ? 'Collapse row' : 'Expand row'), }; export { patternKey } from '@flyteconsole/locale'; diff --git a/packages/zapp/console/src/components/Executions/Tables/styles.ts b/packages/zapp/console/src/components/Executions/Tables/styles.ts index d1469db13..5991ade9e 100644 --- a/packages/zapp/console/src/components/Executions/Tables/styles.ts +++ b/packages/zapp/console/src/components/Executions/Tables/styles.ts @@ -3,8 +3,6 @@ import { headerGridHeight } from 'components/Tables/constants'; import { headerFontFamily, listhoverColor, - nestedListColor, - smallFontSize, tableHeaderColor, tablePlaceholderColor, } from 'components/Theme/constants'; @@ -20,23 +18,8 @@ export const useExecutionTableStyles = makeStyles((theme: Theme) => ({ borderBottom: { borderBottom: `1px solid ${theme.palette.divider}`, }, - borderTop: { - borderTop: `1px solid ${theme.palette.divider}`, - }, - childrenContainer: { - backgroundColor: nestedListColor, - minHeight: theme.spacing(7), - }, - childGroupLabel: { - borderWidth: '2px', - padding: `${theme.spacing(2)}px 0`, - }, errorContainer: { padding: `0 ${theme.spacing(8)}px ${theme.spacing(2)}px`, - '$childrenContainer &': { - paddingTop: theme.spacing(2), - paddingLeft: theme.spacing(2), - }, }, expander: { alignItems: 'center', @@ -56,11 +39,6 @@ export const useExecutionTableStyles = makeStyles((theme: Theme) => ({ headerColumnVersion: { width: theme.spacing(4), }, - headerColumnName: { - fontSize: smallFontSize, - fontWeight: 'bold', - textTransform: 'uppercase', - }, headerRow: { alignItems: 'center', borderBottom: `4px solid ${theme.palette.divider}`, @@ -71,17 +49,6 @@ export const useExecutionTableStyles = makeStyles((theme: Theme) => ({ flexDirection: 'row', height: theme.spacing(headerGridHeight), }, - logLink: { - '&:not(:first-child)': { - borderLeft: `1px solid ${theme.palette.divider}`, - marginLeft: theme.spacing(1), - paddingLeft: theme.spacing(1), - }, - }, - logLinksContainer: { - display: 'flex', - flexDirection: 'row', - }, noRowsContent: { color: tablePlaceholderColor, margin: `${theme.spacing(5)}px auto`, @@ -101,7 +68,6 @@ export const useExecutionTableStyles = makeStyles((theme: Theme) => ({ clickableRow: { cursor: 'pointer', }, - rowContent: {}, rowColumns: { alignItems: 'center', display: 'flex', diff --git a/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx new file mode 100644 index 000000000..19fa4e3f5 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { createTestQueryClient } from 'test/utils'; +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 { NodeExecutionActions } from '../NodeExecutionActions'; + +jest.mock('components/Workflow/workflowQueries'); +jest.mock('components/Launch/LaunchForm/ResumeForm', () => ({ + ResumeForm: jest.fn(({ children }) =>
{children}
), +})); + +const { fetchWorkflow } = require('components/Workflow/workflowQueries'); + +const state = { selectedExecution: null, setSelectedExeccution: jest.fn() }; + +describe('Executions > Tables > NodeExecutionActions', () => { + let queryClient: QueryClient; + let fixture: ReturnType; + let execution: NodeExecution; + + beforeEach(() => { + fixture = basicPythonWorkflow.generate(); + execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + queryClient = createTestQueryClient(); + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); + }); + + const renderComponent = (props) => + render( + + + + + , + ); + + it('should render rerun action, if id can be determined', async () => { + let queryByTitle; + await act(() => { + const component = renderComponent({ execution, state }); + queryByTitle = component.queryByTitle; + }); + await waitFor(() => queryByTitle('View Inputs & Outputs')); + + expect(queryByTitle('View Inputs & Outputs')).toBeInTheDocument(); + expect(queryByTitle('Resume')).not.toBeInTheDocument(); + expect(queryByTitle('Rerun')).toBeInTheDocument(); + }); + + it('should render resume action, if the status is PAUSED', async () => { + const mockExecution = { ...execution, closure: { phase: 100 } }; + let queryByTitle; + await act(() => { + const component = renderComponent({ execution: mockExecution, state }); + queryByTitle = component.queryByTitle; + }); + await waitFor(() => queryByTitle('Resume')); + + expect(queryByTitle('View Inputs & Outputs')).toBeInTheDocument(); + expect(queryByTitle('Rerun')).toBeInTheDocument(); + expect(queryByTitle('Resume')).toBeInTheDocument(); + }); + + it('should render ResumeForm on resume button click', async () => { + const mockExecution = { ...execution, closure: { phase: 100 } }; + let queryByTitle, getByTitle, queryByTestId; + await act(() => { + const component = renderComponent({ execution: mockExecution, state }); + queryByTitle = component.queryByTitle; + getByTitle = component.getByTitle; + queryByTestId = component.queryByTestId; + }); + await waitFor(() => queryByTitle('Resume')); + + expect(queryByTitle('Resume')).toBeInTheDocument(); + + const resumeButton = getByTitle('Resume'); + await fireEvent.click(resumeButton); + + expect(queryByTestId('resume-form')).toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx new file mode 100644 index 000000000..6401fe79b --- /dev/null +++ b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionRow.test.tsx @@ -0,0 +1,80 @@ +import * as React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { createTestQueryClient } from 'test/utils'; +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 { NodeExecutionRow } from '../NodeExecutionRow'; + +jest.mock('components/Workflow/workflowQueries'); +const { fetchWorkflow } = require('components/Workflow/workflowQueries'); + +jest.mock('components/Executions/Tables/RowExpander', () => ({ + RowExpander: jest.fn(() =>
), +})); + +const columns = []; +const node = { + id: 'n1', + scopedId: 'n1', + type: dTypes.start, + name: 'node1', + nodes: [], + edges: [], +}; +const onToggle = jest.fn(); + +describe('Executions > Tables > NodeExecutionRow', () => { + let queryClient: QueryClient; + let fixture: ReturnType; + let execution: NodeExecution; + + beforeEach(() => { + fixture = basicPythonWorkflow.generate(); + execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + queryClient = createTestQueryClient(); + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); + }); + + const renderComponent = (props) => + render( + + + + + , + ); + + it('should not render expander if node is a leaf', async () => { + const { queryByRole, queryByTestId } = renderComponent({ + columns, + node, + nodeExecution: execution, + onToggle, + }); + await waitFor(() => queryByRole('listitem')); + + expect(queryByRole('listitem')).toBeInTheDocument(); + expect(queryByTestId('expander')).not.toBeInTheDocument(); + }); + + it('should render expander if node contains list of nodes', async () => { + const mockNode = { ...node, nodes: [node, node] }; + const { queryByRole, queryByTestId } = renderComponent({ + columns, + node: mockNode, + nodeExecution: execution, + onToggle, + }); + await waitFor(() => queryByRole('listitem')); + + expect(queryByRole('listitem')).toBeInTheDocument(); + expect(queryByTestId('expander')).toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx index 424dbccb4..e9600a9c3 100644 --- a/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionsTable.test.tsx @@ -1,468 +1,181 @@ -import { - fireEvent, - getAllByRole, - getAllByText, - getByText, - getByTitle, - render, - screen, - waitFor, -} from '@testing-library/react'; -import { cacheStatusMessages } from 'components/Executions/constants'; +import { render, waitFor } from '@testing-library/react'; import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; -import { UNKNOWN_DETAILS } from 'components/Executions/contextProvider/NodeExecutionDetails/types'; -import { - ExecutionContext, - ExecutionContextData, - NodeExecutionsRequestConfigContext, -} from 'components/Executions/contexts'; -import { makeNodeExecutionListQuery } from 'components/Executions/nodeExecutionQueries'; -import { NodeExecutionDisplayType } from 'components/Executions/types'; -import { nodeExecutionIsTerminal } from 'components/Executions/utils'; -import { useConditionalQuery } from 'components/hooks/useConditionalQuery'; -import { cloneDeep } from 'lodash'; +import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; -import { dynamicExternalSubWorkflow } from 'mocks/data/fixtures/dynamicExternalSubworkflow'; -import { - dynamicPythonNodeExecutionWorkflow, - dynamicPythonTaskWorkflow, -} from 'mocks/data/fixtures/dynamicPythonWorkflow'; -import { oneFailedTaskWorkflow } from 'mocks/data/fixtures/oneFailedTaskWorkflow'; +import { noExecutionsFoundString } from 'common/constants'; import { mockWorkflowId } from 'mocks/data/fixtures/types'; import { insertFixture } from 'mocks/data/insertFixture'; -import { notFoundError } from 'mocks/errors'; import { mockServer } from 'mocks/server'; -import { FilterOperationName, RequestConfig } from 'models/AdminEntity/types'; -import { nodeExecutionQueryParams } from 'models/Execution/constants'; -import { CatalogCacheStatus, NodeExecutionPhase } from 'models/Execution/enums'; -import { Execution, NodeExecution, TaskNodeMetadata } from 'models/Execution/types'; -import { ResourceType } from 'models/Common/types'; +import { NodeExecutionPhase } from 'models/Execution/enums'; import * as React from 'react'; -import { QueryClient, QueryClientProvider, useQueryClient } from 'react-query'; -import { makeIdentifier } from 'test/modelUtils'; -import { - createTestQueryClient, - disableQueryLogger, - enableQueryLogger, - findNearestAncestorByRole, -} from 'test/utils'; -import * as moduleApi from 'components/Executions/contextProvider/NodeExecutionDetails/getTaskThroughExecution'; -import { titleStrings } from '../constants'; +import { dateToTimestamp } from 'common/utils'; +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 { NodeExecutionsTable } from '../NodeExecutionsTable'; jest.mock('components/Workflow/workflowQueries'); const { fetchWorkflow } = require('components/Workflow/workflowQueries'); -describe('NodeExecutionsTable', () => { - let workflowExecution: Execution; +jest.mock('components/Executions/filters/useExecutionFiltersState'); +const mockUseNodeExecutionFiltersState = useNodeExecutionFiltersState as jest.Mock; +mockUseNodeExecutionFiltersState.mockReturnValue({ filters: [], appliedFilters: [] }); + +jest.mock('components/Executions/Tables/NodeExecutionRow', () => ({ + NodeExecutionRow: jest.fn(({ nodeExecution }) => ( +
+
{nodeExecution?.id?.nodeId}
+
{nodeExecution?.closure?.phase}
+
+ )), +})); + +const mockNodes = (n: number): dNode[] => { + const nodes: dNode[] = []; + for (let i = 1; i <= n; i++) { + nodes.push({ + id: `node${i}`, + scopedId: `n${i}`, + type: 4, + name: `Node ${i}`, + nodes: [], + edges: [], + }); + } + return nodes; +}; + +const mockExecutionsById = (n: number, phases: NodeExecutionPhase[]) => { + const nodeExecutionsById = {}; + + for (let i = 1; i <= n; i++) { + nodeExecutionsById[`n${i}`] = { + closure: { + createdAt: dateToTimestamp(new Date()), + outputUri: '', + phase: phases[i - 1], + }, + id: { + executionId: { domain: 'domain', name: 'name', project: 'project' }, + nodeId: `node${i}`, + }, + inputUri: '', + scopedId: `n${i}`, + }; + } + return nodeExecutionsById; +}; + +describe('NodeExecutionsTableExecutions > Tables > NodeExecutionsTable', () => { let queryClient: QueryClient; - let executionContext: ExecutionContextData; - let requestConfig: RequestConfig; + let fixture: ReturnType; + const initialNodes = mockNodes(2); beforeEach(() => { - requestConfig = {}; queryClient = createTestQueryClient(); + fixture = basicPythonWorkflow.generate(); + insertFixture(mockServer, fixture); + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); }); - const shouldUpdateFn = (nodeExecutions: NodeExecution[]) => - nodeExecutions.some((ne) => !nodeExecutionIsTerminal(ne)); - - const selectNode = async (container: HTMLElement, truncatedName: string, nodeId: string) => { - const nodeNameAnchor = await waitFor(() => getByText(container, truncatedName)); - fireEvent.click(nodeNameAnchor); - // Wait for Details Panel to render and then for the nodeId header - const detailsPanel = await waitFor(() => screen.getByTestId('details-panel')); - await waitFor(() => getByText(detailsPanel, nodeId)); - return detailsPanel; - }; - - const expandParentNode = async (rowContainer: HTMLElement) => { - const expander = await waitFor(() => getByTitle(rowContainer, titleStrings.expandRow)); - fireEvent.click(expander); - return await waitFor(() => getAllByRole(rowContainer, 'list')); - }; - - const TestTable = () => { - const query = useConditionalQuery( - { - ...makeNodeExecutionListQuery(useQueryClient(), workflowExecution.id, requestConfig), - // During tests, we only want to wait for the next tick to refresh - refetchInterval: 1, - }, - shouldUpdateFn, - ); - return query.data ? : null; - }; - - const renderTable = () => + const renderTable = ({ nodeExecutionsById, initialNodes, filteredNodes }) => render( - - - - - - - + + + + + , ); - describe('when rendering the DetailsPanel', () => { - let nodeExecution: NodeExecution; - let fixture: ReturnType; - beforeEach(() => { - fixture = basicPythonWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - - executionContext = { - execution: workflowExecution, - }; - nodeExecution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + it('renders empty content when there are no nodes', async () => { + const { queryByText, queryByTestId } = renderTable({ + initialNodes: [], + nodeExecutionsById: {}, + filteredNodes: [], }); - const updateNodeExecutions = (executions: NodeExecution[]) => { - executions.forEach(mockServer.insertNodeExecution); - mockServer.insertNodeExecutionList(fixture.workflowExecutions.top.data.id, executions); - }; - - it('should render updated state if selected nodeExecution object changes', async () => { - nodeExecution.closure.phase = NodeExecutionPhase.RUNNING; - updateNodeExecutions([nodeExecution]); - const truncatedName = fixture.tasks.python.id.name.split('.').pop() || ''; - // Render table, click first node - const { container } = renderTable(); - const detailsPanel = await selectNode(container, truncatedName, nodeExecution.id.nodeId); - expect(getByText(detailsPanel, 'Running')).toBeInTheDocument(); - - const updatedExecution = cloneDeep(nodeExecution); - updatedExecution.closure.phase = NodeExecutionPhase.FAILED; - updateNodeExecutions([updatedExecution]); - await waitFor(() => expect(getByText(detailsPanel, 'Failed'))); - }); - - describe('with nested children', () => { - let fixture: ReturnType; - beforeEach(() => { - fixture = dynamicPythonNodeExecutionWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - executionContext = { execution: workflowExecution }; - }); - - it('should correctly render details for nested executions', async () => { - const childNodeExecution = - fixture.workflowExecutions.top.nodeExecutions.dynamicNode.nodeExecutions.firstChild.data; - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.dynamic.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const parentNodeEl = await expandParentNode(dynamicRowEl); - const truncatedName = fixture.tasks.python.id.name.split('.').pop() || ''; - await selectNode(parentNodeEl[0], truncatedName, childNodeExecution.id.nodeId); + await waitFor(() => queryByText(noExecutionsFoundString)); - // Wait for Details Panel to render and then for the nodeId header - const detailsPanel = await waitFor(() => screen.getByTestId('details-panel')); - await waitFor(() => expect(getByText(detailsPanel, childNodeExecution.id.nodeId))); - expect(getByText(detailsPanel, fixture.tasks.python.id.name)).toBeInTheDocument(); - }); - }); + expect(queryByText(noExecutionsFoundString)).toBeInTheDocument(); + expect(queryByTestId('node-execution-row')).not.toBeInTheDocument(); }); - describe('for basic executions', () => { - let fixture: ReturnType; - - beforeEach(() => { - fixture = basicPythonWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - - executionContext = { - execution: workflowExecution, - }; - }); - - const updateNodeExecutions = (executions: NodeExecution[]) => { - executions.forEach(mockServer.insertNodeExecution); - mockServer.insertNodeExecutionList(fixture.workflowExecutions.top.data.id, executions); - }; + it('renders NodeExecutionRows with initialNodes when no filteredNodes were provided', async () => { + const phases = [NodeExecutionPhase.FAILED, NodeExecutionPhase.SUCCEEDED]; + const nodeExecutionsById = mockExecutionsById(2, phases); - it('renders task name for task nodes', async () => { - const { getByText } = renderTable(); - await waitFor(() => expect(getByText(fixture.tasks.python.id.name)).toBeInTheDocument()); + const { queryAllByTestId } = renderTable({ + initialNodes, + nodeExecutionsById, + filteredNodes: undefined, }); - it('renders NodeExecutions with no associated spec information as Unknown', async () => { - const workflowExecution = fixture.workflowExecutions.top.data; - // For a NodeExecution which has no node in the associated workflow spec and - // no task executions, we don't have a way to identify its type. - // We'll change the python NodeExecution to reference a node id which doesn't exist - // in the spec and remove its TaskExecutions. - const nodeExecution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; - nodeExecution.id.nodeId = 'unknownNode'; - nodeExecution.metadata = {}; - mockServer.insertNodeExecution(nodeExecution); - mockServer.insertNodeExecutionList(workflowExecution.id, [nodeExecution]); - mockServer.insertTaskExecutionList(nodeExecution.id, []); - - const { container } = renderTable(); - const pythonNodeNameEl = await waitFor(() => - getAllByText(container, nodeExecution.id.nodeId), - ); - const rowEl = findNearestAncestorByRole(pythonNodeNameEl?.[0], 'listitem'); - await waitFor(() => expect(getByText(rowEl, NodeExecutionDisplayType.Unknown))); - }); - - describe('for task nodes with cache status', () => { - let taskNodeMetadata: TaskNodeMetadata; - let cachedNodeExecution: NodeExecution; - beforeEach(() => { - const { nodeExecutions } = fixture.workflowExecutions.top; - const { taskExecutions } = nodeExecutions.pythonNode; - cachedNodeExecution = nodeExecutions.pythonNode.data; - taskNodeMetadata = { - cacheStatus: CatalogCacheStatus.CACHE_MISS, - catalogKey: { - datasetId: makeIdentifier({ - resourceType: ResourceType.DATASET, - }), - sourceTaskExecution: { - ...taskExecutions.firstAttempt.data.id, - }, - }, - }; - cachedNodeExecution.closure.taskNodeMetadata = taskNodeMetadata; - }); - - [ - CatalogCacheStatus.CACHE_HIT, - CatalogCacheStatus.CACHE_LOOKUP_FAILURE, - CatalogCacheStatus.CACHE_POPULATED, - CatalogCacheStatus.CACHE_PUT_FAILURE, - CatalogCacheStatus.CACHE_MISS, - CatalogCacheStatus.CACHE_DISABLED, - ].forEach((cacheStatusValue) => - it(`renders correct icon for ${CatalogCacheStatus[cacheStatusValue]}`, async () => { - taskNodeMetadata.cacheStatus = cacheStatusValue; - updateNodeExecutions([cachedNodeExecution]); - const { getByTitle } = renderTable(); - - await waitFor(() => - expect(getByTitle(cacheStatusMessages[cacheStatusValue])).toBeDefined(), - ); - }), - ); - }); + await waitFor(() => queryAllByTestId('node-execution-row')); + + expect(queryAllByTestId('node-execution-row')).toHaveLength(initialNodes.length); + const ids = queryAllByTestId('node-execution-col-id'); + expect(ids).toHaveLength(initialNodes.length); + const renderedPhases = queryAllByTestId('node-execution-col-phase'); + expect(renderedPhases).toHaveLength(initialNodes.length); + for (const i in initialNodes) { + expect(ids[i]).toHaveTextContent(initialNodes[i].id); + expect(renderedPhases[i]).toHaveTextContent(phases[i].toString()); + } }); - describe('for nodes with children', () => { - describe('with isParentNode flag', () => { - let fixture: ReturnType; - beforeEach(() => { - fixture = dynamicPythonNodeExecutionWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - executionContext = { execution: workflowExecution }; - }); + 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); - it('correctly renders children', async () => { - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.dynamic.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const childContainerList = await expandParentNode(dynamicRowEl); - await waitFor(() => expect(getByText(childContainerList[0], fixture.tasks.python.id.name))); - }); - - it('correctly renders groups', async () => { - const { nodeExecutions } = fixture.workflowExecutions.top; - // We returned two task execution attempts, each with children - const { container } = renderTable(); - const nodeNameEl = await waitFor(() => - getByText(container, nodeExecutions.dynamicNode.data.id.nodeId), - ); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups).toHaveLength(2); - }); - - describe('with initial failure to fetch children', () => { - // Disable react-query logger output to avoid a console.error - // when the request fails. - beforeEach(() => { - disableQueryLogger(); - }); - afterEach(() => { - enableQueryLogger(); - }); - it('renders error icon with retry', async () => { - const { - data: { id: workflowExecutionId }, - nodeExecutions, - } = fixture.workflowExecutions.top; - const parentNodeExecution = nodeExecutions.dynamicNode.data; - // Simulate an error when attempting to list children of first NE. - mockServer.insertNodeExecutionList( - workflowExecutionId, - notFoundError(parentNodeExecution.id.nodeId), - { - [nodeExecutionQueryParams.parentNodeId]: parentNodeExecution.id.nodeId, - }, - ); - - const { container, getByTitle } = renderTable(); - // We expect to find an error icon in place of the child expander - const errorIconButton = await waitFor(() => - getByTitle(titleStrings.childGroupFetchFailed), - ); - // restore proper handler for node execution children - insertFixture(mockServer, fixture); - // click error icon - await fireEvent.click(errorIconButton); - - // wait for expander and open it to verify children loaded correctly - const nodeNameEl = await waitFor(() => - getByText(container, nodeExecutions.dynamicNode.data.id.nodeId), - ); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups.length).toBeGreaterThan(0); - }); - }); + const { queryAllByTestId } = renderTable({ + initialNodes, + nodeExecutionsById, + filteredNodes, }); - describe('without isParentNode flag, using taskNodeMetadata', () => { - let fixture: ReturnType; - beforeEach(() => { - fixture = dynamicPythonTaskWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - executionContext = { - execution: workflowExecution, - }; - }); - - it('correctly renders children', async () => { - // The dynamic task node should have a single child node - // which runs the basic python task. Expand it and then - // look for the python task name to verify it was rendered. - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.dynamic.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const childContainerList = await expandParentNode(dynamicRowEl); - await waitFor(() => expect(getByText(childContainerList[0], fixture.tasks.python.id.name))); - }); - - it('correctly renders groups', async () => { - // We returned two task execution attempts, each with children - const { container } = renderTable(); - const nodeNameEl = await waitFor(() => - getByText( - container, - fixture.workflowExecutions.top.nodeExecutions.dynamicNode.data.id.nodeId, - ), - ); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups).toHaveLength(2); - }); - }); - - describe('without isParentNode flag, using workflowNodeMetadata', () => { - let fixture: ReturnType; - let mockGetTaskThroughExecution: any; - - beforeEach(() => { - fixture = dynamicExternalSubWorkflow.generate(); - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - workflowExecution = fixture.workflowExecutions.top.data; - executionContext = { - execution: workflowExecution, - }; - - mockGetTaskThroughExecution = jest.spyOn(moduleApi, 'getTaskThroughExecution'); - mockGetTaskThroughExecution.mockImplementation(() => { - return Promise.resolve({ - ...UNKNOWN_DETAILS, - displayName: fixture.workflows.sub.id.name, - }); - }); - }); - - afterEach(() => { - mockGetTaskThroughExecution.mockReset(); - }); - - it('correctly renders children', async () => { - const { container } = renderTable(); - const dynamicTaskNameEl = await waitFor(() => - getByText(container, fixture.tasks.generateSubWorkflow.id.name), - ); - const dynamicRowEl = findNearestAncestorByRole(dynamicTaskNameEl, 'listitem'); - const childContainerList = await expandParentNode(dynamicRowEl); - await waitFor(() => - expect(getByText(childContainerList[0], fixture.workflows.sub.id.name)), - ); - }); - - it('correctly renders groups', async () => { - const parentNodeId = - fixture.workflowExecutions.top.nodeExecutions.dynamicWorkflowGenerator.data.metadata - ?.specNodeId || 'not found'; - // We returned a single WF execution child, so there should only - // be one child group - const { container } = renderTable(); - const nodeNameEl = await waitFor(() => getByText(container, parentNodeId)); - const rowEl = findNearestAncestorByRole(nodeNameEl, 'listitem'); - const childGroups = await expandParentNode(rowEl); - expect(childGroups).toHaveLength(1); - }); - }); + await waitFor(() => queryAllByTestId('node-execution-row')); + + expect(queryAllByTestId('node-execution-row')).toHaveLength(initialNodes.length); + const ids = queryAllByTestId('node-execution-col-id'); + expect(ids).toHaveLength(initialNodes.length); + const renderedPhases = queryAllByTestId('node-execution-col-phase'); + expect(renderedPhases).toHaveLength(initialNodes.length); + for (const i in initialNodes) { + expect(ids[i]).toHaveTextContent(initialNodes[i].id); + expect(renderedPhases[i]).toHaveTextContent(phases[i].toString()); + } }); - describe('with a request filter', () => { - let fixture: ReturnType; - - beforeEach(() => { - fixture = oneFailedTaskWorkflow.generate(); - workflowExecution = fixture.workflowExecutions.top.data; - insertFixture(mockServer, fixture); - fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); - // Adding a request filter to only show failed NodeExecutions - requestConfig = { - filter: [ - { - key: 'phase', - operation: FilterOperationName.EQ, - value: NodeExecutionPhase[NodeExecutionPhase.FAILED], - }, - ], - }; - const nodeExecutions = fixture.workflowExecutions.top.nodeExecutions; - mockServer.insertNodeExecutionList(workflowExecution.id, [nodeExecutions.failedNode.data], { - filters: 'eq(phase,FAILED)', - }); - executionContext = { - execution: workflowExecution, - }; + it('renders NodeExecutionRows with filterNodes if appliedFilters is not empty', async () => { + mockUseNodeExecutionFiltersState.mockReturnValueOnce({ + filters: [], + appliedFilters: [{ key: 'phase', operation: 'value_in', value: ['FAILED', 'SUCCEEDED'] }], }); - it('requests child node executions using configuration from context', async () => { - const { getByText, queryByText } = renderTable(); - const { nodeExecutions } = fixture.workflowExecutions.top; + const phases = [NodeExecutionPhase.FAILED, NodeExecutionPhase.SUCCEEDED]; + const nodeExecutionsById = mockExecutionsById(2, phases); + const filteredNodes = mockNodes(1); - await waitFor(() => expect(getByText(nodeExecutions.failedNode.data.id.nodeId))); - - expect(queryByText(nodeExecutions.pythonNode.data.id.nodeId)).toBeNull(); + const { queryAllByTestId } = renderTable({ + initialNodes, + nodeExecutionsById, + filteredNodes, }); + + await waitFor(() => queryAllByTestId('node-execution-row')); + + expect(queryAllByTestId('node-execution-row')).toHaveLength(filteredNodes.length); + const ids = queryAllByTestId('node-execution-col-id'); + expect(ids).toHaveLength(filteredNodes.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[i]).toHaveTextContent(phases[i].toString()); + } }); }); diff --git a/packages/zapp/console/src/components/Executions/Tables/types.ts b/packages/zapp/console/src/components/Executions/Tables/types.ts index 49bf992a0..0f7ac40fe 100644 --- a/packages/zapp/console/src/components/Executions/Tables/types.ts +++ b/packages/zapp/console/src/components/Executions/Tables/types.ts @@ -1,5 +1,6 @@ import { PaginatedFetchableData } from 'components/hooks/types'; import { Execution, NodeExecution, NodeExecutionIdentifier } from 'models/Execution/types'; +import { dNode } from 'models/Graph/types'; import { Workflow } from 'models/Workflow/types'; export interface WorkflowExecutionsTableState { @@ -20,7 +21,7 @@ export interface ColumnDefinition { export interface NodeExecutionCellRendererData { execution: NodeExecution; - state: NodeExecutionsTableState; + node: dNode; } export type NodeExecutionColumnDefinition = ColumnDefinition; diff --git a/packages/zapp/console/src/components/Executions/TaskExecutionsList/test/utils.spec.ts b/packages/zapp/console/src/components/Executions/TaskExecutionsList/test/utils.spec.ts index 1990f4890..18bfacd83 100644 --- a/packages/zapp/console/src/components/Executions/TaskExecutionsList/test/utils.spec.ts +++ b/packages/zapp/console/src/components/Executions/TaskExecutionsList/test/utils.spec.ts @@ -1,4 +1,4 @@ -import { getTaskLogName, getTaskIndex } from 'components/Executions/TaskExecutionsList/utils'; +import { getTaskLogName } from 'components/Executions/TaskExecutionsList/utils'; import { Event } from 'flyteidl'; import { TaskExecutionPhase } from 'models/Execution/enums'; import { obj } from 'test/utils'; @@ -101,19 +101,6 @@ describe('getTaskRetryAttemptsForIndex', () => { }); }); -describe('getTaskIndex', () => { - it('should return index if selected log has a match in externalResources list', () => { - const index = 3; - const log = getMockMapTaskLogItem(TaskExecutionPhase.SUCCEEDED, true, index, 1).logs?.[0]; - - // TS check - if (log) { - const result1 = getTaskIndex(MockMapTaskExecution, log); - expect(result1).toStrictEqual(index); - } - }); -}); - describe('getTaskLogName', () => { it('should return correct names', () => { const taskName1 = 'task_name_1'; diff --git a/packages/zapp/console/src/components/Executions/TaskExecutionsList/utils.ts b/packages/zapp/console/src/components/Executions/TaskExecutionsList/utils.ts index 7beb824ee..e10662bd6 100644 --- a/packages/zapp/console/src/components/Executions/TaskExecutionsList/utils.ts +++ b/packages/zapp/console/src/components/Executions/TaskExecutionsList/utils.ts @@ -1,6 +1,6 @@ import { ExternalResource, LogsByPhase, TaskExecution } from 'models/Execution/types'; import { leftPaddedNumber } from 'common/formatters'; -import { Core, Event } from 'flyteidl'; +import { Event } from 'flyteidl'; import { TaskExecutionPhase } from 'models/Execution/enums'; /** Generates a unique name for a task execution, suitable for display in a @@ -92,27 +92,6 @@ export const getTaskRetryAtemptsForIndex = ( return filtered; }; -export function getTaskIndex( - taskExecution: TaskExecution, - selectedLog: Core.ITaskLog, -): number | null { - const externalResources = taskExecution.closure.metadata?.externalResources ?? []; - for (const item of externalResources) { - const logs = item.logs ?? []; - for (const log of logs) { - if (log.uri) { - if (log.name === selectedLog.name && log.uri === selectedLog.uri) { - return item.index ?? 0; - } - } else if (log.name === selectedLog.name) { - return item.index ?? 0; - } - } - } - - return null; -} - export function getTaskLogName(taskName: string, taskLogName: string): string { const lastDotIndex = taskName.lastIndexOf('.'); const prefix = lastDotIndex !== -1 ? taskName.slice(lastDotIndex + 1) : taskName; diff --git a/packages/zapp/console/src/components/Executions/constants.ts b/packages/zapp/console/src/components/Executions/constants.ts index 2b2a88f35..4a24e391a 100644 --- a/packages/zapp/console/src/components/Executions/constants.ts +++ b/packages/zapp/console/src/components/Executions/constants.ts @@ -1,4 +1,5 @@ import { + graphStatusColors, negativeTextColor, positiveTextColor, secondaryTextColor, @@ -22,53 +23,73 @@ export const workflowExecutionPhaseConstants: { [key in WorkflowExecutionPhase]: ExecutionPhaseConstants; } = { [WorkflowExecutionPhase.ABORTED]: { - badgeColor: statusColors.SKIPPED, text: t('aborted'), + value: 'ABORTED', + badgeColor: statusColors.SKIPPED, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.ABORTING]: { - badgeColor: statusColors.SKIPPED, text: t('aborting'), + value: 'ABORTING', + badgeColor: statusColors.SKIPPED, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.FAILING]: { - badgeColor: statusColors.FAILURE, text: t('failing'), + value: 'FAILING', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILING, textColor: negativeTextColor, }, [WorkflowExecutionPhase.FAILED]: { - badgeColor: statusColors.FAILURE, text: t('failed'), + value: 'FAILED', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.QUEUED]: { - badgeColor: statusColors.QUEUED, text: t('queued'), + value: 'QUEUED', + badgeColor: statusColors.QUEUED, + nodeColor: graphStatusColors.QUEUED, textColor: secondaryTextColor, }, [WorkflowExecutionPhase.RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + value: 'RUNNING', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [WorkflowExecutionPhase.SUCCEEDED]: { - badgeColor: statusColors.SUCCESS, text: t('succeeded'), + value: 'SUCCEEDED', + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [WorkflowExecutionPhase.SUCCEEDING]: { - badgeColor: statusColors.SUCCESS, text: t('succeeding'), + value: 'SUCCEEDING', + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [WorkflowExecutionPhase.TIMED_OUT]: { - badgeColor: statusColors.FAILURE, text: t('timedOut'), + value: 'TIMED_OUT', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.UNDEFINED]: { - badgeColor: statusColors.UNKNOWN, text: t('unknown'), + value: 'UNKNOWN', + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, }; @@ -78,58 +99,87 @@ export const nodeExecutionPhaseConstants: { [key in NodeExecutionPhase]: ExecutionPhaseConstants; } = { [NodeExecutionPhase.ABORTED]: { - badgeColor: statusColors.FAILURE, text: t('aborted'), + value: 'ABORTED', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [NodeExecutionPhase.FAILING]: { - badgeColor: statusColors.FAILURE, text: t('failing'), + value: 'FAILING', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILING, textColor: negativeTextColor, }, [NodeExecutionPhase.FAILED]: { - badgeColor: statusColors.FAILURE, text: t('failed'), + value: 'FAILED', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [NodeExecutionPhase.QUEUED]: { - badgeColor: statusColors.RUNNING, text: t('queued'), + value: 'QUEUED', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.QUEUED, textColor: secondaryTextColor, }, [NodeExecutionPhase.RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + value: 'RUNNING', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [NodeExecutionPhase.DYNAMIC_RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + value: 'RUNNING', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [NodeExecutionPhase.SUCCEEDED]: { - badgeColor: statusColors.SUCCESS, text: t('succeeded'), + value: 'SUCCEEDED', + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [NodeExecutionPhase.TIMED_OUT]: { - badgeColor: statusColors.FAILURE, text: t('timedOut'), + value: 'TIMED_OUT', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [NodeExecutionPhase.SKIPPED]: { - badgeColor: statusColors.UNKNOWN, text: t('skipped'), + value: 'SKIPPED', + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, [NodeExecutionPhase.RECOVERED]: { - badgeColor: statusColors.SUCCESS, text: t('recovered'), + value: 'RECOVERED', + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, + [NodeExecutionPhase.PAUSED]: { + text: t('paused'), + value: 'PAUSED', + badgeColor: statusColors.PAUSED, + nodeColor: graphStatusColors.PAUSED, + textColor: secondaryTextColor, + }, [NodeExecutionPhase.UNDEFINED]: { - badgeColor: statusColors.UNKNOWN, text: t('unknown'), + value: 'UNKNOWN', + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, }; @@ -139,43 +189,59 @@ export const taskExecutionPhaseConstants: { [key in TaskExecutionPhase]: ExecutionPhaseConstants; } = { [TaskExecutionPhase.ABORTED]: { - badgeColor: statusColors.FAILURE, text: t('aborted'), + value: 'ABORTED', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [TaskExecutionPhase.FAILED]: { - badgeColor: statusColors.FAILURE, text: t('failed'), + value: 'FAILED', + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [TaskExecutionPhase.WAITING_FOR_RESOURCES]: { - badgeColor: statusColors.RUNNING, text: t('waiting'), + value: 'WAITING', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [TaskExecutionPhase.QUEUED]: { - badgeColor: statusColors.RUNNING, text: t('queued'), + value: 'QUEUED', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.QUEUED, textColor: secondaryTextColor, }, [TaskExecutionPhase.INITIALIZING]: { - badgeColor: statusColors.RUNNING, text: t('initializing'), + value: 'INITIALIZING', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [TaskExecutionPhase.RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + value: 'RUNNING', + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [TaskExecutionPhase.SUCCEEDED]: { - badgeColor: statusColors.SUCCESS, text: t('succeeded'), + value: 'SUCCEEDED', + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [TaskExecutionPhase.UNDEFINED]: { - badgeColor: statusColors.UNKNOWN, text: t('unknown'), + value: 'UNKNOWN', + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, }; diff --git a/packages/zapp/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx b/packages/zapp/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx index a7247693a..9cf9f8cc2 100644 --- a/packages/zapp/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx +++ b/packages/zapp/console/src/components/Executions/contextProvider/NodeExecutionDetails/createExecutionArray.tsx @@ -70,6 +70,19 @@ const getNodeDetails = (node: dNode, tasks: CompiledTask[]): NodeExecutionInfo = }; } + if (node.value.gateNode) { + const templateName = node.name; + const task = tasks.find((t) => t.template.id.name === templateName); + const taskType = getTaskDisplayType(task?.template.type); + return { + scopedId: node.scopedId, + displayId: node.value.id ?? node.id, + displayName: 'gateNode', + displayType: taskType, + taskTemplate: task?.template, + }; + } + return UNKNOWN_DETAILS; }; diff --git a/packages/zapp/console/src/components/Executions/contexts.ts b/packages/zapp/console/src/components/Executions/contexts.ts index faeb47b6d..e5ca0d492 100644 --- a/packages/zapp/console/src/components/Executions/contexts.ts +++ b/packages/zapp/console/src/components/Executions/contexts.ts @@ -1,15 +1,10 @@ -import { RequestConfig } from 'models/AdminEntity/types'; import { Execution, NodeExecution } from 'models/Execution/types'; -import * as React from 'react'; +import { createContext } from 'react'; export interface ExecutionContextData { execution: Execution; } -export const ExecutionContext = React.createContext( - {} as ExecutionContextData, -); +export const ExecutionContext = createContext({} as ExecutionContextData); -export const NodeExecutionsByIdContext = React.createContext>({}); - -export const NodeExecutionsRequestConfigContext = React.createContext({}); +export const NodeExecutionsByIdContext = createContext>({}); diff --git a/packages/zapp/console/src/components/Executions/filters/startTimeFilters.ts b/packages/zapp/console/src/components/Executions/filters/startTimeFilters.ts index bd268a7b8..16847621e 100644 --- a/packages/zapp/console/src/components/Executions/filters/startTimeFilters.ts +++ b/packages/zapp/console/src/components/Executions/filters/startTimeFilters.ts @@ -2,8 +2,8 @@ import * as moment from 'moment'; import { FilterOperationName } from 'models/AdminEntity/types'; import { FilterMap } from './types'; -const workflowExecutionStartTimeKey = 'execution_created_at'; -const nodeExecutionStartTimeKey = 'node_execution_created_at'; +const workflowExecutionStartTimeKey = 'created_at'; +const nodeExecutionStartTimeKey = 'created_at'; export type StartTimeFilterKey = | 'all' diff --git a/packages/zapp/console/src/components/Executions/filters/statusFilters.ts b/packages/zapp/console/src/components/Executions/filters/statusFilters.ts index 9cddf0150..7b2293820 100644 --- a/packages/zapp/console/src/components/Executions/filters/statusFilters.ts +++ b/packages/zapp/console/src/components/Executions/filters/statusFilters.ts @@ -1,3 +1,7 @@ +import { + nodeExecutionPhaseConstants, + workflowExecutionPhaseConstants, +} from 'components/Executions/constants'; import { NodeExecutionPhase, WorkflowExecutionPhase } from 'models/Execution/enums'; import { FilterMap } from './types'; @@ -15,32 +19,32 @@ export const workflowExecutionStatusFilters: FilterMap { - const queryClient = useQueryClient(); - // Use cached data if the parent node execution is terminal and all children - // in all groups are terminal - const shouldEnableFn = (groups: NodeExecutionGroup[]) => { - if (!nodeExecutionIsTerminal(nodeExecution)) { - return true; - } - return groups.some((group) => group.nodeExecutions.some((ne) => !nodeExecutionIsTerminal(ne))); - }; - - return useConditionalQuery( - { - queryKey: [QueryType.NodeExecutionChildList, nodeExecution.id, config], - queryFn: () => fetchChildNodeExecutionGroups(queryClient, nodeExecution, config), - }, - shouldEnableFn, - ); -} - /** * Query returns all children (not only direct childs) for a list of `nodeExecutions` */ @@ -377,7 +351,16 @@ export function useAllTreeNodeExecutionGroupsQuery( } }; - const key = `${nodeExecutions?.[0]?.scopedId}-${nodeExecutions?.[0]?.closure?.phase}`; + const n = nodeExecutions.length - 1; + let key = ''; + if (n >= 0) { + const keyP1 = `${nodeExecutions[0]?.scopedId}-${nodeExecutions[0].closure.phase}-${nodeExecutions[0].closure?.startedAt?.nanos}`; + key = keyP1; + if (n >= 1) { + const keyP2 = `${nodeExecutions[n]?.scopedId}-${nodeExecutions[n].closure.phase}-${nodeExecutions[n].closure?.startedAt?.nanos}`; + key = keyP1 + '-' + keyP2; + } + } return useConditionalQuery( { diff --git a/packages/zapp/console/src/components/Executions/strings.ts b/packages/zapp/console/src/components/Executions/strings.ts index 8856bb3ec..6d7504195 100644 --- a/packages/zapp/console/src/components/Executions/strings.ts +++ b/packages/zapp/console/src/components/Executions/strings.ts @@ -15,6 +15,7 @@ const str = { succeeded: 'Succeeded', succeeding: 'Succeeding', timedOut: 'Timed Out', + paused: 'Paused', unknown: 'Unknown', cacheDisabledMessage: 'Caching was disabled for this execution.', cacheHitMessage: 'Output for this execution was read from cache.', diff --git a/packages/zapp/console/src/components/Executions/test/CacheStatus.test.tsx b/packages/zapp/console/src/components/Executions/test/CacheStatus.test.tsx new file mode 100644 index 000000000..88603683f --- /dev/null +++ b/packages/zapp/console/src/components/Executions/test/CacheStatus.test.tsx @@ -0,0 +1,89 @@ +import { render } from '@testing-library/react'; +import { cacheStatusMessages, viewSourceExecutionString } from 'components/Executions/constants'; +import { CatalogCacheStatus } from 'models/Execution/enums'; +import { mockWorkflowExecutionId } from 'models/Execution/__mocks__/constants'; +import * as React from 'react'; +import { MemoryRouter } from 'react-router'; +import { Routes } from 'routes/routes'; +import { CacheStatus } from '../CacheStatus'; + +describe('Executions > CacheStatus', () => { + const renderComponent = (props) => + render( + + + , + ); + + describe('check renders', () => { + it('should not render anything, if cacheStatus is null', () => { + const cacheStatus = null; + const { container } = renderComponent({ cacheStatus }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render anything, if cacheStatus is undefined', () => { + const cacheStatus = undefined; + const { container } = renderComponent({ cacheStatus }); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render text with icon, if no variant was provided', () => { + const cacheStatus = CatalogCacheStatus.CACHE_POPULATED; + const { queryByText, queryByTestId } = renderComponent({ cacheStatus }); + + expect(queryByText(cacheStatusMessages[cacheStatus])).toBeInTheDocument(); + expect(queryByTestId('cache-icon')).toBeInTheDocument(); + }); + + it('should render text with icon, if variant = normal', () => { + const cacheStatus = CatalogCacheStatus.CACHE_POPULATED; + const { queryByText, queryByTestId } = renderComponent({ cacheStatus, variant: 'normal' }); + + expect(queryByText(cacheStatusMessages[cacheStatus])).toBeInTheDocument(); + expect(queryByTestId('cache-icon')).toBeInTheDocument(); + }); + + it('should not render text, if variant = iconOnly', () => { + const cacheStatus = CatalogCacheStatus.CACHE_POPULATED; + const { queryByText, queryByTestId } = renderComponent({ cacheStatus, variant: 'iconOnly' }); + + expect(queryByText(cacheStatusMessages[cacheStatus])).not.toBeInTheDocument(); + expect(queryByTestId('cache-icon')).toBeInTheDocument(); + }); + + it('should render source execution link for cache hits', () => { + const cacheStatus = CatalogCacheStatus.CACHE_HIT; + const sourceTaskExecutionId = { + taskId: { ...mockWorkflowExecutionId, version: '1' }, + nodeExecutionId: { nodeId: 'n1', executionId: mockWorkflowExecutionId }, + }; + const { getByText } = renderComponent({ cacheStatus, sourceTaskExecutionId }); + const linkEl = getByText(viewSourceExecutionString); + + expect(linkEl.getAttribute('href')).toEqual( + Routes.ExecutionDetails.makeUrl(mockWorkflowExecutionId), + ); + }); + }); + + describe('check cache statuses', () => { + describe.each` + cacheStatus | expected + ${CatalogCacheStatus.CACHE_DISABLED} | ${cacheStatusMessages[CatalogCacheStatus.CACHE_DISABLED]} + ${CatalogCacheStatus.CACHE_HIT} | ${cacheStatusMessages[CatalogCacheStatus.CACHE_HIT]} + ${CatalogCacheStatus.CACHE_LOOKUP_FAILURE} | ${cacheStatusMessages[CatalogCacheStatus.CACHE_LOOKUP_FAILURE]} + ${CatalogCacheStatus.CACHE_MISS} | ${cacheStatusMessages[CatalogCacheStatus.CACHE_MISS]} + ${CatalogCacheStatus.CACHE_POPULATED} | ${cacheStatusMessages[CatalogCacheStatus.CACHE_POPULATED]} + ${CatalogCacheStatus.CACHE_PUT_FAILURE} | ${cacheStatusMessages[CatalogCacheStatus.CACHE_PUT_FAILURE]} + `('for each case', ({ cacheStatus, expected }) => { + it(`renders correct text ${expected} for status ${cacheStatus}`, async () => { + const { queryByText } = renderComponent({ cacheStatus }); + + expect(queryByText(cacheStatusMessages[cacheStatus])).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx b/packages/zapp/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx new file mode 100644 index 000000000..bef42ac0e --- /dev/null +++ b/packages/zapp/console/src/components/Executions/test/NodeExecutionCacheStatus.test.tsx @@ -0,0 +1,71 @@ +import { render, waitFor } from '@testing-library/react'; +import { cacheStatusMessages } from 'components/Executions/constants'; +import { NodeExecutionDetailsContextProvider } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { basicPythonWorkflow } from 'mocks/data/fixtures/basicPythonWorkflow'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { insertFixture } from 'mocks/data/insertFixture'; +import { mockServer } from 'mocks/server'; +import { CatalogCacheStatus } 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 { NodeExecutionCacheStatus } from '../NodeExecutionCacheStatus'; + +jest.mock('models/Task/utils'); +jest.mock('components/Workflow/workflowQueries'); +const { fetchWorkflow } = require('components/Workflow/workflowQueries'); + +// TODO add test to cover mapTask branch +describe('Executions > NodeExecutionCacheStatus', () => { + let fixture: ReturnType; + let execution: NodeExecution; + let queryClient: QueryClient; + + beforeEach(() => { + fixture = basicPythonWorkflow.generate(); + insertFixture(mockServer, fixture); + execution = fixture.workflowExecutions.top.nodeExecutions.pythonNode.data; + fetchWorkflow.mockImplementation(() => Promise.resolve(fixture.workflows.top)); + queryClient = createTestQueryClient(); + }); + + const renderComponent = (props) => + render( + + + + + + + , + ); + + it('should not render anything, if cacheStatus is undefined', async () => { + const { container } = renderComponent({ execution }); + await waitFor(() => container); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should not render anything, if cacheStatus is null', async () => { + const mockExecution = { + ...execution, + closure: { taskNodeMetadata: { cacheStatus: null } }, + }; + const { container } = renderComponent({ execution: mockExecution }); + await waitFor(() => container); + + expect(container).toBeEmptyDOMElement(); + }); + + it('should render cache hit status text, if execution has cacheStatus CACHE_HIT', async () => { + const cacheStatus = CatalogCacheStatus.CACHE_HIT; + const mockExecution = { ...execution, closure: { taskNodeMetadata: { cacheStatus } } }; + const { queryByText } = renderComponent({ execution: mockExecution }); + await waitFor(() => queryByText(cacheStatusMessages[cacheStatus])); + + expect(queryByText(cacheStatusMessages[cacheStatus])).toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/test/utils.test.ts b/packages/zapp/console/src/components/Executions/test/utils.test.ts index 9588653c0..e0465016d 100644 --- a/packages/zapp/console/src/components/Executions/test/utils.test.ts +++ b/packages/zapp/console/src/components/Executions/test/utils.test.ts @@ -8,11 +8,14 @@ import { Execution, NodeExecution, TaskExecution } from 'models/Execution/types' import { createMockNodeExecutions } from 'models/Execution/__mocks__/mockNodeExecutionsData'; import { createMockTaskExecutionsListResponse } from 'models/Execution/__mocks__/mockTaskExecutionsData'; import { createMockWorkflowExecutionsListResponse } from 'models/Execution/__mocks__/mockWorkflowExecutionsData'; +import { mockNodes, mockNodesWithGateNode } from 'models/Node/__mocks__/mockNodeData'; import { long, waitFor } from 'test/utils'; import { getNodeExecutionTimingMS, + getNodeFrontendPhase, getTaskExecutionTimingMS, getWorkflowExecutionTimingMS, + isNodeGateNode, } from '../utils'; const getMockWorkflowExecution = () => createMockWorkflowExecutionsListResponse(1).executions[0]; @@ -163,3 +166,53 @@ describe('getTaskExecutionTimingMS', () => { expect(firstResult!.duration).toBeLessThan(secondResult!.duration); }); }); + +describe('isNodeGateNode', () => { + const executionId = { project: 'project', domain: 'domain', name: 'name' }; + + it('should return true if nodeId is in the list and has a gateNode field', () => { + expect(isNodeGateNode(mockNodesWithGateNode, { nodeId: 'GateNode', executionId })).toBeTruthy(); + }); + + it('should return false if nodeId is in the list, but a gateNode field is missing', () => { + expect(isNodeGateNode(mockNodes, { nodeId: 'BasicNode', executionId })).toBeFalsy(); + }); + + it('should return false if nodeId is not in the list, but has a gateNode field', () => { + expect(isNodeGateNode(mockNodes, { nodeId: 'GateNode', executionId })).toBeFalsy(); + }); + + it('should return false if nodeId is a gateNode, but the list is empty', () => { + expect(isNodeGateNode([], { nodeId: 'GateNode', executionId })).toBeFalsy(); + }); + + it('should return false if nodeId is not a gateNode and the list is empty', () => { + expect(isNodeGateNode([], { nodeId: 'BasicNode', executionId })).toBeFalsy(); + }); +}); + +describe('getNodeFrontendPhase', () => { + it('should return PAUSED if node is a gateNode in the RUNNING phase', () => { + expect(getNodeFrontendPhase(NodeExecutionPhase.RUNNING, true)).toEqual( + NodeExecutionPhase.PAUSED, + ); + }); + + it('should return phase if node is a gateNode not in the RUNNING phase', () => { + expect(getNodeFrontendPhase(NodeExecutionPhase.FAILED, true)).toEqual( + NodeExecutionPhase.FAILED, + ); + }); + + it('should return RUNNING if node is not a gateNode in the RUNNING phase', () => { + expect(getNodeFrontendPhase(NodeExecutionPhase.RUNNING, false)).toEqual( + NodeExecutionPhase.RUNNING, + ); + }); + + it('should return phase if node is not a gateNode not in the RUNNING phase', () => { + expect(getNodeFrontendPhase(NodeExecutionPhase.SUCCEEDED, false)).toEqual( + NodeExecutionPhase.SUCCEEDED, + ); + }); +}); diff --git a/packages/zapp/console/src/components/Executions/types.ts b/packages/zapp/console/src/components/Executions/types.ts index 35791f7c4..3bed86f03 100644 --- a/packages/zapp/console/src/components/Executions/types.ts +++ b/packages/zapp/console/src/components/Executions/types.ts @@ -7,8 +7,10 @@ import { import { TaskTemplate } from 'models/Task/types'; export interface ExecutionPhaseConstants { - badgeColor: string; text: string; + value: string; + badgeColor: string; + nodeColor: string; textColor: string; } @@ -16,6 +18,7 @@ export enum NodeExecutionDisplayType { MapTask = 'Map Task', BatchHiveTask = 'Hive Batch Task', BranchNode = 'Branch Node', + GateNode = 'Gate Node', DynamicTask = 'Dynamic Task', HiveTask = 'Hive Task', PythonTask = 'Python Task', diff --git a/packages/zapp/console/src/components/Executions/utils.ts b/packages/zapp/console/src/components/Executions/utils.ts index 31c1fa340..61ddca548 100644 --- a/packages/zapp/console/src/components/Executions/utils.ts +++ b/packages/zapp/console/src/components/Executions/utils.ts @@ -15,6 +15,7 @@ import { BaseExecutionClosure, Execution, NodeExecution, + NodeExecutionIdentifier, TaskExecution, } from 'models/Execution/types'; import { CompiledNode } from 'models/Node/types'; @@ -163,3 +164,20 @@ export function isExecutionArchived(execution: Execution): boolean { const state = execution.closure.stateChangeDetails?.state ?? null; return !!state && state === ExecutionState.EXECUTION_ARCHIVED; } + +/** Returns true if current node (by nodeId) has 'gateNode' field in the list of nodes on compiledWorkflowClosure */ +export function isNodeGateNode( + nodes: CompiledNode[], + executionId: NodeExecutionIdentifier, +): boolean { + const node = nodes.find((n) => n.id === executionId.nodeId); + return !!node?.gateNode; +} + +/** Transforms phase to Paused for gate nodes in the running state, otherwise returns the phase unchanged */ +export function getNodeFrontendPhase( + phase: NodeExecutionPhase, + isGateNode: boolean, +): NodeExecutionPhase { + return isGateNode && phase === NodeExecutionPhase.RUNNING ? NodeExecutionPhase.PAUSED : phase; +} diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/BlobInput.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/BlobInput.tsx index 900167338..4b75f58db 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/BlobInput.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/BlobInput.tsx @@ -10,7 +10,7 @@ import { import { makeStyles, Theme } from '@material-ui/core/styles'; import { BlobDimensionality } from 'models/Common/types'; import * as React from 'react'; -import { blobFormatHelperText, blobUriHelperText } from './constants'; +import t from './strings'; import { InputProps } from './types'; import { getLaunchInputId, isBlobValue } from './utils'; @@ -84,7 +84,7 @@ export const BlobInput: React.FC = (props) => {
= (props) => { = ({ @@ -20,6 +22,7 @@ export const LaunchFormActions: React.FC = ({ service, onClose, isError, + submitTitle, }) => { const styles = useStyles(); const submissionInFlight = state.matches(LaunchState.SUBMITTING); @@ -40,13 +43,18 @@ export const LaunchFormActions: React.FC = ({ onClose(); }; - React.useEffect(() => { + useEffect(() => { const subscription = service.subscribe((newState) => { // On transition to final success state, read the resulting execution // id and navigate to the Execution Details page. // if (state.matches({ submit: 'succeeded' })) { if (newState.matches(LaunchState.SUBMIT_SUCCEEDED)) { - history.push(Routes.ExecutionDetails.makeUrl(newState.context.resultExecutionId)); + if (newState.context.resultExecutionId) { + history.push(Routes.ExecutionDetails.makeUrl(newState.context.resultExecutionId)); + } + if ((newState.context as TaskResumeContext).compiledNode) { + onCancel(); + } } }); @@ -66,7 +74,7 @@ export const LaunchFormActions: React.FC = ({ onClick={onCancel} variant="outlined" > - {formStrings.cancel} + {t('cancel')} diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormDialog.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormDialog.tsx index 1c3a25716..e9eb868bd 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormDialog.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormDialog.tsx @@ -6,12 +6,16 @@ import { TaskInitialLaunchParameters, WorkflowInitialLaunchParameters, } from 'components/Launch/LaunchForm/types'; +import { CompiledNode } from 'models/Node/types'; +import { ResumeForm } from './ResumeForm'; interface LaunchFormDialogProps { - id: ResourceIdentifier; - initialParameters: TaskInitialLaunchParameters | WorkflowInitialLaunchParameters; + id?: ResourceIdentifier; + initialParameters?: TaskInitialLaunchParameters | WorkflowInitialLaunchParameters; showLaunchForm: boolean; setShowLaunchForm: React.Dispatch>; + compiledNode?: CompiledNode; + nodeId?: string; } function getLaunchProps(id: ResourceIdentifier) { @@ -23,9 +27,14 @@ function getLaunchProps(id: ResourceIdentifier) { throw new Error('Unknown Resource Type'); } -export const LaunchFormDialog = (props: LaunchFormDialogProps): JSX.Element => { - const { id, initialParameters, showLaunchForm, setShowLaunchForm } = props; - +export const LaunchFormDialog = ({ + id, + initialParameters, + showLaunchForm, + setShowLaunchForm, + compiledNode, + nodeId, +}: LaunchFormDialogProps): JSX.Element => { const onCancelLaunch = () => setShowLaunchForm(false); // prevent child onclick event in the dialog triggers parent onclick event @@ -41,11 +50,20 @@ export const LaunchFormDialog = (props: LaunchFormDialogProps): JSX.Element => { open={showLaunchForm} onClick={dialogOnClick} > - + {id ? ( + + ) : compiledNode && nodeId ? ( + + ) : null} ); }; diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormHeader.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormHeader.tsx index b8429d397..c944a8b85 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormHeader.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormHeader.tsx @@ -1,14 +1,18 @@ import { DialogTitle, Typography } from '@material-ui/core'; import * as React from 'react'; -import { formStrings } from './constants'; import { useStyles } from './styles'; +interface LaunchFormHeaderProps { + title?: string; + formTitle: string; +} + /** Shared header component for the Launch form */ -export const LaunchFormHeader: React.FC<{ title?: string }> = ({ title = '' }) => { +export const LaunchFormHeader: React.FC = ({ title = '', formTitle }) => { const styles = useStyles(); return ( -
{formStrings.title}
+
{formTitle}
{title}
); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx index 5832e54d1..14b3ee298 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchFormInputs.tsx @@ -2,7 +2,7 @@ import { Typography } from '@material-ui/core'; import * as React from 'react'; import { BlobInput } from './BlobInput'; import { CollectionInput } from './CollectionInput'; -import { formStrings, inputsDescription } from './constants'; +import t from './strings'; import { LaunchState } from './launchMachine'; import { MapInput } from './MapInput'; import { NoInputsNeeded } from './NoInputsNeeded'; @@ -80,8 +80,8 @@ const RenderFormInputs: React.FC<{ ) : ( <>
- {formStrings.inputs} - {inputsDescription} + {t('inputs')} + {t('inputsDescription')}
{inputs.map((input) => (
@@ -104,7 +104,7 @@ export const LaunchFormInputsImpl: React.RefForwardingComponent< })); return isEnterInputsState(state) ? ( -
+
{state.matches(LaunchState.UNSUPPORTED_INPUTS) ? ( ) : ( diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchInterruptibleInput.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchInterruptibleInput.tsx index 23412c8b3..12832cfad 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchInterruptibleInput.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchInterruptibleInput.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { Protobuf } from 'flyteidl'; import { useStyles } from './styles'; import { LaunchInterruptibleInputRef } from './types'; -import { formStrings } from './constants'; +import t from './strings'; export const useInterruptibleStyles = makeStyles((theme: Theme) => ({ labelIndeterminate: { @@ -71,17 +71,18 @@ export const LaunchInterruptibleInputImpl: React.ForwardRefRenderFunction< const styles = useStyles(); const interruptibleStyles = useInterruptibleStyles(); + // TODO: to cover all text variants in localization conditional const getInterruptibleLabel = () => { if (indeterminate) { return ( - {`${formStrings.interruptible} (no override)`} + {`${t( + 'interruptible', + )} (no override)`} ); } else if (interruptible) { - return {`${formStrings.interruptible} (enabled)`}; + return {`${t('interruptible')} (enabled)`}; } - return {`${formStrings.interruptible} (disabled)`}; + return {`${t('interruptible')} (disabled)`}; }; return ( @@ -93,7 +94,7 @@ export const LaunchInterruptibleInputImpl: React.ForwardRefRenderFunction< forced on or off. If no value was selected, the workflow's default will be used. -
+
= (props) => { return ( <> - + {showTaskSelector ? ( -
+
= (props) => { service={baseService} onClose={props.onClose} isError={isError} + submitTitle={t('submit')} /> ); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx index 5d48cea2d..2071299b6 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/LaunchWorkflowForm.tsx @@ -1,7 +1,7 @@ import { Accordion, AccordionDetails, AccordionSummary, DialogContent } from '@material-ui/core'; import { getCacheKey } from 'components/Cache/utils'; import * as React from 'react'; -import { formStrings } from './constants'; +import t from './strings'; import { LaunchFormActions } from './LaunchFormActions'; import { LaunchFormHeader } from './LaunchFormHeader'; import { LaunchFormInputs } from './LaunchFormInputs'; @@ -70,13 +70,13 @@ export const LaunchWorkflowForm: React.FC = (props) => return ( <> - + {showWorkflowSelector ? ( -
+
= (props) =>
) : null} {showLaunchPlanSelector ? ( -
+
= (props) => service={baseService} onClose={props.onClose} isError={isError} + submitTitle={t('submit')} /> ); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/MapInput.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/MapInput.tsx index 8c83285b3..ac6b61c90 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/MapInput.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/MapInput.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import RemoveIcon from '@material-ui/icons/Remove'; import Card from '@material-ui/core/Card'; import CardContent from '@material-ui/core/CardContent'; -import { requiredInputSuffix } from './constants'; +import t from './strings'; import { InputProps, InputType, InputTypeDefinition, InputValue } from './types'; import { formatType, toMappedTypeValue } from './utils'; import { getHelperForInput } from './inputHelpers/getHelperForInput'; @@ -73,7 +73,7 @@ const MapSingleInputItem = (props: MapInputItemProps) => { return (
) => { setKey(value); setError(!!value && !isValid(value)); @@ -86,7 +86,7 @@ const MapSingleInputItem = (props: MapInputItemProps) => { className={classes.keyControl} /> ) => { setTouched(true); setValue(value); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/NoInputsNeeded.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/NoInputsNeeded.tsx index 43cb41657..b3b88f671 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/NoInputsNeeded.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/NoInputsNeeded.tsx @@ -3,7 +3,8 @@ import { makeStyles, Theme } from '@material-ui/core/styles'; import classnames from 'classnames'; import { useCommonStyles } from 'components/common/styles'; import * as React from 'react'; -import { taskNoInputsString, workflowNoInputsString } from './constants'; +import { workflowNoInputsString } from './constants'; +import t from './strings'; const useStyles = makeStyles((theme: Theme) => ({ root: { @@ -26,7 +27,7 @@ export const NoInputsNeeded: React.FC = ({ variant }) => { className={classnames(commonStyles.hintText, useStyles().root)} variant="body2" > - {variant === 'workflow' ? workflowNoInputsString : taskNoInputsString} + {variant === 'workflow' ? workflowNoInputsString : t('taskNoInputsString')} ); }; diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/NoneInput.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/NoneInput.tsx index d70d1ee43..f67fee751 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/NoneInput.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/NoneInput.tsx @@ -1,6 +1,6 @@ import { TextField } from '@material-ui/core'; import * as React from 'react'; -import { noneInputTypeDescription } from './constants'; +import t from './strings'; import { InputProps } from './types'; import { getLaunchInputId } from './utils'; @@ -15,7 +15,7 @@ export const NoneInput: React.FC = (props) => { variant="outlined" disabled={true} helperText={description} - value={noneInputTypeDescription} + value={t('noneInputTypeDescription')} /> ); }; diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/ResumeForm.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/ResumeForm.tsx new file mode 100644 index 000000000..ff34b1880 --- /dev/null +++ b/packages/zapp/console/src/components/Launch/LaunchForm/ResumeForm.tsx @@ -0,0 +1,23 @@ +import { CompiledNode } from 'models/Node/types'; +import * as React from 'react'; +import { useState } from 'react'; +import { createInputValueCache, InputValueCacheContext } from './inputValueCache'; +import { ResumeSignalForm } from './ResumeSignalForm'; +import { BaseLaunchFormProps, TaskInitialLaunchParameters } from './types'; + +interface ResumeFormProps extends BaseLaunchFormProps { + compiledNode: CompiledNode; + initialParameters?: TaskInitialLaunchParameters; + nodeId: string; +} + +/** Renders the form for requesting a resume request on a gate node */ +export const ResumeForm: React.FC = (props) => { + const [inputValueCache] = useState(createInputValueCache()); + + return ( + + + + ); +}; diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx new file mode 100644 index 000000000..8690e9cc3 --- /dev/null +++ b/packages/zapp/console/src/components/Launch/LaunchForm/ResumeSignalForm.tsx @@ -0,0 +1,85 @@ +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 { NodeExecution } 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 { useStyles } from './styles'; +import { + BaseInterpretedLaunchState, + BaseLaunchFormProps, + BaseLaunchService, + TaskInitialLaunchParameters, +} from './types'; +import { useResumeFormState } from './useResumeFormState'; +import { LaunchFormInputs } from './LaunchFormInputs'; +import { LaunchFormHeader } from './LaunchFormHeader'; +import launchFormStrings from './strings'; +import { LaunchFormActions } from './LaunchFormActions'; + +export interface ResumeSignalFormProps extends BaseLaunchFormProps { + compiledNode: CompiledNode; + initialParameters?: TaskInitialLaunchParameters; + nodeId: string; +} + +/** Renders the form for requesting a resume request on a gate node */ +export const ResumeSignalForm: React.FC = ({ + compiledNode, + nodeId, + onClose, +}) => { + const { formInputsRef, state, service } = useResumeFormState({ compiledNode, nodeId, onClose }); + const nodeExecutionsById = useContext(NodeExecutionsByIdContext); + const [nodeExecution, setNodeExecution] = useState(nodeExecutionsById[nodeId]); + const styles = useStyles(); + const baseState = state as BaseInterpretedLaunchState; + const baseService = service as BaseLaunchService; + const [isError, setIsError] = useState(false); + const executionData = useNodeExecutionData(nodeExecution.id); + + // Any time the inputs change (even if it's just re-ordering), we must + // change the form key so that the inputs component will re-mount. + const formKey = useMemo(() => { + return getCacheKey(state.context.parsedInputs); + }, [state.context.parsedInputs]); + + useEffect(() => { + const newNodeExecution = nodeExecutionsById[nodeId]; + setNodeExecution(newNodeExecution); + }, [nodeId]); + + return ( + <> + + + + {t('gateNodeInput')} + + + + + + + ); +}; diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/UnsupportedRequiredInputsError.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/UnsupportedRequiredInputsError.tsx index 9ebcbb329..f75980532 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/UnsupportedRequiredInputsError.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/UnsupportedRequiredInputsError.tsx @@ -3,13 +3,7 @@ import ErrorOutline from '@material-ui/icons/ErrorOutline'; import { NonIdealState } from 'components/common/NonIdealState'; import { useCommonStyles } from 'components/common/styles'; import * as React from 'react'; -import { - cannotLaunchTaskString, - cannotLaunchWorkflowString, - requiredInputSuffix, - taskUnsupportedRequiredInputsString, - workflowUnsupportedRequiredInputsString, -} from './constants'; +import t from './strings'; import { ParsedInput } from './types'; const useStyles = makeStyles((theme: Theme) => ({ @@ -23,7 +17,7 @@ const useStyles = makeStyles((theme: Theme) => ({ })); function formatLabel(label: string) { - return label.endsWith(requiredInputSuffix) ? label.substring(0, label.length - 1) : label; + return label.endsWith(t('requiredInputSuffix')) ? label.substring(0, label.length - 1) : label; } export interface UnsupportedRequiredInputsErrorProps { @@ -41,8 +35,8 @@ export const UnsupportedRequiredInputsError: React.FC = { + id: 'resumeTask', + initial: LaunchState.LOADING_INPUTS, + context: defaultBaseContext, + on: defaultHandlers, + states: { + ...(baseStateConfig as StatesConfig), + }, +}; + export const workflowLaunchMachineConfig: MachineConfig< WorkflowLaunchContext, WorkflowLaunchSchema, @@ -478,6 +498,11 @@ export const taskLaunchMachine = Machine(taskLaunchMachineConfig, { }, }); +export const taskResumeMachine = Machine(taskResumeMachineConfig, { + actions: baseActions, + services: baseServices, +}); + /** A full machine for representing the Launch flow, combining the state definitions * with actions/guards/services needed to support them. */ diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/services.ts b/packages/zapp/console/src/components/Launch/LaunchForm/services.ts index 006a26e30..1fc56a534 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/services.ts +++ b/packages/zapp/console/src/components/Launch/LaunchForm/services.ts @@ -1,5 +1,5 @@ import { RefObject } from 'react'; -import { correctInputErrors } from './constants'; +import t from './strings'; import { LaunchFormInputsRef } from './types'; export async function validate(formInputsRef: RefObject): Promise { @@ -8,7 +8,7 @@ export async function validate(formInputsRef: RefObject): P } if (!formInputsRef.current.validate()) { - throw new Error(correctInputErrors); + throw new Error(t('correctInputErrors')); } return true; } diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/strings.ts b/packages/zapp/console/src/components/Launch/LaunchForm/strings.ts new file mode 100644 index 000000000..82663764d --- /dev/null +++ b/packages/zapp/console/src/components/Launch/LaunchForm/strings.ts @@ -0,0 +1,32 @@ +import { createLocalizedString } from '@flyteconsole/locale'; + +const str = { + requiredInputSuffix: '*', + cannotLaunchWorkflowString: 'Workflow cannot be launched', + cannotLaunchTaskString: 'Task cannot be launched', + inputsDescription: 'Enter input values below. Items marked with an asterisk(*) are required.', + gateInputDescription: 'Enter input values below.', + taskNoInputsString: 'This task does not accept any inputs.', + workflowUnsupportedRequiredInputsString: `This Workflow version contains one or more required inputs which are not supported by Flyte Console and do not have default values specified in the Workflow definition or the selected Launch Plan.\n\nYou can launch this Workflow version with the Flyte CLI or by selecting a Launch Plan which provides values for the unsupported inputs.\n\nThe required inputs are :`, + taskUnsupportedRequiredInputsString: `This Task version contains one or more required inputs which are not supported by Flyte Console.\n\nYou can launch this Task version with the Flyte CLI instead.\n\nThe required inputs are :`, + blobUriHelperText: '(required) location of the data', + blobFormatHelperText: '(optional) csv, parquet, etc...', + correctInputErrors: 'Some inputs have errors. Please correct them before submitting.', + noneInputTypeDescription: 'The value of none type is empty', + cancel: 'Cancel', + inputs: 'Inputs', + gateInput: 'Gate input', + role: 'Role', + submit: 'Launch', + resume: 'Resume', + taskVersion: 'Task Version', + title: 'Create New Execution', + resumeTitle: 'Resume Paused Execution', + workflowVersion: 'Workflow Version', + launchPlan: 'Launch Plan', + interruptible: 'Interruptible', + viewNodeInputs: 'View node inputs', +}; + +export { patternKey } from '@flyteconsole/locale'; +export default createLocalizedString(str); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/styles.ts b/packages/zapp/console/src/components/Launch/LaunchForm/styles.ts index 4e166897f..adcffaea7 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/styles.ts +++ b/packages/zapp/console/src/components/Launch/LaunchForm/styles.ts @@ -34,6 +34,9 @@ export const useStyles = makeStyles((theme: Theme) => ({ color: interactiveTextColor, justifyContent: 'flex-end', }, + viewNodeInputs: { + color: interactiveTextColor, + }, noBorder: { '&:before': { height: 0, diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx index a492f6832..bb2f5744b 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchTaskForm.test.tsx @@ -21,14 +21,8 @@ import { Task } from 'models/Task/types'; import { createMockTaskClosure } from 'models/__mocks__/taskData'; import * as React from 'react'; import { delayedPromise, pendingPromise } from 'test/utils'; -import { - AuthRoleStrings, - cannotLaunchTaskString, - formStrings, - inputsDescription, - requiredInputSuffix, - taskNoInputsString, -} from '../constants'; +import { AuthRoleStrings } from '../constants'; +import t from '../strings'; import { LaunchForm } from '../LaunchForm'; import { AuthRoleTypes, LaunchFormProps, TaskInitialLaunchParameters } from '../types'; import { createInputCacheKey, getInputDefintionForLiteralType } from '../utils'; @@ -185,7 +179,7 @@ describe('LaunchForm: Task', () => { const submitButton = await waitFor(() => getSubmitButton(container)); await waitFor(() => expect(submitButton).toBeEnabled()); - expect(getByText(taskNoInputsString)).toBeInTheDocument(); + expect(getByText(t('taskNoInputsString'))).toBeInTheDocument(); }); it('should not render inputs header/description', async () => { @@ -193,8 +187,8 @@ describe('LaunchForm: Task', () => { const submitButton = await waitFor(() => getSubmitButton(container)); await waitFor(() => expect(submitButton).toBeEnabled()); - expect(queryByText(formStrings.inputs)).toBeNull(); - expect(queryByText(inputsDescription)).toBeNull(); + expect(queryByText(t('inputs'))).toBeNull(); + expect(queryByText(t('inputsDescription'))).toBeNull(); }); }); @@ -215,13 +209,13 @@ describe('LaunchForm: Task', () => { it('should not show task selector until options have loaded', async () => { mockListTasks.mockReturnValue(pendingPromise()); const { getByText, queryByText } = renderForm(); - await waitFor(() => getByText(formStrings.title)); - expect(queryByText(formStrings.taskVersion)).not.toBeInTheDocument(); + await waitFor(() => getByText(t('title'))); + expect(queryByText(t('taskVersion'))).not.toBeInTheDocument(); }); it('should select the most recent task version by default', async () => { const { getByLabelText } = renderForm(); - const versionEl = await waitFor(() => getByLabelText(formStrings.taskVersion)); + const versionEl = await waitFor(() => getByLabelText(t('taskVersion'))); expect(versionEl).toHaveValue(mockTaskVersions[0].id.version); }); @@ -282,7 +276,7 @@ describe('LaunchForm: Task', () => { it('should update inputs when selecting a new task version', async () => { const { queryByLabelText, getByTitle } = renderForm(); - const taskVersionDiv = await waitFor(() => getByTitle(formStrings.taskVersion)); + const taskVersionDiv = await waitFor(() => getByTitle(t('taskVersion'))); // Delete the string input so that its corresponding input will // disappear after the new launch plan is loaded. @@ -294,7 +288,7 @@ describe('LaunchForm: Task', () => { const items = await waitFor(() => getAllByRole(taskVersionDiv, 'menuitem')); fireEvent.click(items[1]); - await waitFor(() => getByTitle(formStrings.inputs)); + await waitFor(() => getByTitle(t('inputs'))); expect( queryByLabelText(stringInputName, { // Don't use exact match because the label will be decorated with type info @@ -314,12 +308,12 @@ describe('LaunchForm: Task', () => { fireEvent.change(integerInput, { target: { value: '10' } }); // Click the expander for the task version, select the second item - const taskVersionDiv = getByTitle(formStrings.taskVersion); + const taskVersionDiv = getByTitle(t('taskVersion')); const expander = getByRole(taskVersionDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(taskVersionDiv, 'menuitem')); fireEvent.click(items[1]); - await waitFor(() => getByTitle(formStrings.inputs)); + await waitFor(() => getByTitle(t('inputs'))); expect( getByLabelText(integerInputName, { @@ -333,14 +327,14 @@ describe('LaunchForm: Task', () => { mockCreateWorkflowExecution.mockRejectedValue(new Error(errorString)); const { container, getByText, getByTitle, queryByText } = renderForm(); - await waitFor(() => getByTitle(formStrings.inputs)); + await waitFor(() => getByTitle(t('inputs'))); await fillInputs(container); fireEvent.click(getSubmitButton(container)); await waitFor(() => expect(getByText(errorString)).toBeInTheDocument()); // Click the expander for the launch plan, select the second item - const taskVersionDiv = getByTitle(formStrings.taskVersion); + const taskVersionDiv = getByTitle(t('taskVersion')); const expander = getByRole(taskVersionDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(taskVersionDiv, 'menuitem')); @@ -399,7 +393,7 @@ describe('LaunchForm: Task', () => { describe('Input Values', () => { it('should decorate all inputs with required labels', async () => { const { getByTitle, queryAllByText } = renderForm(); - await waitFor(() => getByTitle(formStrings.inputs)); + await waitFor(() => getByTitle(t('inputs'))); Object.keys(variables).forEach((name) => { const elements = queryAllByText(name, { exact: false, @@ -417,9 +411,7 @@ describe('LaunchForm: Task', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => - expect(getByLabelText(formStrings.taskVersion)).toHaveValue( - mockTaskVersions[2].id.version, - ), + expect(getByLabelText(t('taskVersion'))).toHaveValue(mockTaskVersions[2].id.version), ); }); @@ -430,7 +422,7 @@ describe('LaunchForm: Task', () => { const { getByTitle } = renderForm({ initialParameters }); // Click the expander for the workflow, select the second item - const versionDiv = await waitFor(() => getByTitle(formStrings.taskVersion)); + const versionDiv = await waitFor(() => getByTitle(t('taskVersion'))); const expander = getByRole(versionDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(versionDiv, 'menuitem')); @@ -458,9 +450,7 @@ describe('LaunchForm: Task', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => - expect(getByLabelText(formStrings.taskVersion)).toHaveValue( - mockTaskVersions[0].id.version, - ), + expect(getByLabelText(t('taskVersion'))).toHaveValue(mockTaskVersions[0].id.version), ); }); @@ -491,7 +481,7 @@ describe('LaunchForm: Task', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => - expect(getByLabelText(formStrings.taskVersion)).toHaveValue(missingTask.id.version), + expect(getByLabelText(t('taskVersion'))).toHaveValue(missingTask.id.version), ); }); @@ -499,7 +489,7 @@ describe('LaunchForm: Task', () => { const { getByLabelText } = renderForm(); // Focus the workflow version input - const workflowInput = await waitFor(() => getByLabelText(formStrings.taskVersion)); + const workflowInput = await waitFor(() => getByLabelText(t('taskVersion'))); fireEvent.focus(workflowInput); const expectedValue = mockTaskVersions[0].id.version; @@ -516,7 +506,7 @@ describe('LaunchForm: Task', () => { const inputString = mockTaskVersions[1].id.version.substring(0, 4); const { getByLabelText } = renderForm({ initialParameters }); - const versionInput = await waitFor(() => getByLabelText(formStrings.taskVersion)); + const versionInput = await waitFor(() => getByLabelText(t('taskVersion'))); mockListTasks.mockClear(); fireEvent.change(versionInput, { @@ -539,7 +529,7 @@ describe('LaunchForm: Task', () => { it('should render error message', async () => { const { getByText } = renderForm(); - const errorElement = await waitFor(() => getByText(cannotLaunchTaskString)); + const errorElement = await waitFor(() => getByText(t('cannotLaunchTaskString'))); expect(errorElement).toBeInTheDocument(); }); @@ -552,13 +542,13 @@ describe('LaunchForm: Task', () => { it('should print input labels without decoration', async () => { const { getByText } = renderForm(); const inputElement = await waitFor(() => getByText(binaryInputName, { exact: false })); - expect(inputElement.textContent).not.toContain(requiredInputSuffix); + expect(inputElement.textContent).not.toContain(t('requiredInputSuffix')); }); it('should disable submission', async () => { const { getByRole } = renderForm(); - const submitButton = await waitFor(() => getByRole('button', { name: formStrings.submit })); + const submitButton = await waitFor(() => getByRole('button', { name: t('submit') })); expect(submitButton).toBeDisabled(); }); @@ -575,7 +565,7 @@ describe('LaunchForm: Task', () => { }); await waitFor(() => getByLabelText(binaryInputName, { exact: false })); - expect(queryByText(cannotLaunchTaskString)).toBeNull(); + expect(queryByText(t('cannotLaunchTaskString'))).toBeNull(); }); }); @@ -583,7 +573,7 @@ describe('LaunchForm: Task', () => { it('should render checkbox', async () => { const { getByLabelText } = renderForm(); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -600,7 +590,7 @@ describe('LaunchForm: Task', () => { }); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).toBeChecked(); @@ -610,7 +600,7 @@ describe('LaunchForm: Task', () => { const { getByLabelText } = renderForm(); let inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (no override)`, { exact: true }), + getByLabelText(`${t('interruptible')} (no override)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -618,7 +608,7 @@ describe('LaunchForm: Task', () => { fireEvent.click(inputElement); inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (enabled)`, { exact: true }), + getByLabelText(`${t('interruptible')} (enabled)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).toBeChecked(); @@ -626,7 +616,7 @@ describe('LaunchForm: Task', () => { fireEvent.click(inputElement); inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (disabled)`, { exact: true }), + getByLabelText(`${t('interruptible')} (disabled)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -634,7 +624,7 @@ describe('LaunchForm: Task', () => { fireEvent.click(inputElement); inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (no override)`, { exact: true }), + getByLabelText(`${t('interruptible')} (no override)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -645,7 +635,7 @@ describe('LaunchForm: Task', () => { const { container, getByLabelText } = renderForm(); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -670,7 +660,7 @@ describe('LaunchForm: Task', () => { const { container, getByLabelText } = renderForm({ initialParameters }); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).toBeChecked(); @@ -695,7 +685,7 @@ describe('LaunchForm: Task', () => { const { container, getByLabelText } = renderForm({ initialParameters }); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx index 7abe6393c..8bb2d7095 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx +++ b/packages/zapp/console/src/components/Launch/LaunchForm/test/LaunchWorkflowForm.test.tsx @@ -24,13 +24,7 @@ import { Workflow } from 'models/Workflow/types'; import { createMockWorkflowClosure } from 'models/__mocks__/workflowData'; import * as React from 'react'; import { delayedPromise, pendingPromise } from 'test/utils'; -import { - cannotLaunchWorkflowString, - formStrings, - inputsDescription, - requiredInputSuffix, - workflowNoInputsString, -} from '../constants'; +import t from '../strings'; import { LaunchForm } from '../LaunchForm'; import { LaunchFormProps, WorkflowInitialLaunchParameters } from '../types'; import { createInputCacheKey, getInputDefintionForLiteralType } from '../utils'; @@ -47,6 +41,7 @@ import { stringNoLabelName, } from './constants'; import { createMockObjects } from './utils'; +import { workflowNoInputsString } from '../constants'; describe('LaunchForm: Workflow', () => { let onClose: jest.Mock; @@ -183,8 +178,8 @@ describe('LaunchForm: Workflow', () => { const submitButton = await waitFor(() => getSubmitButton(container)); await waitFor(() => expect(submitButton).toBeEnabled()); - expect(queryByText(formStrings.inputs)).toBeNull(); - expect(queryByText(inputsDescription)).toBeNull(); + expect(queryByText(t('inputs'))).toBeNull(); + expect(queryByText(t('inputsDescription'))).toBeNull(); }); }); @@ -198,27 +193,25 @@ describe('LaunchForm: Workflow', () => { mockListWorkflows.mockReturnValue(pendingPromise()); const { queryByText } = renderForm(); await waitFor(() => {}); - expect(queryByText(formStrings.workflowVersion)).not.toBeInTheDocument(); + expect(queryByText(t('workflowVersion'))).not.toBeInTheDocument(); }); it('should not show launch plan selector until list has loaded', async () => { mockListLaunchPlans.mockReturnValue(pendingPromise()); const { getByLabelText, queryByText } = renderForm(); - await waitFor(() => getByLabelText(formStrings.workflowVersion)); - expect(queryByText(formStrings.launchPlan)).not.toBeInTheDocument(); + await waitFor(() => getByLabelText(t('workflowVersion'))); + expect(queryByText(t('launchPlan'))).not.toBeInTheDocument(); }); it('should select the most recent workflow version by default', async () => { const { getByLabelText } = renderForm(); await waitFor(() => {}); - expect(getByLabelText(formStrings.workflowVersion)).toHaveValue( - mockWorkflowVersions[0].id.version, - ); + expect(getByLabelText(t('workflowVersion'))).toHaveValue(mockWorkflowVersions[0].id.version); }); it('should select the launch plan matching the workflow name by default', async () => { const { getByLabelText } = renderForm(); - const launchPlanEl = await waitFor(() => getByLabelText(formStrings.launchPlan)); + const launchPlanEl = await waitFor(() => getByLabelText(t('launchPlan'))); expect(launchPlanEl).toHaveValue(mockWorkflow.id.name); }); @@ -230,7 +223,7 @@ describe('LaunchForm: Workflow', () => { await waitFor(() => {}); // Find the launch plan selector, verify it has no value selected - const launchPlanInput = getByLabelText(formStrings.launchPlan); + const launchPlanInput = getByLabelText(t('launchPlan')); expect(launchPlanInput).toBeInTheDocument(); expect(launchPlanInput).toHaveValue(''); expect( @@ -300,18 +293,18 @@ describe('LaunchForm: Workflow', () => { it('should update launch plan when selecting a new workflow version', async () => { const { getByTitle } = renderForm(); - await waitFor(() => getByTitle(formStrings.launchPlan)); + await waitFor(() => getByTitle(t('launchPlan'))); mockListLaunchPlans.mockClear(); // Click the expander for the workflow, select the second item - const workflowDiv = getByTitle(formStrings.workflowVersion); + const workflowDiv = getByTitle(t('workflowVersion')); const expander = getByRole(workflowDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(workflowDiv, 'menuitem')); fireEvent.click(items[1]); - await waitFor(() => getByTitle(formStrings.launchPlan)); + await waitFor(() => getByTitle(t('launchPlan'))); expect(mockListLaunchPlans).toHaveBeenCalled(); }); @@ -322,7 +315,7 @@ describe('LaunchForm: Workflow', () => { mockListLaunchPlans.mockClear(); // Click the expander for the workflow, select the second item - const workflowDiv = getByTitle(formStrings.workflowVersion); + const workflowDiv = getByTitle(t('workflowVersion')); const expander = getByRole(workflowDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(workflowDiv, 'menuitem')); @@ -330,12 +323,12 @@ describe('LaunchForm: Workflow', () => { await waitFor(() => {}); expect(mockListLaunchPlans).not.toHaveBeenCalled(); - expect(getByLabelText(formStrings.launchPlan)).toHaveValue(mockWorkflow.id.name); + expect(getByLabelText(t('launchPlan'))).toHaveValue(mockWorkflow.id.name); }); it('should update inputs when selecting a new launch plan', async () => { const { queryByLabelText, getByTitle } = renderForm(); - const launchPlanDiv = await waitFor(() => getByTitle(formStrings.launchPlan)); + const launchPlanDiv = await waitFor(() => getByTitle(t('launchPlan'))); // Delete the string input so that its corresponding input will // disappear after the new launch plan is loaded. @@ -347,7 +340,7 @@ describe('LaunchForm: Workflow', () => { const items = await waitFor(() => getAllByRole(launchPlanDiv, 'menuitem')); fireEvent.click(items[1]); - await waitFor(() => getByTitle(formStrings.inputs)); + await waitFor(() => getByTitle(t('inputs'))); expect( queryByLabelText(stringInputName, { // Don't use exact match because the label will be decorated with type info @@ -367,7 +360,7 @@ describe('LaunchForm: Workflow', () => { await waitFor(() => {}); // Click the expander for the launch plan, select the second item - const launchPlanDiv = getByTitle(formStrings.launchPlan); + const launchPlanDiv = getByTitle(t('launchPlan')); const expander = getByRole(launchPlanDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(launchPlanDiv, 'menuitem')); @@ -395,7 +388,7 @@ describe('LaunchForm: Workflow', () => { mockSingleLaunchPlan = mockLaunchPlans[1]; // Click the expander for the launch plan, select the second item - const launchPlanDiv = getByTitle(formStrings.launchPlan); + const launchPlanDiv = getByTitle(t('launchPlan')); const expander = getByRole(launchPlanDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(launchPlanDiv, 'menuitem')); @@ -468,7 +461,7 @@ describe('LaunchForm: Workflow', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => {}); - expect(getByLabelText(formStrings.workflowVersion)).toHaveValue( + expect(getByLabelText(t('workflowVersion'))).toHaveValue( mockWorkflowVersions[2].id.version, ); }); @@ -480,7 +473,7 @@ describe('LaunchForm: Workflow', () => { const { getByTitle } = renderForm({ initialParameters }); await waitFor(() => {}); // Click the expander for the workflow, select the second item - const versionDiv = getByTitle(formStrings.workflowVersion); + const versionDiv = getByTitle(t('workflowVersion')); const expander = getByRole(versionDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(versionDiv, 'menuitem')); @@ -508,7 +501,7 @@ describe('LaunchForm: Workflow', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => {}); - expect(getByLabelText(formStrings.workflowVersion)).toHaveValue( + expect(getByLabelText(t('workflowVersion'))).toHaveValue( mockWorkflowVersions[0].id.version, ); }); @@ -519,7 +512,7 @@ describe('LaunchForm: Workflow', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => {}); - expect(getByLabelText(formStrings.launchPlan)).toHaveValue(mockLaunchPlans[1].id.name); + expect(getByLabelText(t('launchPlan'))).toHaveValue(mockLaunchPlans[1].id.name); }); it('should only include one instance of the preferred launch plan in the selector', async () => { @@ -529,7 +522,7 @@ describe('LaunchForm: Workflow', () => { const { getByTitle } = renderForm({ initialParameters }); await waitFor(() => {}); // Click the expander for the LaunchPlan, select the second item - const launchPlanDiv = getByTitle(formStrings.launchPlan); + const launchPlanDiv = getByTitle(t('launchPlan')); const expander = getByRole(launchPlanDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(launchPlanDiv, 'menuitem')); @@ -556,7 +549,7 @@ describe('LaunchForm: Workflow', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => {}); - expect(getByLabelText(formStrings.launchPlan)).toHaveValue(mockLaunchPlans[0].id.name); + expect(getByLabelText(t('launchPlan'))).toHaveValue(mockLaunchPlans[0].id.name); }); it('should maintain selected launch plan by name after switching workflow versions', async () => { @@ -564,7 +557,7 @@ describe('LaunchForm: Workflow', () => { await waitFor(() => {}); // Click the expander for the launch plan, select the second item - const launchPlanDiv = getByTitle(formStrings.launchPlan); + const launchPlanDiv = getByTitle(t('launchPlan')); const launchPlanExpander = getByRole(launchPlanDiv, 'button'); fireEvent.click(launchPlanExpander); const launchPlanItems = await waitFor(() => getAllByRole(launchPlanDiv, 'menuitem')); @@ -572,14 +565,14 @@ describe('LaunchForm: Workflow', () => { await waitFor(() => {}); // Click the expander for the workflow, select the second item - const workflowDiv = getByTitle(formStrings.workflowVersion); + const workflowDiv = getByTitle(t('workflowVersion')); const expander = getByRole(workflowDiv, 'button'); fireEvent.click(expander); const items = await waitFor(() => getAllByRole(workflowDiv, 'menuitem')); fireEvent.click(items[1]); await waitFor(() => {}); - expect(getByLabelText(formStrings.launchPlan)).toHaveValue(mockLaunchPlans[1].id.name); + expect(getByLabelText(t('launchPlan'))).toHaveValue(mockLaunchPlans[1].id.name); }); it('should prepopulate inputs with provided initial values', async () => { @@ -610,7 +603,7 @@ describe('LaunchForm: Workflow', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => {}); - expect(getByLabelText(formStrings.workflowVersion)).toHaveValue(missingWorkflow.id.version); + expect(getByLabelText(t('workflowVersion'))).toHaveValue(missingWorkflow.id.version); }); it('loads the preferred launch plan when it does not exist in the list of suggestions', async () => { @@ -621,7 +614,7 @@ describe('LaunchForm: Workflow', () => { }; const { getByLabelText } = renderForm({ initialParameters }); await waitFor(() => {}); - expect(getByLabelText(formStrings.launchPlan)).toHaveValue(missingLaunchPlan.id.name); + expect(getByLabelText(t('launchPlan'))).toHaveValue(missingLaunchPlan.id.name); }); it('should select contents of workflow version input on focus', async () => { @@ -629,7 +622,7 @@ describe('LaunchForm: Workflow', () => { await waitFor(() => {}); // Focus the workflow version input - const workflowInput = getByLabelText(formStrings.workflowVersion); + const workflowInput = getByLabelText(t('workflowVersion')); fireEvent.focus(workflowInput); act(() => { @@ -653,7 +646,7 @@ describe('LaunchForm: Workflow', () => { mockListWorkflows.mockClear(); - const versionInput = getByLabelText(formStrings.workflowVersion); + const versionInput = getByLabelText(t('workflowVersion')); fireEvent.change(versionInput, { target: { value: inputString }, }); @@ -681,7 +674,7 @@ describe('LaunchForm: Workflow', () => { it('should render error message', async () => { const { getByText } = renderForm(); - const errorElement = await waitFor(() => getByText(cannotLaunchWorkflowString)); + const errorElement = await waitFor(() => getByText(t('cannotLaunchWorkflowString'))); expect(errorElement).toBeInTheDocument(); }); @@ -694,13 +687,13 @@ describe('LaunchForm: Workflow', () => { it('should print input labels without decoration', async () => { const { getByText } = renderForm(); const inputElement = await waitFor(() => getByText(binaryInputName, { exact: false })); - expect(inputElement.textContent).not.toContain(requiredInputSuffix); + expect(inputElement.textContent).not.toContain(t('requiredInputSuffix')); }); it('should disable submission', async () => { const { getByRole } = renderForm(); - const submitButton = await waitFor(() => getByRole('button', { name: formStrings.submit })); + const submitButton = await waitFor(() => getByRole('button', { name: t('submit') })); expect(submitButton).toBeDisabled(); }); @@ -710,7 +703,7 @@ describe('LaunchForm: Workflow', () => { simpleVariableDefaults.simpleBinary as Literal; const { queryByText } = renderForm(); await waitFor(() => queryByText(binaryInputName, { exact: false })); - expect(queryByText(cannotLaunchWorkflowString)).toBeNull(); + expect(queryByText(t('cannotLaunchWorkflowString'))).toBeNull(); }); it('should not show error if initial value is provided', async () => { @@ -726,7 +719,7 @@ describe('LaunchForm: Workflow', () => { }); await waitFor(() => queryByText(binaryInputName, { exact: false })); - expect(queryByText(cannotLaunchWorkflowString)).toBeNull(); + expect(queryByText(t('cannotLaunchWorkflowString'))).toBeNull(); }); }); @@ -734,7 +727,7 @@ describe('LaunchForm: Workflow', () => { it('should render checkbox', async () => { const { getByLabelText } = renderForm(); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -751,7 +744,7 @@ describe('LaunchForm: Workflow', () => { }); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).toBeChecked(); @@ -761,7 +754,7 @@ describe('LaunchForm: Workflow', () => { const { getByLabelText } = renderForm(); let inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (no override)`, { exact: true }), + getByLabelText(`${t('interruptible')} (no override)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -769,7 +762,7 @@ describe('LaunchForm: Workflow', () => { fireEvent.click(inputElement); inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (enabled)`, { exact: true }), + getByLabelText(`${t('interruptible')} (enabled)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).toBeChecked(); @@ -777,7 +770,7 @@ describe('LaunchForm: Workflow', () => { fireEvent.click(inputElement); inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (disabled)`, { exact: true }), + getByLabelText(`${t('interruptible')} (disabled)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -785,7 +778,7 @@ describe('LaunchForm: Workflow', () => { fireEvent.click(inputElement); inputElement = await waitFor(() => - getByLabelText(`${formStrings.interruptible} (no override)`, { exact: true }), + getByLabelText(`${t('interruptible')} (no override)`, { exact: true }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -796,7 +789,7 @@ describe('LaunchForm: Workflow', () => { const { container, getByLabelText } = renderForm(); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); @@ -825,7 +818,7 @@ describe('LaunchForm: Workflow', () => { const { container, getByLabelText } = renderForm({ initialParameters }); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).toBeChecked(); @@ -853,7 +846,7 @@ describe('LaunchForm: Workflow', () => { const { container, getByLabelText } = renderForm({ initialParameters }); const inputElement = await waitFor(() => - getByLabelText(formStrings.interruptible, { exact: false }), + getByLabelText(t('interruptible'), { exact: false }), ); expect(inputElement).toBeInTheDocument(); expect(inputElement).not.toBeChecked(); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx b/packages/zapp/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx new file mode 100644 index 000000000..36150b9c1 --- /dev/null +++ b/packages/zapp/console/src/components/Launch/LaunchForm/test/ResumeSignalForm.test.tsx @@ -0,0 +1,195 @@ +import { ThemeProvider } from '@material-ui/styles'; +import { fireEvent, queryAllByRole, render, waitFor } from '@testing-library/react'; +import { APIContext } from 'components/data/apiContext'; +import { mockAPIContextValue } from 'components/data/__mocks__/apiContext'; +import { muiTheme } 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 { dateToTimestamp } from 'common/utils'; +import { NodeExecutionPhase } from 'models/Execution/enums'; +import { createTestQueryClient } from 'test/utils'; +import { mockWorkflowId } from 'mocks/data/fixtures/types'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Core } from 'flyteidl'; +import { CompiledNode } from 'models/Node/types'; +import { CompiledWorkflowClosure } from 'models/Workflow/types'; +import { NodeExecutionDetailsContext } from 'components/Executions/contextProvider/NodeExecutionDetails'; +import { signalInputName } from './constants'; +import { ResumeSignalForm } from '../ResumeSignalForm'; + +const mockNodeExecutionId = 'n0'; +const mockNodeId = 'node0'; + +const mockNodeExecutionsById = { + [mockNodeExecutionId]: { + closure: { + createdAt: dateToTimestamp(new Date()), + outputUri: '', + phase: NodeExecutionPhase.UNDEFINED, + }, + id: { + executionId: { domain: 'domain', name: 'name', project: 'project' }, + nodeId: mockNodeId, + }, + inputUri: '', + scopedId: mockNodeExecutionId, + }, +}; + +const createMockCompiledWorkflowClosure = (nodes: CompiledNode[]): CompiledWorkflowClosure => ({ + primary: { + connections: { + downstream: {}, + upstream: {}, + }, + template: { + id: mockWorkflowId, + nodes, + }, + }, + tasks: [], +}); + +const createMockCompiledNode = (type?: Core.ILiteralType): CompiledNode => ({ + id: mockNodeExecutionId, + metadata: { + name: 'my-signal-name', + timeout: '3600s', + retries: {}, + }, + upstreamNodeIds: [], + gateNode: { + signal: { + signalId: 'my-signal-name', + type, + outputVariableName: 'o0', + }, + }, +}); + +describe('ResumeSignalForm', () => { + let onClose: jest.Mock; + let queryClient: QueryClient; + let mockResumeSignalNode: jest.Mock>; + + beforeEach(() => { + onClose = jest.fn(); + queryClient = createTestQueryClient(); + }); + + const renderForm = (type?: Core.ILiteralType) => { + const mockCompiledNode = createMockCompiledNode(type); + const mockCompiledWorkflowClosure = createMockCompiledWorkflowClosure([mockCompiledNode]); + return render( + + + + + + + + + + + , + ); + }; + + const getSubmitButton = (container: HTMLElement) => { + const buttons = queryAllByRole(container, 'button').filter( + (el) => el.getAttribute('type') === 'submit', + ); + expect(buttons.length).toBe(1); + return buttons[0]; + }; + + describe('With inputs', () => { + beforeEach(() => { + mockResumeSignalNode = jest.fn(); + }); + + it('should render the node id as a header title', async () => { + const { getByText } = renderForm(); + expect(getByText('node0')).toBeInTheDocument(); + }); + + it('should disable the submit button until the input is filled', async () => { + const { container } = renderForm(); + const submitButton = await waitFor(() => getSubmitButton(container)); + expect(submitButton).toBeDisabled(); + }); + + it('should show disabled submit button if the value in input is invalid', async () => { + const { container, getByLabelText } = renderForm({ simple: SimpleType.INTEGER }); + await waitFor(() => {}); + + const integerInput = await waitFor(() => + getByLabelText(signalInputName, { + exact: false, + }), + ); + const submitButton = getSubmitButton(container); + fireEvent.change(integerInput, { target: { value: 'abc' } }); + fireEvent.click(getSubmitButton(container)); + await waitFor(() => expect(submitButton).toBeDisabled()); + + fireEvent.change(integerInput, { target: { value: '123' } }); + await waitFor(() => expect(submitButton).toBeEnabled()); + }); + + it('should allow submission after fixing validation errors', async () => { + const { container, getByLabelText } = renderForm({ simple: SimpleType.INTEGER }); + await waitFor(() => {}); + + const integerInput = await waitFor(() => + getByLabelText(signalInputName, { + exact: false, + }), + ); + const submitButton = getSubmitButton(container); + fireEvent.change(integerInput, { target: { value: 'abc' } }); + await waitFor(() => expect(submitButton).toBeDisabled()); + + fireEvent.change(integerInput, { target: { value: '123' } }); + await waitFor(() => expect(submitButton).toBeEnabled()); + fireEvent.click(submitButton); + await waitFor(() => expect(mockResumeSignalNode).toHaveBeenCalled()); + }); + + it('should show error when the submission fails', async () => { + const errorString = 'Something went wrong'; + mockResumeSignalNode.mockRejectedValue(new Error(errorString)); + + const { container, getByText, getByLabelText } = renderForm({ + simple: SimpleType.INTEGER, + }); + const integerInput = await waitFor(() => + getByLabelText(signalInputName, { + exact: false, + }), + ); + const submitButton = getSubmitButton(container); + fireEvent.change(integerInput, { target: { value: '123' } }); + await waitFor(() => expect(submitButton).toBeEnabled()); + + fireEvent.click(getSubmitButton(container)); + await waitFor(() => expect(getByText(errorString)).toBeInTheDocument()); + }); + }); +}); diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/test/constants.ts b/packages/zapp/console/src/components/Launch/LaunchForm/test/constants.ts index 16274440e..a64bde4c8 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/test/constants.ts +++ b/packages/zapp/console/src/components/Launch/LaunchForm/test/constants.ts @@ -8,6 +8,7 @@ export const integerInputName = 'simpleInteger'; export const binaryInputName = 'simpleBinary'; export const errorInputName = 'simpleError'; export const mapInputName = 'simpleMap'; +export const signalInputName = 'Signal Input'; export const iamRoleString = 'arn:aws:iam::12345678:role/defaultrole'; export const k8sServiceAccountString = 'default-service-account'; diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/types.ts b/packages/zapp/console/src/components/Launch/LaunchForm/types.ts index fb0d50d10..c735416ba 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/types.ts +++ b/packages/zapp/console/src/components/Launch/LaunchForm/types.ts @@ -17,6 +17,8 @@ import { TaskLaunchContext, TaskLaunchEvent, TaskLaunchTypestate, + TaskResumeContext, + TaskResumeTypestate, WorkflowLaunchContext, WorkflowLaunchEvent, WorkflowLaunchTypestate, @@ -143,6 +145,12 @@ export interface LaunchTaskFormState { taskSourceSelectorState: TaskSourceSelectorState; } +export interface ResumeFormState { + formInputsRef: React.RefObject; + state: State; + service: Interpreter; +} + export enum InputType { Binary = 'BINARY', Blob = 'BLOB', diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts b/packages/zapp/console/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts index 383baa78c..950139e06 100644 --- a/packages/zapp/console/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts +++ b/packages/zapp/console/src/components/Launch/LaunchForm/useLaunchTaskFormState.ts @@ -8,7 +8,7 @@ import { WorkflowExecutionIdentifier } from 'models/Execution/types'; import { taskSortFields } from 'models/Task/constants'; import { Task } from 'models/Task/types'; import { RefObject, useEffect, useMemo, useRef } from 'react'; -import { correctInputErrors } from './constants'; +import t from './strings'; import { getInputsForTask } from './getInputs'; import { LaunchState, @@ -100,7 +100,7 @@ async function validate( } if (!roleInputRef.current.validate()) { - throw new Error(correctInputErrors); + throw new Error(t('correctInputErrors')); } return baseValidate(formInputsRef); } diff --git a/packages/zapp/console/src/components/Launch/LaunchForm/useResumeFormState.ts b/packages/zapp/console/src/components/Launch/LaunchForm/useResumeFormState.ts new file mode 100644 index 000000000..1408e610d --- /dev/null +++ b/packages/zapp/console/src/components/Launch/LaunchForm/useResumeFormState.ts @@ -0,0 +1,116 @@ +import { useMachine } from '@xstate/react'; +import { defaultStateMachineConfig } from 'components/common/constants'; +import { APIContextValue, useAPIContext } from 'components/data/apiContext'; +import { Core } from 'flyteidl'; +import { partial } from 'lodash'; +import { CompiledNode } from 'models/Node/types'; +import { RefObject, useMemo, useRef } from 'react'; +import { + TaskResumeContext, + TaskResumeTypestate, + taskResumeMachine, + BaseLaunchEvent, +} from './launchMachine'; +import { validate as baseValidate } from './services'; +import { + BaseLaunchFormProps, + LaunchFormInputsRef, + ParsedInput, + ResumeFormState, + TaskInitialLaunchParameters, +} from './types'; +import { getInputDefintionForLiteralType, getUnsupportedRequiredInputs } from './utils'; + +interface ResumeFormProps extends BaseLaunchFormProps { + compiledNode: CompiledNode; + initialParameters?: TaskInitialLaunchParameters; + nodeId: string; +} + +async function loadInputs({ compiledNode }: TaskResumeContext) { + if (!compiledNode) { + throw new Error('Failed to load inputs: missing compiledNode'); + } + const signalType = compiledNode.gateNode?.signal?.type; + if (!signalType) { + throw new Error('Failed to load inputs: missing signal.type'); + } + const parsedInputs: ParsedInput[] = [ + { + description: '', + label: 'Signal Input', + name: 'signal', + required: true, + typeDefinition: getInputDefintionForLiteralType({ simple: signalType.simple ?? undefined }), + }, + ]; + + return { + parsedInputs, + unsupportedRequiredInputs: getUnsupportedRequiredInputs(parsedInputs), + }; +} + +async function validate(formInputsRef: RefObject) { + return baseValidate(formInputsRef); +} + +async function submit( + { resumeSignalNode }: APIContextValue, + formInputsRef: RefObject, + { compiledNode }: TaskResumeContext, +) { + if (!compiledNode?.gateNode?.signal?.signalId) { + throw new Error('SignalId is empty'); + } + if (formInputsRef.current === null) { + throw new Error('Unexpected empty form inputs ref'); + } + + const literals = formInputsRef.current.getValues(); + + const response = await resumeSignalNode({ + id: compiledNode?.gateNode?.signal?.signalId as unknown as Core.SignalIdentifier, + value: literals['signal'], + }); + + return response; +} + +function getServices(apiContext: APIContextValue, formInputsRef: RefObject) { + return { + loadInputs: partial(loadInputs), + submit: partial(submit, apiContext, formInputsRef), + validate: partial(validate, formInputsRef), + }; +} + +/** Contains all of the form state for a LaunchTaskForm, including input + * definitions, current input values, and errors. + */ +export function useResumeFormState({ compiledNode }: ResumeFormProps): ResumeFormState { + const apiContext = useAPIContext(); + const formInputsRef = useRef(null); + + const services = useMemo( + () => getServices(apiContext, formInputsRef), + [apiContext, formInputsRef], + ); + + const [state, , service] = useMachine( + taskResumeMachine, + { + ...defaultStateMachineConfig, + services, + context: { + compiledNode, + }, + }, + ); + + return { + formInputsRef, + state, + service, + }; +} diff --git a/packages/zapp/console/src/components/Tables/DataList.tsx b/packages/zapp/console/src/components/Tables/DataList.tsx index 2f481a4f3..5f94cc69c 100644 --- a/packages/zapp/console/src/components/Tables/DataList.tsx +++ b/packages/zapp/console/src/components/Tables/DataList.tsx @@ -2,8 +2,9 @@ import { ListProps, Typography } from '@material-ui/core'; import { makeStyles, Theme, useTheme } from '@material-ui/core/styles'; import classnames from 'classnames'; import { useCommonStyles } from 'components/common/styles'; -import { tablePlaceholderColor } from 'components/Theme/constants'; +import { useExecutionTableStyles } from 'components/Executions/Tables/styles'; import * as React from 'react'; +import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { AutoSizer, CellMeasurer, @@ -27,11 +28,6 @@ const useStyles = makeStyles((theme: Theme) => ({ // set minHeight to avoid AutoResizer setting height to 0 minHeight: theme.spacing(minListContainerHeight), }, - noRowsContent: { - color: tablePlaceholderColor, - margin: `${theme.spacing(5)}px auto`, - textAlign: 'center', - }, })); export interface DataListRowClickEventParams { @@ -83,17 +79,18 @@ const DataListImplComponent: React.RefForwardingComponent(); - const lengthRef = React.useRef(0); - const listRef = React.useRef(null); + const lengthRef = useRef(0); + const listRef = useRef(null); /** We want the cache to persist across renders, which useState will do. * But we also don't want to be needlessly creating new caches that are * thrown away immediately. So we're using a creation function which useState * will call to create the initial value. */ - const [cellCache, setCellCache] = React.useState(createCellMeasurerCache); + const [cellCache, setCellCache] = useState(createCellMeasurerCache); - const recomputeRow = React.useMemo( + const recomputeRow = useMemo( () => (rowIndex: number) => { cellCache.clear(rowIndex, 0); if (listRef.current !== null) { @@ -102,7 +99,7 @@ const DataListImplComponent: React.RefForwardingComponent ({ recomputeRowHeights: recomputeRow, @@ -110,7 +107,7 @@ const DataListImplComponent: React.RefForwardingComponent { + useLayoutEffect(() => { if (lengthRef.current >= 0 && items.length > lengthRef.current) { recomputeRow(lengthRef.current); } @@ -133,7 +130,7 @@ const DataListImplComponent: React.RefForwardingComponent ( -
+
{typeof NoRowsContent === 'string' ? ( {NoRowsContent} ) : ( @@ -194,7 +191,7 @@ const DataListImplComponent: React.RefForwardingComponent ); }; -const DataListImpl = React.forwardRef(DataListImplComponent); +const DataListImpl = forwardRef(DataListImplComponent); /** The default version of DataList doesn't require a width/height and will expand to * fill its parent container (this can have odd behavior when using flex or the parent @@ -223,6 +220,6 @@ const DataListComponent: React.RefForwardingComponent ); }; -const DataList = React.forwardRef(DataListComponent); +const DataList = forwardRef(DataListComponent); export { DataList, DataListImpl }; diff --git a/packages/zapp/console/src/components/Theme/constants.ts b/packages/zapp/console/src/components/Theme/constants.ts index 39d879978..b89f4a489 100644 --- a/packages/zapp/console/src/components/Theme/constants.ts +++ b/packages/zapp/console/src/components/Theme/constants.ts @@ -55,6 +55,18 @@ export const statusColors = { SKIPPED: COLOR_SPECTRUM.sunset20.color, UNKNOWN: COLOR_SPECTRUM.gray20.color, WARNING: COLOR_SPECTRUM.yellow40.color, + PAUSED: COLOR_SPECTRUM.amber30.color, +}; + +export const graphStatusColors = { + FAILED: '#e90000', + FAILING: '#f2a4ad', + SUCCEEDED: '#37b789', + ABORTED: '#be25d7', + RUNNING: '#2892f4', + QUEUED: '#dfd71b', + PAUSED: '#f5a684', + UNDEFINED: '#4a2839', }; export type TaskColorMap = Record; diff --git a/packages/zapp/console/src/components/WorkflowGraph/WorkflowGraph.tsx b/packages/zapp/console/src/components/WorkflowGraph/WorkflowGraph.tsx index fad6e0902..d99c2bdc0 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/WorkflowGraph.tsx +++ b/packages/zapp/console/src/components/WorkflowGraph/WorkflowGraph.tsx @@ -1,111 +1,55 @@ -import { dNode } from 'models/Graph/types'; -import { Workflow } from 'models/Workflow/types'; import * as React from 'react'; import ReactFlowGraphComponent from 'components/flytegraph/ReactFlow/ReactFlowGraphComponent'; import { Error } from 'models/Common/types'; import { NonIdealState } from 'components/common/NonIdealState'; -import { DataError } from 'components/Errors/DataError'; -import { WaitForQuery } from 'components/common/WaitForQuery'; -import { useQuery } from 'react-query'; -import { makeNodeExecutionDynamicWorkflowQuery } from 'components/Workflow/workflowQueries'; -import { createDebugLogger } from 'common/log'; import { CompiledNode } from 'models/Node/types'; import { TaskExecutionPhase } from 'models/Execution/enums'; -import { NodeExecutionsByIdContext } from 'components/Executions/contexts'; -import { useContext } from 'react'; -import { checkForDynamicExecutions } from 'components/common/utils'; -import { transformerWorkflowToDag } from './transformerWorkflowToDag'; +import { dNode } from 'models/Graph/types'; +import t from './strings'; export interface WorkflowGraphProps { onNodeSelectionChanged: (selectedNodes: string[]) => void; onPhaseSelectionChanged: (phase: TaskExecutionPhase) => void; selectedPhase?: TaskExecutionPhase; isDetailsTabClosed: boolean; - workflow: Workflow; -} - -interface PrepareDAGResult { - dag: dNode | null; - staticExecutionIdsMap?: any; - error?: Error; -} - -const debug = createDebugLogger('@WorkflowGraph'); - -function workflowToDag(workflow: Workflow): PrepareDAGResult { - try { - if (!workflow.closure) { - throw new Error('Workflow has no closure'); - } - if (!workflow.closure.compiledWorkflow) { - throw new Error('Workflow closure missing a compiled workflow'); - } - const { compiledWorkflow } = workflow.closure; - const { dag, staticExecutionIdsMap } = transformerWorkflowToDag(compiledWorkflow); - - debug('workflowToDag:dag', dag); - - return { dag, staticExecutionIdsMap }; - } catch (e) { - return { - dag: null, - error: e as Error, - }; - } + mergedDag: any; + error: Error | null; + dynamicWorkflows: any; + initialNodes: dNode[]; } - export interface DynamicWorkflowMapping { rootGraphNodeId: CompiledNode; dynamicWorkflow: any; dynamicExecutions: any[]; } -export const WorkflowGraph: React.FC = (props) => { - const { - onNodeSelectionChanged, - onPhaseSelectionChanged, - selectedPhase, - isDetailsTabClosed, - workflow, - } = props; - const nodeExecutionsById = useContext(NodeExecutionsByIdContext); - const { dag, staticExecutionIdsMap, error } = workflowToDag(workflow); - - const dynamicParents = checkForDynamicExecutions(nodeExecutionsById, staticExecutionIdsMap); - const dynamicWorkflowQuery = useQuery(makeNodeExecutionDynamicWorkflowQuery(dynamicParents)); - const renderReactFlowGraph = (dynamicWorkflows) => { - debug('DynamicWorkflows:', dynamicWorkflows); - let mergedDag = dag; - for (const dynamicId in dynamicWorkflows) { - if (staticExecutionIdsMap[dynamicId]) { - if (workflow.closure?.compiledWorkflow) { - const dynamicWorkflow = transformerWorkflowToDag( - workflow.closure?.compiledWorkflow, - dynamicWorkflows, - ); - mergedDag = dynamicWorkflow.dag; - } - } - } - const merged = mergedDag; - return ( - - ); - }; - +export const WorkflowGraph: React.FC = ({ + onNodeSelectionChanged, + onPhaseSelectionChanged, + selectedPhase, + isDetailsTabClosed, + mergedDag, + error, + dynamicWorkflows, + initialNodes, +}) => { if (error) { - return ; - } else { - return ( - - {renderReactFlowGraph} - - ); + return ; + } + + // If the dag is empty, show the message, instead of trying to display it + if (!mergedDag) { + return ; } + + return ( + + ); }; diff --git a/packages/zapp/console/src/components/WorkflowGraph/strings.ts b/packages/zapp/console/src/components/WorkflowGraph/strings.ts new file mode 100644 index 000000000..00e2364d5 --- /dev/null +++ b/packages/zapp/console/src/components/WorkflowGraph/strings.ts @@ -0,0 +1,9 @@ +import { createLocalizedString } from '@flyteconsole/locale'; + +const str = { + graphErrorTitle: 'Cannot render Workflow graph', + graphErrorEmptyGraph: 'The graph is empty', +}; + +export { patternKey } from '@flyteconsole/locale'; +export default createLocalizedString(str); diff --git a/packages/zapp/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx b/packages/zapp/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx index 93817bb9d..b6f9cae71 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx +++ b/packages/zapp/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { createTestQueryClient } from 'test/utils'; import { QueryClient, QueryClientProvider } from 'react-query'; import { WorkflowGraph } from '../WorkflowGraph'; -import { workflow } from './workflow.mock'; jest.mock('../../flytegraph/ReactFlow/ReactFlowWrapper.tsx', () => ({ ReactFlowWrapper: jest.fn(({ children }) => ( @@ -25,8 +24,20 @@ describe('WorkflowGraph', () => { , ); diff --git a/packages/zapp/console/src/components/WorkflowGraph/test/utils.test.ts b/packages/zapp/console/src/components/WorkflowGraph/test/utils.test.ts index 7a5776680..49cfca70c 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/test/utils.test.ts +++ b/packages/zapp/console/src/components/WorkflowGraph/test/utils.test.ts @@ -12,6 +12,7 @@ import { getNodeTypeFromCompiledNode, isStartNode, isEndNode, + getNodeTemplateName, } from '../utils'; describe('getDisplayName', () => { @@ -23,6 +24,7 @@ describe('getDisplayName', () => { }); }); +// TODO add tests for `launchplan` branch describe('getNodeTypeFromCompiledNode', () => { const branchNode = { branchNode: {}, @@ -30,24 +32,31 @@ describe('getNodeTypeFromCompiledNode', () => { const workflowNode = { workflowNode: {}, }; + const gateNode = { + gateNode: {}, + }; const mockBranchNode = { ...mockCompiledTaskNode, ...branchNode }; const mockWorkflowNode = { ...mockCompiledTaskNode, ...workflowNode }; + const mockGateNode = { ...mockCompiledTaskNode, ...gateNode }; - it('should return dTypes.start when is start-node', () => { + it('should return dTypes.start when node is start-node', () => { expect(getNodeTypeFromCompiledNode(mockCompiledStartNode)).toBe(dTypes.start); }); - it('should return dTypes.end when is end-node', () => { + it('should return dTypes.end when node is end-node', () => { expect(getNodeTypeFromCompiledNode(mockCompiledEndNode)).toBe(dTypes.end); }); - it('should return *dTypes.subworkflow (branch is typed as subworkflow for graph) when is node has branchNodes', () => { + it('should return *dTypes.subworkflow (branch is typed as subworkflow for graph) when node has branchNodes', () => { expect(getNodeTypeFromCompiledNode(mockBranchNode)).toBe(dTypes.subworkflow); }); - it('should return dTypes.subworkflow when is node has workflowNode', () => { + it('should return dTypes.subworkflow when node is workflowNode', () => { expect(getNodeTypeFromCompiledNode(mockWorkflowNode)).toBe(dTypes.subworkflow); }); - it('should return dTypes.task when is node is taskNode', () => { + it('should return dTypes.task when node is taskNode', () => { expect(getNodeTypeFromCompiledNode(mockCompiledTaskNode)).toBe(dTypes.task); }); + it('should return dTypes.gateNode when node is gateNode', () => { + expect(getNodeTypeFromCompiledNode(mockGateNode)).toBe(dTypes.gateNode); + }); }); describe('isStartNode', () => { @@ -76,3 +85,35 @@ describe('getSubWorkflowFromId', () => { expect(isStartNode(mockCompiledTaskNode)).toBe(false); }); }); + +describe('getNodeTemplateName', () => { + const name = 'Test'; + const node = { + id: 'n1', + scopedId: 'n1', + type: dTypes.start, + name: 'node1', + nodes: [], + edges: [], + }; + + it('should return undefined when node does not have value field', () => { + expect(getNodeTemplateName(node)).toBeUndefined(); + }); + it('should return undefined when node value is neither workflowNode nor taskNode', () => { + const otherNode = { ...node, value: { gateNode: {} } }; + expect(getNodeTemplateName(otherNode)).toBeUndefined(); + }); + it('should return referenceId name for taskNode', () => { + const otherNode = { ...node, value: { taskNode: { referenceId: { name } } } }; + expect(getNodeTemplateName(otherNode)).toEqual(name); + }); + it('should return launchplan name for launch plan', () => { + const otherNode = { ...node, value: { workflowNode: { launchplanRef: { name } } } }; + expect(getNodeTemplateName(otherNode)).toEqual(name); + }); + it('should return subworkflow name for standard workflowNode', () => { + const otherNode = { ...node, value: { workflowNode: { subWorkflowRef: { name } } } }; + expect(getNodeTemplateName(otherNode)).toEqual(name); + }); +}); diff --git a/packages/zapp/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx b/packages/zapp/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx index a3e7ec999..82cf3b7b8 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx +++ b/packages/zapp/console/src/components/WorkflowGraph/transformerWorkflowToDag.tsx @@ -68,8 +68,8 @@ export const transformerWorkflowToDag = ( let scopedId = ''; if (isStartOrEndNode(compiledNode) && parentDNode && !isStartOrEndNode(parentDNode)) { scopedId = `${parentDNode.scopedId}-${compiledNode.id}`; - } else if (parentDNode && parentDNode.type != dTypes.start) { - if (parentDNode.type == dTypes.branch || parentDNode.type == dTypes.subworkflow) { + } 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}`; @@ -88,6 +88,7 @@ export const transformerWorkflowToDag = ( name: getDisplayName(compiledNode), nodes: [], edges: [], + gateNode: compiledNode.gateNode, } as dNode; staticExecutionIdsMap[output.scopedId] = compiledNode; diff --git a/packages/zapp/console/src/components/WorkflowGraph/utils.ts b/packages/zapp/console/src/components/WorkflowGraph/utils.ts index 2ea656021..96c1cfa4b 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/utils.ts +++ b/packages/zapp/console/src/components/WorkflowGraph/utils.ts @@ -93,6 +93,8 @@ export const getNodeTypeFromCompiledNode = (node: CompiledNode): dTypes => { } else { return dTypes.subworkflow; } + } else if (node.gateNode) { + return dTypes.gateNode; } else { return dTypes.task; } @@ -131,7 +133,7 @@ export const getNodeNameFromDag = (dagData: dNode, nodeId: string) => { return getNodeTemplateName(node); }; -export const getNodeTemplateName = (node: dNode) => { +export const getNodeTemplateName = (node: dNode): string | undefined => { const value = node?.value; if (value?.workflowNode) { const { launchplanRef, subWorkflowRef } = node.value.workflowNode; @@ -143,7 +145,7 @@ export const getNodeTemplateName = (node: dNode) => { return value.taskNode.referenceId.name; } - return ''; + return undefined; }; export const transformWorkflowToKeyedDag = (workflow: Workflow) => { diff --git a/packages/zapp/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx b/packages/zapp/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx index 20a091894..09f2fad63 100644 --- a/packages/zapp/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx +++ b/packages/zapp/console/src/components/common/MapTaskExecutionsList/TaskNameList.tsx @@ -2,10 +2,10 @@ import * as React from 'react'; import { Typography } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import { Core } from 'flyteidl'; -import { getTaskIndex, getTaskLogName } from 'components/Executions/TaskExecutionsList/utils'; +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/NodeExecutionCacheStatus'; +import { CacheStatus } from 'components/Executions/CacheStatus'; import { useCommonStyles } from '../styles'; interface StyleProps { diff --git a/packages/zapp/console/src/components/common/constants.ts b/packages/zapp/console/src/components/common/constants.ts index 64f2f041e..b3475fbf9 100644 --- a/packages/zapp/console/src/components/common/constants.ts +++ b/packages/zapp/console/src/components/common/constants.ts @@ -1,4 +1,5 @@ import { env } from 'common/env'; +import { graphStatusColors } from 'components/Theme/constants'; import { InterpreterOptions } from 'xstate'; export const detailsPanelWidth = 432; @@ -16,5 +17,5 @@ export const defaultStateMachineConfig: Partial = { export const barChartColors = { default: '#e5e5e5', success: '#78dfb1', - failure: '#f2a4ad', + failure: graphStatusColors.FAILING, }; diff --git a/packages/zapp/console/src/components/common/strings.ts b/packages/zapp/console/src/components/common/strings.ts index 31d84397f..2ef3eef54 100644 --- a/packages/zapp/console/src/components/common/strings.ts +++ b/packages/zapp/console/src/components/common/strings.ts @@ -24,6 +24,7 @@ const str = { valueKeyRequired: "Value's key is required", valueValueInvalid: "Value's value is invalid", valueMustBeObject: 'Value must be an object', + gateNodeInput: 'Node input', type: 'Type', }; diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/NodeStatusLegend.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/NodeStatusLegend.tsx index e88abe852..0f901d1a9 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/NodeStatusLegend.tsx +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/NodeStatusLegend.tsx @@ -1,9 +1,17 @@ import * as React from 'react'; import { useState, CSSProperties } from 'react'; import { Button } from '@material-ui/core'; -import { nodePhaseColorMapping } from './utils'; +import { nodeExecutionPhaseConstants } from 'components/Executions/constants'; +import { + graphButtonContainer, + graphButtonStyle, + popupContainerStyle, + rightPositionStyle, +} from './commonStyles'; +import t from './strings'; +import { graphNodePhasesList } from './utils'; -export const LegendItem = ({ color, text }) => { +export const LegendItem = ({ nodeColor, text }) => { /** * @TODO temporary check for nested graph until * nested functionality is deployed @@ -19,14 +27,14 @@ export const LegendItem = ({ color, text }) => { const colorStyle: CSSProperties = { width: '28px', height: '22px', - background: isNested ? color : 'none', - border: `3px solid ${color}`, + background: isNested ? nodeColor : 'none', + border: `3px solid ${nodeColor}`, borderRadius: '4px', paddingRight: '10px', marginRight: '1rem', }; return ( -
+
{text}
@@ -37,73 +45,41 @@ interface LegendProps { initialIsVisible?: boolean; } -export const Legend: React.FC = (props) => { - const { initialIsVisible = false } = props; - +export const Legend: React.FC = ({ initialIsVisible = false }) => { const [isVisible, setIsVisible] = useState(initialIsVisible); - const positionStyle: CSSProperties = { - bottom: '1rem', - right: '1rem', - zIndex: 10, - position: 'absolute', - width: '150px', - }; - - const buttonContainer: CSSProperties = { - width: '100%', - display: 'flex', - justifyContent: 'center', - }; - - const buttonStyle: CSSProperties = { - color: '#555', - width: '100%', - }; - const toggleVisibility = () => { setIsVisible(!isVisible); }; - const renderLegend = () => { - const legendContainerStyle: CSSProperties = { - width: '100%', - padding: '1rem', - background: 'rgba(255,255,255,1)', - border: `1px solid #ddd`, - borderRadius: '4px', - boxShadow: '2px 4px 10px rgba(50,50,50,.2)', - marginBottom: '1rem', - }; - - return ( -
- {Object.keys(nodePhaseColorMapping).map((phase) => { - return ( - - ); - })} - -
- ); - }; + const renderLegend = () => ( +
+ {graphNodePhasesList.map((phase) => { + return ( + + ); + })} + +
+ ); return ( -
+
{isVisible ? renderLegend() : null} -
+
diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx new file mode 100644 index 000000000..7b3fde4ae --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx @@ -0,0 +1,109 @@ +import * as React 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 { + graphButtonContainer, + graphButtonStyle, + leftPositionStyle, + popupContainerStyle, +} from './commonStyles'; +import t from './strings'; + +interface PausedTasksComponentProps { + pausedNodes: dNode[]; + initialIsVisible?: boolean; +} + +const CustomBadge = withStyles({ + badge: { + backgroundColor: nodeExecutionPhaseConstants[NodeExecutionPhase.PAUSED].nodeColor, + color: COLOR_SPECTRUM.white.color, + }, +})(Badge); + +export const PausedTasksComponent: React.FC = ({ + pausedNodes, + initialIsVisible = false, +}) => { + const { compiledWorkflowClosure } = useNodeExecutionContext(); + const [isVisible, setIsVisible] = useState(initialIsVisible); + const [showResumeForm, setShowResumeForm] = useState(false); + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const toggleVisibility = () => { + setIsVisible(!isVisible); + }; + + const toggleNode = (id: string, scopeId: string, level: number) => { + const searchNode = (nodes: dNode[], nodeLevel: number) => { + if (!nodes || nodes.length === 0) { + return; + } + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.id === id && node.scopedId === scopeId && nodeLevel === level) { + nodes[i].expanded = !nodes[i].expanded; + return; + } + if (node.nodes.length > 0 && isExpanded(node)) { + searchNode(node.nodes, nodeLevel + 1); + } + } + }; + searchNode(pausedNodes, 0); + }; + + const onResumeClick = (nodeId: string) => { + setSelectedNodeId(nodeId); + setShowResumeForm(true); + }; + + const compiledNode = (compiledWorkflowClosure?.primary.template.nodes ?? []).find( + (node) => node.id === selectedNodeId, + ); + + const renderPausedTasksBlock = () => ( +
+ +
+ ); + + return ( +
+
+ {isVisible ? renderPausedTasksBlock() : null} +
+ + + +
+
+ {compiledNode && selectedNodeId ? ( + + ) : null} +
+ ); +}; diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx index b84d2f8bc..806f5f9ef 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowGraphComponent.tsx @@ -2,10 +2,15 @@ import * as React from 'react'; import { useState, useEffect, useContext } 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 { isNodeGateNode } from 'components/Executions/utils'; +import { dNode } from 'models/Graph/types'; import { RFWrapperProps, RFGraphTypes, ConvertDagProps } from './types'; import { getRFBackground } from './utils'; import { ReactFlowWrapper } from './ReactFlowWrapper'; import { Legend } from './NodeStatusLegend'; +import { PausedTasksComponent } from './PausedTasksComponent'; const nodeExecutionStatusChanged = (previous, nodeExecutionsById) => { for (const exe in nodeExecutionsById) { @@ -44,16 +49,20 @@ const graphNodeCountChanged = (previous, data) => { } }; -const ReactFlowGraphComponent = (props) => { - const { - data, - onNodeSelectionChanged, - onPhaseSelectionChanged, - selectedPhase, - isDetailsTabClosed, - dynamicWorkflows, - } = props; +const ReactFlowGraphComponent = ({ + data, + onNodeSelectionChanged, + onPhaseSelectionChanged, + selectedPhase, + isDetailsTabClosed, + dynamicWorkflows, + initialNodes, +}) => { const nodeExecutionsById = useContext(NodeExecutionsByIdContext); + const { compiledWorkflowClosure } = useNodeExecutionContext(); + + const [pausedNodes, setPausedNodes] = useState([]); + const [state, setState] = useState({ data, dynamicWorkflows, @@ -141,6 +150,30 @@ const ReactFlowGraphComponent = (props) => { const backgroundStyle = getRFBackground().nested; + useEffect(() => { + const updatedPausedNodes: dNode[] = initialNodes.filter((node) => { + const nodeExecution = nodeExecutionsById[node.id]; + if (nodeExecution) { + const phase = nodeExecution?.closure.phase; + const isGateNode = isNodeGateNode( + compiledWorkflowClosure?.primary.template.nodes ?? [], + nodeExecution.id, + ); + return isGateNode && phase === NodeExecutionPhase.RUNNING; + } + return false; + }); + const nodesWithExecutions = updatedPausedNodes.map((node) => { + const execution = nodeExecutionsById[node.scopedId]; + return { + ...node, + startedAt: execution?.closure.startedAt, + execution, + }; + }); + setPausedNodes(nodesWithExecutions); + }, [initialNodes]); + const containerStyle: React.CSSProperties = { display: 'flex', flex: `1 1 100%`, @@ -160,6 +193,9 @@ const ReactFlowGraphComponent = (props) => { }; return (
+ {pausedNodes && pausedNodes.length > 0 && ( + + )}
diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx index b2a806eff..d86b5ca54 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/ReactFlowWrapper.tsx @@ -11,6 +11,7 @@ import { ReactFlowCustomMaxNested, ReactFlowStaticNested, ReactFlowStaticNode, + ReactFlowGateNode, } from './customNodeComponents'; import { getPositionedNodes, ReactFlowIdHash } from './utils'; @@ -27,6 +28,7 @@ const CustomNodeTypes = { FlyteNode_nestedMaxDepth: ReactFlowCustomMaxNested, FlyteNode_staticNode: ReactFlowStaticNode, FlyteNode_staticNestedNode: ReactFlowStaticNested, + FlyteNode_gateNode: ReactFlowGateNode, }; export const ReactFlowWrapper: React.FC = ({ diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/commonStyles.ts b/packages/zapp/console/src/components/flytegraph/ReactFlow/commonStyles.ts new file mode 100644 index 000000000..4db1f4ee9 --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/commonStyles.ts @@ -0,0 +1,39 @@ +import { CSSProperties } from 'react'; + +const positionStyle: CSSProperties = { + bottom: '1rem', + zIndex: 10, + position: 'absolute', + maxHeight: '520px', +}; + +export const leftPositionStyle: CSSProperties = { + ...positionStyle, + left: '1rem', + width: '336px', +}; + +export const rightPositionStyle: CSSProperties = { + ...positionStyle, + right: '1rem', + width: '150px', +}; + +export const graphButtonContainer: CSSProperties = { + width: '100%', +}; + +export const graphButtonStyle: CSSProperties = { + color: '#555', + width: '100%', +}; + +export const popupContainerStyle: CSSProperties = { + width: '100%', + padding: '1rem', + background: 'rgba(255,255,255,1)', + border: `1px solid #ddd`, + borderRadius: '4px', + boxShadow: '2px 4px 10px rgba(50,50,50,.2)', + marginBottom: '1rem', +}; diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx index 8a74f925f..b5ada2272 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx @@ -1,22 +1,68 @@ import * as React from 'react'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useContext } from 'react'; import { Handle, Position } from 'react-flow-renderer'; import { dTypes } from 'models/Graph/types'; -import CachedOutlined from '@material-ui/icons/CachedOutlined'; -import { CatalogCacheStatus, TaskExecutionPhase } from 'models/Execution/enums'; -import { PublishedWithChangesOutlined } from 'components/common/PublishedWithChanges'; +import { NodeExecutionPhase, TaskExecutionPhase } from 'models/Execution/enums'; import { RENDER_ORDER } from 'components/Executions/TaskExecutionsList/constants'; import { whiteColor } from 'components/Theme/constants'; -import { CacheStatus } from 'components/Executions/NodeExecutionCacheStatus'; +import PlayCircleOutlineIcon from '@material-ui/icons/PlayCircleOutline'; +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 { - COLOR_TASK_TYPE, COLOR_GRAPH_BACKGROUND, getGraphHandleStyle, getGraphNodeStyle, getNestedContainerStyle, getStatusColor, } from './utils'; -import { RFHandleProps } from './types'; +import { RFHandleProps, RFNode } from './types'; +import t from './strings'; + +const taskContainerStyle: React.CSSProperties = { + position: 'absolute', + top: '-.55rem', + zIndex: 0, + right: '.15rem', +}; + +const taskTypeStyle: React.CSSProperties = { + backgroundColor: COLOR_GRAPH_BACKGROUND, + color: 'white', + padding: '.1rem .2rem', + fontSize: '.3rem', +}; + +const renderTaskType = (taskType: dTypes | undefined) => { + if (!taskType) { + return null; + } + return ( +
+
{taskType}
+
+ ); +}; + +const renderBasicNode = ( + taskType: dTypes | undefined, + text: string, + scopedId: string, + styles: React.CSSProperties, + onClick?: () => void, +) => { + return ( +
+ {renderTaskType(taskType)} +
{text}
+ {renderDefaultHandles(scopedId, getGraphHandleStyle('source'), getGraphHandleStyle('target'))} +
+ ); +}; export const renderDefaultHandles = (id: string, sourceStyle: any, targetStyle: any) => { const leftHandleProps: RFHandleProps = { @@ -40,8 +86,8 @@ export const renderDefaultHandles = (id: string, sourceStyle: any, targetStyle: ); }; -export const renderStardEndHandles = (data: any) => { - const isStart = data.nodeType == dTypes.nestedStart || data.nodeType == dTypes.start; +export const renderStardEndHandles = (nodeType: dTypes, scopedId: string) => { + const isStart = nodeType === dTypes.nestedStart || nodeType === dTypes.start; const idPrefix = isStart ? 'start' : 'end'; const position = isStart ? Position.Right : Position.Left; const type = isStart ? 'source' : 'target'; @@ -52,12 +98,12 @@ export const renderStardEndHandles = (data: any) => { * For now we force nestedMaxDepth for any nested types */ const style = - data.nodeType == dTypes.nestedStart || data.nodeType == dTypes.nestedEnd + nodeType === dTypes.nestedStart || nodeType === dTypes.nestedEnd ? getGraphHandleStyle(type, dTypes.nestedMaxDepth) : getGraphHandleStyle(type); const handleProps: RFHandleProps = { - id: `rf-handle-${idPrefix}-${data.scopedId}`, + id: `rf-handle-${idPrefix}-${scopedId}`, type: type, position: position, style: style, @@ -70,12 +116,13 @@ export const renderStardEndHandles = (data: any) => { * Styles start/end nodes as a point; used for nested workflows * @param props.data data property of ReactFlowGraphNodeData */ -export const ReactFlowCustomNestedPoint = ({ data }: any) => { - const containerStyle = getGraphNodeStyle(data.nodeType); +export const ReactFlowCustomNestedPoint = ({ data }: RFNode) => { + const { nodeType, scopedId } = data; + const containerStyle = getGraphNodeStyle(nodeType); return ( <>
- {renderStardEndHandles(data)} + {renderStardEndHandles(nodeType, scopedId)} ); }; @@ -88,119 +135,27 @@ export const ReactFlowCustomNestedPoint = ({ data }: any) => { * @param props.data data property of ReactFlowGraphNodeData */ -export const ReactFlowCustomMaxNested = ({ data }: any) => { +export const ReactFlowCustomMaxNested = ({ data }: RFNode) => { + const { text, taskType, scopedId, onAddNestedView } = data; const styles = getGraphNodeStyle(dTypes.nestedMaxDepth); - const containerStyle = {}; - const taskContainerStyle: React.CSSProperties = { - position: 'absolute', - top: '-.55rem', - zIndex: 0, - right: '.15rem', - }; - const taskTypeStyle: React.CSSProperties = { - backgroundColor: COLOR_GRAPH_BACKGROUND, - color: 'white', - padding: '.1rem .2rem', - fontSize: '.3rem', - }; - - const renderTaskType = () => { - return ( -
-
{data.taskType}
-
- ); - }; const onClick = () => { - data.onAddNestedView(); + onAddNestedView(); }; - return ( -
- {data.taskType ? renderTaskType() : null} -
{data.text}
- {renderDefaultHandles( - data.scopedId, - getGraphHandleStyle('source'), - getGraphHandleStyle('target'), - )} -
- ); + return renderBasicNode(taskType, text, scopedId, styles, onClick); }; -export const ReactFlowStaticNested = ({ data }: any) => { +export const ReactFlowStaticNested = ({ data }: RFNode) => { + const { text, taskType, scopedId } = data; const styles = getGraphNodeStyle(dTypes.staticNestedNode); - const containerStyle = {}; - const taskContainerStyle: React.CSSProperties = { - position: 'absolute', - top: '-.55rem', - zIndex: 0, - right: '.15rem', - }; - const taskTypeStyle: React.CSSProperties = { - backgroundColor: COLOR_GRAPH_BACKGROUND, - color: 'white', - padding: '.1rem .2rem', - fontSize: '.3rem', - }; - - const renderTaskType = () => { - return ( -
-
{data.taskType}
-
- ); - }; - - return ( -
- {data.taskType ? renderTaskType() : null} -
{data.text}
- {renderDefaultHandles( - data.scopedId, - getGraphHandleStyle('source'), - getGraphHandleStyle('target'), - )} -
- ); + return renderBasicNode(taskType, text, scopedId, styles); }; -export const ReactFlowStaticNode = ({ data }: any) => { +export const ReactFlowStaticNode = ({ data }: RFNode) => { + const { text, taskType, scopedId } = data; const styles = getGraphNodeStyle(dTypes.staticNode); - const containerStyle = {}; - const taskContainerStyle: React.CSSProperties = { - position: 'absolute', - top: '-.55rem', - zIndex: 0, - right: '.15rem', - }; - const taskTypeStyle: React.CSSProperties = { - backgroundColor: COLOR_GRAPH_BACKGROUND, - color: 'white', - padding: '.1rem .2rem', - fontSize: '.3rem', - }; - - const renderTaskType = () => { - return ( -
-
{data.taskType}
-
- ); - }; - - return ( -
- {data.taskType ? renderTaskType() : null} -
{data.text}
- {renderDefaultHandles( - data.scopedId, - getGraphHandleStyle('source'), - getGraphHandleStyle('target'), - )} -
- ); + return renderBasicNode(taskType, text, scopedId, styles); }; /** @@ -258,14 +213,82 @@ const TaskPhaseItem = ({ * @param props.data data property of ReactFlowGraphNodeData */ -export const ReactFlowCustomTaskNode = ({ data }: any) => { - const styles = getGraphNodeStyle(data.nodeType, data.nodeExecutionStatus); - const onNodeSelectionChanged = data.onNodeSelectionChanged; - const onPhaseSelectionChanged = data.onPhaseSelectionChanged; - const [selectedNode, setSelectedNode] = useState(false); - const [selectedPhase, setSelectedPhase] = useState( - data.selectedPhase, +export const ReactFlowGateNode = ({ data }: RFNode) => { + const { compiledWorkflowClosure } = useNodeExecutionContext(); + const nodeExecutionsById = useContext(NodeExecutionsByIdContext); + const { nodeType, nodeExecutionStatus, text, scopedId, onNodeSelectionChanged } = data; + const phase = getNodeFrontendPhase(nodeExecutionStatus, true); + const styles = getGraphNodeStyle(nodeType, phase); + const [showResumeForm, setShowResumeForm] = useState(false); + + const compiledNode = (compiledWorkflowClosure?.primary.template.nodes ?? []).find( + (node) => node.id === nodeExecutionsById[scopedId]?.id?.nodeId, + ); + + const iconStyles: React.CSSProperties = { + width: '10px', + height: '10px', + marginLeft: '4px', + marginTop: '1px', + color: COLOR_GRAPH_BACKGROUND, + cursor: 'pointer', + }; + + const handleNodeClick = () => { + onNodeSelectionChanged(true); + }; + + const onResumeClick = (e) => { + e.stopPropagation(); + setShowResumeForm(true); + }; + + return ( +
+
+ {text} + {phase === NodeExecutionPhase.PAUSED && ( + + + + )} +
+ {renderDefaultHandles(scopedId, getGraphHandleStyle('source'), getGraphHandleStyle('target'))} + {compiledNode && ( + + )} +
); +}; + +/** + * Custom component used by ReactFlow. Renders a label (text) + * and any edge handles. + * @param props.data data property of ReactFlowGraphNodeData + */ + +export const ReactFlowCustomTaskNode = ({ data }: RFNode) => { + const { + nodeType, + nodeExecutionStatus, + selectedPhase: initialPhase, + taskType, + text, + nodeLogsByPhase, + cacheStatus, + scopedId, + onNodeSelectionChanged, + onPhaseSelectionChanged, + } = data; + const styles = getGraphNodeStyle(nodeType, nodeExecutionStatus); + const [selectedNode, setSelectedNode] = useState(false); + const [selectedPhase, setSelectedPhase] = useState(initialPhase); useEffect(() => { if (selectedNode === true) { @@ -276,18 +299,6 @@ export const ReactFlowCustomTaskNode = ({ data }: any) => { } }, [selectedNode, onNodeSelectionChanged, selectedPhase, onPhaseSelectionChanged]); - const taskContainerStyle: React.CSSProperties = { - position: 'absolute', - top: '-.55rem', - zIndex: 0, - right: '.15rem', - }; - const taskTypeStyle: React.CSSProperties = { - backgroundColor: COLOR_TASK_TYPE, - color: 'white', - padding: '.1rem .2rem', - fontSize: '.3rem', - }; const mapTaskContainerStyle: React.CSSProperties = { position: 'absolute', top: '-.82rem', @@ -295,7 +306,7 @@ export const ReactFlowCustomTaskNode = ({ data }: any) => { right: '.15rem', }; const taskNameStyle: React.CSSProperties = { - backgroundColor: getStatusColor(data.nodeExecutionStatus), + backgroundColor: getStatusColor(nodeExecutionStatus), color: 'white', padding: '.1rem .2rem', fontSize: '.4rem', @@ -318,18 +329,10 @@ export const ReactFlowCustomTaskNode = ({ data }: any) => { setSelectedPhase(undefined); }; - const renderTaskType = () => { - return ( -
-
{data.taskType}
-
- ); - }; - const renderTaskName = () => { return (
-
{data.text}
+
{text}
); }; @@ -362,20 +365,12 @@ export const ReactFlowCustomTaskNode = ({ data }: any) => { return (
- {data.nodeLogsByPhase ? renderTaskName() : data.taskType ? renderTaskType() : null} + {nodeLogsByPhase ? renderTaskName() : renderTaskType(taskType)}
- {data.nodeLogsByPhase ? renderTaskPhases(data.nodeLogsByPhase) : data.text} - + {nodeLogsByPhase ? renderTaskPhases(nodeLogsByPhase) : text} +
- {renderDefaultHandles( - data.scopedId, - getGraphHandleStyle('source'), - getGraphHandleStyle('target'), - )} + {renderDefaultHandles(scopedId, getGraphHandleStyle('source'), getGraphHandleStyle('target'))}
); }; @@ -385,22 +380,23 @@ export const ReactFlowCustomTaskNode = ({ data }: any) => { * and any edge handles. * @param props.data data property of ReactFlowGraphNodeData */ -export const ReactFlowSubWorkflowContainer = ({ data }: any) => { +export const ReactFlowSubWorkflowContainer = ({ data }: RFNode) => { + const { nodeExecutionStatus, text, scopedId, currentNestedView, onRemoveNestedView } = data; const BREAD_FONT_SIZE = '9px'; - const BREAD_COLOR_ACTIVE = '#8B37FF'; - const BREAD_COLOR_INACTIVE = '#000'; - const borderStyle = getNestedContainerStyle(data.nodeExecutionStatus); + 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); - data.onRemoveNestedView(data.scopedId, index); + onRemoveNestedView(scopedId, index); }; const handleRootClick = () => { - data.onRemoveNestedView(data.scopedId, -1); + onRemoveNestedView(scopedId, -1); }; - const currentNestedDepth = data.currentNestedView?.length || 0; + const currentNestedDepth = currentNestedView?.length || 0; const BreadElement = ({ nestedView, index }) => { const liStyles: React.CSSProperties = { @@ -423,7 +419,7 @@ export const ReactFlowSubWorkflowContainer = ({ data }: any) => {
  • {index === 0 ? {'>'} : null} {nestedView} @@ -470,10 +466,10 @@ export const ReactFlowSubWorkflowContainer = ({ data }: any) => { return (
    - {data.text} + {text}
      - {data.currentNestedView?.map((nestedView, i) => { + {currentNestedView?.map((nestedView, i) => { return ; })}
    @@ -486,7 +482,7 @@ export const ReactFlowSubWorkflowContainer = ({ data }: any) => { {renderBreadCrumb()} {renderDefaultHandles( - data.scopedId, + scopedId, getGraphHandleStyle('source'), getGraphHandleStyle('target'), )} @@ -499,12 +495,13 @@ export const ReactFlowSubWorkflowContainer = ({ data }: any) => { * Custom component renders start node * @param props.data data property of ReactFlowGraphNodeData */ -export const ReactFlowCustomStartNode = ({ data }: any) => { - const styles = getGraphNodeStyle(data.nodeType); +export const ReactFlowCustomStartNode = ({ data }: RFNode) => { + const { text, nodeType, scopedId } = data; + const styles = getGraphNodeStyle(nodeType); return ( <> -
    {data.text}
    - {renderStardEndHandles(data)} +
    {text}
    + {renderStardEndHandles(nodeType, scopedId)} ); }; @@ -513,12 +510,13 @@ export const ReactFlowCustomStartNode = ({ data }: any) => { * Custom component renders start node * @param props.data data property of ReactFlowGraphNodeData */ -export const ReactFlowCustomEndNode = ({ data }: any) => { - const styles = getGraphNodeStyle(data.nodeType); +export const ReactFlowCustomEndNode = ({ data }: RFNode) => { + const { text, nodeType, scopedId } = data; + const styles = getGraphNodeStyle(nodeType); return ( <> -
    {data.text}
    - {renderStardEndHandles(data)} +
    {text}
    + {renderStardEndHandles(nodeType, scopedId)} ); }; diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/strings.ts b/packages/zapp/console/src/components/flytegraph/ReactFlow/strings.ts new file mode 100644 index 000000000..68e2940fe --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/strings.ts @@ -0,0 +1,10 @@ +import { createLocalizedString } from '@flyteconsole/locale'; + +const str = { + pausedTasksButton: 'Paused Tasks', + legendButton: (isVisible: boolean) => `${isVisible ? 'Hide' : 'Show'} Legend`, + resumeTooltip: 'Resume', +}; + +export { patternKey } from '@flyteconsole/locale'; +export default createLocalizedString(str); diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/test/NodeStatusLegend.test.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/test/NodeStatusLegend.test.tsx new file mode 100644 index 000000000..bd350de3f --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/test/NodeStatusLegend.test.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { Legend } from '../NodeStatusLegend'; +import { graphNodePhasesList } from '../utils'; + +describe('flytegraph > ReactFlow > NodeStatusLegend', () => { + const renderComponent = (props) => render(); + + it('should render just the Legend button, if initialIsVisible was not passed', () => { + const { queryByTitle, queryByTestId } = renderComponent({}); + expect(queryByTitle('Show Legend')).toBeInTheDocument(); + expect(queryByTestId('legend-table')).not.toBeInTheDocument(); + }); + + it('should render just the Legend button, if initialIsVisible is false', () => { + const { queryByTitle, queryByTestId } = renderComponent({ initialIsVisible: false }); + expect(queryByTitle('Show Legend')).toBeInTheDocument(); + expect(queryByTestId('legend-table')).not.toBeInTheDocument(); + }); + + it('should render Legend table, if initialIsVisible is true', () => { + const { queryByTitle, queryByTestId, queryAllByTestId } = renderComponent({ + initialIsVisible: true, + }); + expect(queryByTitle('Show Legend')).not.toBeInTheDocument(); + expect(queryByTitle('Hide Legend')).toBeInTheDocument(); + expect(queryByTestId('legend-table')).toBeInTheDocument(); + // the number of items should match the graphNodePhasesList const plus one extra for nested nodes + expect(queryAllByTestId('legend-item').length).toEqual(graphNodePhasesList.length + 1); + }); + + it('should render Legend table on button click, and hide it, when clicked again', async () => { + const { getByRole, queryByTitle, queryByTestId } = renderComponent({}); + expect(queryByTitle('Show Legend')).toBeInTheDocument(); + expect(queryByTitle('Hide Legend')).not.toBeInTheDocument(); + expect(queryByTestId('legend-table')).not.toBeInTheDocument(); + + const button = getByRole('button'); + await fireEvent.click(button); + + expect(queryByTitle('Show Legend')).not.toBeInTheDocument(); + expect(queryByTitle('Hide Legend')).toBeInTheDocument(); + expect(queryByTestId('legend-table')).toBeInTheDocument(); + + await fireEvent.click(button); + + expect(queryByTitle('Show Legend')).toBeInTheDocument(); + expect(queryByTitle('Hide Legend')).not.toBeInTheDocument(); + expect(queryByTestId('legend-table')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx new file mode 100644 index 000000000..ee623fa5f --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +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 { PausedTasksComponent } from '../PausedTasksComponent'; + +const pausedNodes = [ + { + id: 'n1', + scopedId: 'n1', + type: dTypes.gateNode, + name: 'node1', + nodes: [], + edges: [], + }, + { + id: 'n2', + scopedId: 'n2', + type: dTypes.gateNode, + name: 'node2', + nodes: [], + edges: [], + }, +]; + +const compiledWorkflowClosure = { + primary: { + connections: { + downstream: {}, + upstream: {}, + }, + template: { + id: { + project: '', + domain: '', + name: '', + version: '', + }, + nodes: [ + { + id: 'n1', + scopedId: 'n1', + type: dTypes.gateNode, + name: 'node1', + nodes: [], + edges: [], + }, + ], + }, + }, + tasks: [], +}; + +jest.mock('components/Launch/LaunchForm/LaunchFormDialog', () => ({ + LaunchFormDialog: jest.fn(({ children }) => ( +
    {children}
    + )), +})); + +jest.mock('components/Executions/ExecutionDetails/Timeline/NodeExecutionName', () => ({ + NodeExecutionName: jest.fn(({ children }) =>
    {children}
    ), +})); + +describe('flytegraph > ReactFlow > PausedTasksComponent', () => { + const renderComponent = (props) => + render( + + + , + ); + + it('should render just the Paused Tasks button, if initialIsVisible was not passed', () => { + const { queryByTitle, queryByTestId } = renderComponent({ pausedNodes }); + expect(queryByTitle('Paused Tasks')).toBeInTheDocument(); + expect(queryByTestId('paused-tasks-table')).not.toBeInTheDocument(); + }); + + it('should render just the Paused Tasks button, if initialIsVisible is false', () => { + const { queryByTitle, queryByTestId } = renderComponent({ + pausedNodes, + initialIsVisible: false, + }); + expect(queryByTitle('Paused Tasks')).toBeInTheDocument(); + expect(queryByTestId('paused-tasks-table')).not.toBeInTheDocument(); + }); + + it('should render Paused Tasks table, if initialIsVisible is true', () => { + const { queryByTitle, queryByTestId, queryAllByTestId } = renderComponent({ + pausedNodes, + initialIsVisible: true, + }); + expect(queryByTitle('Paused Tasks')).toBeInTheDocument(); + expect(queryByTestId('paused-tasks-table')).toBeInTheDocument(); + expect(queryAllByTestId('task-name-item').length).toEqual(pausedNodes.length); + }); + + it('should render Paused Tasks table on button click, and hide it, when clicked again', async () => { + const { getByRole, queryByTitle, queryByTestId } = renderComponent({ pausedNodes }); + expect(queryByTitle('Paused Tasks')).toBeInTheDocument(); + expect(queryByTestId('paused-tasks-table')).not.toBeInTheDocument(); + + const button = getByRole('button'); + await fireEvent.click(button); + + expect(queryByTestId('paused-tasks-table')).toBeInTheDocument(); + + await fireEvent.click(button); + + expect(queryByTestId('paused-tasks-table')).not.toBeInTheDocument(); + }); + + it('should render LaunchFormDialog on resume button click', async () => { + const { getByRole, queryByTitle, queryByTestId, getByTestId } = renderComponent({ + pausedNodes, + }); + expect(queryByTitle('Paused Tasks')).toBeInTheDocument(); + expect(queryByTestId('paused-tasks-table')).not.toBeInTheDocument(); + + const button = getByRole('button'); + await fireEvent.click(button); + + expect(queryByTestId('paused-tasks-table')).toBeInTheDocument(); + expect(queryByTestId(`resume-gate-node-${pausedNodes[0].id}`)).toBeInTheDocument(); + + const resumeButton = getByTestId(`resume-gate-node-${pausedNodes[0].id}`); + await fireEvent.click(resumeButton); + + expect(queryByTestId('launch-form-dialog')).toBeInTheDocument(); + }); +}); diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/test/utils.test.ts b/packages/zapp/console/src/components/flytegraph/ReactFlow/test/utils.test.ts new file mode 100644 index 000000000..33a53de57 --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/test/utils.test.ts @@ -0,0 +1,23 @@ +import { nodeExecutionPhaseConstants } from 'components/Executions/constants'; +import { NodeExecutionPhase } from 'models/Execution/enums'; +import { COLOR_NOT_EXECUTED, getStatusColor } from '../utils'; + +describe('getStatusColor', () => { + describe.each` + nodeExecutionStatus | expected + ${undefined} | ${COLOR_NOT_EXECUTED} + ${NodeExecutionPhase.FAILED} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.FAILED].nodeColor} + ${NodeExecutionPhase.FAILING} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.FAILING].nodeColor} + ${NodeExecutionPhase.SUCCEEDED} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.SUCCEEDED].nodeColor} + ${NodeExecutionPhase.ABORTED} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.ABORTED].nodeColor} + ${NodeExecutionPhase.RUNNING} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.RUNNING].nodeColor} + ${NodeExecutionPhase.QUEUED} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.QUEUED].nodeColor} + ${NodeExecutionPhase.PAUSED} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.PAUSED].nodeColor} + ${NodeExecutionPhase.UNDEFINED} | ${nodeExecutionPhaseConstants[NodeExecutionPhase.UNDEFINED].nodeColor} + `('for each case', ({ nodeExecutionStatus, expected }) => { + it(`should return ${expected} when called with nodeExecutionStatus = ${nodeExecutionStatus}`, () => { + const result = getStatusColor(nodeExecutionStatus); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx index 9c499d793..13c52bf7e 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/transformDAGToReactFlowV2.tsx @@ -236,6 +236,11 @@ export const buildGraphMapping = (props): ReactFlowGraphMapping => { const parse = (props: ParseProps) => { const { contextNode, contextParent, rootParentNode, nodeDataProps } = props; let context: ReactFlowGraph | null = null; + if (!contextNode) { + debug('\t graph parse: contextNode is ', contextNode); + return; + } + contextNode.nodes .filter((n) => !!n) .map((node: dNode) => { diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/types.ts b/packages/zapp/console/src/components/flytegraph/ReactFlow/types.ts index 328c89a8b..c57e4b2cc 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/types.ts +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/types.ts @@ -1,5 +1,5 @@ -import { CatalogCacheStatus } from 'models/Execution/enums'; -import { NodeExecutionsById } from 'models/Execution/types'; +import { CatalogCacheStatus, NodeExecutionPhase, TaskExecutionPhase } from 'models/Execution/enums'; +import { LogsByPhase } from 'models/Execution/types'; import { dNode, dTypes } from 'models/Graph/types'; import { HandleProps } from 'react-flow-renderer'; @@ -65,16 +65,24 @@ export interface DagToReactFlowProps extends ConvertDagProps { parents: any; } -export interface RFCustomData { - nodeExecutionStatus: NodeExecutionsById; +interface RFCustomData { + nodeExecutionStatus: NodeExecutionPhase; text: string; handles: []; nodeType: dTypes; scopedId: string; dag: any; - taskType?: dTypes; - cacheStatus?: CatalogCacheStatus; - onNodeSelectionChanged?: any; - onAddNestedView: any; - onRemoveNestedView: any; + taskType: dTypes; + cacheStatus: CatalogCacheStatus; + 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 { + data: RFCustomData; } diff --git a/packages/zapp/console/src/components/flytegraph/ReactFlow/utils.tsx b/packages/zapp/console/src/components/flytegraph/ReactFlow/utils.tsx index 99b7f80a5..3d18db427 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/utils.tsx +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/utils.tsx @@ -2,13 +2,14 @@ import * as React from 'react'; import { NodeExecutionPhase, TaskExecutionPhase } from 'models/Execution/enums'; import { dTypes } from 'models/Graph/types'; import { CSSProperties } from 'react'; +import { graphStatusColors } from 'components/Theme/constants'; +import { nodeExecutionPhaseConstants } from 'components/Executions/constants'; import { RFBackgroundProps } from './types'; const dagre = require('dagre'); -export const COLOR_EXECUTED = '#2892f4'; +export const COLOR_EXECUTED = graphStatusColors.RUNNING; export const COLOR_NOT_EXECUTED = '#c6c6c6'; -export const COLOR_TASK_TYPE = '#666666'; export const COLOR_GRAPH_BACKGROUND = '#666666'; export const GRAPH_PADDING_FACTOR = 50; @@ -88,15 +89,16 @@ export const getGraphHandleStyle = (handleType: string, type?: dTypes): CSSPrope } }; -export const nodePhaseColorMapping = { - [NodeExecutionPhase.FAILED]: { color: '#e90000', text: 'Failed' }, - [NodeExecutionPhase.FAILING]: { color: '#f2a4ad', text: 'Failing' }, - [NodeExecutionPhase.SUCCEEDED]: { color: '#37b789', text: 'Succeded' }, - [NodeExecutionPhase.ABORTED]: { color: '#be25d7', text: 'Aborted' }, - [NodeExecutionPhase.RUNNING]: { color: '#2892f4', text: 'Running' }, - [NodeExecutionPhase.QUEUED]: { color: '#dfd71b', text: 'Queued' }, - [NodeExecutionPhase.UNDEFINED]: { color: '#4a2839', text: 'Undefined' }, -}; +export const graphNodePhasesList = [ + NodeExecutionPhase.FAILED, + NodeExecutionPhase.FAILING, + NodeExecutionPhase.SUCCEEDED, + NodeExecutionPhase.ABORTED, + NodeExecutionPhase.RUNNING, + NodeExecutionPhase.QUEUED, + NodeExecutionPhase.PAUSED, + NodeExecutionPhase.UNDEFINED, +]; /** * Maps node execution phases to UX colors @@ -106,11 +108,12 @@ export const nodePhaseColorMapping = { export const getStatusColor = ( nodeExecutionStatus?: NodeExecutionPhase | TaskExecutionPhase, ): string => { - if (nodeExecutionStatus && nodePhaseColorMapping[nodeExecutionStatus]) { - return nodePhaseColorMapping[nodeExecutionStatus].color; + // should explicitly check for undefined, as one of the phases is '0' and fails the presence check + if (nodeExecutionStatus !== undefined && nodeExecutionPhaseConstants[nodeExecutionStatus]) { + return nodeExecutionPhaseConstants[nodeExecutionStatus].nodeColor; } else { /** @TODO decide what we want default color to be */ - return '#c6c6c6'; + return COLOR_NOT_EXECUTED; } }; diff --git a/packages/zapp/console/src/models/Common/constants.ts b/packages/zapp/console/src/models/Common/constants.ts index 8ec7a5a28..044c2eb72 100644 --- a/packages/zapp/console/src/models/Common/constants.ts +++ b/packages/zapp/console/src/models/Common/constants.ts @@ -10,6 +10,7 @@ export const endpointPrefixes = { projectDomainAtributes: '/project_domain_attributes', relaunchExecution: '/executions/relaunch', recoverExecution: '/executions/recover', + setSignal: '/signal/set', task: '/tasks', taskExecution: '/task_executions', taskExecutionChildren: '/children/task_executions', diff --git a/packages/zapp/console/src/models/Execution/api.ts b/packages/zapp/console/src/models/Execution/api.ts index 9aa39a11d..a16eb0bf3 100644 --- a/packages/zapp/console/src/models/Execution/api.ts +++ b/packages/zapp/console/src/models/Execution/api.ts @@ -191,6 +191,11 @@ interface RelaunchParams { name?: string; } +interface ResumeSignalParams { + id: Core.SignalIdentifier; + value: Core.ILiteral; +} + /** Submits a request to relaunch a WorkflowExecution by id */ export const relaunchWorkflowExecution = ({ id, name }: RelaunchParams, config?: RequestConfig) => postAdminEntity( @@ -203,6 +208,18 @@ export const relaunchWorkflowExecution = ({ id, name }: RelaunchParams, config?: config, ); +/** Submits a request to resume a signal node */ +export const resumeSignalNode = ({ id, value }: ResumeSignalParams, config?: RequestConfig) => + postAdminEntity( + { + data: { id, value }, + path: endpointPrefixes.setSignal, + requestMessageType: Admin.SignalSetRequest, + responseMessageType: Admin.SignalSetResponse, + }, + config, + ); + interface RecoverParams { id: WorkflowExecutionIdentifier; name?: string; diff --git a/packages/zapp/console/src/models/Execution/enums.ts b/packages/zapp/console/src/models/Execution/enums.ts index da80201b4..d9270f027 100644 --- a/packages/zapp/console/src/models/Execution/enums.ts +++ b/packages/zapp/console/src/models/Execution/enums.ts @@ -13,8 +13,11 @@ export type ExecutionMode = Admin.ExecutionMetadata.ExecutionMode; export const ExecutionMode = Admin.ExecutionMetadata.ExecutionMode; export type WorkflowExecutionPhase = Core.WorkflowExecution.Phase; export const WorkflowExecutionPhase = Core.WorkflowExecution.Phase; -export type NodeExecutionPhase = Core.NodeExecution.Phase; -export const NodeExecutionPhase = Core.NodeExecution.Phase; +enum FrontendPhase { + PAUSED = 100, +} +export type NodeExecutionPhase = Core.NodeExecution.Phase | FrontendPhase; +export const NodeExecutionPhase = { ...Core.NodeExecution.Phase, ...FrontendPhase }; export type TaskExecutionPhase = Core.TaskExecution.Phase; export const TaskExecutionPhase = Core.TaskExecution.Phase; enum MapCacheStatus { diff --git a/packages/zapp/console/src/models/Execution/types.ts b/packages/zapp/console/src/models/Execution/types.ts index b2990fc75..780fb9de4 100644 --- a/packages/zapp/console/src/models/Execution/types.ts +++ b/packages/zapp/console/src/models/Execution/types.ts @@ -1,12 +1,7 @@ import { Admin, Core, Event, Protobuf, Service } from 'flyteidl'; import { Identifier, LiteralMap, LiteralMapBlob, TaskLog, UrlBlob } from 'models/Common/types'; import { CompiledWorkflow } from 'models/Workflow/types'; -import { - ExecutionMode, - NodeExecutionPhase, - TaskExecutionPhase, - WorkflowExecutionPhase, -} from './enums'; +import { ExecutionMode, TaskExecutionPhase, WorkflowExecutionPhase } from './enums'; export type WorkflowExecutionIdentifier = RequiredNonNullable; export type ExecutionError = RequiredNonNullable; @@ -104,7 +99,7 @@ export interface NodeExecutionClosure extends Admin.INodeExecutionClosure { duration?: Protobuf.Duration; error?: ExecutionError; outputUri: string; - phase: NodeExecutionPhase; + phase: Core.NodeExecution.Phase; startedAt?: Protobuf.ITimestamp; taskNodeMetadata?: TaskNodeMetadata; workflowNodeMetadata?: WorkflowNodeMetadata; diff --git a/packages/zapp/console/src/models/Graph/types.ts b/packages/zapp/console/src/models/Graph/types.ts index 0ad26e65e..b979e1cfb 100644 --- a/packages/zapp/console/src/models/Graph/types.ts +++ b/packages/zapp/console/src/models/Graph/types.ts @@ -26,6 +26,7 @@ export enum dTypes { nestedMaxDepth, staticNode, staticNestedNode, + gateNode, } /** diff --git a/packages/zapp/console/src/models/Node/__mocks__/mockNodeData.ts b/packages/zapp/console/src/models/Node/__mocks__/mockNodeData.ts index 186f29808..28a377849 100644 --- a/packages/zapp/console/src/models/Node/__mocks__/mockNodeData.ts +++ b/packages/zapp/console/src/models/Node/__mocks__/mockNodeData.ts @@ -5,3 +5,8 @@ export const mockNodes: CompiledNode[] = mockTasks.map(({ template const { id } = template; return { id: id.name, taskNode: { referenceId: id } }; }); + +export const mockNodesWithGateNode: CompiledNode[] = [ + ...mockNodes, + { id: 'GateNode', gateNode: {} }, +]; diff --git a/packages/zapp/console/src/models/Node/types.ts b/packages/zapp/console/src/models/Node/types.ts index c723d4f90..cb578b6fc 100644 --- a/packages/zapp/console/src/models/Node/types.ts +++ b/packages/zapp/console/src/models/Node/types.ts @@ -33,6 +33,7 @@ export interface CompiledNode extends Core.INode { taskNode?: TaskNode; upstreamNodeIds?: string[]; workflowNode?: WorkflowNode; + gateNode?: Core.IGateNode; } /** Holds all connections/edges for a given `CompiledNode` */ diff --git a/yarn.lock b/yarn.lock index fc89e93a2..fe3eee717 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1662,10 +1662,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@flyteorg/flyteidl@1.1.4": - version "1.1.4" - resolved "https://registry.yarnpkg.com/@flyteorg/flyteidl/-/flyteidl-1.1.4.tgz#7a02d80e22c623817b6061f1f5e88ec243041cf1" - integrity sha512-CmO/H3vbjluV/G4J9TFnSo3HiefOQ8f46BbpordSlVA2CANWQZaNc6Csz7JTGdotyZ2DdUXJkBcPRiA/r9H3lQ== +"@flyteorg/flyteidl@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@flyteorg/flyteidl/-/flyteidl-1.2.1.tgz#124032da9db6dfabf10007c4549ba469d0b62127" + integrity sha512-NYMwCS7V0gpvSTi+lkvx8NuxvXIsLr/lcKRwdLPMbnHDcpXMq7pTRBUJk+0iicdMqm+bUJOgx7uQYUG0hxp68w== "@flyteorg/flyteidl@1.2.2": version "1.2.2" From d4dad9dcc79db9ddffa0c6a11923c8a1b14a7c92 Mon Sep 17 00:00:00 2001 From: Olga Nad Date: Wed, 2 Nov 2022 12:53:52 -0500 Subject: [PATCH 2/2] fix: clean-up Signed-off-by: Olga Nad --- packages/zapp/console/package.json | 2 +- .../console/src/components/Entities/EntityExecutions.tsx | 2 -- .../ExecutionDetails/NodeExecutionDetailsPanelContent.tsx | 3 +-- yarn.lock | 5 ----- 4 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/zapp/console/package.json b/packages/zapp/console/package.json index b6903cd04..5f4001ee5 100644 --- a/packages/zapp/console/package.json +++ b/packages/zapp/console/package.json @@ -57,7 +57,7 @@ "@commitlint/cli": "^8.3.5", "@commitlint/config-conventional": "^8.3.4", "@date-io/moment": "1.3.9", - "@flyteorg/flyteidl": "1.2.1", + "@flyteorg/flyteidl": "1.2.2", "@material-ui/core": "^4.0.0", "@material-ui/icons": "^4.0.0", "@material-ui/pickers": "^3.2.2", diff --git a/packages/zapp/console/src/components/Entities/EntityExecutions.tsx b/packages/zapp/console/src/components/Entities/EntityExecutions.tsx index 56ae01473..b59bfce88 100644 --- a/packages/zapp/console/src/components/Entities/EntityExecutions.tsx +++ b/packages/zapp/console/src/components/Entities/EntityExecutions.tsx @@ -14,8 +14,6 @@ import { executionSortFields } from 'models/Execution/constants'; import { compact } from 'lodash'; import { useOnlyMyExecutionsFilterState } from 'components/Executions/filters/useOnlyMyExecutionsFilterState'; import { executionFilterGenerator } from './generators'; -import { entityStrings } from './constants'; -import t, { patternKey } from './strings'; const useStyles = makeStyles((theme: Theme) => ({ filtersContainer: { diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx index c47747f78..e8b0b080d 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx @@ -240,8 +240,7 @@ export const NodeExecutionDetailsPanelContent: React.FC