init pipeline

4.0
vapao 2022-12-12 17:26:53 +08:00
parent e5cf784498
commit 230d14644a
20 changed files with 803 additions and 0 deletions

View File

@ -0,0 +1,132 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* 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 (
<div className={css.container} onClick={() => S.node = {}}>
{nodes.map((row, idx) => (
<div key={idx} className={css.row}>
{row.map((item, idx) => (
<Node key={idx} node={item} onAction={handleAction}/>
))}
</div>
))}
{nodes.length === 0 && (
<div className={css.item} onClick={handleAddDownstream}>
<div className={css.add}>
<PlusOutlined className={css.icon}/>
</div>
<div className={css.title} style={{color: '#999999'}}>点击添加节点</div>
</div>
)}
<NodeConfig doRefresh={handleRefresh}/>
</div>
)
}
export default observer(Editor)

View File

@ -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 <Avatar size={props.size || 42} src={iconRemoteExec}/>
case 'build':
return <Avatar size={props.size || 42} src={iconBuild}/>
case 'parameter':
return <Avatar size={props.size || 42} src={iconParameter}/>
case 'data_transfer':
return <Avatar size={props.size || 42} src={iconDataTransfer}/>
case 'data_upload':
return <Avatar size={props.size || 42} src={iconDataUpload}/>
case 'push_spug':
return <Avatar size={props.size || 42} src={iconPushSpug}/>
case 'push_dd':
return <Avatar size={props.size || 42} src={iconPushDD}/>
case undefined:
return <Avatar size={props.size || 42} src={iconSelect}/>
default:
return <Avatar size={props.size || 42}/>
}
}
export default Icon

View File

@ -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 <div className={css.box}/>
case ' -':
return <div className={clsNames(css.box, css.line)}/>
case '--':
return <div className={clsNames(css.box, css.line, css.line2)}/>
case ' 7':
return <div className={clsNames(css.box, css.angle)}/>
case '-7':
return <div className={clsNames(css.box, css.angle, css.angle2)}/>
case ' |':
return (
<div className={clsNames(css.box, css.arrow)}>
<div className={css.triangle}/>
</div>
)
default:
return (
<div className={clsNames(css.box, css.node, S.node.id === node.id && css.active)}
onClick={handleNodeClick}>
<Icon module={node.module}/>
{node.name ? (
<div className={css.title}>{node.name}</div>
) : (
<div className={css.title} style={{color: '#595959'}}>请选择节点</div>
)}
<Dropdown className={css.action} trigger="click" menu={{items: menus}} onClick={handleActionClick}>
<MoreOutlined/>
</Dropdown>
</div>
)
}
}
export default observer(Node)

View File

@ -0,0 +1,111 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* 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 (
<Drawer
open={visible}
width={500}
mask={false}
closable={false}
getContainer={false}
bodyStyle={{padding: 0, position: 'relative'}}>
<div className={css.container} onClick={e => e.stopPropagation()}>
<div className={css.header}>
<div className={clsNames(css.item, tab === 'node' && css.active)} onClick={() => setTab('node')}>
<AppstoreOutlined/>
<span>选择节点</span>
</div>
<div className={clsNames(css.item, tab === 'conf' && css.active)} onClick={() => setTab('conf')}>
<SettingOutlined/>
<span>节点配置</span>
</div>
</div>
<div style={{marginTop: 72, display: tab === 'node' ? 'block' : 'none'}}>
<div className={css.category}>内置节点</div>
<div className={css.items}>
{NODES.map(item => (
<div key={item.module} className={clsNames(css.item, S.node?.module === item.module && css.active)}
onClick={() => handleNode(item)}>
<Icon size={36} module={item.module}/>
<div className={css.title}>{item.name}</div>
</div>
))}
</div>
</div>
<div className={css.body} style={{display: tab === 'conf' ? 'block' : 'none'}}>
<Form layout="vertical" form={form}>
<Form.Item required name="name" label="节点名称">
<Input placeholder="请输入节点名称"/>
</Form.Item>
<Form.Item required name="targets" label="选择主机">
<HostSelector type="button"/>
</Form.Item>
<Form.Item required name="interpreter" label="执行解释器">
<Radio.Group buttonStyle="solid">
<Radio.Button value="sh">Shell</Radio.Button>
<Radio.Button value="python">Python</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item required label="执行内容" shouldUpdate={(p, c) => p.interpreter !== c.interpreter}>
{({getFieldValue}) => (
<Form.Item name="command" noStyle>
<ACEditor
mode={getFieldValue('interpreter')}
onChange={val => console.log(val)}
width="464px"
height="220px"/>
</Form.Item>
)}
</Form.Item>
</Form>
</div>
<div className={css.footer} style={{display: tab === 'conf' ? 'block' : 'none'}}>
<Button type="primary" onClick={handleSave}>保存</Button>
</div>
</div>
</Drawer>
)
}
export default observer(NodeConfig)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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'
}
}
]
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* 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 (
<AuthDiv auth="system.account.view">
<Breadcrumb>
<Breadcrumb.Item>首页</Breadcrumb.Item>
<Breadcrumb.Item>流水线</Breadcrumb.Item>
</Breadcrumb>
<Editor/>
</AuthDiv>
)
})

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* 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()

View File

@ -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
}