diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cff4054ece..75990faacf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,7 +22,7 @@ "@patternfly/react-styles": "^5.2.1", "@patternfly/react-table": "^5.2.1", "@patternfly/react-tokens": "^5.2.1", - "@patternfly/react-topology": "^5.3.0-prerelease.5", + "@patternfly/react-topology": "^5.3.0-prerelease.12", "@patternfly/react-virtualized-extension": "^5.0.0", "@types/classnames": "^2.3.1", "axios": "^1.6.4", @@ -3920,9 +3920,9 @@ "integrity": "sha512-8GYz/jnJTGAWUJt5eRAW5dtyiHPKETeFJBPGHaUQnvi/t1ZAkoy8i4Kd/RlHsDC7ktiu813SKCmlzwBwldAHKg==" }, "node_modules/@patternfly/react-topology": { - "version": "5.3.0-prerelease.5", - "resolved": "https://registry.npmjs.org/@patternfly/react-topology/-/react-topology-5.3.0-prerelease.5.tgz", - "integrity": "sha512-u5Jr3B4nvv/7uqn+u4e7I3CZwFVXLX7uanQP08rhvauWBG2hAhQYwxFEGRCm0j+1LEQWd2dwXvauGQM5gL0Itw==", + "version": "5.3.0-prerelease.12", + "resolved": "https://registry.npmjs.org/@patternfly/react-topology/-/react-topology-5.3.0-prerelease.12.tgz", + "integrity": "sha512-bwJ6/5ZeiMV8uj+fYQHYnTp+5xQ99r1ejCXmBx2z1hSxIwLMt0DrtMK7Lorj5qfdcYDfM2G85+tNonAFZd0GQg==", "dependencies": { "@patternfly/react-core": "^5.1.1", "@patternfly/react-icons": "^5.1.1", diff --git a/frontend/package.json b/frontend/package.json index 74cce3cfab..1b3559d554 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -63,7 +63,7 @@ "@patternfly/react-styles": "^5.2.1", "@patternfly/react-table": "^5.2.1", "@patternfly/react-tokens": "^5.2.1", - "@patternfly/react-topology": "^5.3.0-prerelease.5", + "@patternfly/react-topology": "^5.3.0-prerelease.12", "@patternfly/react-virtualized-extension": "^5.0.0", "@types/classnames": "^2.3.1", "axios": "^1.6.4", diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx index 76d1c84693..3a80de0fbe 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipeline/PipelineDetails.tsx @@ -51,7 +51,12 @@ const PipelineDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath }) = usePipelineVersionById(pipelineId, pipelineVersionId); const [pipeline, isPipelineLoaded, pipelineLoadError] = usePipelineById(pipelineId); - const { taskMap, nodes } = usePipelineTaskTopology(pipelineVersion?.pipeline_spec); + const nodes = usePipelineTaskTopology(pipelineVersion?.pipeline_spec); + + const selectedNode = React.useMemo( + () => nodes.find((n) => n.id === selectedId), + [selectedId, nodes], + ); const isLoaded = isPipelineVersionLoaded && isPipelineLoaded && !!pipelineVersion?.pipeline_spec; if (pipelineVersionLoadError || pipelineLoadError) { @@ -76,11 +81,11 @@ const PipelineDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath }) = return ( <> - + setSelectedId(null)} /> } diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx index 006828eed3..f0ce22a4cb 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRun/PipelineRunDetails.tsx @@ -55,12 +55,15 @@ const PipelineRunDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath, const [selectedId, setSelectedId] = React.useState(null); const [executions, executionsLoaded, executionsError] = useExecutionsForPipelineRun(runResource); - const { taskMap, nodes } = usePipelineTaskTopology( - pipelineSpec, - runResource?.run_details, - executions, + const nodes = usePipelineTaskTopology(pipelineSpec, runResource?.run_details, executions); + + const selectedNode = React.useMemo( + () => nodes.find((n) => n.id === selectedId), + [selectedId, nodes], ); + const getFirstNode = (firstId: string) => nodes.find((n) => n.id === firstId); + const loaded = runLoaded && (versionLoaded || !!runResource?.pipeline_spec); const error = versionError || runError; @@ -87,11 +90,11 @@ const PipelineRunDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath, return ( <> - + setSelectedId(null)} /> } @@ -161,7 +164,7 @@ const PipelineRunDetails: PipelineCoreDetailsPageComponent = ({ breadcrumbPath, const firstId = ids[0]; if (ids.length === 0) { setSelectedId(null); - } else if (taskMap[firstId]) { + } else if (getFirstNode(firstId)) { setDetailsTab(null); setSelectedId(firstId); } diff --git a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRunJob/PipelineRunJobDetails.tsx b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRunJob/PipelineRunJobDetails.tsx index bdb13816ec..81e1934919 100644 --- a/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRunJob/PipelineRunJobDetails.tsx +++ b/frontend/src/concepts/pipelines/content/pipelinesDetails/pipelineRunJob/PipelineRunJobDetails.tsx @@ -53,7 +53,14 @@ const PipelineRunJobDetails: PipelineCoreDetailsPageComponent = ({ ); const [selectedId, setSelectedId] = React.useState(null); - const { taskMap, nodes } = usePipelineTaskTopology(version?.pipeline_spec); + const nodes = usePipelineTaskTopology(version?.pipeline_spec); + + const selectedNode = React.useMemo( + () => nodes.find((n) => n.id === selectedId), + [selectedId, nodes], + ); + + const getFirstNode = (firstId: string) => nodes.find((n) => n.id === firstId)?.data?.pipelineTask; const loaded = versionLoaded && jobLoaded; const error = versionError || jobError; @@ -80,11 +87,11 @@ const PipelineRunJobDetails: PipelineCoreDetailsPageComponent = ({ return ( <> - + setSelectedId(null)} /> } @@ -136,7 +143,7 @@ const PipelineRunJobDetails: PipelineCoreDetailsPageComponent = ({ const firstId = ids[0]; if (ids.length === 0) { setSelectedId(null); - } else if (taskMap[firstId]) { + } else if (getFirstNode(firstId)) { setDetailsTab(null); setSelectedId(firstId); } diff --git a/frontend/src/concepts/pipelines/topology/pipelineTaskTypes.ts b/frontend/src/concepts/pipelines/topology/pipelineTaskTypes.ts index db8f50610e..a43eb61885 100644 --- a/frontend/src/concepts/pipelines/topology/pipelineTaskTypes.ts +++ b/frontend/src/concepts/pipelines/topology/pipelineTaskTypes.ts @@ -1,10 +1,10 @@ +import { WhenStatus } from '@patternfly/react-topology'; import { ArtifactStateKF, ExecutionStateKF, InputDefinitionParameterType, RuntimeStateKF, } from '~/concepts/pipelines/kfTypes'; -import { createNode } from '~/concepts/topology'; import { VolumeMount } from '~/types'; export type PipelineTaskParam = { @@ -50,16 +50,5 @@ export type PipelineTask = { status?: PipelineTaskRunStatus; /** Volume Mounts */ volumeMounts?: VolumeMount[]; -}; - -export type KubeFlowTaskTopology = { - /** - * Details of a selected node. - * [Task.name]: Task - */ - taskMap: Record; - /** - * Nodes to render in topology. - */ - nodes: ReturnType[]; + whenStatus?: WhenStatus; }; diff --git a/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.ts b/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.ts index a44cea3e36..1dd1b5af18 100644 --- a/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.ts +++ b/frontend/src/concepts/pipelines/topology/usePipelineTaskTopology.ts @@ -1,7 +1,13 @@ -import { PipelineSpecVariable, RunDetailsKF, TaskKF } from '~/concepts/pipelines/kfTypes'; +import { + PipelineComponentsKF, + PipelineExecutorsKF, + PipelineSpecVariable, + RunDetailsKF, + TaskKF, +} from '~/concepts/pipelines/kfTypes'; import { createNode } from '~/concepts/topology'; import { PipelineNodeModelExpanded } from '~/concepts/topology/types'; -import { createArtifactNode } from '~/concepts/topology/utils'; +import { createArtifactNode, createGroupNode } from '~/concepts/topology/utils'; import { Execution } from '~/third_party/mlmd'; import { composeArtifactType, @@ -13,15 +19,72 @@ import { parseVolumeMounts, translateStatusForNode, } from './parseUtils'; -import { KubeFlowTaskTopology } from './pipelineTaskTypes'; +import { PipelineTask } from './pipelineTaskTypes'; + +const EMPTY_STATE: PipelineNodeModelExpanded[] = []; + +const getNestedNodes = ( + spec: PipelineSpecVariable, + items: Record, + components: PipelineComponentsKF, + executors: PipelineExecutorsKF, + runDetails?: RunDetailsKF, +): [nestedNodes: PipelineNodeModelExpanded[], children: string[]] => { + const nodes: PipelineNodeModelExpanded[] = []; + const children: string[] = []; + + Object.entries(items).forEach(([name, details]) => { + const componentRef = details.componentRef.name; + const component = components[componentRef]; + const status = parseRuntimeInfoFromRunDetails(name, runDetails); + const runAfter: string[] = details.dependentTasks ?? []; + const hasSubTask = + Object.keys(components).find((task) => task === componentRef) && + components[componentRef]?.dag; + const subTasks = components[componentRef]?.dag?.tasks; + const executorLabel = component?.executorLabel; + const executor = executorLabel ? executors[executorLabel] : undefined; + + const pipelineTask: PipelineTask = { + type: 'groupTask', + name, + steps: executor ? [executor.container] : undefined, + inputs: parseInputOutput(component?.inputDefinitions), + outputs: parseInputOutput(component?.outputDefinitions), + status, + volumeMounts: parseVolumeMounts(spec.platform_spec, executorLabel), + }; + + if (hasSubTask && subTasks) { + const [nestedNodes, nestedChildren] = getNestedNodes(spec, subTasks, components, executors); + + const itemNode = createGroupNode( + name, + name, + pipelineTask, + runAfter, + translateStatusForNode(status?.state), + nestedChildren, + ); + nodes.push(itemNode, ...nestedNodes); + } else { + nodes.push( + createNode(name, name, pipelineTask, runAfter, translateStatusForNode(status?.state)), + ); + } + children.push(name); + }); + + return [nodes, children]; +}; export const usePipelineTaskTopology = ( spec?: PipelineSpecVariable, runDetails?: RunDetailsKF, executions?: Execution[] | null, -): KubeFlowTaskTopology => { +): PipelineNodeModelExpanded[] => { if (!spec) { - return { taskMap: {}, nodes: [] }; + return EMPTY_STATE; } const pipelineSpec = spec.pipeline_spec ?? spec; @@ -29,107 +92,111 @@ export const usePipelineTaskTopology = ( components, deploymentSpec: { executors }, root: { - dag: { tasks: rootTasks }, + dag: { tasks }, }, } = pipelineSpec; const componentArtifactMap = parseComponentsForArtifactRelationship(components); - const nodes: PipelineNodeModelExpanded[] = []; - const taskMap: KubeFlowTaskTopology['taskMap'] = {}; - - const createNodes = (tasks: Record, parentTask?: string) => { - const taskArtifactMap = parseTasksForArtifactRelationship(tasks); - Object.entries(tasks).forEach(([taskId, taskValue]) => { - const taskName = taskValue.taskInfo.name; - - const componentRef = taskValue.componentRef.name; - const component = components[componentRef]; - const artifactsInComponent = componentArtifactMap[componentRef]; - const isGroupNode = !!component?.dag; - - const executorLabel = component?.executorLabel; - const executor = executorLabel ? executors[executorLabel] : undefined; - - const status = executions - ? parseRuntimeInfoFromExecutions(taskId, executions) - : parseRuntimeInfoFromRunDetails(taskId, runDetails); - - const runAfter: string[] = taskValue.dependentTasks ?? []; - - if (artifactsInComponent) { - const artifactNodeData = taskArtifactMap[taskId]; - - Object.entries(artifactsInComponent).forEach(([artifactKey, data]) => { - const label = artifactKey; - const { artifactId } = - artifactNodeData?.find((a) => artifactKey === a.outputArtifactKey) ?? {}; - - // if no node needs it as an input, we don't really need a well known id - const id = artifactId ?? artifactKey; - - nodes.push( - createArtifactNode({ - id, - label, - artifactType: data.schemaTitle, - runAfter: [taskId], - status: translateStatusForNode(status?.state), - }), - ); - - taskMap[id] = { - type: 'artifact', - name: label, - inputs: { - artifacts: [{ label: id, type: composeArtifactType(data) }], - }, - }; - }); - } - - // This task - taskMap[taskId] = { - type: isGroupNode ? 'groupTask' : 'task', - name: taskName, - steps: executor ? [executor.container] : undefined, - inputs: parseInputOutput(component?.inputDefinitions), - outputs: parseInputOutput(component?.outputDefinitions), - status, - volumeMounts: parseVolumeMounts(spec.platform_spec, executorLabel), - }; - if (taskValue.dependentTasks) { - // This task's runAfters may need artifact relationships -- find those artifactIds - runAfter.push( - ...taskValue.dependentTasks - .map((dependantTaskId) => { - const art = taskArtifactMap[dependantTaskId]; - return art ? art.map((v) => v.artifactId) : null; - }) - .filter((v): v is string[] => !!v) - .flat(), + const taskArtifactMap = parseTasksForArtifactRelationship(tasks); + + return Object.entries(tasks).reduce((acc, [taskId, taskValue]) => { + const taskName = taskValue.taskInfo.name; + + const componentRef = taskValue.componentRef.name; + const component = components[componentRef]; + const artifactsInComponent = componentArtifactMap[componentRef]; + const isGroupNode = !!component?.dag; + const groupTasks = component?.dag?.tasks; + + const executorLabel = component?.executorLabel; + const executor = executorLabel ? executors[executorLabel] : undefined; + + const status = executions + ? parseRuntimeInfoFromExecutions(taskId, executions) + : parseRuntimeInfoFromRunDetails(taskId, runDetails); + + const nodes: PipelineNodeModelExpanded[] = []; + const runAfter: string[] = taskValue.dependentTasks ?? []; + + if (artifactsInComponent) { + const artifactNodeData = taskArtifactMap[taskId]; + + Object.entries(artifactsInComponent).forEach(([artifactKey, data]) => { + const label = artifactKey; + const { artifactId } = + artifactNodeData?.find((a) => artifactKey === a.outputArtifactKey) ?? {}; + + // if no node needs it as an input, we don't really need a well known id + const id = artifactId ?? artifactKey; + + const pipelineTask: PipelineTask = { + type: 'artifact', + name: label, + inputs: { + artifacts: [{ label: id, type: composeArtifactType(data) }], + }, + }; + + nodes.push( + createArtifactNode( + id, + label, + pipelineTask, + [taskId], + translateStatusForNode(status?.state), + data.schemaTitle, + ), ); - } else if (parentTask) { - // Create an edge from the grouped task to its parent task - // Prevent the node floating on the topology - // This logic could be removed once we have the stacked node to better deal with groups - runAfter.push(parentTask); - } + }); + } + + if (taskValue.dependentTasks) { + // This task's runAfters may need artifact relationships -- find those artifactIds + runAfter.push( + ...taskValue.dependentTasks + .map((dependantTaskId) => { + const art = taskArtifactMap[dependantTaskId]; + return art ? art.map((v) => v.artifactId) : null; + }) + .filter((v): v is string[] => !!v) + .flat(), + ); + } + + const pipelineTask: PipelineTask = { + type: isGroupNode ? 'groupTask' : 'task', + name: taskName, + steps: executor ? [executor.container] : undefined, + inputs: parseInputOutput(component?.inputDefinitions), + outputs: parseInputOutput(component?.outputDefinitions), + status, + volumeMounts: parseVolumeMounts(spec.platform_spec, executorLabel), + }; + // This task's rendering information + if (isGroupNode && groupTasks) { + const [nestedNodes, children] = getNestedNodes( + spec, + groupTasks, + components, + executors, + runDetails, + ); + const itemNode = createGroupNode( + taskId, + taskName, + pipelineTask, + runAfter, + translateStatusForNode(status?.state), + children, + ); + nodes.push(itemNode, ...nestedNodes); + } else { nodes.push( - createNode({ - id: taskId, - label: taskName, - runAfter, - status: translateStatusForNode(status?.state), - }), + createNode(taskId, taskName, pipelineTask, runAfter, translateStatusForNode(status?.state)), ); - // This task's rendering information - if (isGroupNode) { - // TODO: better handle group nodes - createNodes(component.dag.tasks, taskId); - } - }); - }; - createNodes(rootTasks); - return { nodes, taskMap }; + } + + return [...acc, ...nodes]; + }, []); }; diff --git a/frontend/src/concepts/topology/NodeStatusIcon.tsx b/frontend/src/concepts/topology/NodeStatusIcon.tsx new file mode 100644 index 0000000000..af28d62158 --- /dev/null +++ b/frontend/src/concepts/topology/NodeStatusIcon.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { + NotStartedIcon, + SyncAltIcon, + CheckCircleIcon, + ExclamationCircleIcon, + BanIcon, +} from '@patternfly/react-icons'; +import { Icon, Tooltip } from '@patternfly/react-core'; +import { RuntimeStateKF, runtimeStateLabels } from '~/concepts/pipelines/kfTypes'; + +const NodeStatusIcon: React.FC<{ runStatus: RuntimeStateKF | string }> = ({ runStatus }) => { + let icon: React.ReactNode; + let status: React.ComponentProps['status']; + let label: string; + + switch (runStatus) { + case runtimeStateLabels[RuntimeStateKF.PENDING]: + case runtimeStateLabels[RuntimeStateKF.RUNTIME_STATE_UNSPECIFIED]: + case undefined: + icon = ; + label = runtimeStateLabels[RuntimeStateKF.PENDING]; + break; + case runtimeStateLabels[RuntimeStateKF.RUNNING]: + icon = ; + label = runtimeStateLabels[RuntimeStateKF.RUNNING]; + break; + case runtimeStateLabels[RuntimeStateKF.SKIPPED]: + icon = ; + label = runtimeStateLabels[RuntimeStateKF.SKIPPED]; + break; + case runtimeStateLabels[RuntimeStateKF.SUCCEEDED]: + icon = ; + status = 'success'; + label = runtimeStateLabels[RuntimeStateKF.SUCCEEDED]; + break; + case runtimeStateLabels[RuntimeStateKF.FAILED]: + icon = ; + status = 'danger'; + label = runtimeStateLabels[RuntimeStateKF.FAILED]; + break; + case runtimeStateLabels[RuntimeStateKF.CANCELING]: + icon = ; + label = runtimeStateLabels[RuntimeStateKF.CANCELING]; + break; + case runtimeStateLabels[RuntimeStateKF.CANCELED]: + icon = ; + label = runtimeStateLabels[RuntimeStateKF.CANCELED]; + break; + case runtimeStateLabels[RuntimeStateKF.PAUSED]: + icon = ; + label = runtimeStateLabels[RuntimeStateKF.PAUSED]; + break; + default: + icon = null; + label = ''; + } + + return ( + + + {icon} + + + ); +}; + +export default NodeStatusIcon; diff --git a/frontend/src/concepts/topology/PipelineDefaultTaskGroup.tsx b/frontend/src/concepts/topology/PipelineDefaultTaskGroup.tsx new file mode 100644 index 0000000000..ebd9eb807f --- /dev/null +++ b/frontend/src/concepts/topology/PipelineDefaultTaskGroup.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; + +import { + LabelPosition, + WithSelectionProps, + isNode, + DefaultTaskGroup, + observer, + Node, + GraphElement, + RunStatus, + DEFAULT_LAYER, + Layer, + ScaleDetailsLevel, + TOP_LAYER, + NodeModel, + useHover, + PipelineNodeModel, +} from '@patternfly/react-topology'; +import { Flex, FlexItem, Popover, Stack, StackItem } from '@patternfly/react-core'; +import { PipelineNodeModelExpanded, StandardTaskNodeData } from '~/concepts/topology/types'; +import NodeStatusIcon from '~/concepts/topology/NodeStatusIcon'; +import { NODE_HEIGHT, NODE_WIDTH } from './const'; + +type PipelinesDefaultGroupProps = { + element: GraphElement; +} & WithSelectionProps; + +type PipelinesDefaultGroupInnerProps = Omit & { + element: Node; +}; + +const DefaultTaskGroupInner: React.FunctionComponent = observer( + ({ element, selected, onSelect }) => { + const [hover, hoverRef] = useHover(); + const popoverRef = React.useRef(null); + const detailsLevel = element.getGraph().getDetailsLevel(); + + const getPopoverTasksList = (items: Node[]) => ( + + {items.map((item: Node) => ( + + + + + + {item.getLabel()} + + + ))} + + ); + + const groupNode = ( + + ); + + return ( + + }> + {element.isCollapsed() ? ( + + {groupNode} + + ) : ( + groupNode + )} + + + ); + }, +); + +const PipelineDefaultTaskGroup: React.FunctionComponent = ({ + element, + ...rest +}: PipelinesDefaultGroupProps & WithSelectionProps) => { + if (!isNode(element)) { + throw new Error('DefaultTaskGroup must be used only on Node elements'); + } + + return ; +}; + +export default observer(PipelineDefaultTaskGroup); diff --git a/frontend/src/concepts/topology/PipelineVisualizationSurface.tsx b/frontend/src/concepts/topology/PipelineVisualizationSurface.tsx index 29bc56cff3..0d0a8bc305 100644 --- a/frontend/src/concepts/topology/PipelineVisualizationSurface.tsx +++ b/frontend/src/concepts/topology/PipelineVisualizationSurface.tsx @@ -31,15 +31,25 @@ const PipelineVisualizationSurface: React.FC const controller = useVisualizationController(); const [error, setError] = React.useState(); React.useEffect(() => { - const spacerNodes = getSpacerNodes(nodes); + const currentModel = controller.toModel(); + const updateNodes = nodes.map((node) => { + const currentNode = currentModel.nodes?.find((n) => n.id === node.id); + if (currentNode) { + return { ...node, collapsed: currentNode.collapsed }; + } + return node; + }); + + const spacerNodes = getSpacerNodes(updateNodes); // Dagre likes the root nodes to be first in the order - const renderNodes = [...spacerNodes, ...nodes].sort( + const renderNodes = [...spacerNodes, ...updateNodes].sort( (a, b) => (a.runAfterTasks?.length ?? 0) - (b.runAfterTasks?.length ?? 0), ); // TODO: We can have a weird edge issue if the node is off by a few pixels vertically from the center const edges = getEdgesFromNodes(renderNodes); + try { controller.fromModel( { diff --git a/frontend/src/concepts/topology/const.ts b/frontend/src/concepts/topology/const.ts index 72073d2cdf..1a01bdda8a 100644 --- a/frontend/src/concepts/topology/const.ts +++ b/frontend/src/concepts/topology/const.ts @@ -1,5 +1,8 @@ export const PIPELINE_LAYOUT = 'PipelineLayout'; -export const PIPELINE_NODE_SEPARATION_VERTICAL = 100; +export const PIPELINE_NODE_SEPARATION_VERTICAL = 70; +export const PIPELINE_NODE_SEPARATION_HORIZONTAL = 110; -export const NODE_WIDTH = 100; +export const NODE_WIDTH = 130; export const NODE_HEIGHT = 35; + +export const EXECUTION_TASK_NODE_TYPE = 'EXECUTION_TASK_NODE'; diff --git a/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx b/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx index 68ee73c6a6..cdac0bd40d 100644 --- a/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx +++ b/frontend/src/concepts/topology/customNodes/ArtifactTaskNode.tsx @@ -29,11 +29,10 @@ type IconTaskNodeProps = { const IconTaskNode: React.FC = observer(({ element, selected, onSelect }) => { const data = element.getData(); - const status = data?.status; const bounds = element.getBounds(); const iconSize = bounds.height - ICON_PADDING * 2; - const runStatusModifier = status && getRunStatusModifier(status); + const runStatusModifier = data?.runStatus && getRunStatusModifier(data.runStatus); useAnchor( React.useCallback( @@ -98,8 +97,12 @@ const ArtifactTaskNodeInner: React.FC = observer( const data = element.getData(); const scale = element.getGraph().getScale(); const iconSize = 24; - const whenDecorator = data?.whenStatus ? ( - + const whenDecorator = data?.pipelineTask.whenStatus ? ( + ) : null; const upScale = 1 / scale; @@ -118,7 +121,8 @@ const ArtifactTaskNodeInner: React.FC = observer( hover selected={selected} onSelect={onSelect} - status={data?.status} + hiddenDetailsShownStatuses={[]} + status={data?.runStatus} scaleNode={isHover} {...rest} > diff --git a/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx b/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx index 23ad94386f..9f0eeba370 100644 --- a/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx +++ b/frontend/src/concepts/topology/customNodes/StandardTaskNode.tsx @@ -12,9 +12,10 @@ import { WithContextMenuProps, WithSelectionProps, } from '@patternfly/react-topology'; +import { PipelineNodeModelExpanded } from '~/concepts/topology/types'; type StandardTaskNodeProps = { - element: GraphElement; + element: GraphElement; } & WithContextMenuProps & WithSelectionProps; @@ -28,8 +29,12 @@ const StandardTaskNode: React.FunctionComponent = ({ const [hover, hoverRef] = useHover(); const detailsLevel = element.getGraph().getDetailsLevel(); - const whenDecorator = data?.whenStatus ? ( - + const whenDecorator = data?.pipelineTask.whenStatus ? ( + ) : null; return ( @@ -39,7 +44,7 @@ const StandardTaskNode: React.FunctionComponent = ({ onSelect={onSelect} selected={selected} scaleNode={hover && detailsLevel !== ScaleDetailsLevel.high} - status={data?.status} + status={data?.runStatus} hideDetailsAtMedium hiddenDetailsShownStatuses={[ RunStatus.Succeeded, @@ -47,8 +52,8 @@ const StandardTaskNode: React.FunctionComponent = ({ RunStatus.Failed, RunStatus.Running, ]} - whenOffset={data?.whenStatus ? DEFAULT_WHEN_OFFSET : 0} - whenSize={data?.whenStatus ? DEFAULT_WHEN_SIZE : 0} + whenOffset={data?.pipelineTask.whenStatus ? DEFAULT_WHEN_OFFSET : 0} + whenSize={data?.pipelineTask.whenStatus ? DEFAULT_WHEN_SIZE : 0} {...rest} > {whenDecorator} diff --git a/frontend/src/concepts/topology/factories.ts b/frontend/src/concepts/topology/factories.ts index 08f06a48a1..c4aa64383a 100644 --- a/frontend/src/concepts/topology/factories.ts +++ b/frontend/src/concepts/topology/factories.ts @@ -13,6 +13,8 @@ import StandardTaskNode from '~/concepts/topology/customNodes/StandardTaskNode'; import { ICON_TASK_NODE_TYPE } from './utils'; import ArtifactTaskNode from './customNodes/ArtifactTaskNode'; import PipelineTaskEdge from './PipelineTaskEdge'; +import PipelineDefaultTaskGroup from './PipelineDefaultTaskGroup'; +import { EXECUTION_TASK_NODE_TYPE } from './const'; export const pipelineComponentFactory: ComponentFactory = (kind, type) => { if (kind === ModelKind.graph) { @@ -27,6 +29,8 @@ export const pipelineComponentFactory: ComponentFactory = (kind, type) => { return SpacerNode; case DEFAULT_EDGE_TYPE: return PipelineTaskEdge; + case EXECUTION_TASK_NODE_TYPE: + return withSelection()(PipelineDefaultTaskGroup); default: return undefined; } diff --git a/frontend/src/concepts/topology/types.ts b/frontend/src/concepts/topology/types.ts index 1c35f045b2..7f6d1b240b 100644 --- a/frontend/src/concepts/topology/types.ts +++ b/frontend/src/concepts/topology/types.ts @@ -1,17 +1,9 @@ -import { PipelineNodeModel, RunStatus, WhenStatus } from '@patternfly/react-topology'; - -export type NodeConstructDetails = { - id: string; - label?: string; - artifactType?: string; - runAfter?: string[]; - status?: RunStatus; - tasks?: string[]; -}; +import { PipelineNodeModel, RunStatus } from '@patternfly/react-topology'; +import { PipelineTask } from '~/concepts/pipelines/topology'; export type StandardTaskNodeData = { - status?: RunStatus; - whenStatus?: WhenStatus; + pipelineTask: PipelineTask; + runStatus?: RunStatus; artifactType?: string; }; diff --git a/frontend/src/concepts/topology/useTopologyController.ts b/frontend/src/concepts/topology/useTopologyController.ts index 35e0eb52ba..2b4c6ca453 100644 --- a/frontend/src/concepts/topology/useTopologyController.ts +++ b/frontend/src/concepts/topology/useTopologyController.ts @@ -3,12 +3,16 @@ import { Graph, GRAPH_LAYOUT_END_EVENT, Layout, - NODE_SEPARATION_HORIZONTAL, PipelineDagreGroupsLayout, Visualization, } from '@patternfly/react-topology'; -import { pipelineComponentFactory } from '~/concepts/topology/factories'; -import { PIPELINE_LAYOUT, PIPELINE_NODE_SEPARATION_VERTICAL } from './const'; +import pipelineElementFactory from '@patternfly/react-topology/dist/esm/pipelines/elements/pipelineElementFactory'; +import { pipelineComponentFactory } from './factories'; +import { + PIPELINE_LAYOUT, + PIPELINE_NODE_SEPARATION_HORIZONTAL, + PIPELINE_NODE_SEPARATION_VERTICAL, +} from './const'; const useTopologyController = (graphId: string): Visualization | null => { const [controller, setController] = React.useState(null); @@ -16,12 +20,13 @@ const useTopologyController = (graphId: string): Visualization | null => { React.useEffect(() => { const visualizationController = new Visualization(); visualizationController.setFitToScreenOnLayout(true); + visualizationController.registerElementFactory(pipelineElementFactory); visualizationController.registerComponentFactory(pipelineComponentFactory); visualizationController.registerLayoutFactory( (type: string, graph: Graph): Layout | undefined => new PipelineDagreGroupsLayout(graph, { - nodesep: PIPELINE_NODE_SEPARATION_VERTICAL, - ranksep: NODE_SEPARATION_HORIZONTAL, + nodesep: PIPELINE_NODE_SEPARATION_HORIZONTAL, + ranksep: PIPELINE_NODE_SEPARATION_VERTICAL, ignoreGroups: true, rankdir: 'TB', }), diff --git a/frontend/src/concepts/topology/utils.ts b/frontend/src/concepts/topology/utils.ts index ace5d54849..006408098e 100644 --- a/frontend/src/concepts/topology/utils.ts +++ b/frontend/src/concepts/topology/utils.ts @@ -1,7 +1,8 @@ -import { DEFAULT_TASK_NODE_TYPE } from '@patternfly/react-topology'; +import { DEFAULT_TASK_NODE_TYPE, RunStatus } from '@patternfly/react-topology'; import { genRandomChars } from '~/utilities/string'; -import { NODE_HEIGHT, NODE_WIDTH } from './const'; -import { NodeConstructDetails, PipelineNodeModelExpanded } from './types'; +import { PipelineTask } from '~/concepts/pipelines/topology'; +import { EXECUTION_TASK_NODE_TYPE, NODE_HEIGHT, NODE_WIDTH } from './const'; +import { PipelineNodeModelExpanded } from './types'; export const createNodeId = (prefix = 'node'): string => `${prefix}-${genRandomChars()}`; @@ -10,26 +11,71 @@ export const ICON_TASK_NODE_TYPE = 'ICON_TASK_NODE'; export const ARTIFACT_NODE_WIDTH = 44; export const ARTIFACT_NODE_HEIGHT = NODE_HEIGHT; -export const createNode = (details: NodeConstructDetails): PipelineNodeModelExpanded => ({ - id: details.id, - label: details.label, +export const NODE_PADDING_VERTICAL = 40; +export const NODE_PADDING_HORIZONTAL = 15; + +export const createNode = ( + id: string, + label: string, + pipelineTask: PipelineTask, + runAfterTasks?: string[], + runStatus?: RunStatus, +): PipelineNodeModelExpanded => ({ + id, + label, type: DEFAULT_TASK_NODE_TYPE, width: NODE_WIDTH, height: NODE_HEIGHT, - runAfterTasks: details.runAfter, - data: details.status - ? { - status: details.status, - } - : undefined, + runAfterTasks, + data: { + pipelineTask, + runStatus, + }, }); -export const createArtifactNode = (details: NodeConstructDetails): PipelineNodeModelExpanded => ({ - id: details.id, - label: `${details.label} (Type: ${details.artifactType?.slice(7)})`, +export const createArtifactNode = ( + id: string, + label: string, + pipelineTask: PipelineTask, + runAfterTasks?: string[], + runStatus?: RunStatus, + artifactType?: string, +): PipelineNodeModelExpanded => ({ + id, + label: `${label} (Type: ${artifactType?.slice(7)})`, type: ICON_TASK_NODE_TYPE, width: ARTIFACT_NODE_WIDTH, height: ARTIFACT_NODE_HEIGHT, - runAfterTasks: details.runAfter, - data: { status: details.status ?? undefined, artifactType: details.artifactType }, + runAfterTasks, + data: { + pipelineTask, + artifactType, + runStatus, + }, +}); + +export const createGroupNode = ( + id: string, + label: string, + pipelineTask: PipelineTask, + runAfterTasks?: string[], + runStatus?: RunStatus, + children?: string[], +): PipelineNodeModelExpanded => ({ + id, + label, + type: EXECUTION_TASK_NODE_TYPE, + group: true, + collapsed: true, + width: NODE_WIDTH, + height: NODE_HEIGHT, + runAfterTasks, + children, + style: { + padding: [NODE_PADDING_VERTICAL + 24, NODE_PADDING_HORIZONTAL], + }, + data: { + pipelineTask, + runStatus, + }, });