From 5f923f206c9fc56687756c08d546800437254350 Mon Sep 17 00:00:00 2001 From: Andrew Ballantyne Date: Thu, 16 Jan 2020 16:28:25 -0500 Subject: [PATCH 1/4] Pipeline Topology Implementation --- .../import/pipeline/PipelineTemplate.tsx | 2 +- .../detail-page-tabs/PipelineRunDetails.tsx | 6 +- .../PipelineRunVisualization.tsx | 74 +++----- .../pipeline-details/PipelineDetails.tsx | 2 +- .../PipelineVisualization.tsx | 40 ++-- .../PipelineVisualizationGraph.scss | 176 ------------------ .../PipelineVisualizationGraph.tsx | 49 ----- .../PipelineVisualizationTask.scss | 1 - .../PipelineVisualizationTask.tsx | 4 +- .../PipelineVisualizationGraph.spec.tsx | 49 ----- .../PipelineVisualizationGraph.spec.tsx.snap | 135 -------------- .../PipelineTopologyGraph.scss | 10 + .../PipelineTopologyGraph.tsx | 33 ++++ .../PipelineVisualizationSurface.tsx | 51 +++++ .../pipeline-topology/SpacerNode.tsx | 8 + .../pipelines/pipeline-topology/TaskEdge.tsx | 24 +++ .../pipelines/pipeline-topology/TaskNode.tsx | 23 +++ .../pipelines/pipeline-topology/const.ts | 18 ++ .../pipelines/pipeline-topology/draw-utils.ts | 87 +++++++++ .../pipelines/pipeline-topology/factories.ts | 60 ++++++ .../pipelines/pipeline-topology/types.ts | 19 ++ .../pipelines/pipeline-topology/utils.ts | 157 ++++++++++++++++ .../topology/src/layouts/DagreLayout.ts | 28 ++- 23 files changed, 576 insertions(+), 480 deletions(-) delete mode 100644 frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.scss delete mode 100644 frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.tsx delete mode 100644 frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/PipelineVisualizationGraph.spec.tsx delete mode 100644 frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineVisualizationSurface.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/SpacerNode.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskEdge.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/draw-utils.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts diff --git a/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx b/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx index f720d7ba536..81c989db860 100644 --- a/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx +++ b/frontend/packages/dev-console/src/components/import/pipeline/PipelineTemplate.tsx @@ -6,7 +6,7 @@ import { Alert, Expandable } from '@patternfly/react-core'; import { CheckboxField } from '@console/shared'; import { CLUSTER_PIPELINE_NS } from '../../../const'; import { PipelineModel } from '../../../models'; -import { PipelineVisualization } from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualization'; +import PipelineVisualization from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualization'; const MISSING_DOCKERFILE_LABEL_TEXT = 'The pipeline template for Dockerfiles is not available at this time.'; diff --git a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx index 1491eabd5fd..7e27050aa22 100644 --- a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx +++ b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunDetails.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; import { SectionHeading, ResourceSummary } from '@console/internal/components/utils'; -import { K8sResourceKind } from '@console/internal/module/k8s'; -import { PipelineRunVisualization } from './PipelineRunVisualization'; +import PipelineRunVisualization from './PipelineRunVisualization'; +import { PipelineRun } from '../../../utils/pipeline-augment'; export interface PipelineRunDetailsProps { - obj: K8sResourceKind; + obj: PipelineRun; } export const PipelineRunDetails: React.FC = ({ obj: pipelineRun }) => { diff --git a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx index 6b79579f29d..cc730387b91 100644 --- a/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx +++ b/frontend/packages/dev-console/src/components/pipelineruns/detail-page-tabs/PipelineRunVisualization.tsx @@ -1,57 +1,35 @@ import * as React from 'react'; -import { K8sResourceKind, k8sGet } from '@console/internal/module/k8s'; -import { getPipelineTasks } from '../../../utils/pipeline-utils'; +import { Alert } from '@patternfly/react-core'; +import { k8sGet } from '@console/internal/module/k8s'; import { PipelineModel } from '../../../models'; -import { pipelineRunFilterReducer } from '../../../utils/pipeline-filter-reducer'; -import { PipelineVisualizationGraph } from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph'; +import PipelineVisualization from '../../pipelines/detail-page-tabs/pipeline-details/PipelineVisualization'; +import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; -export interface PipelineRunVisualizationProps { - pipelineRun: K8sResourceKind; -} +type PipelineRunVisualizationProps = { + pipelineRun: PipelineRun; +}; -export interface PipelineVisualizationRunState { - pipeline: K8sResourceKind; - errorCode?: number; -} +const PipelineRunVisualization: React.FC = ({ pipelineRun }) => { + const [errorMessage, setErrorMessage] = React.useState(null); + const [pipeline, setPipeline] = React.useState(null); -export class PipelineRunVisualization extends React.Component< - PipelineRunVisualizationProps, - PipelineVisualizationRunState -> { - constructor(props) { - super(props); - this.state = { - pipeline: { apiVersion: '', metadata: {}, kind: 'PipelineRun' }, - errorCode: null, - }; - } + React.useEffect(() => { + k8sGet(PipelineModel, pipelineRun.spec.pipelineRef.name, pipelineRun.metadata.namespace) + .then((res: Pipeline) => setPipeline(res)) + .catch((error) => + setErrorMessage(error?.message || 'Could not load visualization at this time.'), + ); + }, [pipelineRun, setPipeline]); - componentDidMount() { - k8sGet( - PipelineModel, - this.props.pipelineRun.spec.pipelineRef.name, - this.props.pipelineRun.metadata.namespace, - ) - .then((res) => { - this.setState({ - pipeline: res, - }); - }) - .catch((error) => this.setState({ errorCode: error.response.status })); + if (errorMessage) { + return ; } - render() { - const { pipelineRun } = this.props; - if (this.state.errorCode === 404) { - return null; - } - return ( - - ); + if (!pipeline || !pipelineRun) { + return null; } -} + + return ; +}; + +export default PipelineRunVisualization; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx index 8e556c1221f..827dd8e896c 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineDetails.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { SectionHeading, ResourceSummary, ResourceLink } from '@console/internal/components/utils'; import { referenceForModel } from '@console/internal/module/k8s'; import { Pipeline, getResourceModelFromTask } from '../../../../utils/pipeline-augment'; -import { PipelineVisualization } from './PipelineVisualization'; +import PipelineVisualization from './PipelineVisualization'; interface PipelineDetailsProps { obj: Pipeline; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx index 82cd5824dc3..35324649f79 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx @@ -1,15 +1,33 @@ import * as React from 'react'; -import { K8sResourceKind } from '@console/internal/module/k8s'; -import { getPipelineTasks } from '../../../../utils/pipeline-utils'; -import { PipelineVisualizationGraph } from './PipelineVisualizationGraph'; +import { Alert } from '@patternfly/react-core'; +import { Pipeline, PipelineRun } from '../../../../utils/pipeline-augment'; +import PipelineTopologyGraph from '../../pipeline-topology/PipelineTopologyGraph'; +import { getTopologyNodesEdges } from '../../pipeline-topology/utils'; -export interface PipelineVisualizationProps { - pipeline?: K8sResourceKind; +interface PipelineTopologyVisualizationProps { + pipeline: Pipeline; + pipelineRun?: PipelineRun; } -export const PipelineVisualization: React.FC = ({ pipeline }) => ( - -); +const PipelineVisualization: React.FC = ({ + pipeline, + pipelineRun, +}) => { + const { nodes, edges } = getTopologyNodesEdges(pipeline, pipelineRun); + + if (nodes.length === 0 && edges.length === 0) { + // Nothing to render + // TODO: Confirm wording with UX; ODC-1860 + return ; + } + + return ( + + ); +}; + +export default PipelineVisualization; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.scss b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.scss deleted file mode 100644 index 5816c756eca..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.scss +++ /dev/null @@ -1,176 +0,0 @@ -$border-color: var(--pf-global--BorderColor--light-100); -$gutter: 1.7em; - -.odc-pipeline-vis-graph { - margin: var(--pf-global--spacer--md) 0; - overflow: auto; - position: relative; - -webkit-overflow-scrolling: touch; - font-size: 12px; - - // reset - &__stage-column { - list-style: none; - padding: 0; - } - - &__stages { - display: inline-flex; - background: var(--pf-global--BackgroundColor--300); - border-radius: 20px; - padding: 20px; - } - - &__stage { - margin: 0 16px; - - &:not(:first-child) { - &:not(.is-parallel) { - & .odc-pipeline-vis-task:first-child { - &::before { - left: 0px; - width: 33px; - transform: translateX(-100%); - } - } - } - &.is-parallel { - & .odc-pipeline-vis-task { - &:first-child { - &::before { - content: ''; - position: absolute; - top: 1.25em; - border-top: 1px solid $border-color; - width: $gutter; - height: 0; - } - &::before { - left: 0; - width: 33px; - transform: translateX(-100%); - } - } - } - } - & .odc-pipeline-vis-task:first-child { - &::before { - content: ''; - position: absolute; - top: 1.25em; - - border-top: 1px solid $border-color; - width: $gutter; - height: 0; - } - } - & .odc-pipeline-vis-task { - &__connector { - display: none; - &::after { - content: ''; - width: 0.67em; - height: 1.2em; - position: absolute; - top: -2.75em; - border-top: 1px solid $border-color; - left: -17px; - border-right: 1px solid var(--pf-global--BorderColor--light-100); - border-radius: 0 20px; - } - } - &:not(:first-child) { - //connect each task - &::before { - content: ''; - border-radius: 0 0 0 1.33em; - top: -4.33em; - position: absolute; - border-bottom: 1px solid var(--pf-global--BorderColor--light-100); - width: 10px; - height: 5.75em; - } - // Left connecting lines - &::before { - left: -10px; - border-left: 1px solid $border-color; - border-radius: 0 0 0 12px; - } - } - &:nth-child(2) { - .odc-pipeline-vis-task__connector { - display: block; - } - &::before { - top: -1.9em; - height: 3.3em; - } - } - } - } - &:not(:last-child) { - &.is-parallel { - & .odc-pipeline-vis-task { - &:first-child { - &::after { - right: 0; - transform: translateX(100%); - } - } - } - } - & .odc-pipeline-vis-task { - &__connector { - display: none; - &::before { - content: ''; - width: 0.67em; - height: 1.2em; - position: absolute; - top: -2.75em; - border-top: 1px solid $border-color; - right: -17px; - border-left: 1px solid var(--pf-global--BorderColor--light-100); - border-radius: 20px 0 0; - } - } - //connect each task - &:not(:first-child) { - &::after { - content: ''; - border-radius: 0 0 0 1.33em; - top: -4.33em; - position: absolute; - border-bottom: 1px solid var(--pf-global--BorderColor--light-100); - width: 10px; - height: 5.75em; - } - - // Right connecting lines - &::after { - right: -10px; - border-right: 1px solid $border-color; - border-radius: 0 0 12px; - } - } - &:nth-child(2) { - .odc-pipeline-vis-task__connector { - display: block; - } - &::after { - top: -1.9em; - height: 3.3em; - } - } - } - } - &:last-child { - & .odc-pipeline-vis-task:first-child { - &::after { - content: ''; - width: $gutter / 2 + 0.05; - } - } - } - } -} diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.tsx deleted file mode 100644 index cc8d0048462..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationGraph.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from 'react'; -import * as cx from 'classnames'; -import { PipelineVisualizationTaskItem } from '../../../../utils/pipeline-utils'; -import { PipelineVisualizationTask } from './PipelineVisualizationTask'; - -import './PipelineVisualizationGraph.scss'; - -export interface PipelineVisualizationGraphProps { - pipelineRun?: string; - graph: PipelineVisualizationTaskItem[][]; - namespace: string; - runStatus?: string; -} - -export const PipelineVisualizationGraph: React.FC = ({ - pipelineRun, - graph, - namespace, - runStatus, -}) => { - return ( -
-
- {graph.map((stage) => { - return ( -
1 })} - key={stage.map((t) => `${t.taskRef.name}-${t.name}`).join(',')} - > -
    - {stage.map((task) => { - return ( - - ); - })} -
-
- ); - })} -
-
- ); -}; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss index 99b93b06971..6608e88e4da 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.scss @@ -2,7 +2,6 @@ $border-color: var(--pf-global--BorderColor--light-100); .odc-pipeline-vis-task { position: relative; width: 10em; - margin-top: 1.5em; &__content { width: inherit; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx index ba416b30d93..ab84edbdd8b 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx @@ -142,8 +142,8 @@ const TaskComponent: React.FC = ({ ); return ( -
  • +
    {path ? {visTask} : visTask} -
  • + ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/PipelineVisualizationGraph.spec.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/PipelineVisualizationGraph.spec.tsx deleted file mode 100644 index 4e2d765ed26..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/PipelineVisualizationGraph.spec.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from 'react'; -import { shallow, ShallowWrapper } from 'enzyme'; -import * as Renderer from 'react-test-renderer'; -import { getPipelineTasks } from '../../../../../utils/pipeline-utils'; -import { PipelineVisualizationGraph } from '../PipelineVisualizationGraph'; -import { PipelineVisualizationProps } from '../PipelineVisualization'; -import { mockPipelineGraph } from './pipeline-visualization-test-data'; -import { mockPipeline } from './pipeline-mock'; -import { mockPipelineRun } from './pipelinerun-mock'; - -jest.mock('react-dom', () => ({ - findDOMNode: () => ({}), - createPortal: (node) => node, -})); - -jest.mock('../PipelineVisualizationTask'); -describe('PipelineVisualizationGraph', () => { - const props = { - namespace: 'test', - graph: mockPipelineGraph, - }; - let wrapper: ShallowWrapper; - beforeEach(() => { - wrapper = shallow(); - }); - - it('renders a Pipeline visualization graph', () => { - expect(wrapper.exists()).toBeTruthy(); - }); - - it('should contain right number of stages', () => { - const noOfStages = wrapper.find('.odc-pipeline-vis-graph__stage-column').length; - - expect(noOfStages).toEqual(mockPipelineGraph.length); - }); - - it('should match the previous pipeline snapshot', () => { - const tree = Renderer.create().toJSON(); - expect(tree).toMatchSnapshot(); - }); - - it('should match the previous pipelineRun snapshot', () => { - const graph = getPipelineTasks(mockPipeline, mockPipelineRun); - const tree = Renderer.create( - , - ).toJSON(); - expect(tree).toMatchSnapshot(); - }); -}); diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap deleted file mode 100644 index 205686c366c..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/__tests__/__snapshots__/PipelineVisualizationGraph.spec.tsx.snap +++ /dev/null @@ -1,135 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PipelineVisualizationGraph should match the previous pipeline snapshot 1`] = ` -
    -
    -
    -
      -
    • - - start-app - -
    • -
    -
    -
    -
      -
    • - - test-app-1 - -
    • -
    • - - test-app-2 - -
    • -
    -
    -
    -
      -
    • - - build-image-1 - -
    • -
    • - - build-image-2 - -
    • -
    -
    -
    -
      -
    • - - deploy - -
    • -
    -
    -
    -
    -`; - -exports[`PipelineVisualizationGraph should match the previous pipelineRun snapshot 1`] = ` -
    -
    -
    -
      -
    • - - build-skaffold-web - - - Succeeded - - - 32s - -
    • -
    • - - deploy-web - - - Succeeded - - - 32s - -
    • -
    -
    -
    -
    -`; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss new file mode 100644 index 00000000000..c73da1d44d9 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss @@ -0,0 +1,10 @@ +.odc-pipeline-topology-visualization { + display: inline-block; + background: var(--pf-global--BackgroundColor--300); + border-radius: 20px; + font-size: var(--pf-global--FontSize--xs); + margin-bottom: var(--pf-global--spacer--md); + max-width: 100%; + overflow: auto; + padding: var(--pf-global--spacer--lg) var(--pf-global--spacer--xl); +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx new file mode 100644 index 00000000000..cea4f9367f5 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { ModelKind } from '@console/topology'; +import PipelineVisualizationSurface from './PipelineVisualizationSurface'; +import { PipelineLayout } from './const'; +import { PipelineEdgeModel, PipelineNodeModel } from './types'; + +import './PipelineTopologyGraph.scss'; + +type PipelineTopologyGraphProps = { + id: string; + nodes: PipelineNodeModel[]; + edges: PipelineEdgeModel[]; +}; + +const PipelineTopologyGraph: React.FC = ({ id, nodes, edges }) => { + return ( +
    + +
    + ); +}; + +export default PipelineTopologyGraph; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineVisualizationSurface.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineVisualizationSurface.tsx new file mode 100644 index 00000000000..61ea80f6c14 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineVisualizationSurface.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { Model, Visualization, VisualizationSurface } from '@console/topology'; +import { LayoutCallback } from '@console/topology/src/layouts/DagreLayout'; +import { componentFactory, layoutFactory } from './factories'; +import { DROP_SHADOW_SPACING, NODE_WIDTH, NODE_HEIGHT } from './const'; + +type PipelineVisualizationSurfaceProps = { + model: Model; +}; + +const PipelineVisualizationSurface: React.FC = ({ model }) => { + const [vis, setVis] = React.useState(null); + const [maxSize, setMaxSize] = React.useState(null); + + const onLayoutUpdate: LayoutCallback = React.useCallback( + (nodes) => { + const nodeBounds = nodes.map((node) => node.getBounds()); + const maxX = nodeBounds.map((bounds) => bounds.x).reduce((x1, x2) => Math.max(x1, x2), 0); + const maxY = nodeBounds.map((bounds) => bounds.y).reduce((y1, y2) => Math.max(y1, y2), 0); + + setMaxSize({ + // Nodes are rendered from the top-left + height: maxY + NODE_HEIGHT + DROP_SHADOW_SPACING, + width: maxX + NODE_WIDTH, + }); + }, + [setMaxSize], + ); + + React.useEffect(() => { + if (vis === null) { + const visualization = new Visualization(); + visualization.registerLayoutFactory(layoutFactory(onLayoutUpdate)); + visualization.registerComponentFactory(componentFactory); + visualization.fromModel(model); + setVis(visualization); + } else { + vis.fromModel(model); + } + }, [vis, model, onLayoutUpdate]); + + if (!vis) return null; + + return ( +
    + +
    + ); +}; + +export default PipelineVisualizationSurface; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/SpacerNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/SpacerNode.tsx new file mode 100644 index 00000000000..6aa54e0a870 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/SpacerNode.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; +import { observer, Node } from '@console/topology'; + +const SpacerNode: React.FC<{ element: Node }> = () => { + return ; +}; + +export default observer(SpacerNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskEdge.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskEdge.tsx new file mode 100644 index 00000000000..f0b178d6d39 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskEdge.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import { Edge, Point } from '@console/topology'; +import { integralShapePath } from './draw-utils'; + +const TaskEdge: React.FC<{ element: Edge }> = ({ element }) => { + const startPoint: Point = element.getStartPoint(); + const endPoint: Point = element.getEndPoint(); + const sourceNode = element.getSource(); + const targetNode = element.getTarget(); + + return ( + + ); +}; + +export default TaskEdge; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx new file mode 100644 index 00000000000..4397bea84d7 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { observer, Node } from '@console/topology'; +import { pipelineRunFilterReducer } from '../../../utils/pipeline-filter-reducer'; +import { PipelineVisualizationTask } from '../detail-page-tabs/pipeline-details/PipelineVisualizationTask'; +import { DROP_SHADOW_SPACING } from './const'; + +const TaskNode: React.FC<{ element: Node }> = ({ element }) => { + const { height, width } = element.getBounds(); + const { pipeline, pipelineRun, task } = element.getData(); + + return ( + + + + ); +}; + +export default observer(TaskNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts new file mode 100644 index 00000000000..cc7b4516908 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts @@ -0,0 +1,18 @@ +export const NODE_SEPARATION_HORIZONTAL = 25; +export const NODE_SEPARATION_VERTICAL = 20; +export const DROP_SHADOW_SPACING = 5; + +export const NODE_WIDTH = 120; +export const NODE_HEIGHT = 30; + +export enum NodeType { + TASK_NODE = 'task', + SPACER_NODE = 'spacer', +} +export enum DrawDesign { + INTEGRAL_SHAPE = 'integral-shape', + STRAIGHT = 'line', +} +export enum PipelineLayout { + DAGRE = 'dagre', +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/draw-utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/draw-utils.ts new file mode 100644 index 00000000000..7d710426ca0 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/draw-utils.ts @@ -0,0 +1,87 @@ +import { Point } from '@console/topology'; +import { DrawDesign, NODE_SEPARATION_HORIZONTAL } from './const'; + +type SingleDraw = (p: Point) => string; +type DoubleDraw = (p1: Point, p2: Point) => string; +type TripleDraw = (p1: Point, p2: Point, p3: Point) => string; +type DetermineDirection = (p1: Point, p2: Point) => boolean; + +const join = (...segments: string[]) => segments.filter((seg) => !!seg).join(' '); + +const leftRight: DetermineDirection = (p1, p2) => p1.x < p2.x; +const topDown: DetermineDirection = (p1, p2) => p1.y < p2.y; +const bottomUp: DetermineDirection = (p1, p2) => p1.y > p2.y; + +const point: SingleDraw = (p) => `${p.x},${p.y}`; +const moveTo: SingleDraw = (p) => `M ${point(p)}`; +const lineTo: SingleDraw = (p) => `L ${point(p)}`; +const quadTo: DoubleDraw = (corner, end) => `Q ${point(corner)} ${point(end)}`; + +// TODO: Try to simplify +// x should not be greater than (NODE_SEPARATION_HORIZONTAL / 2) +const CURVE_SIZE = { x: 8, y: 10 }; +const curve: TripleDraw = (fromPoint, cornerPoint, toPoint) => { + const topToBottom = topDown(fromPoint, toPoint); + if (topToBottom) { + const rightAndDown = leftRight(fromPoint, cornerPoint) && topDown(cornerPoint, toPoint); + const downAndRight = topDown(fromPoint, cornerPoint) && leftRight(cornerPoint, toPoint); + if (rightAndDown) { + return join( + lineTo(cornerPoint.clone().translate(-CURVE_SIZE.x, 0)), + quadTo(cornerPoint, cornerPoint.clone().translate(0, CURVE_SIZE.y)), + ); + } + if (downAndRight) { + return join( + lineTo(cornerPoint.clone().translate(0, -CURVE_SIZE.y)), + quadTo(cornerPoint, cornerPoint.clone().translate(CURVE_SIZE.x, 0)), + ); + } + } else { + const rightAndUp = leftRight(fromPoint, cornerPoint) && bottomUp(cornerPoint, toPoint); + const upAndRight = bottomUp(fromPoint, cornerPoint) && leftRight(cornerPoint, toPoint); + if (rightAndUp) { + return join( + lineTo(cornerPoint.clone().translate(-CURVE_SIZE.x, 0)), + quadTo(cornerPoint, cornerPoint.clone().translate(0, -CURVE_SIZE.y)), + ); + } + if (upAndRight) { + return join( + lineTo(cornerPoint.clone().translate(0, CURVE_SIZE.y)), + quadTo(cornerPoint, cornerPoint.clone().translate(CURVE_SIZE.x, 0)), + ); + } + } + + return ''; +}; + +export const straightPath: DoubleDraw = (start, finish) => join(moveTo(start), lineTo(finish)); + +export const integralShapePath: DoubleDraw = (start, finish) => { + // Integral shape: ∫ + let firstCurve: string = null; + let secondCurve: string = null; + + if (start.y !== finish.y) { + const cornerX = start.x + Math.floor(NODE_SEPARATION_HORIZONTAL / 2); + const firstCorner = new Point(cornerX, start.y); + const secondCorner = new Point(cornerX, finish.y); + + firstCurve = curve(start, firstCorner, secondCorner); + secondCurve = curve(firstCorner, secondCorner, finish); + } + + return join(moveTo(start), firstCurve, secondCurve, lineTo(finish)); +}; + +export const path = (start: Point, finish: Point, drawDesign?: DrawDesign) => { + switch (drawDesign) { + case DrawDesign.INTEGRAL_SHAPE: + return integralShapePath(start, finish); + case DrawDesign.STRAIGHT: + default: + return straightPath(start, finish); + } +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts new file mode 100644 index 00000000000..ddb45fb1082 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts @@ -0,0 +1,60 @@ +import { + ComponentFactory, + DagreLayout, + GraphComponent, + LayoutFactory, + ModelKind, + Graph, +} from '@console/topology'; +import { LayoutCallback } from '@console/topology/src/layouts/DagreLayout'; +import { + NODE_SEPARATION_HORIZONTAL, + NodeType, + PipelineLayout, + NODE_SEPARATION_VERTICAL, +} from './const'; +import SpacerNode from './SpacerNode'; +import TaskNode from './TaskNode'; +import TaskEdge from './TaskEdge'; + +export const componentFactory: ComponentFactory = (kind: ModelKind, type: string) => { + switch (kind) { + case ModelKind.graph: + return GraphComponent; + case ModelKind.edge: + return TaskEdge; + case ModelKind.node: + switch (type) { + case NodeType.TASK_NODE: + return TaskNode; + case NodeType.SPACER_NODE: + return SpacerNode; + default: + return undefined; + } + default: + return undefined; + } +}; + +// TODO: Fix this hack as it's not the best way to get the layout update +type CallbackLayout = (onLayout: LayoutCallback) => LayoutFactory; +export const layoutFactory: CallbackLayout = (onLayout) => (type: string, graph: Graph) => { + switch (type) { + case PipelineLayout.DAGRE: + return new DagreLayout( + graph, + { + nodesep: NODE_SEPARATION_VERTICAL, + ranksep: NODE_SEPARATION_HORIZONTAL, + edgesep: 0, + ranker: 'longest-path', + rankdir: 'LR', + align: 'UL', + }, + { onLayout }, + ); + default: + return undefined; + } +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts new file mode 100644 index 00000000000..1282aa6f499 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts @@ -0,0 +1,19 @@ +import { EdgeModel, NodeModel } from '@console/topology'; +import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; +import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; +import { NodeType } from './const'; + +export type PipelineEdgeModel = EdgeModel; + +export type PipelineNodeModelData = { + task: PipelineVisualizationTaskItem; + pipeline?: Pipeline; + pipelineRun?: PipelineRun; +}; + +export type PipelineNodeModel = NodeModel & { + data: PipelineNodeModelData; +}; + +export type NodeCreator = (name: string, data: PipelineNodeModelData) => PipelineNodeModel; +export type NodeCreatorSetup = (type: NodeType, width?: number) => NodeCreator; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts new file mode 100644 index 00000000000..96c77bafc56 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts @@ -0,0 +1,157 @@ +import * as _ from 'lodash'; +import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; +import { getPipelineTasks, PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; +import { NODE_HEIGHT, NodeType, NODE_WIDTH } from './const'; +import { PipelineEdgeModel, PipelineNodeModel, NodeCreator, NodeCreatorSetup } from './types'; + +const createGenericNode: NodeCreatorSetup = (type, width?) => (name, data) => ({ + id: name, + data, + height: NODE_HEIGHT, + width: width != null ? width : NODE_WIDTH, + type, +}); + +// Node variations +export const createTaskNode: NodeCreator = createGenericNode(NodeType.TASK_NODE); +export const createSpacerNode: NodeCreator = createGenericNode(NodeType.SPACER_NODE, 0); + +export const handleParallelToParallelNodes = (nodes: PipelineNodeModel[]): PipelineNodeModel[] => { + type ParallelNodeReference = { + node: PipelineNodeModel; + runAfter: string[]; + atIndex: number; + }; + type ParallelNodeMap = { + [id: string]: ParallelNodeReference[]; + }; + + // Collect only multiple run-afters + const multipleRunBeforeMap: ParallelNodeMap = nodes.reduce((acc, node, idx) => { + const { + data: { + task: { runAfter }, + }, + } = node; + if (runAfter && runAfter.length > 1) { + const id: string = [...runAfter] + .sort((a, b) => a.localeCompare(b)) + .reduce((str, ref) => `${str}|${ref}`); + + if (!Array.isArray(acc[id])) { + acc[id] = []; + } + acc[id].push({ + node, + runAfter, + atIndex: idx, + }); + } + return acc; + }, {} as ParallelNodeMap); + + // Trim out single occurrences + const multiParallelToParallelList: ParallelNodeReference[][] = Object.values( + multipleRunBeforeMap, + ).filter((data: ParallelNodeReference[]) => data.length > 1); + + if (multiParallelToParallelList.length === 0) { + // No parallel to parallel + return nodes; + } + + // Insert a spacer node between the multiple nodes on the sides of a parallel-to-parallel + const newNodes: PipelineNodeModel[] = []; + multiParallelToParallelList.forEach((p2p: ParallelNodeReference[]) => { + // All nodes in each array share their runAfters + const { runAfter } = p2p[0]; + + const names: string[] = p2p.map((p2pData) => p2pData.node.id); + const parallelSpacerName = `parallel-${names.join('-')}`; + + newNodes.push( + createSpacerNode(parallelSpacerName, { + task: { + name: parallelSpacerName, + runAfter, + // TODO: Find a way to abstract this away from calls; it's a valid part of PipelineTasks, just not spacerNodes + taskRef: { name: '' }, + }, + }), + ); + + // Update all impacted nodes to point at the spacer node as the spacer points at their original runAfters + nodes.forEach((node) => { + if (names.includes(node.id)) { + const { + data: { task }, + } = node; + + // Recreate the node with the new runAfter pointing to the spacer node + newNodes.push( + createTaskNode(node.id, { + ...node.data, + task: { + ...task, + runAfter: [parallelSpacerName], + }, + }), + ); + } else { + // Unaffected node, just carry it over + newNodes.push(node); + } + }); + }); + + return newNodes; +}; + +const tasksToNodes = ( + taskList: PipelineVisualizationTaskItem[], + pipeline?: Pipeline, + pipelineRun?: PipelineRun, +): PipelineNodeModel[] => { + const nodeList: PipelineNodeModel[] = taskList.map((task) => + createTaskNode(task.name, { + task, + pipeline, + pipelineRun, + }), + ); + + return handleParallelToParallelNodes(nodeList); +}; + +export const getEdgesFromNodes = (nodes: PipelineNodeModel[]): PipelineEdgeModel[] => + _.flatten( + nodes.map((node) => { + const { + data: { + task: { name, runAfter = [] }, + }, + } = node; + + if (runAfter.length === 0) return null; + + return runAfter.map((beforeName) => ({ + id: `${name}-to-${beforeName}`, + type: 'edge', + source: beforeName, + target: name, + })); + }), + ).filter((edgeList) => !!edgeList); + +export const getTopologyNodesEdges = ( + pipeline: Pipeline, + pipelineRun?: PipelineRun, +): { nodes: PipelineNodeModel[]; edges: PipelineEdgeModel[] } => { + const taskList: PipelineVisualizationTaskItem[] = _.flatten( + getPipelineTasks(pipeline, pipelineRun), + ); + const nodes: PipelineNodeModel[] = tasksToNodes(taskList, pipeline, pipelineRun); + const edges: PipelineEdgeModel[] = getEdgesFromNodes(nodes); + + return { nodes, edges }; +}; diff --git a/frontend/packages/topology/src/layouts/DagreLayout.ts b/frontend/packages/topology/src/layouts/DagreLayout.ts index 3d0751968ad..e6e52a47bfd 100644 --- a/frontend/packages/topology/src/layouts/DagreLayout.ts +++ b/frontend/packages/topology/src/layouts/DagreLayout.ts @@ -7,8 +7,15 @@ import { leafNodeElements } from '../utils/element-utils'; class DagreNode { private node: Node; + public width: number; + + public height: number; + constructor(node: Node) { this.node = node; + + this.width = this.node.getBounds().width; + this.height = this.node.getBounds().height; } getId(): string { @@ -69,13 +76,23 @@ class DagreEdge { } } +export type LayoutCallback = (nodes: Node[], edges: Edge[]) => void; +type AdditionalDagreLayoutOptions = { onLayout: LayoutCallback }; export default class DagreLayout implements Layout { - // eslint-disable-next-line @typescript-eslint/ban-ts-ignore - // @ts-ignore - private graph: Graph; // Usage is TBD + private graph: Graph; - constructor(graph: Graph) { + private readonly options: dagre.GraphLabel; + + private readonly onLayout: LayoutCallback | undefined; + + constructor( + graph: Graph, + options?: dagre.GraphLabel, + additionalOptions?: AdditionalDagreLayoutOptions, + ) { this.graph = graph; + this.options = options || {}; + this.onLayout = additionalOptions?.onLayout; } destroy(): void {} @@ -95,6 +112,7 @@ export default class DagreLayout implements Layout { marginy: 0, nodesep: 20, ranker: 'tight-tree', + ...this.options, }); _.forEach(nodes, (node) => { @@ -115,5 +133,7 @@ export default class DagreLayout implements Layout { ); } }); + + this.onLayout && this.onLayout(this.graph.getNodes(), this.graph.getEdges()); }; } From b59a4707eabbb023d95f53368bc8c5862353bb0e Mon Sep 17 00:00:00 2001 From: Andrew Ballantyne Date: Sun, 19 Jan 2020 13:41:06 -0500 Subject: [PATCH 2/4] Pipeline Builder Visualization --- .../pipelines/PipelinesResourceList.tsx | 2 +- .../PipelineVisualization.tsx | 2 + .../PipelineVisualizationTask.tsx | 14 +- .../pipeline-builder/PipelineBuilder.tsx | 40 ++++ .../PipelineBuilderVisualization.tsx | 55 +++++ .../pipelines/pipeline-builder/hooks.ts | 213 ++++++++++++++++++ .../pipelines/pipeline-builder/utils.ts | 17 ++ .../pipeline-topology/BuilderNode.scss | 5 + .../pipeline-topology/BuilderNode.tsx | 55 +++++ .../PipelineTopologyGraph.tsx | 23 +- .../PipelineVisualizationSurface.tsx | 19 +- .../pipeline-topology/TaskListNode.scss | 12 + .../pipeline-topology/TaskListNode.tsx | 84 +++++++ .../pipelines/pipeline-topology/TaskNode.tsx | 7 +- .../pipelines/pipeline-topology/const.ts | 32 ++- .../pipelines/pipeline-topology/factories.ts | 30 +-- .../pipelines/pipeline-topology/types.ts | 48 +++- .../pipelines/pipeline-topology/utils.ts | 164 ++++++++++---- frontend/packages/dev-console/src/plugin.tsx | 14 ++ 19 files changed, 747 insertions(+), 89 deletions(-) create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx index e696c0e240b..9ce3a6613b8 100644 --- a/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/PipelinesResourceList.tsx @@ -32,7 +32,7 @@ const PipelinesResourceList: React.FC = (props) => { createProps={{ to: `/k8s/${namespace ? `ns/${namespace}` : 'cluster'}/${referenceForModel( PipelineModel, - )}/~new`, + )}/~new/builder`, }} filterLabel="by name" textFilter="name" diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx index 35324649f79..ab2435021f3 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx @@ -3,6 +3,7 @@ import { Alert } from '@patternfly/react-core'; import { Pipeline, PipelineRun } from '../../../../utils/pipeline-augment'; import PipelineTopologyGraph from '../../pipeline-topology/PipelineTopologyGraph'; import { getTopologyNodesEdges } from '../../pipeline-topology/utils'; +import { PipelineLayout } from '../../pipeline-topology/const'; interface PipelineTopologyVisualizationProps { pipeline: Pipeline; @@ -26,6 +27,7 @@ const PipelineVisualization: React.FC = ({ id={pipelineRun?.metadata?.name || pipeline.metadata.name} nodes={nodes} edges={edges} + layout={PipelineLayout.DAGRE_VIEWER} /> ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx index ab84edbdd8b..2ca0aa2ef86 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualizationTask.tsx @@ -15,7 +15,7 @@ import { createStepStatus, StepStatus, TaskStatus } from './pipeline-step-utils' import './PipelineVisualizationTask.scss'; interface TaskProps { - pipelineRun?: string; + pipelineRunName?: string; name: string; loaded?: boolean; task?: { @@ -27,7 +27,7 @@ interface TaskProps { } interface PipelineVisualizationTaskProp { - pipelineRun?: string; + pipelineRunName?: string; namespace: string; task: { name?: string; @@ -42,7 +42,7 @@ interface PipelineVisualizationTaskProp { } export const PipelineVisualizationTask: React.FC = ({ - pipelineRun, + pipelineRunName, task, namespace, pipelineRunStatus, @@ -82,7 +82,7 @@ export const PipelineVisualizationTask: React.FC return ( ); }; const TaskComponent: React.FC = ({ - pipelineRun, + pipelineRunName, namespace, task, status, @@ -104,8 +104,8 @@ const TaskComponent: React.FC = ({ const stepStatusList: StepStatus[] = stepList.map((step) => createStepStatus(step, status)); const showStatusState: boolean = isPipelineRun && !!status && !!status.reason; const visualName = name || _.get(task, ['metadata', 'name'], ''); - const path = pipelineRun - ? `${resourcePathFromModel(PipelineRunModel, pipelineRun, namespace)}/logs/${name}` + const path = pipelineRunName + ? `${resourcePathFromModel(PipelineRunModel, pipelineRunName, namespace)}/logs/${name}` : undefined; const visTask = ( <> diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx new file mode 100644 index 00000000000..52203f38013 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { RouteComponentProps } from 'react-router'; +import PipelineBuilderVisualization from './PipelineBuilderVisualization'; +import { Pipeline, PipelineTask } from '../../../utils/pipeline-augment'; +import { PipelineModel } from '../../../models'; + +const PIPELINE_SKELETON: Pipeline = { + apiVersion: PipelineModel.apiVersion, + kind: PipelineModel.kind, + metadata: { + name: 'new-pipeline', + }, + spec: { + tasks: [], + }, +}; + +type PipelineBuilderProps = RouteComponentProps<{ ns: string }>; + +const PipelineBuilder: React.FC = ({ match }) => { + const { + params: { ns: namespace }, + } = match; + const [pipeline, setPipeline] = React.useState(PIPELINE_SKELETON); + + return ( +
    +

    Pipeline Builder - In Progress

    + + setPipeline({ ...pipeline, spec: { ...pipeline.spec, tasks: [...updatedTasks] } }) + } + pipelineTasks={pipeline.spec.tasks} + /> +
    + ); +}; + +export default PipelineBuilder; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx new file mode 100644 index 00000000000..2a18f6ce001 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Alert } from '@patternfly/react-core'; +import PipelineTopologyGraph from '../pipeline-topology/PipelineTopologyGraph'; +import { PipelineTask } from '../../../utils/pipeline-augment'; +import { getEdgesFromNodes } from '../pipeline-topology/utils'; +import { LoadingBox } from '@console/internal/components/utils'; +import { useNodes } from './hooks'; +import { PipelineLayout } from '../pipeline-topology/const'; + +type PipelineBuilderVisualizationProps = { + namespace: string; + onUpdateTasks: (updatedTasks: PipelineTask[]) => void; + pipelineTasks: PipelineTask[]; +}; + +const PipelineBuilderVisualization: React.FC = ({ + namespace, + onUpdateTasks, + pipelineTasks, +}) => { + const { tasksLoaded, tasksCount, nodes, loadingTasksError } = useNodes( + namespace, + onUpdateTasks, + pipelineTasks, + ); + + if (loadingTasksError) { + return ( + + {loadingTasksError} + + ); + } + if (!tasksLoaded) { + return ; + } + if (tasksCount === 0) { + // No tasks, nothing we can do here... + return ; + } + + return ( + n.id).join('-')} + id="pipeline-builder" + fluid + nodes={nodes} + edges={getEdgesFromNodes(nodes)} + layout={PipelineLayout.DAGRE_BUILDER} + /> + ); +}; + +export default PipelineBuilderVisualization; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts new file mode 100644 index 00000000000..549b944f8e8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts @@ -0,0 +1,213 @@ +import * as React from 'react'; +import { k8sList, K8sResourceKind } from '@console/internal/module/k8s'; +import { ClusterTaskModel, TaskModel } from '../../../models'; +import { PipelineTask } from '../../../utils/pipeline-augment'; +import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; +import { PipelineMixedNodeModel, PipelineTaskListNode } from '../pipeline-topology/types'; +import { + createTaskListNode, + handleParallelToParallelNodes, + tasksToBuilderNodes, +} from '../pipeline-topology/utils'; +import { AddNodeDirection } from '../pipeline-topology/const'; +import { convertResourceToTask } from './utils'; + +type UseTasks = { + namespacedTasks: K8sResourceKind[] | null; + clusterTasks: K8sResourceKind[] | null; + errorMsg?: string; +}; +export const useTasks = (namespace?: string, taskNames?: string[]): UseTasks => { + const [namespacedTasks, setNamespacedTasks] = React.useState(null); + const [clusterTasks, setClusterTasks] = React.useState(null); + const [loadErrorMsg, setLoadErrorMsg] = React.useState(undefined); + + React.useEffect(() => { + if (!namespacedTasks) { + if (!namespace) { + setNamespacedTasks([]); + } else { + k8sList(TaskModel, { ns: namespace }) + .then((res: K8sResourceKind[]) => { + setNamespacedTasks(res); + }) + .catch(() => { + setLoadErrorMsg(`Failed to load namespace Tasks. ${loadErrorMsg || ''}`); + }); + } + } + + if (!clusterTasks) { + k8sList(ClusterTaskModel) + .then((res: K8sResourceKind[]) => { + setClusterTasks(res); + }) + .catch(() => { + setLoadErrorMsg(`Failed to load ClusterTasks. ${loadErrorMsg || ''}`); + }); + } + }, [ + namespace, + namespacedTasks, + setNamespacedTasks, + clusterTasks, + setClusterTasks, + setLoadErrorMsg, + loadErrorMsg, + ]); + + const includedValues = (resource: K8sResourceKind) => + !taskNames?.includes(resource.metadata.name); + + return { + namespacedTasks: namespacedTasks?.filter(includedValues), + clusterTasks: clusterTasks?.filter(includedValues), + errorMsg: loadErrorMsg, + }; +}; + +type UseNodes = { + nodes: PipelineMixedNodeModel[]; + tasksCount: number; + tasksLoaded: boolean; + loadingTasksError?: string; +}; +export const useNodes = ( + namespace: string, + onUpdateTasks: (v: PipelineTask[]) => void, + pipelineTasks: PipelineTask[], +): UseNodes => { + const { clusterTasks, namespacedTasks, errorMsg } = useTasks( + namespace, + pipelineTasks.map((plTask) => plTask.name), + ); + const [listNodes, setListNodes] = React.useState([]); + + const pipelineTaskList = React.useRef(pipelineTasks); + pipelineTaskList.current = pipelineTasks; + + const newListNode = (name: string, runAfter?: string[]): PipelineTaskListNode => + createTaskListNode(name, { + namespaceTaskList: namespacedTasks, + clusterTaskList: clusterTasks, + onNewTask: (resource: K8sResourceKind) => { + onUpdateTasks([ + ...pipelineTaskList.current.map((task) => { + if (!task.runAfter?.includes(name)) { + return task; + } + return { + ...task, + runAfter: task.runAfter.map((taskName) => { + if (taskName === name) { + return resource.metadata.name; + } + return taskName; + }), + }; + }), + convertResourceToTask(resource, runAfter), + ]); + setListNodes(listNodes.filter((n) => n.id !== name)); + }, + task: { + name, + runAfter: runAfter || [], + }, + }); + + const onNewListNode = (task: PipelineVisualizationTaskItem, direction: AddNodeDirection) => { + let newNode: PipelineTaskListNode; + switch (direction) { + case AddNodeDirection.AFTER: { + const taskName = 'new-after-node'; + + onUpdateTasks( + pipelineTaskList.current.map((pipelineTask) => { + if (!pipelineTask?.runAfter?.includes(task.name)) { + return pipelineTask; + } + + const remainingRunAfters = (pipelineTask.runAfter || []).filter( + (runAfterName) => runAfterName !== task.name, + ); + + return { + ...pipelineTask, + runAfter: [...remainingRunAfters, taskName], + }; + }), + ); + newNode = newListNode(taskName, [task.name]); + break; + } + case AddNodeDirection.BEFORE: { + const taskName = 'new-before-node'; + const pipelineMap: { [name: string]: PipelineTask } = pipelineTaskList.current.reduce( + (map, pipelineTask) => ({ ...map, [pipelineTask.name]: pipelineTask }), + {}, + ); + + const runAfterItem = pipelineMap[task.name]; + const existingRunAfters = runAfterItem.runAfter || []; + pipelineMap[task.name] = { + ...pipelineMap[task.name], + runAfter: [taskName], + }; + + newNode = newListNode(taskName, existingRunAfters); + onUpdateTasks(Object.values(pipelineMap)); + break; + } + case AddNodeDirection.PARALLEL: { + const taskName = 'new-parallel-node'; + const pipelineMap: { [name: string]: PipelineTask } = pipelineTaskList.current.reduce( + (map, pipelineTask) => ({ ...map, [pipelineTask.name]: pipelineTask }), + {}, + ); + + const runParallelItem = pipelineMap[task.name]; + const myRunAfters: string[] = runParallelItem.runAfter || []; + onUpdateTasks( + pipelineTaskList.current.map((pipelineTask) => { + const currentRunAfters = pipelineTask?.runAfter || []; + if (!currentRunAfters.includes(runParallelItem.name)) { + return pipelineTask; + } + + return { + ...pipelineTask, + runAfter: [...currentRunAfters, taskName], + }; + }), + ); + + newNode = newListNode(taskName, myRunAfters); + break; + } + default: + throw new Error(`Invalid direction ${direction}`); + } + setListNodes([...listNodes, newNode]); + }; + + const existingNodes: PipelineMixedNodeModel[] = + pipelineTasks.length > 0 + ? tasksToBuilderNodes(pipelineTasks, onNewListNode) + : [newListNode('initial-node')]; + + const nodes: PipelineMixedNodeModel[] = handleParallelToParallelNodes([ + ...existingNodes, + ...listNodes, + ]); + + const localTaskCount = namespacedTasks?.length || 0; + const clusterTaskCount = clusterTasks?.length || 0; + + return { + tasksCount: localTaskCount + clusterTaskCount, + tasksLoaded: !!namespacedTasks && !!clusterTasks, + loadingTasksError: errorMsg, + nodes, + }; +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts new file mode 100644 index 00000000000..e2d0100018d --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts @@ -0,0 +1,17 @@ +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { PipelineTask } from '../../../utils/pipeline-augment'; +import { ClusterTaskModel } from '../../../models'; + +export const convertResourceToTask = ( + resource: K8sResourceKind, + runAfter?: string[], +): PipelineTask => { + return { + name: resource.metadata.name, + runAfter, + taskRef: { + kind: resource.kind === ClusterTaskModel.kind ? ClusterTaskModel.kind : undefined, + name: resource.metadata.name, + }, + }; +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss new file mode 100644 index 00000000000..bdc379ec779 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss @@ -0,0 +1,5 @@ +.odc-builder-node { + &__add-icon { + cursor: pointer; + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx new file mode 100644 index 00000000000..9ccfdd9bec7 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { PlusIcon } from '@patternfly/react-icons'; +import { Node, observer } from '@console/topology'; +import { AddNodeDirection, BUILDER_NODE_ADD_RADIUS } from './const'; +import TaskNode from './TaskNode'; +import { BuilderNodeModelData } from './types'; + +import './BuilderNode.scss'; + +const drawAdd = (x, y, onClick) => { + return ( + + + + + + + ); +}; + +const BuilderNode: React.FC<{ element: Node }> = ({ element }) => { + const [showAdd, setShowAdd] = React.useState(false); + const { width, height } = element.getBounds(); + const { onAddNode } = element.getData() as BuilderNodeModelData; + + return ( + setShowAdd(true)} + onBlur={() => setShowAdd(false)} + onMouseOver={() => setShowAdd(true)} + onMouseOut={() => setShowAdd(false)} + > + + + + {drawAdd(width + BUILDER_NODE_ADD_RADIUS, height / 2, () => + onAddNode(AddNodeDirection.AFTER), + )} + {drawAdd(-BUILDER_NODE_ADD_RADIUS, height / 2, () => onAddNode(AddNodeDirection.BEFORE))} + {drawAdd(width / 2, height + BUILDER_NODE_ADD_RADIUS, () => + onAddNode(AddNodeDirection.PARALLEL), + )} + + + ); +}; + +export default observer(BuilderNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx index cea4f9367f5..9191baabdee 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx @@ -2,25 +2,38 @@ import * as React from 'react'; import { ModelKind } from '@console/topology'; import PipelineVisualizationSurface from './PipelineVisualizationSurface'; import { PipelineLayout } from './const'; -import { PipelineEdgeModel, PipelineNodeModel } from './types'; +import { PipelineEdgeModel, PipelineMixedNodeModel } from './types'; import './PipelineTopologyGraph.scss'; type PipelineTopologyGraphProps = { id: string; - nodes: PipelineNodeModel[]; + fluid?: boolean; + nodes: PipelineMixedNodeModel[]; edges: PipelineEdgeModel[]; + layout: PipelineLayout; }; -const PipelineTopologyGraph: React.FC = ({ id, nodes, edges }) => { +const PipelineTopologyGraph: React.FC = ({ + id, + fluid, + nodes, + edges, + layout, +}) => { + console.debug('updating graph', nodes, edges); + return ( -
    +
    const [vis, setVis] = React.useState(null); const [maxSize, setMaxSize] = React.useState(null); + const layout: PipelineLayout = model.graph.layout as PipelineLayout; + const onLayoutUpdate: LayoutCallback = React.useCallback( (nodes) => { const nodeBounds = nodes.map((node) => node.getBounds()); const maxX = nodeBounds.map((bounds) => bounds.x).reduce((x1, x2) => Math.max(x1, x2), 0); const maxY = nodeBounds.map((bounds) => bounds.y).reduce((y1, y2) => Math.max(y1, y2), 0); + let horizontalMargin = 0; + let verticalMargin = 0; + if (layout) { + horizontalMargin = getLayoutData(layout).marginx || 0; + verticalMargin = getLayoutData(layout).marginy || 0; + } + setMaxSize({ // Nodes are rendered from the top-left - height: maxY + NODE_HEIGHT + DROP_SHADOW_SPACING, - width: maxX + NODE_WIDTH, + height: maxY + NODE_HEIGHT + DROP_SHADOW_SPACING + verticalMargin, + width: maxX + NODE_WIDTH + horizontalMargin, }); }, - [setMaxSize], + [setMaxSize, layout], ); React.useEffect(() => { @@ -36,6 +46,7 @@ const PipelineVisualizationSurface: React.FC setVis(visualization); } else { vis.fromModel(model); + vis.getGraph().layout(); } }, [vis, model, onLayoutUpdate]); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss new file mode 100644 index 00000000000..b621015ee8f --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss @@ -0,0 +1,12 @@ +.odc-task-list-node { + border: 1px solid var(--pf-global--BorderColor--light-100); + + &__trigger-background { + background: var(--pf-global--BackgroundColor--light-100); + } + &__trigger { + padding: var(--pf-global--spacer--xs) var(--pf-global--spacer--md); + width: 100%; + } +} + diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx new file mode 100644 index 00000000000..961da320f92 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import * as FocusTrap from 'focus-trap-react'; +import { Button } from '@patternfly/react-core'; +import { CaretDownIcon } from '@patternfly/react-icons'; +import Popper from '@console/shared/src/components/popper/Popper'; +import { KebabMenuItems, KebabOption } from '@console/internal/components/utils'; +import { K8sResourceKind } from '@console/internal/module/k8s'; +import { observer, Node } from '@console/topology'; +import { NewTaskNodeCallback, TaskListNodeModelData } from './types'; + +import './TaskListNode.scss'; + +const taskToOption = (task: K8sResourceKind, callback: NewTaskNodeCallback): KebabOption => { + const { + metadata: { name }, + } = task; + + return { + label: name, + callback: () => { + callback(task); + }, + }; +}; + +const TaskListNode: React.FC<{ element: Node }> = ({ element }) => { + const triggerRef = React.useRef(null); + const [isMenuOpen, setMenuOpen] = React.useState(false); + const { height, width } = element.getBounds(); + const { + clusterTaskList, + namespaceTaskList, + onNewTask, + } = element.getData() as TaskListNodeModelData; + + const options = [ + ...namespaceTaskList.map((task) => taskToOption(task, onNewTask)), + ...clusterTaskList.map((task) => taskToOption(task, onNewTask)), + ]; + + return ( + +
    + +
    + { + if (!e || !triggerRef?.current?.contains(e.target as Element)) { + setMenuOpen(false); + } + }} + reference={() => triggerRef.current} + > + +
    + { + option.callback && option.callback(); + }} + className="oc-kebab__popper-items" + /> +
    +
    +
    +
    + ); +}; + +export default observer(TaskListNode); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx index 4397bea84d7..ef28e7da3c0 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskNode.tsx @@ -3,18 +3,19 @@ import { observer, Node } from '@console/topology'; import { pipelineRunFilterReducer } from '../../../utils/pipeline-filter-reducer'; import { PipelineVisualizationTask } from '../detail-page-tabs/pipeline-details/PipelineVisualizationTask'; import { DROP_SHADOW_SPACING } from './const'; +import { TaskNodeModelData } from './types'; const TaskNode: React.FC<{ element: Node }> = ({ element }) => { const { height, width } = element.getBounds(); - const { pipeline, pipelineRun, task } = element.getData(); + const { pipeline, pipelineRun, task } = element.getData() as TaskNodeModelData; return ( ); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts index cc7b4516908..4e49eb6c052 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts @@ -1,6 +1,9 @@ +import * as dagre from 'dagre'; + export const NODE_SEPARATION_HORIZONTAL = 25; export const NODE_SEPARATION_VERTICAL = 20; export const DROP_SHADOW_SPACING = 5; +export const BUILDER_NODE_ADD_RADIUS = 10; export const NODE_WIDTH = 120; export const NODE_HEIGHT = 30; @@ -8,11 +11,38 @@ export const NODE_HEIGHT = 30; export enum NodeType { TASK_NODE = 'task', SPACER_NODE = 'spacer', + TASK_LIST_NODE = 'task-list', + BUILDER_NODE = 'builder', } export enum DrawDesign { INTEGRAL_SHAPE = 'integral-shape', STRAIGHT = 'line', } export enum PipelineLayout { - DAGRE = 'dagre', + DAGRE_BUILDER = 'dagre-builder', + DAGRE_VIEWER = 'dagre-viewer', +} + +export enum AddNodeDirection { + BEFORE = 'in-run-after', + AFTER = 'has-run-after', + PARALLEL = 'shared-parallel', } + +const DAGRE_SHARED_PROPS: dagre.GraphLabel = { + nodesep: NODE_SEPARATION_VERTICAL, + ranksep: NODE_SEPARATION_HORIZONTAL, + edgesep: 0, + ranker: 'longest-path', + rankdir: 'LR', + align: 'UL', +}; +export const DAGRE_VIEWER_PROPS: dagre.GraphLabel = { + ...DAGRE_SHARED_PROPS, +}; +export const DAGRE_BUILDER_PROPS: dagre.GraphLabel = { + ...DAGRE_SHARED_PROPS, + ranksep: NODE_SEPARATION_HORIZONTAL + BUILDER_NODE_ADD_RADIUS, + marginx: 30, + marginy: 30, +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts index ddb45fb1082..de84776b1b4 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/factories.ts @@ -7,15 +7,13 @@ import { Graph, } from '@console/topology'; import { LayoutCallback } from '@console/topology/src/layouts/DagreLayout'; -import { - NODE_SEPARATION_HORIZONTAL, - NodeType, - PipelineLayout, - NODE_SEPARATION_VERTICAL, -} from './const'; +import { NodeType, PipelineLayout } from './const'; import SpacerNode from './SpacerNode'; import TaskNode from './TaskNode'; import TaskEdge from './TaskEdge'; +import TaskListNode from './TaskListNode'; +import BuilderNode from './BuilderNode'; +import { getLayoutData } from './utils'; export const componentFactory: ComponentFactory = (kind: ModelKind, type: string) => { switch (kind) { @@ -29,6 +27,10 @@ export const componentFactory: ComponentFactory = (kind: ModelKind, type: string return TaskNode; case NodeType.SPACER_NODE: return SpacerNode; + case NodeType.TASK_LIST_NODE: + return TaskListNode; + case NodeType.BUILDER_NODE: + return BuilderNode; default: return undefined; } @@ -41,19 +43,9 @@ export const componentFactory: ComponentFactory = (kind: ModelKind, type: string type CallbackLayout = (onLayout: LayoutCallback) => LayoutFactory; export const layoutFactory: CallbackLayout = (onLayout) => (type: string, graph: Graph) => { switch (type) { - case PipelineLayout.DAGRE: - return new DagreLayout( - graph, - { - nodesep: NODE_SEPARATION_VERTICAL, - ranksep: NODE_SEPARATION_HORIZONTAL, - edgesep: 0, - ranker: 'longest-path', - rankdir: 'LR', - align: 'UL', - }, - { onLayout }, - ); + case PipelineLayout.DAGRE_BUILDER: + case PipelineLayout.DAGRE_VIEWER: + return new DagreLayout(graph, getLayoutData(type), { onLayout }); default: return undefined; } diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts index 1282aa6f499..d0fe32cdde6 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts @@ -1,19 +1,53 @@ import { EdgeModel, NodeModel } from '@console/topology'; import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; -import { NodeType } from './const'; +import { AddNodeDirection, NodeType } from './const'; +import { K8sResourceKind } from '@console/internal/module/k8s'; -export type PipelineEdgeModel = EdgeModel; +// Builder Callbacks +export type NewTaskListNodeCallback = (direction: AddNodeDirection) => void; +export type NewTaskNodeCallback = (resource: K8sResourceKind) => void; -export type PipelineNodeModelData = { +// Node Data Models +export type PipelineRunAfterNodeModelData = { + task: { + name: string; + runAfter?: string[]; + }; +}; +export type TaskListNodeModelData = PipelineRunAfterNodeModelData & { + clusterTaskList: K8sResourceKind[]; + namespaceTaskList: K8sResourceKind[]; + onNewTask: NewTaskNodeCallback; +}; +export type BuilderNodeModelData = PipelineRunAfterNodeModelData & { + task: PipelineVisualizationTaskItem; + onAddNode: NewTaskListNodeCallback; +}; +export type SpacerNodeModelData = PipelineRunAfterNodeModelData & {}; +export type TaskNodeModelData = PipelineRunAfterNodeModelData & { task: PipelineVisualizationTaskItem; pipeline?: Pipeline; pipelineRun?: PipelineRun; }; -export type PipelineNodeModel = NodeModel & { - data: PipelineNodeModelData; +// Graph Models +type PipelineNodeModel = NodeModel & { + data: D; + type: NodeType; }; +export type PipelineMixedNodeModel = PipelineNodeModel; +export type PipelineTaskNodeModel = PipelineNodeModel; +export type PipelineTaskListNode = PipelineNodeModel; + +export type PipelineEdgeModel = EdgeModel; -export type NodeCreator = (name: string, data: PipelineNodeModelData) => PipelineNodeModel; -export type NodeCreatorSetup = (type: NodeType, width?: number) => NodeCreator; +// Node Creators +export type NodeCreator = ( + name: string, + data: D, +) => PipelineNodeModel; +export type NodeCreatorSetup = ( + type: NodeType, + width?: number, +) => NodeCreator; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts index 96c77bafc56..54fa2109c11 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts @@ -1,8 +1,28 @@ +import * as dagre from 'dagre'; import * as _ from 'lodash'; import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; import { getPipelineTasks, PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; -import { NODE_HEIGHT, NodeType, NODE_WIDTH } from './const'; -import { PipelineEdgeModel, PipelineNodeModel, NodeCreator, NodeCreatorSetup } from './types'; +import { + NODE_HEIGHT, + NodeType, + NODE_WIDTH, + AddNodeDirection, + PipelineLayout, + DAGRE_BUILDER_PROPS, + DAGRE_VIEWER_PROPS, +} from './const'; +import { + PipelineEdgeModel, + NodeCreator, + NodeCreatorSetup, + SpacerNodeModelData, + TaskListNodeModelData, + TaskNodeModelData, + PipelineMixedNodeModel, + PipelineTaskNodeModel, + BuilderNodeModelData, + PipelineRunAfterNodeModelData, +} from './types'; const createGenericNode: NodeCreatorSetup = (type, width?) => (name, data) => ({ id: name, @@ -13,12 +33,37 @@ const createGenericNode: NodeCreatorSetup = (type, width?) => (name, data) => ({ }); // Node variations -export const createTaskNode: NodeCreator = createGenericNode(NodeType.TASK_NODE); -export const createSpacerNode: NodeCreator = createGenericNode(NodeType.SPACER_NODE, 0); +export const createTaskNode: NodeCreator = createGenericNode(NodeType.TASK_NODE); +export const createSpacerNode: NodeCreator = createGenericNode( + NodeType.SPACER_NODE, + 0, +); +export const createTaskListNode: NodeCreator = createGenericNode( + NodeType.TASK_LIST_NODE, +); +export const createBuilderNode: NodeCreator = createGenericNode( + NodeType.BUILDER_NODE, +); + +export const getNodeCreator = (type: NodeType): NodeCreator => { + switch (type) { + case NodeType.TASK_LIST_NODE: + return createTaskListNode; + case NodeType.BUILDER_NODE: + return createBuilderNode; + case NodeType.SPACER_NODE: + return createSpacerNode; + case NodeType.TASK_NODE: + default: + return createTaskNode; + } +}; -export const handleParallelToParallelNodes = (nodes: PipelineNodeModel[]): PipelineNodeModel[] => { +export const handleParallelToParallelNodes = ( + nodes: PipelineMixedNodeModel[], +): PipelineMixedNodeModel[] => { type ParallelNodeReference = { - node: PipelineNodeModel; + node: PipelineTaskNodeModel; runAfter: string[]; atIndex: number; }; @@ -61,7 +106,8 @@ export const handleParallelToParallelNodes = (nodes: PipelineNodeModel[]): Pipel } // Insert a spacer node between the multiple nodes on the sides of a parallel-to-parallel - const newNodes: PipelineNodeModel[] = []; + const newNodes: PipelineMixedNodeModel[] = []; + const newRunAfterNodeMap: { [nodeId: string]: string[] } = {}; multiParallelToParallelList.forEach((p2p: ParallelNodeReference[]) => { // All nodes in each array share their runAfters const { runAfter } = p2p[0]; @@ -69,50 +115,59 @@ export const handleParallelToParallelNodes = (nodes: PipelineNodeModel[]): Pipel const names: string[] = p2p.map((p2pData) => p2pData.node.id); const parallelSpacerName = `parallel-${names.join('-')}`; + names.forEach((p2pNodeId) => { + if (!Array.isArray(newRunAfterNodeMap[p2pNodeId])) { + newRunAfterNodeMap[p2pNodeId] = []; + } + newRunAfterNodeMap[p2pNodeId].push(parallelSpacerName); + }); + newNodes.push( createSpacerNode(parallelSpacerName, { task: { name: parallelSpacerName, runAfter, - // TODO: Find a way to abstract this away from calls; it's a valid part of PipelineTasks, just not spacerNodes - taskRef: { name: '' }, }, }), ); + }); - // Update all impacted nodes to point at the spacer node as the spacer points at their original runAfters - nodes.forEach((node) => { - if (names.includes(node.id)) { - const { - data: { task }, - } = node; - - // Recreate the node with the new runAfter pointing to the spacer node - newNodes.push( - createTaskNode(node.id, { - ...node.data, - task: { - ...task, - runAfter: [parallelSpacerName], - }, - }), - ); - } else { - // Unaffected node, just carry it over - newNodes.push(node); - } - }); + // Update all impacted nodes to point at the spacer node as the spacer points at their original runAfters + nodes.forEach((node) => { + const newRunAfters: string[] | undefined = newRunAfterNodeMap[node.id]; + if (newRunAfters && newRunAfters.length > 0) { + const { + data: { task }, + type, + } = node; + + const createNode: NodeCreator = getNodeCreator(type); + + // Recreate the node with the new runAfter pointing to the spacer node + newNodes.push( + createNode(node.id, { + ...node.data, + task: { + ...task, + runAfter: newRunAfters, + }, + }), + ); + } else { + // Unaffected node, just carry it over + newNodes.push(node); + } }); return newNodes; }; -const tasksToNodes = ( +export const tasksToNodes = ( taskList: PipelineVisualizationTaskItem[], pipeline?: Pipeline, pipelineRun?: PipelineRun, -): PipelineNodeModel[] => { - const nodeList: PipelineNodeModel[] = taskList.map((task) => +): PipelineMixedNodeModel[] => { + const nodeList: PipelineTaskNodeModel[] = taskList.map((task) => createTaskNode(task.name, { task, pipeline, @@ -123,22 +178,36 @@ const tasksToNodes = ( return handleParallelToParallelNodes(nodeList); }; -export const getEdgesFromNodes = (nodes: PipelineNodeModel[]): PipelineEdgeModel[] => +export const tasksToBuilderNodes = ( + taskList: PipelineVisualizationTaskItem[], + onAddNode: (task: PipelineVisualizationTaskItem, direction: AddNodeDirection) => void, +): PipelineMixedNodeModel[] => { + return taskList.map((task) => { + return createBuilderNode(task.name, { + task, + onAddNode: (direction: AddNodeDirection) => { + onAddNode(task, direction); + }, + }); + }); +}; + +export const getEdgesFromNodes = (nodes: PipelineMixedNodeModel[]): PipelineEdgeModel[] => _.flatten( nodes.map((node) => { const { data: { - task: { name, runAfter = [] }, + task: { name: target, runAfter = [] }, }, } = node; if (runAfter.length === 0) return null; - return runAfter.map((beforeName) => ({ - id: `${name}-to-${beforeName}`, + return runAfter.map((source) => ({ + id: `${source}~to~${target}`, type: 'edge', - source: beforeName, - target: name, + source, + target, })); }), ).filter((edgeList) => !!edgeList); @@ -146,12 +215,23 @@ export const getEdgesFromNodes = (nodes: PipelineNodeModel[]): PipelineEdgeModel export const getTopologyNodesEdges = ( pipeline: Pipeline, pipelineRun?: PipelineRun, -): { nodes: PipelineNodeModel[]; edges: PipelineEdgeModel[] } => { +): { nodes: PipelineMixedNodeModel[]; edges: PipelineEdgeModel[] } => { const taskList: PipelineVisualizationTaskItem[] = _.flatten( getPipelineTasks(pipeline, pipelineRun), ); - const nodes: PipelineNodeModel[] = tasksToNodes(taskList, pipeline, pipelineRun); + const nodes: PipelineMixedNodeModel[] = tasksToNodes(taskList, pipeline, pipelineRun); const edges: PipelineEdgeModel[] = getEdgesFromNodes(nodes); return { nodes, edges }; }; + +export const getLayoutData = (layout: PipelineLayout): dagre.GraphLabel => { + switch (layout) { + case PipelineLayout.DAGRE_BUILDER: + return DAGRE_BUILDER_PROPS; + case PipelineLayout.DAGRE_VIEWER: + return DAGRE_VIEWER_PROPS; + default: + return null; + } +}; diff --git a/frontend/packages/dev-console/src/plugin.tsx b/frontend/packages/dev-console/src/plugin.tsx index caea61b3fd7..c67cd53cb9b 100644 --- a/frontend/packages/dev-console/src/plugin.tsx +++ b/frontend/packages/dev-console/src/plugin.tsx @@ -465,6 +465,20 @@ const plugin: Plugin = [ ).PipelinesPage, }, }, + { + type: 'Page/Route', + properties: { + perspective: 'dev', + exact: true, + path: [`/k8s/ns/:ns/${referenceForModel(PipelineModel)}/~new/builder`], + loader: async () => + ( + await import( + './components/pipelines/pipeline-builder/PipelineBuilder' /* webpackChunkName: "pipeline-builder" */ + ) + ).default, + }, + }, { type: 'Page/Route', properties: { From 25ead209d98adc42e220dcaa44242862df5342c8 Mon Sep 17 00:00:00 2001 From: Andrew Ballantyne Date: Tue, 21 Jan 2020 17:07:38 -0500 Subject: [PATCH 3/4] Pipeline Builder Page --- .../pipelines/PipelineDetailsPage.tsx | 8 +- .../detail-page-tabs/PipelineParameters.tsx | 98 +++++++------------ .../PipelineParametersForm.tsx | 44 +++++++++ .../detail-page-tabs/PipelineResources.tsx | 81 +++++---------- .../PipelineResourcesForm.tsx | 44 +++++++++ .../pipelines/detail-page-tabs/index.ts | 2 + .../PipelineVisualization.tsx | 14 +-- .../pipeline-builder/PipelineBuilder.tsx | 40 -------- .../pipeline-builder/PipelineBuilderForm.scss | 5 + .../pipeline-builder/PipelineBuilderForm.tsx | 74 ++++++++++++++ .../pipeline-builder/PipelineBuilderPage.tsx | 69 +++++++++++++ .../pipelines/pipeline-builder/types.ts | 11 +++ .../pipelines/pipeline-builder/utils.ts | 36 ++++++- .../pipeline-builder/validation-utils.ts | 28 ++++++ .../PipelineTopologyGraph.scss | 1 - .../PipelineTopologyGraph.tsx | 2 - frontend/packages/dev-console/src/plugin.tsx | 2 +- .../dev-console/src/utils/pipeline-augment.ts | 1 - 18 files changed, 386 insertions(+), 174 deletions(-) create mode 100644 frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParametersForm.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResourcesForm.tsx delete mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts diff --git a/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx b/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx index a95bd2a9bb1..f01cc079075 100644 --- a/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/PipelineDetailsPage.tsx @@ -12,8 +12,8 @@ import { getLatestRun } from '../../utils/pipeline-augment'; import { PipelineRunModel, PipelineModel } from '../../models'; import { PipelineDetails, - PipelineParameters, - PipelineResources, + PipelineParametersForm, + PipelineResourcesForm, PipelineRuns, } from './detail-page-tabs'; import PipelineForm from './pipeline-form/PipelineForm'; @@ -76,7 +76,7 @@ class PipelineDetailsPage extends React.Component ( @@ -87,7 +87,7 @@ class PipelineDetailsPage extends React.Component ( diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx index c532a2144ff..397b18db5a4 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParameters.tsx @@ -1,67 +1,43 @@ import * as React from 'react'; -import * as _ from 'lodash'; -import { Form, TextInputTypes } from '@patternfly/react-core'; -import { FormikProps, FormikValues } from 'formik'; -import { useAccessReview } from '@console/internal/components/utils'; -import { getActiveNamespace } from '@console/internal/actions/ui'; -import { MultiColumnField, InputField, FormFooter } from '@console/shared'; +import { TextInputTypes } from '@patternfly/react-core'; +import { MultiColumnField, InputField } from '@console/shared'; + +type PipelineParametersProps = { + addLabel?: string; + fieldName: string; + isReadOnly?: boolean; +}; + +const PipelineParameters: React.FC = (props) => { + const { addLabel = 'Add Pipeline Params', fieldName, isReadOnly = false } = props; -const PipelineParameters: React.FC> = ({ - handleSubmit, - handleReset, - isSubmitting, - status, - errors, - dirty, -}) => { - const pipelineParameterAccess = useAccessReview({ - group: 'tekton.dev', - resource: 'pipelines', - namespace: getActiveNamespace(), - verb: 'update', - }); return ( -
    -
    - - - - - -
    - {pipelineParameterAccess && ( - - )} -
    -
    + + + + + ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParametersForm.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParametersForm.tsx new file mode 100644 index 00000000000..d7158a2c348 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineParametersForm.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { Form } from '@patternfly/react-core'; +import { FormikProps, FormikValues } from 'formik'; +import { useAccessReview } from '@console/internal/components/utils'; +import { getActiveNamespace } from '@console/internal/actions/ui'; +import { FormFooter } from '@console/shared'; +import PipelineParameters from './PipelineParameters'; + +const PipelineParametersForm: React.FC> = ({ + handleSubmit, + handleReset, + isSubmitting, + status, + errors, + dirty, +}) => { + const pipelineParameterAccess = useAccessReview({ + group: 'tekton.dev', + resource: 'pipelines', + namespace: getActiveNamespace(), + verb: 'update', + }); + return ( +
    +
    + +
    + {pipelineParameterAccess && ( + + )} +
    +
    + ); +}; + +export default PipelineParametersForm; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx index 8deabdef94f..a2ab3505d6c 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx @@ -1,10 +1,6 @@ import * as React from 'react'; -import * as _ from 'lodash'; -import { Form, TextInputTypes } from '@patternfly/react-core'; -import { FormikProps, FormikValues } from 'formik'; -import { useAccessReview } from '@console/internal/components/utils'; -import { getActiveNamespace } from '@console/internal/actions/ui'; -import { MultiColumnField, InputField, DropdownField, FormFooter } from '@console/shared'; +import { TextInputTypes } from '@patternfly/react-core'; +import { MultiColumnField, InputField, DropdownField } from '@console/shared'; enum resourceTypes { '' = 'Select resource type', @@ -14,56 +10,31 @@ enum resourceTypes { storage = 'Storage', } -const PipelineResources: React.FC> = ({ - handleSubmit, - handleReset, - isSubmitting, - status, - errors, - dirty, -}) => { - const pipelineResourceAccess = useAccessReview({ - group: 'tekton.dev', - resource: 'pipelines', - namespace: getActiveNamespace(), - verb: 'update', - }); +type PipelineResourcesParam = { + addLabel?: string; + fieldName: string; + isReadOnly?: boolean; +}; + +const PipelineResources: React.FC = (props) => { + const { addLabel = 'Add Pipeline Resources', fieldName, isReadOnly = false } = props; + return ( -
    -
    - - - - -
    - {pipelineResourceAccess && ( - - )} -
    -
    + + + + ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResourcesForm.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResourcesForm.tsx new file mode 100644 index 00000000000..fffb4010280 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResourcesForm.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { Form } from '@patternfly/react-core'; +import { FormikProps, FormikValues } from 'formik'; +import { useAccessReview } from '@console/internal/components/utils'; +import { getActiveNamespace } from '@console/internal/actions/ui'; +import { FormFooter } from '@console/shared'; +import PipelineResources from './PipelineResources'; + +const PipelineResourcesForm: React.FC> = ({ + handleSubmit, + handleReset, + isSubmitting, + status, + errors, + dirty, +}) => { + const pipelineResourceAccess = useAccessReview({ + group: 'tekton.dev', + resource: 'pipelines', + namespace: getActiveNamespace(), + verb: 'update', + }); + return ( +
    +
    + +
    + {pipelineResourceAccess && ( + + )} +
    +
    + ); +}; + +export default PipelineResourcesForm; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts index 90664a10c77..6c027dfafbd 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/index.ts @@ -1,4 +1,6 @@ export { default as PipelineDetails } from './pipeline-details/PipelineDetails'; export { default as PipelineParameters } from './PipelineParameters'; +export { default as PipelineParametersForm } from './PipelineParametersForm'; export { default as PipelineResources } from './PipelineResources'; +export { default as PipelineResourcesForm } from './PipelineResourcesForm'; export { default as PipelineRuns } from './PipelineRuns'; diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx index ab2435021f3..a4a5a1295f8 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/pipeline-details/PipelineVisualization.tsx @@ -23,12 +23,14 @@ const PipelineVisualization: React.FC = ({ } return ( - +
    + +
    ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx deleted file mode 100644 index 52203f38013..00000000000 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilder.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import * as React from 'react'; -import { RouteComponentProps } from 'react-router'; -import PipelineBuilderVisualization from './PipelineBuilderVisualization'; -import { Pipeline, PipelineTask } from '../../../utils/pipeline-augment'; -import { PipelineModel } from '../../../models'; - -const PIPELINE_SKELETON: Pipeline = { - apiVersion: PipelineModel.apiVersion, - kind: PipelineModel.kind, - metadata: { - name: 'new-pipeline', - }, - spec: { - tasks: [], - }, -}; - -type PipelineBuilderProps = RouteComponentProps<{ ns: string }>; - -const PipelineBuilder: React.FC = ({ match }) => { - const { - params: { ns: namespace }, - } = match; - const [pipeline, setPipeline] = React.useState(PIPELINE_SKELETON); - - return ( -
    -

    Pipeline Builder - In Progress

    - - setPipeline({ ...pipeline, spec: { ...pipeline.spec, tasks: [...updatedTasks] } }) - } - pipelineTasks={pipeline.spec.tasks} - /> -
    - ); -}; - -export default PipelineBuilder; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss new file mode 100644 index 00000000000..37db5b57dee --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss @@ -0,0 +1,5 @@ +.odc-pipeline-builder-form { + &__short-section { + max-width: 400px; + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx new file mode 100644 index 00000000000..7dabda6bce7 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { FormikProps, FormikValues } from 'formik'; +import { Form, ActionGroup, Button, ButtonVariant, TextInputTypes } from '@patternfly/react-core'; +import { ButtonBar } from '@console/internal/components/utils'; +import { InputField } from '@console/shared'; +import { PipelineTask } from '../../../utils/pipeline-augment'; +import { PipelineParameters, PipelineResources } from '../detail-page-tabs'; +import PipelineBuilderVisualization from './PipelineBuilderVisualization'; + +import './PipelineBuilderForm.scss'; + +type PipelineBuilderFormProps = FormikProps & { + namespace: string; +}; + +const PipelineBuilderForm: React.FC = (props) => { + const { + status, + isSubmitting, + dirty, + handleReset, + handleSubmit, + errors, + namespace, + setFieldValue, + values, + } = props; + + return ( +
    +
    + +
    + +
    +

    Tasks

    + setFieldValue('tasks', updatedTasks)} + pipelineTasks={values.tasks} + /> +
    + +
    +

    Parameters

    + +
    + +
    +

    Resources

    + +
    + + + + + + + +
    + ); +}; + +export default PipelineBuilderForm; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx new file mode 100644 index 00000000000..b792d86a011 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { RouteComponentProps } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; +import { Formik, FormikBag } from 'formik'; +import { Button } from '@patternfly/react-core'; +import { history, PageHeading } from '@console/internal/components/utils'; +import { k8sCreate } from '@console/internal/module/k8s'; +import { getBadgeFromType } from '@console/shared/src'; +import { PipelineModel } from '../../../models'; +import PipelineBuilderForm from './PipelineBuilderForm'; +import { PipelineBuilderFormValues, PipelineBuilderFormikValues } from './types'; +import { convertBuilderFormToPipeline, getPipelineURL } from './utils'; +import { validationSchema } from './validation-utils'; + +type PipelineBuilderPageProps = RouteComponentProps<{ ns?: string }>; + +const PipelineBuilderPage: React.FC = ({ match }) => { + const { + params: { ns }, + } = match; + + const initialValues: PipelineBuilderFormValues = { + name: 'new-pipeline', + params: [], + resources: [], + tasks: [], + }; + + const handleSubmit = ( + values: PipelineBuilderFormikValues, + actions: FormikBag, + ) => { + return k8sCreate(PipelineModel, convertBuilderFormToPipeline(values, ns)) + .then(() => { + actions.setSubmitting(false); + history.push(`${getPipelineURL(ns)}/${values.name}`); + }) + .catch((e) => { + actions.setStatus({ submitError: e.message }); + }); + }; + + return ( + <> + + Pipeline Builder + + +
    +
    + +
    +
    +
    + } + /> +
    + + ); +}; + +export default PipelineBuilderPage; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts new file mode 100644 index 00000000000..4bd211e1722 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts @@ -0,0 +1,11 @@ +import { FormikValues } from 'formik'; +import { PipelineParam, PipelineResource, PipelineTask } from '../../../utils/pipeline-augment'; + +export type PipelineBuilderFormValues = { + name: string; + params: PipelineParam[]; + resources: PipelineResource[]; + tasks: PipelineTask[]; +}; + +export type PipelineBuilderFormikValues = FormikValues & PipelineBuilderFormValues; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts index e2d0100018d..3299b52e59e 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts @@ -1,6 +1,11 @@ -import { K8sResourceKind } from '@console/internal/module/k8s'; -import { PipelineTask } from '../../../utils/pipeline-augment'; -import { ClusterTaskModel } from '../../../models'; +import { + apiVersionForModel, + K8sResourceKind, + referenceForModel, +} from '@console/internal/module/k8s'; +import { Pipeline, PipelineTask } from '../../../utils/pipeline-augment'; +import { ClusterTaskModel, PipelineModel } from '../../../models'; +import { PipelineBuilderFormikValues } from './types'; export const convertResourceToTask = ( resource: K8sResourceKind, @@ -15,3 +20,28 @@ export const convertResourceToTask = ( }, }; }; + +export const getPipelineURL = (namespace: string) => { + return `/k8s/ns/${namespace}/${referenceForModel(PipelineModel)}`; +}; + +export const convertBuilderFormToPipeline = ( + formValues: PipelineBuilderFormikValues, + namespace: string, +): Pipeline => { + const { name, resources, params, tasks } = formValues; + + return { + apiVersion: apiVersionForModel(PipelineModel), + kind: PipelineModel.kind, + metadata: { + name, + namespace, + }, + spec: { + params, + resources, + tasks, + }, + }; +}; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts new file mode 100644 index 00000000000..1974dd87c42 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts @@ -0,0 +1,28 @@ +import * as yup from 'yup'; + +export const validationSchema = yup.object({ + name: yup.string().required('Required'), + params: yup.array().of( + yup.object({ + name: yup + .string() + .min(1) + .required('Required'), + description: yup.string(), + default: yup.string(), + }), + ), + resources: yup.array().of( + yup.object({ + name: yup + .string() + .min(1) + .required('Required'), + description: yup.string(), + }), + ), + tasks: yup + .array() + .min(1, 'Must define at least one task') + .required('Required'), +}); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss index c73da1d44d9..ae79d389e67 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.scss @@ -3,7 +3,6 @@ background: var(--pf-global--BackgroundColor--300); border-radius: 20px; font-size: var(--pf-global--FontSize--xs); - margin-bottom: var(--pf-global--spacer--md); max-width: 100%; overflow: auto; padding: var(--pf-global--spacer--lg) var(--pf-global--spacer--xl); diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx index 9191baabdee..8889abe18a7 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/PipelineTopologyGraph.tsx @@ -21,8 +21,6 @@ const PipelineTopologyGraph: React.FC = ({ edges, layout, }) => { - console.debug('updating graph', nodes, edges); - return (
    = [ loader: async () => ( await import( - './components/pipelines/pipeline-builder/PipelineBuilder' /* webpackChunkName: "pipeline-builder" */ + './components/pipelines/pipeline-builder/PipelineBuilderPage' /* webpackChunkName: "pipeline-builder-page" */ ) ).default, }, diff --git a/frontend/packages/dev-console/src/utils/pipeline-augment.ts b/frontend/packages/dev-console/src/utils/pipeline-augment.ts index c2c1e4cc104..82632175dae 100644 --- a/frontend/packages/dev-console/src/utils/pipeline-augment.ts +++ b/frontend/packages/dev-console/src/utils/pipeline-augment.ts @@ -65,7 +65,6 @@ export type KeyedRuns = { [key: string]: Runs }; export interface Pipeline extends K8sResourceKind { latestRun?: PipelineRun; spec?: { - pipelineRef?: { name: string }; params?: PipelineParam[]; resources?: PipelineResource[]; tasks: PipelineTask[]; From 6048e5ff3d41f97c45917b90e04b9458d3ed1ae8 Mon Sep 17 00:00:00 2001 From: Andrew Ballantyne Date: Wed, 22 Jan 2020 14:47:07 -0500 Subject: [PATCH 4/4] Pipeline Builder Task Sidebar --- .../src/components/pipelines/const.ts | 7 + .../detail-page-tabs/PipelineResources.tsx | 11 +- .../pipeline-builder/PipelineBuilderForm.scss | 9 + .../pipeline-builder/PipelineBuilderForm.tsx | 131 +++++++++---- .../pipeline-builder/PipelineBuilderPage.tsx | 16 +- .../PipelineBuilderVisualization.tsx | 23 ++- .../pipelines/pipeline-builder/const.ts | 1 + .../pipelines/pipeline-builder/hooks.ts | 67 +++++-- .../task-sidebar/Sidebar.scss | 51 +++++ .../pipeline-builder/task-sidebar/Sidebar.tsx | 69 +++++++ .../task-sidebar/TaskSidebar.scss | 5 + .../task-sidebar/TaskSidebar.tsx | 175 ++++++++++++++++++ .../task-sidebar/TaskSidebarParam.tsx | 35 ++++ .../task-sidebar/TaskSidebarResource.tsx | 62 +++++++ .../pipelines/pipeline-builder/types.ts | 36 +++- .../pipelines/pipeline-builder/utils.ts | 14 +- .../pipeline-builder/validation-utils.ts | 2 +- .../pipeline-topology/BuilderNode.scss | 3 + .../pipeline-topology/BuilderNode.tsx | 36 +++- .../pipeline-topology/TaskListNode.scss | 4 + .../pipeline-topology/TaskListNode.tsx | 6 +- .../pipelines/pipeline-topology/const.ts | 1 + .../pipelines/pipeline-topology/types.ts | 13 +- .../pipelines/pipeline-topology/utils.ts | 7 + .../dev-console/src/utils/pipeline-augment.ts | 55 +++++- .../dev-console/src/utils/pipeline-utils.ts | 5 +- 26 files changed, 744 insertions(+), 100 deletions(-) create mode 100644 frontend/packages/dev-console/src/components/pipelines/const.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/const.ts create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.scss create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.scss create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarParam.tsx create mode 100644 frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarResource.tsx diff --git a/frontend/packages/dev-console/src/components/pipelines/const.ts b/frontend/packages/dev-console/src/components/pipelines/const.ts new file mode 100644 index 00000000000..d082278f866 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/const.ts @@ -0,0 +1,7 @@ +export enum PipelineResourceType { + '' = 'Select resource type', + git = 'Git', + image = 'Image', + cluster = 'Cluster', + storage = 'Storage', +} diff --git a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx index a2ab3505d6c..4ed27b5fd12 100644 --- a/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/detail-page-tabs/PipelineResources.tsx @@ -1,14 +1,7 @@ import * as React from 'react'; import { TextInputTypes } from '@patternfly/react-core'; import { MultiColumnField, InputField, DropdownField } from '@console/shared'; - -enum resourceTypes { - '' = 'Select resource type', - git = 'Git', - image = 'Image', - cluster = 'Cluster', - storage = 'Storage', -} +import { PipelineResourceType } from '../const'; type PipelineResourcesParam = { addLabel?: string; @@ -33,7 +26,7 @@ const PipelineResources: React.FC = (props) => { placeholder="Name" isReadOnly={isReadOnly} /> - + ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss index 37db5b57dee..f057050d69f 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.scss @@ -1,4 +1,13 @@ .odc-pipeline-builder-form { + height: 100%; + overflow: hidden; + + &__content { + height: 100%; + overflow: auto; + padding: 30px; + } + &__short-section { max-width: 400px; } diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx index 7dabda6bce7..934f243c77e 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderForm.tsx @@ -6,7 +6,11 @@ import { ButtonBar } from '@console/internal/components/utils'; import { InputField } from '@console/shared'; import { PipelineTask } from '../../../utils/pipeline-augment'; import { PipelineParameters, PipelineResources } from '../detail-page-tabs'; +import { TASK_INCOMPLETE_ERROR_MESSAGE } from './const'; import PipelineBuilderVisualization from './PipelineBuilderVisualization'; +import Sidebar from './task-sidebar/Sidebar'; +import TaskSidebar from './task-sidebar/TaskSidebar'; +import { SelectedBuilderTask, TaskErrorMap } from './types'; import './PipelineBuilderForm.scss'; @@ -14,7 +18,29 @@ type PipelineBuilderFormProps = FormikProps & { namespace: string; }; +const pruneErrors = (taskErrors: TaskErrorMap): TaskErrorMap => { + return Object.keys(taskErrors).reduce((newTaskMap, taskName) => { + const taskValue = taskErrors[taskName]; + + if ( + !taskValue.inputResourceCount && + !taskValue.outputResourceCount && + !taskValue.paramsMissingDefaults + ) { + return newTaskMap; + } + + return { + ...newTaskMap, + [taskName]: taskValue, + }; + }, {} as TaskErrorMap); +}; + const PipelineBuilderForm: React.FC = (props) => { + const [selectedTask, setSelectedTask] = React.useState(null); + const [taskErrors, setTaskErrors] = React.useState({}); + const { status, isSubmitting, @@ -29,44 +55,81 @@ const PipelineBuilderForm: React.FC = (props) => { return (
    -
    - -
    +
    +
    + +
    -
    -

    Tasks

    - setFieldValue('tasks', updatedTasks)} - pipelineTasks={values.tasks} - /> -
    +
    +

    Tasks

    + { + setSelectedTask({ + taskIndex: values.tasks.findIndex(({ name }) => name === task.name), + resource, + }); + }} + onSetError={( + taskName, + inputResourceCount, + outputResourceCount, + paramsMissingDefaults, + ) => { + setTaskErrors({ + ...taskErrors, + [taskName]: { + inputResourceCount, + outputResourceCount, + paramsMissingDefaults, + message: TASK_INCOMPLETE_ERROR_MESSAGE, + }, + }); + }} + onUpdateTasks={(updatedTasks: PipelineTask[]) => setFieldValue('tasks', updatedTasks)} + pipelineTasks={values.tasks} + /> +
    -
    -

    Parameters

    - -
    +
    +

    Parameters

    + +
    -
    -

    Resources

    - -
    +
    +

    Resources

    + +
    - - - - - - + + + + + + +
    + setSelectedTask(null)}> + {() => ( + setTaskErrors(pruneErrors(newTaskErrors))} + setFieldValue={setFieldValue} + selectedPipelineTaskIndex={selectedTask.taskIndex} + taskResource={selectedTask.resource} + /> + )} +
    ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx index b792d86a011..7657cabe4b5 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderPage.tsx @@ -53,15 +53,13 @@ const PipelineBuilderPage: React.FC = ({ match }) => {
    -
    - } - /> -
    + } + /> ); }; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx index 2a18f6ce001..9064c828f78 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/PipelineBuilderVisualization.tsx @@ -1,27 +1,42 @@ import * as React from 'react'; import { Alert } from '@patternfly/react-core'; -import PipelineTopologyGraph from '../pipeline-topology/PipelineTopologyGraph'; +import { LoadingBox } from '@console/internal/components/utils'; import { PipelineTask } from '../../../utils/pipeline-augment'; +import { PipelineLayout } from '../pipeline-topology/const'; +import PipelineTopologyGraph from '../pipeline-topology/PipelineTopologyGraph'; import { getEdgesFromNodes } from '../pipeline-topology/utils'; -import { LoadingBox } from '@console/internal/components/utils'; import { useNodes } from './hooks'; -import { PipelineLayout } from '../pipeline-topology/const'; +import { + SelectTaskCallback, + SetTaskErrorCallback, + TaskErrorMap, + UpdateTaskCallback, +} from './types'; type PipelineBuilderVisualizationProps = { namespace: string; - onUpdateTasks: (updatedTasks: PipelineTask[]) => void; + onSetError: SetTaskErrorCallback; + onTaskSelection: SelectTaskCallback; + onUpdateTasks: UpdateTaskCallback; pipelineTasks: PipelineTask[]; + tasksInError: TaskErrorMap; }; const PipelineBuilderVisualization: React.FC = ({ namespace, + onSetError, + onTaskSelection, onUpdateTasks, pipelineTasks, + tasksInError, }) => { const { tasksLoaded, tasksCount, nodes, loadingTasksError } = useNodes( namespace, + onSetError, + onTaskSelection, onUpdateTasks, pipelineTasks, + tasksInError, ); if (loadingTasksError) { diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/const.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/const.ts new file mode 100644 index 00000000000..e83d9357442 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/const.ts @@ -0,0 +1 @@ +export const TASK_INCOMPLETE_ERROR_MESSAGE = 'Incomplete Task'; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts index 549b944f8e8..1e74b2f9451 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/hooks.ts @@ -1,7 +1,11 @@ import * as React from 'react'; -import { k8sList, K8sResourceKind } from '@console/internal/module/k8s'; +import { k8sList } from '@console/internal/module/k8s'; import { ClusterTaskModel, TaskModel } from '../../../models'; -import { PipelineTask } from '../../../utils/pipeline-augment'; +import { + PipelineResourceTask, + PipelineTask, + PipelineTaskRef, +} from '../../../utils/pipeline-augment'; import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; import { PipelineMixedNodeModel, PipelineTaskListNode } from '../pipeline-topology/types'; import { @@ -10,16 +14,23 @@ import { tasksToBuilderNodes, } from '../pipeline-topology/utils'; import { AddNodeDirection } from '../pipeline-topology/const'; +import { + SelectTaskCallback, + SetTaskErrorCallback, + TaskErrorMap, + UpdateTaskCallback, +} from './types'; import { convertResourceToTask } from './utils'; type UseTasks = { - namespacedTasks: K8sResourceKind[] | null; - clusterTasks: K8sResourceKind[] | null; + namespacedTasks: PipelineResourceTask[] | null; + clusterTasks: PipelineResourceTask[] | null; errorMsg?: string; + getTask: (taskRef: PipelineTaskRef) => PipelineResourceTask; }; export const useTasks = (namespace?: string, taskNames?: string[]): UseTasks => { - const [namespacedTasks, setNamespacedTasks] = React.useState(null); - const [clusterTasks, setClusterTasks] = React.useState(null); + const [namespacedTasks, setNamespacedTasks] = React.useState(null); + const [clusterTasks, setClusterTasks] = React.useState(null); const [loadErrorMsg, setLoadErrorMsg] = React.useState(undefined); React.useEffect(() => { @@ -28,7 +39,7 @@ export const useTasks = (namespace?: string, taskNames?: string[]): UseTasks => setNamespacedTasks([]); } else { k8sList(TaskModel, { ns: namespace }) - .then((res: K8sResourceKind[]) => { + .then((res: PipelineResourceTask[]) => { setNamespacedTasks(res); }) .catch(() => { @@ -39,7 +50,7 @@ export const useTasks = (namespace?: string, taskNames?: string[]): UseTasks => if (!clusterTasks) { k8sList(ClusterTaskModel) - .then((res: K8sResourceKind[]) => { + .then((res: PipelineResourceTask[]) => { setClusterTasks(res); }) .catch(() => { @@ -56,13 +67,19 @@ export const useTasks = (namespace?: string, taskNames?: string[]): UseTasks => loadErrorMsg, ]); - const includedValues = (resource: K8sResourceKind) => + const includedValues = (resource: PipelineResourceTask) => !taskNames?.includes(resource.metadata.name); return { namespacedTasks: namespacedTasks?.filter(includedValues), clusterTasks: clusterTasks?.filter(includedValues), errorMsg: loadErrorMsg, + getTask: (taskRef) => { + if (taskRef.kind === ClusterTaskModel.kind) { + return clusterTasks.find((task) => task.metadata.name === taskRef.name); + } + return namespacedTasks.find((task) => task.metadata.name === taskRef.name); + }, }; }; @@ -74,10 +91,13 @@ type UseNodes = { }; export const useNodes = ( namespace: string, - onUpdateTasks: (v: PipelineTask[]) => void, + onSetError: SetTaskErrorCallback, + onTaskSelection: SelectTaskCallback, + onUpdateTasks: UpdateTaskCallback, pipelineTasks: PipelineTask[], + tasksInError: TaskErrorMap, ): UseNodes => { - const { clusterTasks, namespacedTasks, errorMsg } = useTasks( + const { clusterTasks, namespacedTasks, errorMsg, getTask } = useTasks( namespace, pipelineTasks.map((plTask) => plTask.name), ); @@ -90,7 +110,8 @@ export const useNodes = ( createTaskListNode(name, { namespaceTaskList: namespacedTasks, clusterTaskList: clusterTasks, - onNewTask: (resource: K8sResourceKind) => { + onNewTask: (resource: PipelineResourceTask) => { + const newPipelineTask: PipelineTask = convertResourceToTask(resource, runAfter); onUpdateTasks([ ...pipelineTaskList.current.map((task) => { if (!task.runAfter?.includes(name)) { @@ -106,9 +127,25 @@ export const useNodes = ( }), }; }), - convertResourceToTask(resource, runAfter), + newPipelineTask, ]); setListNodes(listNodes.filter((n) => n.id !== name)); + + const hasNonDefaultParams = newPipelineTask.params + ?.map(({ value }) => !value) + .reduce((acc, missingDefault) => missingDefault || acc, false); + const inputResourceCount = resource.spec?.inputs?.resources?.length || 0; + const outputResourceCount = resource.spec?.outputs?.resources?.length || 0; + const totalResourceCount = inputResourceCount + outputResourceCount; + + if (hasNonDefaultParams || totalResourceCount > 0) { + onSetError( + newPipelineTask.name, + inputResourceCount, + outputResourceCount, + hasNonDefaultParams, + ); + } }, task: { name, @@ -193,7 +230,9 @@ export const useNodes = ( const existingNodes: PipelineMixedNodeModel[] = pipelineTasks.length > 0 - ? tasksToBuilderNodes(pipelineTasks, onNewListNode) + ? tasksToBuilderNodes(pipelineTasks, tasksInError, onNewListNode, (task) => + onTaskSelection(task, getTask(task.taskRef)), + ) : [newListNode('initial-node')]; const nodes: PipelineMixedNodeModel[] = handleParallelToParallelNodes([ diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.scss new file mode 100644 index 00000000000..b620e0b15f8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.scss @@ -0,0 +1,51 @@ +$overview-sidebar-width: 550px; + +.odc-sidebar { + background: #fff; + bottom: 0; + box-shadow: var(--pf-global--BoxShadow--md); + overflow-x: hidden; + overflow-y: auto; + position: absolute; + right: 0; + transition: + opacity 175ms ease-out, + transform 225ms ease-out; + top: 0; + width: calc(100% - 15px); + visibility: hidden; + z-index: 5; + + @media(min-width: 762px) { + max-width: $overview-sidebar-width; + } + + &__content { + padding: var(--pf-global--spacer--lg); + } + + // CSSTransitionGroup classes + &-appear { // start in + opacity: 0; + transform: translateX(10%); + visibility: visible; + } + &-appear-active { // finish in + opacity: 1; + transform: translateX(0); + visibility: visible; + } + &-enter-done { + visibility: visible; + } + &-exit { // start out + opacity: 1; + transform: translateX(0); + visibility: visible; + } + &-exit-active { // finish out + opacity: 0; + transform: translateX(10%); + visibility: visible; + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.tsx new file mode 100644 index 00000000000..ec0c0f577a8 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/Sidebar.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { CSSTransition } from 'react-transition-group'; + +import './Sidebar.scss'; + +type LazyRender = () => React.ReactNode; +type SidebarProps = { + children: React.ReactNode | LazyRender; + onRequestClose: () => void; + open: boolean; +}; + +const DURATION = 225; + +const Sidebar: React.FC = (props) => { + const { children, onRequestClose, open } = props; + + const [canClose, setCanClose] = React.useState(false); + const contentRef = React.useRef(null); + const closeRef = React.useCallback( + (e) => { + if (canClose && !contentRef?.current?.contains(e?.target)) { + onRequestClose(); + } + }, + [canClose, onRequestClose], + ); + + React.useEffect(() => { + let timeout = null; + if (open) { + timeout = setTimeout(() => setCanClose(true), DURATION); + } else { + setCanClose(false); + } + + return () => { + clearTimeout(timeout); + }; + }, [open, setCanClose]); + React.useEffect(() => { + window.addEventListener('click', closeRef); + + return () => { + window.removeEventListener('click', closeRef); + }; + }, [closeRef]); + + const render = () => { + if (typeof children === 'function') { + if (open) { + return (children as LazyRender)(); + } + } else { + return children; + } + return null; + }; + + return ( + +
    + {render()} +
    +
    + ); +}; + +export default Sidebar; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.scss new file mode 100644 index 00000000000..4527f89cab3 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.scss @@ -0,0 +1,5 @@ +.odc-task-sidebar { + &__header { + font-size: var(--pf-global--FontSize--md); + } +} diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.tsx new file mode 100644 index 00000000000..bb43fbd7b19 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebar.tsx @@ -0,0 +1,175 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { useField } from 'formik'; +import { TextInputTypes } from '@patternfly/react-core'; +import { InputField } from '@console/shared'; +import { ResourceIcon } from '@console/internal/components/utils'; +import { referenceFor } from '@console/internal/module/k8s'; +import { + getResourceModelFromTask, + PipelineResource, + PipelineResourceTask, + PipelineResourceTaskResource, + PipelineTask, + PipelineTaskParam, + PipelineTaskResource, +} from '../../../../utils/pipeline-augment'; +import TaskSidebarParam from './TaskSidebarParam'; +import { convertResourceToTask } from '../utils'; +import TaskSidebarResource from './TaskSidebarResource'; +import { TaskErrorMap } from '../types'; + +type TaskSidebarProps = { + errorMap: TaskErrorMap; + resourceList: PipelineResource[]; + setFieldValue: (formikId: string, newValue: any) => void; + selectedPipelineTaskIndex: number; + taskResource: PipelineResourceTask; + updateErrorMap: (errorMap: TaskErrorMap) => void; +}; + +const TaskSidebar: React.FC = (props) => { + const { + errorMap, + resourceList, + setFieldValue, + selectedPipelineTaskIndex, + taskResource, + updateErrorMap, + } = props; + const formikTaskReference = `tasks.${selectedPipelineTaskIndex}`; + const [taskField] = useField(formikTaskReference); + + const thisTaskError = errorMap[taskField.value.name]; + + const params = taskResource.spec?.inputs?.params; + const inputResources = taskResource.spec?.inputs?.resources; + const outputResources = taskResource.spec?.outputs?.resources; + + const renderResource = (type: 'inputs' | 'outputs') => ( + resource: PipelineResourceTaskResource, + ) => { + const taskResources: PipelineTaskResource[] = taskField.value?.resources?.[type] || []; + const thisResource = taskResources.find( + (taskFieldResource) => taskFieldResource.name === resource.name, + ); + + return ( + { + const newResources = [ + ...taskResources + .map((tResource) => { + if (tResource.name === resourceName) { + return null; + } + return taskResource; + }) + .filter((r) => !!r), + { + name: resourceName, + resource: selectedResource.name, + }, + ]; + setFieldValue(`${formikTaskReference}.resources.${type}`, newResources); + + const id = type === 'inputs' ? 'inputResourceCount' : 'outputResourceCount'; + if (thisTaskError && thisTaskError[id] === newResources.length) { + // Has errors but no longer resource errors + if (!thisTaskError[id]) { + return; + } + + updateErrorMap({ + ...errorMap, + [taskField.value.name]: _.omit(thisTaskError, id), + }); + } + }} + taskResource={thisResource} + resource={resource} + /> + ); + }; + + return ( +
    +

    + + {taskResource.metadata.name} +

    + + + {params && ( + <> +

    Parameters

    + {params.map((param) => { + const taskParams = taskField.value?.params || []; + const thisParam = taskParams.find( + (taskFieldParam) => taskFieldParam.name === param.name, + ); + return ( + { + const newParams = taskParams.map((taskParam) => { + if (taskParam === thisParam) { + return { + ...taskParam, + value, + } as PipelineTaskParam; + } + return taskParam; + }); + setFieldValue(formikTaskReference, { + ...taskField.value, + params: newParams, + }); + + const datalessParams = newParams.filter((p) => !p.value).length > 0; + if (thisTaskError && !datalessParams) { + // Has errors but no longer param errors + if (!thisTaskError.paramsMissingDefaults) { + return; + } + + updateErrorMap({ + ...errorMap, + [taskField.value.name]: _.omit(thisTaskError, 'paramsMissingDefaults'), + }); + } + }} + /> + ); + })} + + )} + + {inputResources && ( + <> +

    Input Resources

    + {inputResources && inputResources.map(renderResource('inputs'))} + + )} + {outputResources && ( + <> +

    Output Resources

    + {outputResources && outputResources.map(renderResource('outputs'))} + + )} +
    + ); +}; + +export default TaskSidebar; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarParam.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarParam.tsx new file mode 100644 index 00000000000..966d630b3d2 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarParam.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { FormGroup, TextInput } from '@patternfly/react-core'; +import { PipelineResourceTaskParam, PipelineTaskParam } from '../../../../utils/pipeline-augment'; + +type TaskSidebarParamProps = { + resourceParam: PipelineResourceTaskParam; + taskParam?: PipelineTaskParam; + onChange: (newValue: string) => void; +}; + +const TaskSidebarParam: React.FC = (props) => { + const { onChange, resourceParam, taskParam } = props; + + return ( + + { + onChange(value); + }} + /> + + ); +}; + +export default TaskSidebarParam; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarResource.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarResource.tsx new file mode 100644 index 00000000000..70688926881 --- /dev/null +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/task-sidebar/TaskSidebarResource.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { FormGroup } from '@patternfly/react-core'; +import { Dropdown } from '@console/internal/components/utils'; +import { + PipelineResource, + PipelineResourceTaskResource, + PipelineTaskResource, +} from '../../../../utils/pipeline-augment'; + +type TaskSidebarResourceProps = { + availableResources: PipelineResource[]; + onChange: (resourceName: string, resource: PipelineResource) => void; + resource: PipelineResourceTaskResource; + taskResource?: PipelineTaskResource; +}; + +const TaskSidebarResource: React.FC = (props) => { + const { availableResources, onChange, resource, taskResource } = props; + + const dropdownResources = availableResources.filter(({ type }) => resource.type === type); + + const defaultOptions = {}; + if (!taskResource?.resource) { + defaultOptions[''] = 'Select resource...'; + } + + return ( + 0} + isRequired + > + ({ ...acc, [name]: name }), + defaultOptions, + )} + disabled={dropdownResources.length === 0} + selectedKey={taskResource?.resource || ''} + dropDownClassName="dropdown--full-width" + onChange={(value: string) => { + onChange( + resource.name, + dropdownResources.find(({ name }) => name === value), + ); + }} + /> + + ); +}; + +export default TaskSidebarResource; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts index 4bd211e1722..2dfd6340505 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/types.ts @@ -1,5 +1,11 @@ import { FormikValues } from 'formik'; -import { PipelineParam, PipelineResource, PipelineTask } from '../../../utils/pipeline-augment'; +import { + PipelineParam, + PipelineResource, + PipelineResourceTask, + PipelineTask, +} from '../../../utils/pipeline-augment'; +import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; export type PipelineBuilderFormValues = { name: string; @@ -9,3 +15,31 @@ export type PipelineBuilderFormValues = { }; export type PipelineBuilderFormikValues = FormikValues & PipelineBuilderFormValues; + +export type SelectedBuilderTask = { + resource: PipelineResourceTask; + taskIndex: number; +}; + +export type TaskErrorMapData = { + inputResourceCount: number; + outputResourceCount: number; + paramsMissingDefaults: boolean; + message: string; +}; +export type TaskErrorMap = { + [pipelineInErrorName: string]: TaskErrorMapData; +}; + +export type SetTaskErrorCallback = ( + pipelineInErrorName: string, + inputResourceCount: number, + outputResourceCount: number, + paramsMissingDefaults: boolean, +) => void; + +export type SelectTaskCallback = ( + task: PipelineVisualizationTaskItem, + taskResource: PipelineResourceTask, +) => void; +export type UpdateTaskCallback = (updatedTaskList: PipelineTask[]) => void; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts index 3299b52e59e..e6a8fef477e 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/utils.ts @@ -1,14 +1,10 @@ -import { - apiVersionForModel, - K8sResourceKind, - referenceForModel, -} from '@console/internal/module/k8s'; -import { Pipeline, PipelineTask } from '../../../utils/pipeline-augment'; +import { apiVersionForModel, referenceForModel } from '@console/internal/module/k8s'; +import { Pipeline, PipelineResourceTask, PipelineTask } from '../../../utils/pipeline-augment'; import { ClusterTaskModel, PipelineModel } from '../../../models'; import { PipelineBuilderFormikValues } from './types'; export const convertResourceToTask = ( - resource: K8sResourceKind, + resource: PipelineResourceTask, runAfter?: string[], ): PipelineTask => { return { @@ -18,6 +14,10 @@ export const convertResourceToTask = ( kind: resource.kind === ClusterTaskModel.kind ? ClusterTaskModel.kind : undefined, name: resource.metadata.name, }, + params: resource.spec.inputs?.params?.map((param) => ({ + name: param.name, + value: param.default, + })), }; }; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts index 1974dd87c42..774cf5422c5 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-builder/validation-utils.ts @@ -18,7 +18,7 @@ export const validationSchema = yup.object({ .string() .min(1) .required('Required'), - description: yup.string(), + type: yup.string().required('Required'), }), ), tasks: yup diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss index bdc379ec779..e4eddad923d 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.scss @@ -2,4 +2,7 @@ &__add-icon { cursor: pointer; } + &__error-icon { + font-size: 10px; + } } diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx index 9ccfdd9bec7..ae849406fef 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/BuilderNode.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; -import { PlusIcon } from '@patternfly/react-icons'; +import { Tooltip } from '@patternfly/react-core'; +import { ExclamationIcon, PlusIcon } from '@patternfly/react-icons'; import { Node, observer } from '@console/topology'; -import { AddNodeDirection, BUILDER_NODE_ADD_RADIUS } from './const'; +import { AddNodeDirection, BUILDER_NODE_ADD_RADIUS, BUILDER_NODE_ERROR_RADIUS } from './const'; import TaskNode from './TaskNode'; import { BuilderNodeModelData } from './types'; @@ -18,10 +19,34 @@ const drawAdd = (x, y, onClick) => { ); }; +const drawError = (errorStr: string) => { + return ( + + + + + + + + + + + ); +}; + const BuilderNode: React.FC<{ element: Node }> = ({ element }) => { const [showAdd, setShowAdd] = React.useState(false); const { width, height } = element.getBounds(); - const { onAddNode } = element.getData() as BuilderNodeModelData; + const data: BuilderNodeModelData = element.getData(); + const { error, onAddNode, onNodeSelection } = data; return ( = ({ element }) => { height={height + BUILDER_NODE_ADD_RADIUS * 2} fill="transparent" /> - + onNodeSelection(data)}> + + {error?.message && drawError(error.message)} + {drawAdd(width + BUILDER_NODE_ADD_RADIUS, height / 2, () => onAddNode(AddNodeDirection.AFTER), diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss index b621015ee8f..50f7c285915 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.scss @@ -8,5 +8,9 @@ padding: var(--pf-global--spacer--xs) var(--pf-global--spacer--md); width: 100%; } + &__list-items { + max-height: 350px; + overflow: auto; + } } diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx index 961da320f92..9d44e1b295f 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/TaskListNode.tsx @@ -4,13 +4,13 @@ import { Button } from '@patternfly/react-core'; import { CaretDownIcon } from '@patternfly/react-icons'; import Popper from '@console/shared/src/components/popper/Popper'; import { KebabMenuItems, KebabOption } from '@console/internal/components/utils'; -import { K8sResourceKind } from '@console/internal/module/k8s'; import { observer, Node } from '@console/topology'; +import { PipelineResourceTask } from '../../../utils/pipeline-augment'; import { NewTaskNodeCallback, TaskListNodeModelData } from './types'; import './TaskListNode.scss'; -const taskToOption = (task: K8sResourceKind, callback: NewTaskNodeCallback): KebabOption => { +const taskToOption = (task: PipelineResourceTask, callback: NewTaskNodeCallback): KebabOption => { const { metadata: { name }, } = task; @@ -72,7 +72,7 @@ const TaskListNode: React.FC<{ element: Node }> = ({ element }) => { onClick={(e, option) => { option.callback && option.callback(); }} - className="oc-kebab__popper-items" + className="oc-kebab__popper-items odc-task-list-node__list-items" />
    diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts index 4e49eb6c052..4ca046489f8 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/const.ts @@ -4,6 +4,7 @@ export const NODE_SEPARATION_HORIZONTAL = 25; export const NODE_SEPARATION_VERTICAL = 20; export const DROP_SHADOW_SPACING = 5; export const BUILDER_NODE_ADD_RADIUS = 10; +export const BUILDER_NODE_ERROR_RADIUS = 9; export const NODE_WIDTH = 120; export const NODE_HEIGHT = 30; diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts index d0fe32cdde6..2bd8d7c9f10 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/types.ts @@ -1,12 +1,13 @@ import { EdgeModel, NodeModel } from '@console/topology'; -import { Pipeline, PipelineRun } from '../../../utils/pipeline-augment'; +import { Pipeline, PipelineResourceTask, PipelineRun } from '../../../utils/pipeline-augment'; import { PipelineVisualizationTaskItem } from '../../../utils/pipeline-utils'; +import { TaskErrorMapData } from '../pipeline-builder/types'; import { AddNodeDirection, NodeType } from './const'; -import { K8sResourceKind } from '@console/internal/module/k8s'; // Builder Callbacks export type NewTaskListNodeCallback = (direction: AddNodeDirection) => void; -export type NewTaskNodeCallback = (resource: K8sResourceKind) => void; +export type NewTaskNodeCallback = (resource: PipelineResourceTask) => void; +export type NodeSelectionCallback = (nodeData: BuilderNodeModelData) => void; // Node Data Models export type PipelineRunAfterNodeModelData = { @@ -16,13 +17,15 @@ export type PipelineRunAfterNodeModelData = { }; }; export type TaskListNodeModelData = PipelineRunAfterNodeModelData & { - clusterTaskList: K8sResourceKind[]; - namespaceTaskList: K8sResourceKind[]; + clusterTaskList: PipelineResourceTask[]; + namespaceTaskList: PipelineResourceTask[]; onNewTask: NewTaskNodeCallback; }; export type BuilderNodeModelData = PipelineRunAfterNodeModelData & { + error?: TaskErrorMapData; task: PipelineVisualizationTaskItem; onAddNode: NewTaskListNodeCallback; + onNodeSelection: NodeSelectionCallback; }; export type SpacerNodeModelData = PipelineRunAfterNodeModelData & {}; export type TaskNodeModelData = PipelineRunAfterNodeModelData & { diff --git a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts index 54fa2109c11..ee16dde4070 100644 --- a/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts +++ b/frontend/packages/dev-console/src/components/pipelines/pipeline-topology/utils.ts @@ -23,6 +23,7 @@ import { BuilderNodeModelData, PipelineRunAfterNodeModelData, } from './types'; +import { TaskErrorMap } from '../pipeline-builder/types'; const createGenericNode: NodeCreatorSetup = (type, width?) => (name, data) => ({ id: name, @@ -180,11 +181,17 @@ export const tasksToNodes = ( export const tasksToBuilderNodes = ( taskList: PipelineVisualizationTaskItem[], + taskErrorMap: TaskErrorMap, onAddNode: (task: PipelineVisualizationTaskItem, direction: AddNodeDirection) => void, + onNodeSelection: (task: PipelineVisualizationTaskItem) => void, ): PipelineMixedNodeModel[] => { return taskList.map((task) => { return createBuilderNode(task.name, { + error: taskErrorMap[task.name], task, + onNodeSelection: () => { + onNodeSelection(task); + }, onAddNode: (direction: AddNodeDirection) => { onAddNode(task, direction); }, diff --git a/frontend/packages/dev-console/src/utils/pipeline-augment.ts b/frontend/packages/dev-console/src/utils/pipeline-augment.ts index 82632175dae..e278d90f3ad 100644 --- a/frontend/packages/dev-console/src/utils/pipeline-augment.ts +++ b/frontend/packages/dev-console/src/utils/pipeline-augment.ts @@ -34,13 +34,30 @@ export interface TaskStatus { Failed: number; } +export interface PipelineTaskRef { + kind?: string; + name: string; +} + +export interface PipelineTaskParam { + name: string; + value: any; +} +export interface PipelineTaskResources { + inputs?: PipelineTaskResource[]; + outputs?: PipelineTaskResource[]; +} +export interface PipelineTaskResource { + name: string; + resource?: string; + from?: string[]; +} export interface PipelineTask { name: string; runAfter?: string[]; - taskRef: { - kind?: string; - name: string; - }; + taskRef: PipelineTaskRef; + params?: PipelineTaskParam[]; + resources?: PipelineTaskResources; } export interface Resource { @@ -49,10 +66,10 @@ export interface Resource { } export interface PipelineResource { - name?: string; + name: string; type?: string; resourceRef?: { - name?: string; + name: string; }; } @@ -94,6 +111,32 @@ export interface PipelineRun extends K8sResourceKind { }; } +export interface PipelineResourceTaskParam extends PipelineParam { + type: string; +} +export interface PipelineResourceTaskResource { + name: string; + type: string; +} +export interface PipelineResourceTask extends K8sResourceKind { + spec: { + inputs?: { + params?: PipelineResourceTaskParam[]; + resources?: PipelineResourceTaskResource[]; + }; + outputs?: { + resources?: PipelineResourceTaskResource[]; + }; + steps: { + // TODO: Figure out required fields + args?: string[]; + command?: string[]; + image?: string; + resources?: {}[]; + }[]; + }; +} + export interface Condition { type: string; status: string; diff --git a/frontend/packages/dev-console/src/utils/pipeline-utils.ts b/frontend/packages/dev-console/src/utils/pipeline-utils.ts index 33911998d0a..f32cb03de67 100644 --- a/frontend/packages/dev-console/src/utils/pipeline-utils.ts +++ b/frontend/packages/dev-console/src/utils/pipeline-utils.ts @@ -14,6 +14,7 @@ import { runStatus, PipelineParam, PipelineRunParam, + PipelineTaskRef, } from './pipeline-augment'; import { pipelineFilterReducer, pipelineRunStatus } from './pipeline-filter-reducer'; @@ -43,9 +44,7 @@ export interface PipelineVisualizationTaskItem { resources?: Resources; params?: object; runAfter?: string[]; - taskRef: { - name: string; - }; + taskRef: PipelineTaskRef; } export const TaskStatusClassNameMap = {