diff --git a/spug_web/src/pages/pipeline/Editor.js b/spug_web/src/pages/pipeline/Editor.js new file mode 100644 index 0000000..d5d3611 --- /dev/null +++ b/spug_web/src/pages/pipeline/Editor.js @@ -0,0 +1,132 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import React, { useState, useEffect } from 'react'; +import { observer } from 'mobx-react'; +import { PlusOutlined } from '@ant-design/icons'; +import { message } from 'antd'; +import NodeConfig from './NodeConfig'; +import Node from './Node'; +import { transfer } from './utils'; +import S from './store'; +import lds from 'lodash'; +import css from './editor.module.less'; + +function Editor(props) { + const [record, setRecord] = useState({}) + const [nodes, setNodes] = useState([]) + + useEffect(() => { + const data = transfer(record.pipeline || []) + setNodes(data) + }, [record]) + + function handleAction({key, domEvent}) { + domEvent.stopPropagation() + switch (key) { + case 'upstream': + return handleAddUpstream() + case 'downstream': + return handleAddDownstream() + case 'delete': + return handleDelNode() + default: + return + } + } + + function _findIndexAndUpNode() { + let index + let [upNode, streamIdx] = [null, null] + const id = S.actionNode.id + for (let idx in record.pipeline) { + const node = record.pipeline[idx] + if (node.id === id) { + index = Number(idx) + } + idx = lds.findIndex(node.downstream, {id}) + if (idx >= 0) { + upNode = node + streamIdx = idx + } + } + return [index, upNode, streamIdx] + } + + function handleAddUpstream() { + const oldID = S.actionNode.id + const newID = new Date().getTime() + const [index, upNode, streamIdx] = _findIndexAndUpNode() + if (upNode) upNode.downstream.splice(streamIdx, 1, {id: newID}) + record.pipeline.splice(index, 0, {id: newID, downstream: [{id: oldID}]}) + setRecord(Object.assign({}, record)) + } + + function handleAddDownstream(e) { + if (e) e.stopPropagation() + const oldID = S.actionNode.id + const newNode = {id: new Date().getTime()} + if (record.pipeline) { + const idx = lds.findIndex(record.pipeline, {id: oldID}) + if (record.pipeline[idx].downstream) { + record.pipeline[idx].downstream.push(newNode) + } else { + record.pipeline[idx].downstream = [newNode] + } + record.pipeline.splice(idx + 1, 0, newNode) + } else { + record.pipeline = [newNode] + } + setRecord(Object.assign({}, record)) + S.node = newNode + } + + function handleDelNode() { + const {downstream} = S.actionNode + const [index, upNode, streamIdx] = _findIndexAndUpNode() + if (index === 0 && downstream && downstream.length > 1) { + return message.error('该节点为起始节点且有多个下游节点无法删除') + } + if (upNode) { + upNode.downstream.splice(streamIdx, 1) + if (downstream) { + for (let item of downstream.slice().reverse()) { + upNode.downstream.splice(streamIdx, 0, item) + } + } + } + record.pipeline.splice(index, 1) + setRecord(Object.assign({}, record)) + } + + function handleRefresh(node) { + const index = lds.findIndex(record.pipeline, {id: node.id}) + record.pipeline.splice(index, 1, node) + setRecord(Object.assign({}, record)) + } + + return ( +
S.node = {}}> + {nodes.map((row, idx) => ( +
+ {row.map((item, idx) => ( + + ))} +
+ ))} + {nodes.length === 0 && ( +
+
+ +
+
点击添加节点
+
+ )} + +
+ ) +} + +export default observer(Editor) \ No newline at end of file diff --git a/spug_web/src/pages/pipeline/Icon.js b/spug_web/src/pages/pipeline/Icon.js new file mode 100644 index 0000000..dbe9364 --- /dev/null +++ b/spug_web/src/pages/pipeline/Icon.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { Avatar } from 'antd'; +import iconRemoteExec from './assets/icon_remote_exec.png'; +import iconBuild from './assets/icon_build.png'; +import iconParameter from './assets/icon_parameter.png'; +import iconDataTransfer from './assets/icon_data_transfer.png'; +import iconDataUpload from './assets/icon_data_upload.png'; +import iconPushSpug from './assets/icon_push_spug.png'; +import iconPushDD from './assets/icon_push_dd.png'; +import iconSelect from './assets/icon_select.png'; + +function Icon(props) { + switch (props.module) { + case 'remote_exec': + return + case 'build': + return + case 'parameter': + return + case 'data_transfer': + return + case 'data_upload': + return + case 'push_spug': + return + case 'push_dd': + return + case undefined: + return + default: + return + } +} + +export default Icon \ No newline at end of file diff --git a/spug_web/src/pages/pipeline/Node.js b/spug_web/src/pages/pipeline/Node.js new file mode 100644 index 0000000..7855005 --- /dev/null +++ b/spug_web/src/pages/pipeline/Node.js @@ -0,0 +1,76 @@ +import React from 'react' +import { observer } from 'mobx-react'; +import { Dropdown } from 'antd'; +import { MoreOutlined } from '@ant-design/icons'; +import Icon from './Icon'; +import { clsNames } from 'libs'; +import css from './node.module.less'; +import S from './store'; + +function Node(props) { + function handleNodeClick(e) { + e.stopPropagation() + S.node = props.node + } + + function handleActionClick(e) { + e.stopPropagation() + S.actionNode = props.node + } + + const menus = [ + { + key: 'upstream', + label: '添加上游节点', + onClick: props.onAction + }, + { + key: 'downstream', + label: '添加下游节点', + onClick: props.onAction + }, + { + key: 'delete', + danger: true, + label: '删除此节点', + onClick: props.onAction + } + ] + + const node = props.node + switch (node) { + case ' ': + return
+ case ' -': + return
+ case '--': + return
+ case ' 7': + return
+ case '-7': + return
+ case ' |': + return ( +
+
+
+ ) + default: + return ( +
+ + {node.name ? ( +
{node.name}
+ ) : ( +
请选择节点
+ )} + + + +
+ ) + } +} + +export default observer(Node) \ No newline at end of file diff --git a/spug_web/src/pages/pipeline/NodeConfig.js b/spug_web/src/pages/pipeline/NodeConfig.js new file mode 100644 index 0000000..d7de306 --- /dev/null +++ b/spug_web/src/pages/pipeline/NodeConfig.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import React, { useState, useEffect } from 'react'; +import { observer } from 'mobx-react'; +import { Drawer, Form, Radio, Button, Input, message } from 'antd'; +import { AppstoreOutlined, SettingOutlined } from '@ant-design/icons'; +import Icon from './Icon'; +import { ACEditor } from 'components'; +import HostSelector from 'pages/host/Selector'; +import { clsNames } from 'libs'; +import S from './store'; +import css from './nodeConfig.module.less'; +import { NODES } from './data' + +function NodeConfig(props) { + const [tab, setTab] = useState('node') + const [form] = Form.useForm() + + useEffect(() => { + setTab(S.node.module ? 'conf' : 'node') + form.setFieldsValue(S.node) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [S.node]) + + function handleNode({module, name}) { + S.node.module = module + if (!S.node.name) S.node.name = name + setTab('conf') + S.node = {...S.node} + } + + function handleSave() { + message.success('保存成功') + const data = form.getFieldsValue() + Object.assign(S.node, data) + props.doRefresh(S.node) + } + + const visible = !!S.node.id + return ( + +
e.stopPropagation()}> +
+
setTab('node')}> + + 选择节点 +
+
setTab('conf')}> + + 节点配置 +
+
+ +
+
内置节点
+
+ {NODES.map(item => ( +
handleNode(item)}> + +
{item.name}
+
+ ))} +
+
+ +
+
+ + + + + + + + + Shell + Python + + + p.interpreter !== c.interpreter}> + {({getFieldValue}) => ( + + console.log(val)} + width="464px" + height="220px"/> + + )} + +
+
+
+ +
+
+
+ ) +} + +export default observer(NodeConfig) \ No newline at end of file diff --git a/spug_web/src/pages/pipeline/assets/icon_build.png b/spug_web/src/pages/pipeline/assets/icon_build.png new file mode 100644 index 0000000..f52fac4 Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_build.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_data_transfer.png b/spug_web/src/pages/pipeline/assets/icon_data_transfer.png new file mode 100644 index 0000000..e775040 Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_data_transfer.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_data_upload.png b/spug_web/src/pages/pipeline/assets/icon_data_upload.png new file mode 100644 index 0000000..516642c Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_data_upload.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_health.png b/spug_web/src/pages/pipeline/assets/icon_health.png new file mode 100644 index 0000000..5134ef3 Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_health.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_parameter.png b/spug_web/src/pages/pipeline/assets/icon_parameter.png new file mode 100644 index 0000000..6861921 Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_parameter.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_push_dd.png b/spug_web/src/pages/pipeline/assets/icon_push_dd.png new file mode 100644 index 0000000..56406a2 Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_push_dd.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_push_spug.png b/spug_web/src/pages/pipeline/assets/icon_push_spug.png new file mode 100644 index 0000000..af18571 Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_push_spug.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_remote_exec.png b/spug_web/src/pages/pipeline/assets/icon_remote_exec.png new file mode 100644 index 0000000..bcdf03c Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_remote_exec.png differ diff --git a/spug_web/src/pages/pipeline/assets/icon_select.png b/spug_web/src/pages/pipeline/assets/icon_select.png new file mode 100644 index 0000000..633fbb8 Binary files /dev/null and b/spug_web/src/pages/pipeline/assets/icon_select.png differ diff --git a/spug_web/src/pages/pipeline/data.js b/spug_web/src/pages/pipeline/data.js new file mode 100644 index 0000000..9a1cf96 --- /dev/null +++ b/spug_web/src/pages/pipeline/data.js @@ -0,0 +1,51 @@ +export const NODES = [ + {module: 'remote_exec', name: '执行命令'}, + {module: 'build', name: '构建'}, + {module: 'parameter', name: '参数化'}, + {module: 'data_transfer', name: '数据传输'}, + {module: 'data_upload', name: '数据上传'}, + {module: 'push_dd', name: '钉钉推送'}, + {module: 'push_spug', name: '推送助手'}, +] + +export const DATAS = { + 'name': 'test', + 'pipeline': [ + { + 'module': 'build', + 'name': '构建', + 'id': 0, + 'repository': 1, + 'target': 2, + 'workspace': '/data/spug', + 'command': 'mvn build', + 'downstream': [ + {'id': 1, 'state': 'success'} + ] + }, + { + 'module': 'remote_exec', + 'name': '执行命令', + 'id': 1, + 'targets': [2, 3], + 'interpreter': 'sh', + 'command': 'date && sleep 3', + 'downstream': [ + {'id': 2, 'state': 'success'} + ] + }, + { + 'module': 'data_transfer', + 'name': '数据传输', + 'id': 2, + 'source': { + 'target': 1, + 'path': '/data/spug' + }, + 'dest': { + 'targets': [2, 3], + 'path': '/data/dist' + } + } + ] +} diff --git a/spug_web/src/pages/pipeline/editor.module.less b/spug_web/src/pages/pipeline/editor.module.less new file mode 100644 index 0000000..f1ab3df --- /dev/null +++ b/spug_web/src/pages/pipeline/editor.module.less @@ -0,0 +1,57 @@ +.container { + position: relative; + border-radius: 4px; + min-height: calc(100vh - 127px); + margin: -12px -12px 0 -12px; + padding: 12px 0 0 12px; +} + +.triangle { + width: 0; + height: 0; + border: 100px solid transparent; + border-top-color: #999999; +} + +.row { + display: flex; + flex-direction: row; +} + +.item { + width: 240px; + height: 80px; + display: flex; + flex-direction: row; + align-items: center; + box-shadow: 0 0 15px #9999994c; + border: 1px solid transparent; + border-radius: 6px; + padding: 0 12px; + background: #ffffff; + cursor: pointer; + + .title { + width: 164px; + margin-left: 12px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .add { + width: 40px; + height: 40px; + border-radius: 20px; + border: 2px dashed #dddddd; + background: #f5f5f5; + display: flex; + justify-content: center; + align-items: center; + + .icon { + font-size: 18px; + color: #999999; + } + } +} diff --git a/spug_web/src/pages/pipeline/index.js b/spug_web/src/pages/pipeline/index.js new file mode 100644 index 0000000..20ad22f --- /dev/null +++ b/spug_web/src/pages/pipeline/index.js @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import React from 'react'; +import { observer } from 'mobx-react'; +import { } from 'antd'; +import { AuthDiv, Breadcrumb } from 'components'; +import Editor from './Editor'; + +export default observer(function () { + return ( + + + 首页 + 流水线 + + + + ) +}) diff --git a/spug_web/src/pages/pipeline/node.module.less b/spug_web/src/pages/pipeline/node.module.less new file mode 100644 index 0000000..3cfca2e --- /dev/null +++ b/spug_web/src/pages/pipeline/node.module.less @@ -0,0 +1,115 @@ +.box { + position: relative; + width: 240px; + height: 80px; + margin-right: 24px; +} + +.triangle { + position: absolute; + bottom: -8px; + left: 112px; + width: 0; + height: 0; + border: 8px solid transparent; + border-top-color: #999999; +} + +.node { + box-shadow: 0 0 15px #9999994c; + background: #ffffff; + border: 1px solid transparent; + border-radius: 6px; + display: flex; + align-items: center; + cursor: pointer; + padding: 0 24px; + + &:hover { + box-shadow: 0 0 6px #2563fcbb; + .action { + display: block; + } + } + + .title { + flex: 1; + font-size: 20px; + margin-left: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .action { + display: none; + padding: 12px; + margin-right: -12px; + font-size: 18px; + } +} + +.active { + border-color: #4d8ffd; + box-shadow: 0 0 6px #2563fcbb; + background-color: #e6f7ff; +} + +.line { + &:after { + content: ' '; + height: 2px; + background: #999999; + position: absolute; + top: 39px; + left: -24px; + right: 0; + } +} + +.line2 { + &:after { + left: -144px; + } +} + +.angle { + &:before { + content: ' '; + position: absolute; + background: #999999; + top: 39px; + bottom: 39px; + left: -24px; + right: 119px; + } + + &:after { + content: ' '; + position: absolute; + background: #999999; + top: 40px; + left: 119px; + right: 119px; + bottom: 0; + } +} + +.angle2 { + &:before { + left: -144px; + } +} + +.arrow { + &:after { + content: ' '; + background: #999999; + position: absolute; + top: 0; + left: 119px; + right: 119px; + bottom: 4px; + } +} + diff --git a/spug_web/src/pages/pipeline/nodeConfig.module.less b/spug_web/src/pages/pipeline/nodeConfig.module.less new file mode 100644 index 0000000..73bf7da --- /dev/null +++ b/spug_web/src/pages/pipeline/nodeConfig.module.less @@ -0,0 +1,97 @@ +.container { + font-size: 12px; + + .header { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 8px 16px 0 16px; + background: #f0f0f0; + display: flex; + flex-direction: row; + align-items: flex-end; + + .item { + width: 100px; + height: 40px; + border-radius: 6px 6px 0 0; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + margin-right: 6px; + font-size: 14px; + cursor: pointer; + + span { + margin-left: 4px; + } + + &:hover { + color: #2563fc; + } + } + + .active { + background: #ffffff; + color: #2563fc; + } + } + + .body { + padding: 24px 18px; + overflow: auto; + position: absolute; + top: 48px; + left: 0; + right: 0; + bottom: 48px; + } + + .footer { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 48px; + border-top: 1px solid #dfdfdf; + padding: 8px 18px; + } + + .category { + font-size: 14px; + margin: 0 0 6px 18px; + } + + .items { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 0 12px; + + .item { + width: 150px; + height: 50px; + box-shadow: 0 0 3px #9999994c; + border: 1px solid transparent; + display: flex; + flex-direction: row; + align-items: center; + padding: 0 12px; + margin: 6px; + border-radius: 6px; + cursor: pointer; + + .title { + margin-left: 8px; + } + } + + .active { + border-color: #4d8ffd; + background: #f5faff; + color: #2563fc; + } + } +} diff --git a/spug_web/src/pages/pipeline/store.js b/spug_web/src/pages/pipeline/store.js new file mode 100644 index 0000000..c72e43c --- /dev/null +++ b/spug_web/src/pages/pipeline/store.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import { observable } from 'mobx'; + +class Store { + @observable nodes = []; + @observable node = {}; + @observable actionNode = {}; + @observable isFetching = true; +} + +export default new Store() diff --git a/spug_web/src/pages/pipeline/utils.js b/spug_web/src/pages/pipeline/utils.js new file mode 100644 index 0000000..498f63b --- /dev/null +++ b/spug_web/src/pages/pipeline/utils.js @@ -0,0 +1,92 @@ +let response = [] +let nodes = {} +let layer = 0 + +function loop(keys) { + const tmp = [] + let downKeys = [] + for (let key of keys) { + const node = nodes[key] + tmp.push(node.id) + for (let item of node.downstream || []) { + downKeys.push(item.id) + } + } + response[layer] = tmp + layer += 1 + if (downKeys.length) { + loop(downKeys) + } +} + +export function transfer(data) { + if (data.length === 0) return [] + response = [] + nodes = {} + layer = 0 + for (let item of data) { + nodes[item.id] = item + } + loop([data[0].id]) + + let idx = response.length - 2 + while (idx >= 0) { + let cIdx = 0 + const currentRow = response[idx] + while (cIdx < currentRow.length) { + const node = nodes[currentRow[cIdx]] + if (node.downstream) { + const downRow = response[idx + 1] + for (let item of node.downstream) { + const sKey = item.id + let dIdx = downRow.indexOf(sKey) + while (dIdx < cIdx) { // 下级在左侧,则在下级前补空 + let tIdx = idx + 1 + while (tIdx < response.length) { // 下下级对应位置也要补空 + response[tIdx].splice(dIdx, 0, ' ') + tIdx += 1 + } + dIdx += 1 + } + if (dIdx === cIdx) continue; + while (dIdx > cIdx + 1) { // 下级在右侧跨列,则当前级补- + const flag = [' 7', '-7'].includes(currentRow[cIdx]) ? '--' : ' -' + cIdx += 1 + currentRow.splice(cIdx, 0, flag) + } + if ([' 7', '-7'].includes(currentRow[cIdx])) { + currentRow.splice(cIdx + 1, 0, '-7') + } else { + currentRow.splice(cIdx + 1, 0, ' 7') + } + cIdx += 1 + } + } + cIdx += 1 + } + idx -= 1 + } + + for (let row of response) { + for (let idx in row) { + const key = row[idx] + row[idx] = nodes[key] || key + } + } + + idx = 1 + while (idx < response.length) { + const nRow = [] + for (let item of response[idx]) { + if (item.id) { + nRow.push(' |') + } else { + nRow.push(' ') + } + } + response.splice(idx, 0, nRow) + idx += 2 + } + + return response +} \ No newline at end of file