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/ExecutionDetailsActions.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx index a4b39da2d..cba7244a3 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionDetailsActions.tsx @@ -10,6 +10,7 @@ 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'; @@ -57,26 +58,28 @@ 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 [initialParameters, setInitialParameters] = React.useState< + const [showLaunchForm, setShowLaunchForm] = useState(false); + const [initialParameters, setInitialParameters] = useState< TaskInitialLaunchParameters | undefined >(undefined); const executionData = useNodeExecutionData(nodeExecutionId); const execution = useNodeExecution(nodeExecutionId); - const id = details.taskTemplate?.id; + const id = details?.taskTemplate?.id; - React.useEffect(() => { + useEffect(() => { if (!id) { return; } @@ -99,15 +102,15 @@ 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 resumeAction = () => { + // TODO https://github.com/flyteorg/flyteconsole/issues/587 Launch form for node id + }; + return ( <>
@@ -121,17 +124,26 @@ export const ExecutionDetailsActions = (props: ExecutionDetailsActionsProps): JS {t('flyteDeck')} )} - + {id && initialParameters && details && ( + + )} + {phase === NodeExecutionPhase.PAUSED && ( + + )}
- - {execution?.value?.closure?.deckUri ? ( + {id && initialParameters && ( + + )} + {execution?.value?.closure?.deckUri && (

{t('flyteDeck')}

@@ -141,7 +153,7 @@ export const ExecutionDetailsActions = (props: ExecutionDetailsActionsProps): JS
- ) : null} + )} ); }; diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx index 80fd8d792..c55f14ed7 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/ExecutionTabContent.tsx @@ -146,9 +146,7 @@ export const ExecutionTabContent: React.FC = ({ return (
- - - +
@@ -159,6 +157,7 @@ export const ExecutionTabContent: React.FC = ({ mergedDag={mergedDag} error={error} dynamicWorkflows={dynamicWorkflows} + initialNodes={initialNodes} onNodeSelectionChanged={onNodeSelectionChanged} selectedPhase={selectedPhase} onPhaseSelectionChanged={setSelectedPhase} @@ -181,13 +180,15 @@ export const ExecutionTabContent: React.FC = ({ return ( <> - {renderContent()} + + {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 a4a262156..68ef14786 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/NodeExecutionDetailsPanelContent.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import { IconButton, Typography, Tab, Tabs } from '@material-ui/core'; import { makeStyles, Theme } from '@material-ui/core/styles'; import Close from '@material-ui/icons/Close'; @@ -34,7 +34,7 @@ import { transformWorkflowToKeyedDag, getNodeNameFromDag } from 'components/Work import { TaskVersionDetailsLink } from 'components/Entities/VersionDetails/VersionDetailsLink'; import { Identifier } from 'models/Common/types'; import { NodeExecutionCacheStatus } from '../NodeExecutionCacheStatus'; -import { makeListTaskExecutionsQuery, makeNodeExecutionQuery } from '../nodeExecutionQueries'; +import { makeListTaskExecutionsQuery } from '../nodeExecutionQueries'; import { NodeExecutionDetails } from '../types'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; import { getTaskExecutionDetailReasons } from './utils'; @@ -42,6 +42,8 @@ import { ExpandableMonospaceText } from '../../common/ExpandableMonospaceText'; import { fetchWorkflowExecution } from '../useWorkflowExecution'; import { NodeExecutionTabs } from './NodeExecutionTabs'; import { ExecutionDetailsActions } from './ExecutionDetailsActions'; +import { NodeExecutionsByIdContext } from '../contexts'; +import { getNodeFrontendPhase, isNodeGateNode } from '../utils'; const useStyles = makeStyles((theme: Theme) => { const paddingVertical = `${theme.spacing(2)}px`; @@ -140,7 +142,7 @@ const tabIds = { interface NodeExecutionDetailsProps { nodeExecutionId: NodeExecutionIdentifier; - phase?: TaskExecutionPhase; + taskPhase: TaskExecutionPhase; onClose?: () => void; } @@ -233,18 +235,30 @@ 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 nodeExecutionsById = useContext(NodeExecutionsByIdContext); + const { compiledWorkflowClosure } = useNodeExecutionContext(); + const isGateNode = isNodeGateNode( + compiledWorkflowClosure?.primary.template.nodes ?? [], + nodeExecutionId, + ); const [isReasonsVisible, setReasonsVisible] = useState(false); const [dag, setDag] = useState(null); const [details, setDetails] = useState(); const [selectedTaskExecution, setSelectedTaskExecution] = useState(null); + const [nodeExecution, setNodeExecution] = useState( + nodeExecutionsById[nodeExecutionId.nodeId], + ); + const [nodePhase, setNodePhase] = useState( + nodeExecution?.closure.phase ?? NodeExecutionPhase.UNDEFINED, + ); const isMounted = useRef(false); useEffect(() => { @@ -254,13 +268,6 @@ 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) => { @@ -276,13 +283,14 @@ export const NodeExecutionDetailsPanelContent: React.FC { setReasonsVisible(false); + const newNodeExecution = nodeExecutionsById[nodeExecutionId.nodeId]; + setNodeExecution(newNodeExecution); + setNodePhase(newNodeExecution?.closure.phase ?? NodeExecutionPhase.UNDEFINED); }, [nodeExecutionId]); useEffect(() => { setSelectedTaskExecution(null); - }, [nodeExecutionId, phase]); - - const nodeExecution = nodeExecutionQuery.data; + }, [nodeExecutionId, taskPhase]); const getWorkflowDag = async () => { const workflowExecution = await fetchWorkflowExecution( @@ -336,12 +344,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); @@ -350,7 +359,7 @@ export const NodeExecutionDetailsPanelContent: React.FC
- + {isRunningPhase && ( )} @@ -379,13 +388,14 @@ export const NodeExecutionDetailsPanelContent: React.FC ) : null; - const displayName = details?.displayName ?? ; + const emptyName = isGateNode ? <> : ; + const displayName = details?.displayName ?? emptyName; return (
@@ -401,13 +411,11 @@ export const NodeExecutionDetailsPanelContent: React.FC {statusContent} {!dag && detailsContent} - {details && ( - - )} +
{dag ? : tabsContent} 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..57040bde8 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, Typography } 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: { @@ -49,48 +51,75 @@ 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 templateName = getNodeTemplateName(node); + const nodeLevel = node?.level ?? 0; + return ( +
+
+
+ {node.nodes?.length ? ( + onToggle(node.id, node.scopedId, nodeLevel)} + /> + ) : ( +
+ )} +
-
- - - {templateName} - +
+ + + {templateName} + +
+
+ {onAction && ( + + onAction(node.id)}> + + + + )}
-
- ); - })} -
- ); -}); + ); + })} +
+ ); + }, +); diff --git a/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx b/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx index b94e31dcc..08f4b98c6 100644 --- a/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx +++ b/packages/zapp/console/src/components/Executions/ExecutionDetails/strings.tsx @@ -3,6 +3,7 @@ import { createLocalizedString } from '@flyteconsole/locale'; const str = { rerun: 'RERUN', flyteDeck: 'Flyte Deck', + resume: 'Resume', }; export { patternKey } from '@flyteconsole/locale'; 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/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/NodeExecutionCacheStatus.tsx b/packages/zapp/console/src/components/Executions/NodeExecutionCacheStatus.tsx index a16960dec..11171b62e 100644 --- a/packages/zapp/console/src/components/Executions/NodeExecutionCacheStatus.tsx +++ b/packages/zapp/console/src/components/Executions/NodeExecutionCacheStatus.tsx @@ -1,68 +1,11 @@ -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'; - -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; - } - } -}); +import { useEffect, useState } from 'react'; +import { CacheStatus } from './CacheStatus'; export interface NodeExecutionCacheStatusProps { execution: NodeExecution; @@ -85,9 +28,9 @@ export const NodeExecutionCacheStatus: React.FC = }) => { const taskNodeMetadata = execution.closure?.taskNodeMetadata; const detailsContext = useNodeExecutionContext(); - const [nodeDetails, setNodeDetails] = React.useState(); + const [nodeDetails, setNodeDetails] = useState(); - React.useEffect(() => { + useEffect(() => { let isCurrent = true; detailsContext.getNodeExecutionDetails(execution).then((res) => { if (isCurrent) { @@ -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..32f7fcb0c 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,49 @@ 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 { useEffect, useState } from 'react'; +import { NodeExecutionPhase } from 'models/Execution/enums'; import { NodeExecutionsTableState } from './types'; import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; import { NodeExecutionDetails } from '../types'; import t from './strings'; +import { getNodeFrontendPhase, isNodeGateNode } from '../utils'; interface NodeExecutionActionsProps { execution: NodeExecution; state: NodeExecutionsTableState; } -export const NodeExecutionActions = (props: NodeExecutionActionsProps): JSX.Element => { - const { execution, state } = props; +export const NodeExecutionActions = ({ + execution, + state, +}: NodeExecutionActionsProps): JSX.Element => { + const { compiledWorkflowClosure, getNodeExecutionDetails } = useNodeExecutionContext(); - const detailsContext = useNodeExecutionContext(); - const [showLaunchForm, setShowLaunchForm] = React.useState(false); - const [nodeExecutionDetails, setNodeExecutionDetails] = React.useState< + const [showLaunchForm, setShowLaunchForm] = 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) => { + const isGateNode = isNodeGateNode( + compiledWorkflowClosure?.primary.template.nodes ?? [], + execution.id, + ); + const phase = getNodeFrontendPhase(execution.closure.phase, isGateNode); + + useEffect(() => { + getNodeExecutionDetails(execution).then((res) => { setNodeExecutionDetails(res); }); }); - React.useEffect(() => { + useEffect(() => { if (!id) { return; } @@ -73,6 +85,10 @@ export const NodeExecutionActions = (props: NodeExecutionActionsProps): JSX.Elem setShowLaunchForm(true); }; + const resumeAction = () => { + // TODO https://github.com/flyteorg/flyteconsole/issues/587 Launch form for node id + }; + const renderRerunAction = () => { if (!id || !initialParameters) { return <>; @@ -97,6 +113,13 @@ export const NodeExecutionActions = (props: NodeExecutionActionsProps): JSX.Elem return (
+ {phase === NodeExecutionPhase.PAUSED && ( + + + + + + )} diff --git a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx index e988c0136..cdc81bd4f 100644 --- a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionRow.tsx @@ -6,6 +6,7 @@ import { useTheme } from 'components/Theme/useTheme'; import { isEqual } from 'lodash'; import { NodeExecution } from 'models/Execution/types'; import * as React from 'react'; +import { useContext, useState } from 'react'; import { NodeExecutionPhase } from 'models/Execution/enums'; import { NodeExecutionsRequestConfigContext } from '../contexts'; import { useChildNodeExecutionGroupsQuery } from '../nodeExecutionQueries'; @@ -56,10 +57,10 @@ export const NodeExecutionRow: React.FC = ({ style, }) => { const theme = useTheme(); - const { columns, state } = React.useContext(NodeExecutionsTableContext); - const requestConfig = React.useContext(NodeExecutionsRequestConfigContext); + const { columns, state } = useContext(NodeExecutionsTableContext); + const requestConfig = useContext(NodeExecutionsRequestConfigContext); - const [expanded, setExpanded] = React.useState(false); + const [expanded, setExpanded] = useState(false); const toggleExpanded = () => { setExpanded(!expanded); }; diff --git a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx index 6e69a97d3..8c0cf1268 100644 --- a/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/NodeExecutionsTable.tsx @@ -16,6 +16,7 @@ import { NodeExecutionRow } from './NodeExecutionRow'; import { NoExecutionsContent } from './NoExecutionsContent'; import { useColumnStyles, useExecutionTableStyles } from './styles'; import { NodeExecutionsByIdContext } from '../contexts'; +import { useNodeExecutionContext } from '../contextProvider/NodeExecutionDetails'; export interface NodeExecutionsTableProps { setSelectedExecution: (execution: NodeExecutionIdentifier | null) => void; @@ -41,6 +42,7 @@ export const NodeExecutionsTable: React.FC = ({ const commonStyles = useCommonStyles(); const tableStyles = useExecutionTableStyles(); const nodeExecutionsById = useContext(NodeExecutionsByIdContext); + const { compiledWorkflowClosure } = useNodeExecutionContext(); useEffect(() => { if (nodeExecutionsById) { @@ -81,7 +83,10 @@ export const NodeExecutionsTable: React.FC = ({ const columnStyles = useColumnStyles(); // Memoizing columns so they won't be re-generated unless the styles change - const columns = useMemo(() => generateColumns(columnStyles), [columnStyles]); + const columns = useMemo( + () => generateColumns(columnStyles, compiledWorkflowClosure?.primary.template.nodes ?? []), + [columnStyles], + ); const tableContext = useMemo( () => ({ columns, state: { selectedExecution, setSelectedExecution } }), [columns, selectedExecution, setSelectedExecution], diff --git a/packages/zapp/console/src/components/Executions/Tables/constants.ts b/packages/zapp/console/src/components/Executions/Tables/constants.ts index 8e7238444..e8e3c6159 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, diff --git a/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx b/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx index 6508c033e..b85ce7a2b 100644 --- a/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/nodeExecutionColumns.tsx @@ -3,17 +3,19 @@ import { formatDateLocalTimezone, formatDateUTC, millisecondsToHMS } from 'commo 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 { 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'; +import t from '../strings'; const ExecutionName: React.FC = ({ execution, state }) => { const detailsContext = useNodeExecutionContext(); @@ -110,39 +112,45 @@ const DisplayType: React.FC = ({ execution }) => export function generateColumns( styles: ReturnType, + nodes: CompiledNode[], ): NodeExecutionColumnDefinition[] { return [ { cellRenderer: (props) => , 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 } }) => { @@ -162,7 +170,7 @@ export function generateColumns( }, className: styles.columnStartedAt, key: 'startedAt', - label: 'start time', + label: t('startedAtLabel'), }, { cellRenderer: ({ execution }) => { @@ -181,10 +189,10 @@ export function generateColumns( label: () => ( <> - duration + {t('durationLabel')} - Queued Time + {t('queuedTimeLabel')} ), diff --git a/packages/zapp/console/src/components/Executions/Tables/strings.tsx b/packages/zapp/console/src/components/Executions/Tables/strings.tsx index 402c312c7..c540a5e85 100644 --- a/packages/zapp/console/src/components/Executions/Tables/strings.tsx +++ b/packages/zapp/console/src/components/Executions/Tables/strings.tsx @@ -1,8 +1,16 @@ 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', }; export { patternKey } from '@flyteconsole/locale'; 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..eb1e68999 --- /dev/null +++ b/packages/zapp/console/src/components/Executions/Tables/test/NodeExecutionActions.test.tsx @@ -0,0 +1,58 @@ +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 { NodeExecutionActions } from '../NodeExecutionActions'; + +jest.mock('components/Workflow/workflowQueries'); +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 () => { + const { queryByTitle } = renderComponent({ execution, state }); + await waitFor(() => queryByTitle('Rerun')); + + expect(queryByTitle('View Inputs & Outputs')).toBeInTheDocument(); + expect(queryByTitle('Rerun')).toBeInTheDocument(); + expect(queryByTitle('Resume')).not.toBeInTheDocument(); + }); + + it('should render resume action, if the status is PAUSED', async () => { + const mockExecution = { ...execution, closure: { phase: 100 } }; + const { queryByTitle } = renderComponent({ execution: mockExecution, state }); + await waitFor(() => queryByTitle('Resume')); + + expect(queryByTitle('View Inputs & Outputs')).toBeInTheDocument(); + expect(queryByTitle('Rerun')).toBeInTheDocument(); + expect(queryByTitle('Resume')).toBeInTheDocument(); + }); +}); 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..ded16e059 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,63 @@ export const workflowExecutionPhaseConstants: { [key in WorkflowExecutionPhase]: ExecutionPhaseConstants; } = { [WorkflowExecutionPhase.ABORTED]: { - badgeColor: statusColors.SKIPPED, text: t('aborted'), + badgeColor: statusColors.SKIPPED, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.ABORTING]: { - badgeColor: statusColors.SKIPPED, text: t('aborting'), + badgeColor: statusColors.SKIPPED, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.FAILING]: { - badgeColor: statusColors.FAILURE, text: t('failing'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILING, textColor: negativeTextColor, }, [WorkflowExecutionPhase.FAILED]: { - badgeColor: statusColors.FAILURE, text: t('failed'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.QUEUED]: { - badgeColor: statusColors.QUEUED, text: t('queued'), + badgeColor: statusColors.QUEUED, + nodeColor: graphStatusColors.QUEUED, textColor: secondaryTextColor, }, [WorkflowExecutionPhase.RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [WorkflowExecutionPhase.SUCCEEDED]: { - badgeColor: statusColors.SUCCESS, text: t('succeeded'), + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [WorkflowExecutionPhase.SUCCEEDING]: { - badgeColor: statusColors.SUCCESS, text: t('succeeding'), + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [WorkflowExecutionPhase.TIMED_OUT]: { - badgeColor: statusColors.FAILURE, text: t('timedOut'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [WorkflowExecutionPhase.UNDEFINED]: { - badgeColor: statusColors.UNKNOWN, text: t('unknown'), + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, }; @@ -78,58 +89,75 @@ export const nodeExecutionPhaseConstants: { [key in NodeExecutionPhase]: ExecutionPhaseConstants; } = { [NodeExecutionPhase.ABORTED]: { - badgeColor: statusColors.FAILURE, text: t('aborted'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [NodeExecutionPhase.FAILING]: { - badgeColor: statusColors.FAILURE, text: t('failing'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILING, textColor: negativeTextColor, }, [NodeExecutionPhase.FAILED]: { - badgeColor: statusColors.FAILURE, text: t('failed'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [NodeExecutionPhase.QUEUED]: { - badgeColor: statusColors.RUNNING, text: t('queued'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.QUEUED, textColor: secondaryTextColor, }, [NodeExecutionPhase.RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [NodeExecutionPhase.DYNAMIC_RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [NodeExecutionPhase.SUCCEEDED]: { - badgeColor: statusColors.SUCCESS, text: t('succeeded'), + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [NodeExecutionPhase.TIMED_OUT]: { - badgeColor: statusColors.FAILURE, text: t('timedOut'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [NodeExecutionPhase.SKIPPED]: { - badgeColor: statusColors.UNKNOWN, text: t('skipped'), + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, [NodeExecutionPhase.RECOVERED]: { - badgeColor: statusColors.SUCCESS, text: t('recovered'), + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, + [NodeExecutionPhase.PAUSED]: { + text: t('paused'), + badgeColor: statusColors.PAUSED, + nodeColor: graphStatusColors.PAUSED, + textColor: secondaryTextColor, + }, [NodeExecutionPhase.UNDEFINED]: { - badgeColor: statusColors.UNKNOWN, text: t('unknown'), + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, }; @@ -139,43 +167,51 @@ export const taskExecutionPhaseConstants: { [key in TaskExecutionPhase]: ExecutionPhaseConstants; } = { [TaskExecutionPhase.ABORTED]: { - badgeColor: statusColors.FAILURE, text: t('aborted'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.ABORTED, textColor: negativeTextColor, }, [TaskExecutionPhase.FAILED]: { - badgeColor: statusColors.FAILURE, text: t('failed'), + badgeColor: statusColors.FAILURE, + nodeColor: graphStatusColors.FAILED, textColor: negativeTextColor, }, [TaskExecutionPhase.WAITING_FOR_RESOURCES]: { - badgeColor: statusColors.RUNNING, text: t('waiting'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [TaskExecutionPhase.QUEUED]: { - badgeColor: statusColors.RUNNING, text: t('queued'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.QUEUED, textColor: secondaryTextColor, }, [TaskExecutionPhase.INITIALIZING]: { - badgeColor: statusColors.RUNNING, text: t('initializing'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [TaskExecutionPhase.RUNNING]: { - badgeColor: statusColors.RUNNING, text: t('running'), + badgeColor: statusColors.RUNNING, + nodeColor: graphStatusColors.RUNNING, textColor: secondaryTextColor, }, [TaskExecutionPhase.SUCCEEDED]: { - badgeColor: statusColors.SUCCESS, text: t('succeeded'), + badgeColor: statusColors.SUCCESS, + nodeColor: graphStatusColors.SUCCEEDED, textColor: positiveTextColor, }, [TaskExecutionPhase.UNDEFINED]: { - badgeColor: statusColors.UNKNOWN, text: t('unknown'), + badgeColor: statusColors.UNKNOWN, + nodeColor: graphStatusColors.UNDEFINED, textColor: secondaryTextColor, }, }; 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..f48fa635b 100644 --- a/packages/zapp/console/src/components/Executions/types.ts +++ b/packages/zapp/console/src/components/Executions/types.ts @@ -7,8 +7,9 @@ import { import { TaskTemplate } from 'models/Task/types'; export interface ExecutionPhaseConstants { - badgeColor: string; text: string; + badgeColor: string; + nodeColor: string; textColor: string; } 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/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 443b09778..5038160c2 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/WorkflowGraph.tsx +++ b/packages/zapp/console/src/components/WorkflowGraph/WorkflowGraph.tsx @@ -1,8 +1,10 @@ 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 { CompiledNode } from 'models/Node/types'; import { TaskExecutionPhase } from 'models/Execution/enums'; +import { dNode } from 'models/Graph/types'; export interface WorkflowGraphProps { onNodeSelectionChanged: (selectedNodes: string[]) => void; @@ -12,6 +14,7 @@ export interface WorkflowGraphProps { mergedDag: any; error: Error | null; dynamicWorkflows: any; + initialNodes: dNode[]; } export interface DynamicWorkflowMapping { rootGraphNodeId: CompiledNode; @@ -26,6 +29,7 @@ export const WorkflowGraph: React.FC = ({ mergedDag, error, dynamicWorkflows, + initialNodes, }) => { if (error) { return ; @@ -39,6 +43,7 @@ export const WorkflowGraph: React.FC = ({ onPhaseSelectionChanged={onPhaseSelectionChanged} selectedPhase={selectedPhase} isDetailsTabClosed={isDetailsTabClosed} + initialNodes={initialNodes} /> ); }; 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 338cd0491..b6f9cae71 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx +++ b/packages/zapp/console/src/components/WorkflowGraph/test/WorkflowGraph.test.tsx @@ -37,6 +37,7 @@ describe('WorkflowGraph', () => { }} error={null} dynamicWorkflows={[]} + initialNodes={[]} /> , ); 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..04f6a657f 100644 --- a/packages/zapp/console/src/components/WorkflowGraph/test/utils.test.ts +++ b/packages/zapp/console/src/components/WorkflowGraph/test/utils.test.ts @@ -23,6 +23,7 @@ describe('getDisplayName', () => { }); }); +// TODO add tests for `launchplan` branch describe('getNodeTypeFromCompiledNode', () => { const branchNode = { branchNode: {}, @@ -30,24 +31,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', () => { 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..d5743021f 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; } 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/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..244d771f4 --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/PausedTasksComponent.tsx @@ -0,0 +1,90 @@ +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 { + 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 [isVisible, setIsVisible] = useState(initialIsVisible); + + 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 resumeAction = () => { + // TODO https://github.com/flyteorg/flyteconsole/issues/587 Launch form for node id + }; + + const renderPausedTasksBlock = () => ( +
+ +
+ ); + + return ( +
+
+ {isVisible ? renderPausedTasksBlock() : 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..c0c6ff222 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 pausedNodes: 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 = pausedNodes.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..bc3533c12 100644 --- a/packages/zapp/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/customNodeComponents.tsx @@ -2,21 +2,64 @@ import * as React from 'react'; import { useState, useEffect } 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 { - 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 +83,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 +95,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 +113,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 +132,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 +210,66 @@ 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 { nodeType, nodeExecutionStatus, text, scopedId, onNodeSelectionChanged } = data; + const phase = getNodeFrontendPhase(nodeExecutionStatus, true); + const styles = getGraphNodeStyle(nodeType, phase); + + const iconStyles: React.CSSProperties = { + width: '10px', + height: '10px', + marginLeft: '4px', + marginTop: '1px', + color: COLOR_GRAPH_BACKGROUND, + cursor: 'pointer', + }; + + const handleNodeClick = () => { + onNodeSelectionChanged(true); + }; + + const handleActionClick = (e) => { + // TODO https://github.com/flyteorg/flyteconsole/issues/587 Launch form for node id + e.stopPropagation(); + }; + + return ( +
+
+ {text} + {phase === NodeExecutionPhase.PAUSED && ( + + + + )} +
+ {renderDefaultHandles(scopedId, getGraphHandleStyle('source'), getGraphHandleStyle('target'))} +
); +}; + +/** + * 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 +280,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 +287,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 +310,10 @@ export const ReactFlowCustomTaskNode = ({ data }: any) => { setSelectedPhase(undefined); }; - const renderTaskType = () => { - return ( -
-
{data.taskType}
-
- ); - }; - const renderTaskName = () => { return (
-
{data.text}
+
{text}
); }; @@ -362,20 +346,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 +361,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 +400,7 @@ export const ReactFlowSubWorkflowContainer = ({ data }: any) => {
  • {index === 0 ? {'>'} : null} {nestedView} @@ -470,10 +447,10 @@ export const ReactFlowSubWorkflowContainer = ({ data }: any) => { return (
    - {data.text} + {text}
      - {data.currentNestedView?.map((nestedView, i) => { + {currentNestedView?.map((nestedView, i) => { return ; })}
    @@ -486,7 +463,7 @@ export const ReactFlowSubWorkflowContainer = ({ data }: any) => { {renderBreadCrumb()} {renderDefaultHandles( - data.scopedId, + scopedId, getGraphHandleStyle('source'), getGraphHandleStyle('target'), )} @@ -499,12 +476,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 +491,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..3320f7852 --- /dev/null +++ b/packages/zapp/console/src/components/flytegraph/ReactFlow/test/PausedTasksComponent.test.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +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: [], + }, +]; + +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(); + }); +}); 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/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/Execution/enums.ts b/packages/zapp/console/src/models/Execution/enums.ts index da80201b4..5d45d3a5f 100644 --- a/packages/zapp/console/src/models/Execution/enums.ts +++ b/packages/zapp/console/src/models/Execution/enums.ts @@ -14,7 +14,7 @@ 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; +export const NodeExecutionPhase = { ...Core.NodeExecution.Phase, PAUSED: 100 }; export type TaskExecutionPhase = Core.TaskExecution.Phase; export const TaskExecutionPhase = Core.TaskExecution.Phase; enum MapCacheStatus { 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` */