add pipeline module

4.0
vapao 2023-02-22 16:52:53 +08:00
parent 65b5e806b9
commit 50030d544a
29 changed files with 798 additions and 263 deletions

View File

@ -0,0 +1,3 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.

View File

@ -0,0 +1,23 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from django.db import models
from libs.mixins import ModelMixin
from apps.account.models import User
import json
class Pipeline(models.Model, ModelMixin):
name = models.CharField(max_length=64)
nodes = models.TextField(default='[]')
created_by = models.ForeignKey(User, on_delete=models.PROTECT)
created_at = models.DateTimeField(auto_now_add=True)
def to_view(self):
tmp = self.to_dict()
tmp['nodes'] = json.loads(self.nodes)
return tmp
class Meta:
db_table = 'pipelines'
ordering = ('-id',)

View File

@ -0,0 +1,10 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from django.urls import path
from apps.pipeline.views import PipeView
urlpatterns = [
path('', PipeView.as_view()),
]

View File

@ -0,0 +1,59 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from django.views.generic import View
from libs import JsonParser, Argument, json_response, auth
from apps.pipeline.models import Pipeline
import json
class PipeView(View):
def get(self, request):
form, error = JsonParser(
Argument('id', type=int, required=False)
).parse(request.GET)
if error is None:
if form.id:
pipe = Pipeline.objects.filter(pk=form.id).first()
if not pipe:
return json_response(error='未找到指定流程')
response = pipe.to_view()
else:
pipes = Pipeline.objects.all()
response = [x.to_list() for x in pipes]
return json_response(response)
@auth('deploy.app.add|deploy.app.edit|config.app.add|config.app.edit')
def post(self, request):
form, error = JsonParser(
Argument('id', type=int, required=False),
Argument('name', help='请输入流程名称'),
Argument('nodes', type=list, handler=json.dumps, default='[]')
).parse(request.body)
if error is None:
if form.id:
Pipeline.objects.filter(pk=form.id).update(**form)
pipe = Pipeline.objects.get(pk=form.id)
else:
pipe = Pipeline.objects.create(created_by=request.user, **form)
return json_response(pipe.to_view())
return json_response(error=error)
def patch(self, request):
form, error = JsonParser(
Argument('id', type=int, help='请指定操作对象'),
Argument('name', required=False),
Argument('nodes', type=list, handler=json.dumps, required=False),
).parse(request.body, True)
if error is None:
Pipeline.objects.filter(pk=form.id).update(**form)
return json_response(error=error)
@auth('deploy.app.del|config.app.del')
def delete(self, request):
form, error = JsonParser(
Argument('id', type=int, help='请指定操作对象')
).parse(request.GET)
if error is None:
Pipeline.objects.filter(pk=form.id).delete()
return json_response(error=error)

View File

@ -59,29 +59,26 @@ class SSHConsumer(BaseConsumer):
self.ssh = None self.ssh = None
def loop_read(self): def loop_read(self):
is_ready, data = False, b'' is_ready, buf_size = False, 4096
while True: while True:
out = self.chan.recv(32 * 1024) data = self.chan.recv(buf_size)
if not out: if not data:
self.close(3333) self.close(3333)
break break
data += out while self.chan.recv_ready():
data += self.chan.recv(buf_size)
try: try:
text = data.decode() text = data.decode()
except UnicodeDecodeError: except UnicodeDecodeError:
try: try:
text = data.decode(encoding='GBK') text = data.decode(encoding='GBK')
except UnicodeDecodeError: except UnicodeDecodeError:
time.sleep(0.01)
if self.chan.recv_ready():
continue
text = data.decode(errors='ignore') text = data.decode(errors='ignore')
if not is_ready: if not is_ready:
self.send(text_data='\033[2J\033[3J\033[1;1H') self.send(text_data='\033[2J\033[3J\033[1;1H')
is_ready = True is_ready = True
self.send(text_data=text) self.send(text_data=text)
data = b''
def receive(self, text_data=None, bytes_data=None): def receive(self, text_data=None, bytes_data=None):
data = text_data or bytes_data data = text_data or bytes_data

View File

@ -4,6 +4,8 @@
from git import Repo, RemoteReference, TagReference, InvalidGitRepositoryError, GitCommandError from git import Repo, RemoteReference, TagReference, InvalidGitRepositoryError, GitCommandError
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from datetime import datetime from datetime import datetime
from io import StringIO
import subprocess
import shutil import shutil
import os import os
@ -104,3 +106,138 @@ class Git:
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
if self.fd: if self.fd:
self.fd.close() self.fd.close()
class RemoteGit:
def __init__(self, host, url, path, credential=None):
self.ssh = host.get_ssh()
self.url = url
self.path = path
self.credential = credential
self._ask_env = None
def _make_ask_env(self):
if not self.credential:
return None
if self._ask_env:
return self._ask_env
ask_file = f'{self.ssh.exec_file}.1'
if self.credential.type == 'pw':
env = dict(GIT_ASKPASS=ask_file)
body = '#!/bin/bash\n'
body += 'case "$1" in\n'
body += ' Username*)\n'
body += ' echo "{0.username}";;\n'
body += ' Password*)\n'
body += ' echo "{0.secret}";;\n'
body += 'esac'
body = body.format(self.credential)
self.ssh.put_file_by_fl(StringIO(body), ask_file)
else:
env = dict(GIT_SSH=ask_file)
key_file = f'{self.ssh.exec_file}.2'
self.ssh.put_file_by_fl(StringIO(self.credential.secret), key_file)
self.ssh.sftp.chmod(key_file, 0o600)
body = f'ssh -o StrictHostKeyChecking=no -i {key_file} $@'
self.ssh.put_file_by_fl(StringIO(body), ask_file)
self.ssh.sftp.chmod(ask_file, 0o755)
return env
def _check_path(self):
body = f'git rev-parse --resolve-git-dir {self.path}/.git'
code, _ = self.ssh.exec_command(body)
return code == 0
@classmethod
def check_auth(cls, url, credential=None):
env = dict()
if credential:
if credential.type == 'pw':
ask_command = '#!/bin/bash\n'
ask_command += 'case "$1" in\n'
ask_command += ' Username*)\n'
ask_command += ' echo "{0.username}";;\n'
ask_command += ' Password*)\n'
ask_command += ' echo "{0.secret}";;\n'
ask_command += 'esac'
ask_command = ask_command.format(credential)
ask_file = NamedTemporaryFile()
ask_file.write(ask_command.encode())
ask_file.flush()
os.chmod(ask_file.name, 0o755)
env.update(GIT_ASKPASS=ask_file.name)
print(ask_file.name)
else:
key_file = NamedTemporaryFile()
key_file.write(credential.secret.encode())
key_file.flush()
os.chmod(key_file.name, 0o600)
ask_command = f'ssh -o StrictHostKeyChecking=no -i {key_file.name} $@'
ask_file = NamedTemporaryFile()
ask_file.write(ask_command.encode())
ask_file.flush()
os.chmod(ask_file.name, 0o755)
env.update(GIT_SSH=ask_file.name)
print(ask_file.name)
print(key_file.name)
command = f'git ls-remote -h {url} HEAD'
res = subprocess.run(command, shell=True, capture_output=True, env=env)
return res.returncode == 0, res.stderr.decode()
def clone(self):
env = self._make_ask_env()
code, out = self.ssh.exec_command(f'git clone {self.url} {self.path}', env)
if code != 0:
raise Exception(out)
def fetch_branches_tags(self):
body = f'set -e\ncd {self.path}\n'
if not self._check_path():
self.clone()
else:
body += 'git fetch -q --tags --force\n'
body += 'git --no-pager branch -r --format="%(refname:short)" | grep -v /HEAD | while read branch; do\n'
body += ' echo "Branch: $branch"\n'
body += ' git --no-pager log -20 --date="format-local:%Y-%m-%d %H:%M" --format="%H %cd %cn %s" $branch\n'
body += 'done\n'
body += 'echo "Tags:"\n'
body += 'git --no-pager for-each-ref --format="%(refname:short) %(if)%(taggername)%(then)%(taggername)'
body += '%(else)%(authorname)%(end) %(creatordate:format-local:%Y-%m-%d %H:%M) %(subject)" '
body += '--sort=-creatordate refs/tags\n'
env = self._make_ask_env()
code, out = self.ssh.exec_command(body, env)
if code != 0:
raise Exception(out)
branches, tags, each = {}, [], []
for line in out.splitlines():
if line.startswith('Branch:'):
branch = line.split()[-1]
branches[branch] = each = []
elif line.startswith('Tags:'):
tags = each = []
else:
each.append(line)
return branches, tags
def checkout(self, marker):
body = f'set -e\ncd {self.path}\n'
if not self._check_path():
self.clone()
else:
body += 'git fetch -q --tags --force\n'
body += f'git checkout -f {marker}'
env = self._make_ask_env()
code, out = self.ssh.exec_command(body, env)
if code != 0:
raise Exception(out)
def __enter__(self):
self.ssh.get_client()
return self
def __exit__(self, *args, **kwargs):
self.ssh.client.close()
self.ssh.client = None

View File

@ -3,52 +3,13 @@
# Released under the AGPL-3.0 License. # Released under the AGPL-3.0 License.
from paramiko.client import SSHClient, AutoAddPolicy from paramiko.client import SSHClient, AutoAddPolicy
from paramiko.rsakey import RSAKey from paramiko.rsakey import RSAKey
from paramiko.auth_handler import AuthHandler
from paramiko.ssh_exception import AuthenticationException, SSHException from paramiko.ssh_exception import AuthenticationException, SSHException
from paramiko.py3compat import b, u
from io import StringIO from io import StringIO
from uuid import uuid4 from uuid import uuid4
import time import time
import re import re
def _finalize_pubkey_algorithm(self, key_type):
if "rsa" not in key_type:
return key_type
if re.search(r"-OpenSSH_(?:[1-6]|7\.[0-7])", self.transport.remote_version):
pubkey_algo = "ssh-rsa"
if key_type.endswith("-cert-v01@openssh.com"):
pubkey_algo += "-cert-v01@openssh.com"
self.transport._agreed_pubkey_algorithm = pubkey_algo
return pubkey_algo
my_algos = [x for x in self.transport.preferred_pubkeys if "rsa" in x]
if not my_algos:
raise SSHException(
"An RSA key was specified, but no RSA pubkey algorithms are configured!" # noqa
)
server_algo_str = u(
self.transport.server_extensions.get("server-sig-algs", b(""))
)
if server_algo_str:
server_algos = server_algo_str.split(",")
agreement = list(filter(server_algos.__contains__, my_algos))
if agreement:
pubkey_algo = agreement[0]
else:
err = "Unable to agree on a pubkey algorithm for signing a {!r} key!" # noqa
raise AuthenticationException(err.format(key_type))
else:
pubkey_algo = "ssh-rsa"
if key_type.endswith("-cert-v01@openssh.com"):
pubkey_algo += "-cert-v01@openssh.com"
self.transport._agreed_pubkey_algorithm = pubkey_algo
return pubkey_algo
AuthHandler._finalize_pubkey_algorithm = _finalize_pubkey_algorithm
class SSH: class SSH:
def __init__(self, hostname, port=22, username='root', pkey=None, password=None, default_env=None, def __init__(self, hostname, port=22, username='root', pkey=None, password=None, default_env=None,
connect_timeout=10, term=None): connect_timeout=10, term=None):
@ -56,7 +17,7 @@ class SSH:
self.client = None self.client = None
self.channel = None self.channel = None
self.sftp = None self.sftp = None
self.exec_file = None self.exec_file = f'/tmp/spug.{uuid4().hex}'
self.term = term or {} self.term = term or {}
self.pid = None self.pid = None
self.eof = 'Spug EOF 2108111926' self.eof = 'Spug EOF 2108111926'
@ -124,29 +85,18 @@ class SSH:
out += line out += line
return exit_code, out return exit_code, out
def _win_exec_command_with_stream(self, command, environment=None):
channel = self.client.get_transport().open_session()
if environment:
channel.update_environment(environment)
channel.set_combine_stderr(True)
channel.get_pty(width=102)
channel.exec_command(command)
stdout = channel.makefile("rb", -1)
out = stdout.readline()
while out:
yield channel.exit_status, self._decode(out)
out = stdout.readline()
yield channel.recv_exit_status(), self._decode(out)
def exec_command_with_stream(self, command, environment=None): def exec_command_with_stream(self, command, environment=None):
channel = self._get_channel() channel = self._get_channel()
command = self._handle_command(command, environment) command = self._handle_command(command, environment)
channel.sendall(command) channel.sendall(command)
exit_code, line = -1, '' buf_size, exit_code, line = 4096, -1, ''
while True: while True:
line = self._decode(channel.recv(8196)) out = channel.recv(buf_size)
if not line: if not out:
break break
while channel.recv_ready():
out += channel.recv(buf_size)
line = self._decode(out)
match = self.regex.search(line) match = self.regex.search(line)
if match: if match:
exit_code = int(match.group(1)) exit_code = int(match.group(1))
@ -185,18 +135,23 @@ class SSH:
if self.channel: if self.channel:
return self.channel return self.channel
counter = 0 self.channel = self.client.invoke_shell(term='xterm', **self.term)
self.channel = self.client.invoke_shell(**self.term) self.channel.settimeout(3600)
command = '[ -n "$BASH_VERSION" ] && set +o history\n' command = '[ -n "$BASH_VERSION" ] && set +o history\n'
command += '[ -n "$ZSH_VERSION" ] && set +o zle && set -o no_nomatch\n' command += '[ -n "$ZSH_VERSION" ] && set +o zle && set -o no_nomatch && HISTFILE=""\n'
command += 'export PS1= && stty -echo\n' command += 'export PS1= && stty -echo\n'
command += f'trap \'rm -f {self.exec_file}*\' EXIT\n'
command += self._make_env_command(self.default_env) command += self._make_env_command(self.default_env)
command += f'echo {self.eof} $$\n' command += f'echo {self.eof} $$\n'
time.sleep(0.2) # compatibility
self.channel.sendall(command) self.channel.sendall(command)
out = '' counter, buf_size = 0, 4096
while True: while True:
if self.channel.recv_ready(): if self.channel.recv_ready():
out += self._decode(self.channel.recv(8196)) out = self.channel.recv(buf_size)
if self.channel.recv_ready():
out += self.channel.recv(buf_size)
out = self._decode(out)
match = self.regex.search(out) match = self.regex.search(out)
if match: if match:
self.pid = int(match.group(1)) self.pid = int(match.group(1))
@ -232,17 +187,11 @@ class SSH:
return f'export {str_envs}\n' return f'export {str_envs}\n'
def _handle_command(self, command, environment): def _handle_command(self, command, environment):
new_command = commands = '' new_command = self._make_env_command(environment)
if not self.exec_file:
self.exec_file = f'/tmp/spug.{uuid4().hex}'
commands += f'trap \'rm -f {self.exec_file}\' EXIT\n'
new_command += self._make_env_command(environment)
new_command += command new_command += command
new_command += f'\necho {self.eof} $?\n' new_command += f'\necho {self.eof} $?\n'
self.put_file_by_fl(StringIO(new_command), self.exec_file) self.put_file_by_fl(StringIO(new_command), self.exec_file)
commands += f'. {self.exec_file}\n' return f'. {self.exec_file}\n'
return commands
def _decode(self, content): def _decode(self, content):
try: try:
@ -253,12 +202,8 @@ class SSH:
def __enter__(self): def __enter__(self):
self.get_client() self.get_client()
transport = self.client.get_transport()
if 'windows' in transport.remote_version.lower():
self.exec_command = self.exec_command_raw
self.exec_command_with_stream = self._win_exec_command_with_stream
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, *args, **kwargs):
self.client.close() self.client.close()
self.client = None self.client = None

View File

@ -47,6 +47,8 @@ INSTALLED_APPS = [
'apps.notify', 'apps.notify',
'apps.repository', 'apps.repository',
'apps.home', 'apps.home',
'apps.credential',
'apps.pipeline',
'channels', 'channels',
] ]

View File

@ -33,5 +33,7 @@ urlpatterns = [
path('home/', include('apps.home.urls')), path('home/', include('apps.home.urls')),
path('notify/', include('apps.notify.urls')), path('notify/', include('apps.notify.urls')),
path('file/', include('apps.file.urls')), path('file/', include('apps.file.urls')),
path('credential/', include('apps.credential.urls')),
path('pipeline/', include('apps.pipeline.urls')),
path('apis/', include('apps.apis.urls')), path('apis/', include('apps.apis.urls')),
] ]

View File

@ -5,7 +5,7 @@
"dependencies": { "dependencies": {
"@ant-design/icons": "^4.3.0", "@ant-design/icons": "^4.3.0",
"ace-builds": "^1.4.13", "ace-builds": "^1.4.13",
"antd": "4.21.5", "antd": "^4.24.5",
"axios": "^0.21.0", "axios": "^0.21.0",
"bizcharts": "^3.5.9", "bizcharts": "^3.5.9",
"history": "^4.10.1", "history": "^4.10.1",

View File

@ -9,7 +9,6 @@ import { Layout, message } from 'antd';
import { NotFound } from 'components'; import { NotFound } from 'components';
import Sider from './Sider'; import Sider from './Sider';
import Header from './Header'; import Header from './Header';
import Footer from './Footer'
import routes from '../routes'; import routes from '../routes';
import { hasPermission, isMobile } from 'libs'; import { hasPermission, isMobile } from 'libs';
import styles from './layout.module.less'; import styles from './layout.module.less';
@ -50,7 +49,6 @@ export default function () {
{Routes} {Routes}
<Route component={NotFound}/> <Route component={NotFound}/>
</Switch> </Switch>
<Footer/>
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</Layout> </Layout>

View File

@ -42,7 +42,11 @@ function HostSelector(props) {
}, [store.treeData]) }, [store.treeData])
useEffect(() => { useEffect(() => {
if (Array.isArray(props.value)) {
setSelectedRowKeys([...props.value]) setSelectedRowKeys([...props.value])
} else {
setSelectedRowKeys([props.value])
}
}, [props.value]) }, [props.value])
useEffect(() => { useEffect(() => {
@ -114,6 +118,20 @@ function HostSelector(props) {
} }
} }
function ButtonAction() {
if (!props.value || props.value.length === 0) {
return <Button icon={<PlusOutlined/>} onClick={() => setVisible(true)}>添加目标主机</Button>
}
const number = props.value.length || 1
return (
<Alert
type="info"
className={styles.area}
message={<div>已选择 <b style={{fontSize: 18, color: '#1890ff'}}>{number}</b> </div>}
onClick={() => setVisible(true)}/>
)
}
return ( return (
<div className={styles.selector}> <div className={styles.selector}>
{props.mode !== 'group' && ( {props.mode !== 'group' && (
@ -121,17 +139,8 @@ function HostSelector(props) {
<div onClick={() => setVisible(true)}>{props.children}</div> <div onClick={() => setVisible(true)}>{props.children}</div>
) : ( ) : (
props.type === 'button' ? ( props.type === 'button' ? (
props.value.length > 0 ? ( <ButtonAction/>
<Alert
type="info"
className={styles.area}
message={<div>已选择 <b style={{fontSize: 18, color: '#1890ff'}}>{props.value.length}</b> </div>}
onClick={() => setVisible(true)}/>
) : ( ) : (
<Button icon={<PlusOutlined/>} onClick={() => setVisible(true)}>
添加目标主机
</Button>
)) : (
<div style={{display: 'flex', alignItems: 'center'}}> <div style={{display: 'flex', alignItems: 'center'}}>
{props.value.length > 0 && <span style={{marginRight: 16}}>已选择 {props.value.length} </span>} {props.value.length > 0 && <span style={{marginRight: 16}}>已选择 {props.value.length} </span>}
<Button type="link" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button> <Button type="link" style={{padding: 0}} onClick={() => setVisible(true)}>选择主机</Button>
@ -209,6 +218,7 @@ HostSelector.defaultProps = {
value: [], value: [],
type: 'text', type: 'text',
mode: 'ids', mode: 'ids',
onlyOne: false,
onChange: () => null onChange: () => null
} }

View File

@ -4,10 +4,12 @@
* Released under the AGPL-3.0 License. * Released under the AGPL-3.0 License.
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { PlusOutlined } from '@ant-design/icons'; import { Button, message } from 'antd';
import { message } from 'antd'; import { RollbackOutlined, EditOutlined } from '@ant-design/icons';
import NodeConfig from './NodeConfig'; import NodeConfig from './NodeConfig';
import PipeForm from './Form';
import Node from './Node'; import Node from './Node';
import { transfer } from './utils'; import { transfer } from './utils';
import S from './store'; import S from './store';
@ -15,13 +17,27 @@ import lds from 'lodash';
import css from './editor.module.less'; import css from './editor.module.less';
function Editor(props) { function Editor(props) {
const [record, setRecord] = useState({}) const params = useParams()
const [nodes, setNodes] = useState([]) const [nodes, setNodes] = useState([])
const [visible, setVisible] = useState(false)
useEffect(() => { useEffect(() => {
const data = transfer(record.pipeline || []) if (params.id === 'new') {
S.record = {name: '新建流水线', nodes: []}
handleAddDownstream()
} else {
S.fetchRecord(params.id)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (S.record.nodes.length) {
const data = transfer(S.record.nodes)
setNodes(data) setNodes(data)
}, [record]) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [S.record])
function handleAction({key, domEvent}) { function handleAction({key, domEvent}) {
domEvent.stopPropagation() domEvent.stopPropagation()
@ -41,45 +57,49 @@ function Editor(props) {
let index let index
let [upNode, streamIdx] = [null, null] let [upNode, streamIdx] = [null, null]
const id = S.actionNode.id const id = S.actionNode.id
for (let idx in record.pipeline) { for (let idx in S.record.nodes) {
const node = record.pipeline[idx] const node = S.record.nodes[idx]
if (node.id === id) { if (node.id === id) {
index = Number(idx) index = Number(idx)
} }
idx = lds.findIndex(node.downstream, {id}) if (node.downstream) {
idx = node.downstream.indexOf(id)
if (idx >= 0) { if (idx >= 0) {
upNode = node upNode = node
streamIdx = idx streamIdx = idx
} }
} }
}
return [index, upNode, streamIdx] return [index, upNode, streamIdx]
} }
function handleAddUpstream() { function handleAddUpstream() {
const oldID = S.actionNode.id const oldID = S.actionNode.id
const newID = new Date().getTime() const newID = new Date().getTime()
const newNode = {id: newID, downstream: [oldID]}
const [index, upNode, streamIdx] = _findIndexAndUpNode() const [index, upNode, streamIdx] = _findIndexAndUpNode()
if (upNode) upNode.downstream.splice(streamIdx, 1, {id: newID}) if (upNode) upNode.downstream.splice(streamIdx, 1, newID)
record.pipeline.splice(index, 0, {id: newID, downstream: [{id: oldID}]}) S.record.nodes.splice(index, 0, newNode)
setRecord(Object.assign({}, record)) S.record = {...S.record}
S.node = newNode
} }
function handleAddDownstream(e) { function handleAddDownstream() {
if (e) e.stopPropagation()
const oldID = S.actionNode.id const oldID = S.actionNode.id
const newID = new Date().getTime()
const newNode = {id: new Date().getTime()} const newNode = {id: new Date().getTime()}
if (record.pipeline) { if (S.record.nodes.length) {
const idx = lds.findIndex(record.pipeline, {id: oldID}) const idx = lds.findIndex(S.record.nodes, {id: oldID})
if (record.pipeline[idx].downstream) { if (S.record.nodes[idx].downstream) {
record.pipeline[idx].downstream.push(newNode) S.record.nodes[idx].downstream.push(newID)
} else { } else {
record.pipeline[idx].downstream = [newNode] S.record.nodes[idx].downstream = [newID]
} }
record.pipeline.splice(idx + 1, 0, newNode) S.record.nodes.splice(idx + 1, 0, newNode)
} else { } else {
record.pipeline = [newNode] S.record.nodes = [newNode]
} }
setRecord(Object.assign({}, record)) S.record = {...S.record}
S.node = newNode S.node = newNode
} }
@ -97,18 +117,26 @@ function Editor(props) {
} }
} }
} }
record.pipeline.splice(index, 1) S.record.nodes.splice(index, 1)
setRecord(Object.assign({}, record)) S.record = {...S.record}
} }
function handleRefresh(node) { function handleRefresh(node) {
const index = lds.findIndex(record.pipeline, {id: node.id}) const index = lds.findIndex(S.record.nodes, {id: node.id})
record.pipeline.splice(index, 1, node) S.record.nodes.splice(index, 1, node)
setRecord(Object.assign({}, record)) return S.updateRecord()
} }
return ( return (
<div className={css.container} onClick={() => S.node = {}}> <div className={css.container} onMouseDown={() => S.node = {}}>
<div className={css.header}>
<div className={css.title}>{S.record.name}</div>
<EditOutlined className={css.edit} onClick={() => setVisible(true)}/>
<div style={{flex: 1}}/>
<Button className={css.back} type="link" icon={<RollbackOutlined/>}>返回列表</Button>
</div>
<div className={css.body}>
<div className={css.nodes}>
{nodes.map((row, idx) => ( {nodes.map((row, idx) => (
<div key={idx} className={css.row}> <div key={idx} className={css.row}>
{row.map((item, idx) => ( {row.map((item, idx) => (
@ -116,16 +144,11 @@ function Editor(props) {
))} ))}
</div> </div>
))} ))}
{nodes.length === 0 && (
<div className={css.item} onClick={handleAddDownstream}>
<div className={css.add}>
<PlusOutlined className={css.icon}/>
</div> </div>
<div className={css.title} style={{color: '#999999'}}>点击添加节点</div>
</div>
)}
<NodeConfig doRefresh={handleRefresh}/> <NodeConfig doRefresh={handleRefresh}/>
</div> </div>
{visible && <PipeForm onCancel={() => setVisible(false)}/>}
</div>
) )
} }

View File

@ -0,0 +1,48 @@
/**
* 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 } from 'react';
import { observer } from 'mobx-react';
import { Modal, Form, Input, Select } from 'antd';
import store from './store';
function PipeForm(props) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false)
function handleSubmit() {
setLoading(true);
const formData = form.getFieldsValue();
store.record = Object.assign(store.record, formData)
store.updateRecord()
.then(props.onCancel, () => setLoading(false))
}
return (
<Modal
open
maskClosable={false}
title="编辑流程信息"
onCancel={props.onCancel}
confirmLoading={loading}
onOk={handleSubmit}>
<Form form={form} initialValues={store.record} layout="vertical">
<Form.Item required name="name" label="流程名称">
<Input placeholder="请输入流程名称"/>
</Form.Item>
<Form.Item required name="group_id" label="所属分组">
<Select placeholder="请选择所属分组">
<Select.Option value="1">默认分组</Select.Option>
</Select>
</Form.Item>
<Form.Item name="desc" label="备注信息">
<Input.TextArea placeholder="请输入备注信息"/>
</Form.Item>
</Form>
</Modal>
)
}
export default observer(PipeForm)

View File

@ -1,7 +1,7 @@
import React from 'react' import React from 'react'
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { Dropdown } from 'antd'; import { Dropdown } from 'antd';
import { MoreOutlined } from '@ant-design/icons'; import { MoreOutlined, DeleteOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import Icon from './Icon'; import Icon from './Icon';
import { clsNames } from 'libs'; import { clsNames } from 'libs';
import css from './node.module.less'; import css from './node.module.less';
@ -22,17 +22,20 @@ function Node(props) {
{ {
key: 'upstream', key: 'upstream',
label: '添加上游节点', label: '添加上游节点',
icon: <ArrowUpOutlined/>,
onClick: props.onAction onClick: props.onAction
}, },
{ {
key: 'downstream', key: 'downstream',
label: '添加下游节点', label: '添加下游节点',
icon: <ArrowDownOutlined/>,
onClick: props.onAction onClick: props.onAction
}, },
{ {
key: 'delete', key: 'delete',
danger: true, danger: true,
label: '删除此节点', label: '删除此节点',
icon: <DeleteOutlined/>,
onClick: props.onAction onClick: props.onAction
} }
] ]
@ -57,18 +60,21 @@ function Node(props) {
) )
default: default:
return ( return (
<React.Fragment>
<div className={clsNames(css.box, css.node, S.node.id === node.id && css.active)} <div className={clsNames(css.box, css.node, S.node.id === node.id && css.active)}
onClick={handleNodeClick}> onMouseDown={handleNodeClick}>
<Icon module={node.module}/> <Icon size={36} module={node.module}/>
{node.name ? ( {node.name ? (
<div className={css.title}>{node.name}</div> <div className={css.title}>{node.name}</div>
) : ( ) : (
<div className={css.title} style={{color: '#595959'}}>请选择节点</div> <div className={css.title} style={{color: '#595959'}}>请选择节点</div>
)} )}
<Dropdown className={css.action} trigger="click" menu={{items: menus}} onClick={handleActionClick}> <Dropdown className={css.action} trigger="click" menu={{items: menus}} onMouseDown={handleActionClick}>
<MoreOutlined/> <MoreOutlined/>
</Dropdown> </Dropdown>
</div> </div>
<div className={css.blank}/>
</React.Fragment>
) )
} }
} }

View File

@ -5,11 +5,10 @@
*/ */
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { Drawer, Form, Radio, Button, Input, message } from 'antd'; import { Drawer, Button } from 'antd';
import { AppstoreOutlined, SettingOutlined } from '@ant-design/icons'; import { AppstoreOutlined, SettingOutlined } from '@ant-design/icons';
import ModuleConfig from './modules/index';
import Icon from './Icon'; import Icon from './Icon';
import { ACEditor } from 'components';
import HostSelector from 'pages/host/Selector';
import { clsNames } from 'libs'; import { clsNames } from 'libs';
import S from './store'; import S from './store';
import css from './nodeConfig.module.less'; import css from './nodeConfig.module.less';
@ -17,11 +16,11 @@ import { NODES } from './data'
function NodeConfig(props) { function NodeConfig(props) {
const [tab, setTab] = useState('node') const [tab, setTab] = useState('node')
const [form] = Form.useForm() const [loading, setLoading] = useState(false)
const [handler, setHandler] = useState()
useEffect(() => { useEffect(() => {
setTab(S.node.module ? 'conf' : 'node') setTab(S.node.module ? 'conf' : 'node')
form.setFieldsValue(S.node)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [S.node]) }, [S.node])
@ -33,22 +32,26 @@ function NodeConfig(props) {
} }
function handleSave() { function handleSave() {
message.success('保存成功') const data = handler()
const data = form.getFieldsValue() if (typeof data === 'object') {
setLoading(true)
Object.assign(S.node, data) Object.assign(S.node, data)
props.doRefresh(S.node) props.doRefresh(S.node)
.finally(() => setLoading(false))
}
} }
const visible = !!S.node.id
return ( return (
<Drawer <Drawer
open={visible} open={!!S.node.id}
width={500} width={500}
mask={false} mask={false}
closable={false} closable={false}
getContainer={false} getContainer={false}
style={{marginTop: 12}}
contentWrapperStyle={{overflow: 'hidden', borderTopLeftRadius: 6}}
bodyStyle={{padding: 0, position: 'relative'}}> bodyStyle={{padding: 0, position: 'relative'}}>
<div className={css.container} onClick={e => e.stopPropagation()}> <div className={css.container} onMouseDown={e => e.stopPropagation()}>
<div className={css.header}> <div className={css.header}>
<div className={clsNames(css.item, tab === 'node' && css.active)} onClick={() => setTab('node')}> <div className={clsNames(css.item, tab === 'node' && css.active)} onClick={() => setTab('node')}>
<AppstoreOutlined/> <AppstoreOutlined/>
@ -66,7 +69,7 @@ function NodeConfig(props) {
{NODES.map(item => ( {NODES.map(item => (
<div key={item.module} className={clsNames(css.item, S.node?.module === item.module && css.active)} <div key={item.module} className={clsNames(css.item, S.node?.module === item.module && css.active)}
onClick={() => handleNode(item)}> onClick={() => handleNode(item)}>
<Icon size={36} module={item.module}/> <Icon size={42} module={item.module}/>
<div className={css.title}>{item.name}</div> <div className={css.title}>{item.name}</div>
</div> </div>
))} ))}
@ -74,34 +77,11 @@ function NodeConfig(props) {
</div> </div>
<div className={css.body} style={{display: tab === 'conf' ? 'block' : 'none'}}> <div className={css.body} style={{display: tab === 'conf' ? 'block' : 'none'}}>
<Form layout="vertical" form={form}> <ModuleConfig node={S.node} setHandler={setHandler}/>
<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>
<div className={css.footer} style={{display: tab === 'conf' ? 'block' : 'none'}}> <div className={css.footer} style={{display: tab === 'conf' ? 'block' : 'none'}}>
<Button type="primary" onClick={handleSave}>保存</Button> <Button type="primary" loading={loading} onClick={handleSave}>保存</Button>
</div> </div>
</div> </div>
</Drawer> </Drawer>

View File

@ -15,13 +15,12 @@ export const DATAS = {
'module': 'build', 'module': 'build',
'name': '构建', 'name': '构建',
'id': 0, 'id': 0,
'condition': 'success',
'repository': 1, 'repository': 1,
'target': 2, 'target': 2,
'workspace': '/data/spug', 'workspace': '/data/spug',
'command': 'mvn build', 'command': 'mvn build',
'downstream': [ 'downstream': [1, 2, 3]
{'id': 1, 'state': 'success'}
]
}, },
{ {
'module': 'remote_exec', 'module': 'remote_exec',
@ -30,9 +29,6 @@ export const DATAS = {
'targets': [2, 3], 'targets': [2, 3],
'interpreter': 'sh', 'interpreter': 'sh',
'command': 'date && sleep 3', 'command': 'date && sleep 3',
'downstream': [
{'id': 2, 'state': 'success'}
]
}, },
{ {
'module': 'data_transfer', 'module': 'data_transfer',
@ -42,10 +38,34 @@ export const DATAS = {
'target': 1, 'target': 1,
'path': '/data/spug' 'path': '/data/spug'
}, },
'dest': { 'destination': {
'targets': [2, 3], 'targets': [2, 3],
'path': '/data/dist' 'path': '/data/dist'
} }
} },
{
'module': 'remote_exec',
'name': '执行命令',
'id': 3,
'targets': [2, 3],
'interpreter': 'sh',
'command': 'date && sleep 3',
},
{
'module': 'remote_exec',
'name': '执行命令',
'id': 4,
'targets': [2, 3],
'interpreter': 'sh',
'command': 'date && sleep 3',
},
{
'module': 'remote_exec',
'name': '执行命令',
'id': 5,
'targets': [2, 3],
'interpreter': 'sh',
'command': 'date && sleep 3',
},
] ]
} }

View File

@ -1,9 +1,52 @@
.container { .container {
position: relative; position: relative;
display: flex;
flex-direction: column;
border-radius: 4px; border-radius: 4px;
min-height: calc(100vh - 127px); margin: -24px -24px 0;
margin: -12px -12px 0 -12px; }
padding: 12px 0 0 12px;
.header {
display: flex;
flex-direction: row;
align-items: center;
background: #ffffff;
height: 56px;
padding-left: 24px;
padding-right: 12px;
.title {
font-size: 14px;
font-weight: bold;
}
.back {
color: #999999;
&:hover {
color: #2563fc;
}
}
.edit {
margin-left: 12px;
cursor: pointer;
font-size: 14px;
&:hover {
color: #2563fc;
}
}
}
.body {
position: relative;
.nodes {
height: calc(100vh - 48px - 56px);
padding: 24px;
overflow: auto;
}
} }
.triangle { .triangle {
@ -17,41 +60,3 @@
display: flex; display: flex;
flex-direction: row; 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

@ -5,17 +5,12 @@
*/ */
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { } from 'antd'; import { AuthDiv } from 'components';
import { AuthDiv, Breadcrumb } from 'components';
import Editor from './Editor'; import Editor from './Editor';
export default observer(function () { export default observer(function () {
return ( return (
<AuthDiv auth="system.account.view"> <AuthDiv auth="system.account.view">
<Breadcrumb>
<Breadcrumb.Item>首页</Breadcrumb.Item>
<Breadcrumb.Item>流水线</Breadcrumb.Item>
</Breadcrumb>
<Editor/> <Editor/>
</AuthDiv> </AuthDiv>
) )

View File

@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react';
import { observer } from 'mobx-react';
import { Form, Input, Select, Radio, message } from 'antd';
import { ACEditor } from 'components';
import { http } from 'libs';
import HostSelector from 'pages/host/Selector';
import credStore from 'pages/system/credential/store';
import css from './index.module.less';
function Build(props) {
const [form] = Form.useForm()
const [tips, setTips] = useState()
useEffect(() => {
props.setHandler(() => handleSave)
if (credStore.records.length === 0) credStore.fetchRecords()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function handleSave() {
const data = form.getFieldsValue()
if (!data.name) return message.error('请输入节点名称')
if (!data.condition) return message.error('请选择节点的执行条件')
if (!data.target) return message.error('请选择构建主机')
if (!data.workspace) return message.error('请输入工作目录')
if (!data.command) return message.error('请输入构建命令')
return data
}
function checkGit() {
setTips()
const data = form.getFieldsValue()
if (!data.git_url) return
http.post('/api/credential/check/', {id: data.credential_id, type: 'git', data: data.git_url})
.then(res => {
if (!res.is_pass) {
setTips(res.message)
}
})
}
return (
<Form layout="vertical" form={form} initialValues={props.node}>
<Form.Item required name="name" label="节点名称">
<Input placeholder="请输入节点名称"/>
</Form.Item>
<Form.Item required name="condition" label="执行条件" tooltip="当该节点为流程的起始节点时(无上游节点),该条件将会被忽略。">
<Radio.Group>
<Radio.Button value="success">上游执行成功时</Radio.Button>
<Radio.Button value="failure">上游执行失败时</Radio.Button>
<Radio.Button value="always">总是执行</Radio.Button>
</Radio.Group>
</Form.Item>
<div style={{display: 'flex', justifyContent: 'space-between'}}>
<Form.Item required name="git_url" label="Git仓库" style={{marginBottom: 0}}>
<Input placeholder="请输入Git仓库地址" style={{width: 300}} onBlur={checkGit}/>
</Form.Item>
<Form.Item name="credential_id" label="访问凭据" style={{marginBottom: 0}}>
<Select allowClear placeholder="请选择访问凭据" style={{width: 140}} onChange={checkGit}>
<Select.Option value=""></Select.Option>
{credStore.records.map(item => (
<Select.Option key={item.id} value={item.id}>{item.name}</Select.Option>
))}
</Select>
</Form.Item>
</div>
<div className={css.formTips}>
{tips ? (
<pre className={css.content}>{tips}</pre>
) : null}
</div>
<Form.Item required name="target" label="构建主机">
<HostSelector onlyOne type="button"/>
</Form.Item>
<Form.Item required name="workspace" label="工作目录">
<Input placeholder="请输入工作目录路径"/>
</Form.Item>
<Form.Item required name="command" label="构建命令">
<ACEditor
mode="sh"
width="464px"
height="220px"/>
</Form.Item>
</Form>
)
}
export default observer(Build)

View File

@ -0,0 +1,56 @@
import React, { useEffect } from 'react';
import { Form, Input, message, Card, Radio } from 'antd';
import HostSelector from 'pages/host/Selector';
function DataTransfer(props) {
const [form] = Form.useForm()
useEffect(() => {
props.setHandler(() => handleSave)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function handleSave() {
const data = form.getFieldsValue()
if (!data.name) return message.error('请输入节点名称')
if (!data.condition) return message.error('请选择节点的执行条件')
if (!data.source.path) return message.error('请输入数据源路径')
if (!data.source.target) return message.error('请选择数据源主机')
if (!data.destination.path) return message.error('请输入传输目标路径')
if (!data.destination.targets) return message.error('请选择传输目标主机')
return data
}
return (
<Form layout="vertical" form={form} initialValues={props.node}>
<Form.Item required name="name" label="节点名称">
<Input placeholder="请输入节点名称"/>
</Form.Item>
<Form.Item required name="condition" label="执行条件" tooltip="当该节点为流程的起始节点时(无上游节点),该条件将会被忽略。">
<Radio.Group>
<Radio.Button value="success">上游执行成功时</Radio.Button>
<Radio.Button value="failure">上游执行失败时</Radio.Button>
<Radio.Button value="always">总是执行</Radio.Button>
</Radio.Group>
</Form.Item>
<Card type="inner" title="数据源" style={{margin: '24px 0'}} bodyStyle={{paddingBottom: 0}}>
<Form.Item required name={['source', 'path']} label="数据源路径">
<Input placeholder="请输入数据源路径"/>
</Form.Item>
<Form.Item required name={['source', 'target']} label="数据源主机">
<HostSelector onlyOne type="button"/>
</Form.Item>
</Card>
<Card type="inner" title="传输目标" style={{margin: '24px 0'}} bodyStyle={{paddingBottom: 0}}>
<Form.Item required name={['destination', 'path']} label="目标路径">
<Input placeholder="请输入目标路径"/>
</Form.Item>
<Form.Item required name={['destination', 'targets']} label="目标主机">
<HostSelector type="button"/>
</Form.Item>
</Card>
</Form>
)
}
export default DataTransfer

View File

@ -0,0 +1,59 @@
import React, { useEffect } from 'react';
import { Form, Input, Radio, message } from 'antd';
import { ACEditor } from 'components';
import HostSelector from 'pages/host/Selector';
function SSHExec(props) {
const [form] = Form.useForm()
useEffect(() => {
props.setHandler(() => handleSave)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function handleSave() {
const data = form.getFieldsValue()
if (!data.name) return message.error('请输入节点名称')
if (!data.condition) return message.error('请选择节点的执行条件')
if (!data.targets || data.targets.length === 0) return message.error('请选择执行主机')
if (!data.interpreter) return message.error('请选择执行解释器')
if (!data.command) return message.error('请输入执行内容')
return data
}
return (
<Form layout="vertical" form={form} initialValues={props.node}>
<Form.Item required name="name" label="节点名称">
<Input placeholder="请输入节点名称"/>
</Form.Item>
<Form.Item required name="condition" label="执行条件" tooltip="当该节点为流程的起始节点时(无上游节点),该条件将会被忽略。">
<Radio.Group>
<Radio.Button value="success">上游执行成功时</Radio.Button>
<Radio.Button value="failure">上游执行失败时</Radio.Button>
<Radio.Button value="always">总是执行</Radio.Button>
</Radio.Group>
</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')}
width="464px"
height="220px"/>
</Form.Item>
)}
</Form.Item>
</Form>
)
}
export default SSHExec

View File

@ -0,0 +1,19 @@
import React from 'react';
import SSHExec from './SSHExec';
import Build from './Build';
import DataTransfer from './DataTransfer';
function ModuleConfig(props) {
switch (props.node.module) {
case 'remote_exec':
return <SSHExec {...props}/>
case 'build':
return <Build {...props}/>
case 'data_transfer':
return <DataTransfer {...props}/>
default:
return <div>hello</div>
}
}
export default ModuleConfig

View File

@ -0,0 +1,10 @@
.formTips {
margin-bottom: 16px;
padding: 4px;
.content {
font-size: 13px;
margin: 0;
color: #ff4d4f;
}
}

View File

@ -1,8 +1,14 @@
.box { .box {
position: relative; position: relative;
width: 240px; width: 240px;
height: 80px; height: 70px;
margin-right: 24px; margin-right: 24px;
flex-shrink: 0;
}
.blank {
width: 24px;
flex-shrink: 0;
} }
.triangle { .triangle {
@ -24,6 +30,7 @@
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
padding: 0 24px; padding: 0 24px;
margin: 0;
&:hover { &:hover {
box-shadow: 0 0 6px #2563fcbb; box-shadow: 0 0 6px #2563fcbb;
@ -34,7 +41,7 @@
.title { .title {
flex: 1; flex: 1;
font-size: 20px; font-size: 14px;
margin-left: 12px; margin-left: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -61,7 +68,7 @@
height: 2px; height: 2px;
background: #999999; background: #999999;
position: absolute; position: absolute;
top: 39px; top: 34px;
left: -24px; left: -24px;
right: 0; right: 0;
} }
@ -78,8 +85,8 @@
content: ' '; content: ' ';
position: absolute; position: absolute;
background: #999999; background: #999999;
top: 39px; top: 34px;
bottom: 39px; bottom: 34px;
left: -24px; left: -24px;
right: 119px; right: 119px;
} }
@ -88,7 +95,7 @@
content: ' '; content: ' ';
position: absolute; position: absolute;
background: #999999; background: #999999;
top: 40px; top: 34px;
left: 119px; left: 119px;
right: 119px; right: 119px;
bottom: 0; bottom: 0;

View File

@ -71,8 +71,8 @@
padding: 0 12px; padding: 0 12px;
.item { .item {
width: 150px; width: 226px;
height: 50px; height: 60px;
box-shadow: 0 0 3px #9999994c; box-shadow: 0 0 3px #9999994c;
border: 1px solid transparent; border: 1px solid transparent;
display: flex; display: flex;
@ -85,10 +85,11 @@
.title { .title {
margin-left: 8px; margin-left: 8px;
font-size: 14px;
} }
} }
.active { .item:hover, .active {
border-color: #4d8ffd; border-color: #4d8ffd;
background: #f5faff; background: #f5faff;
color: #2563fc; color: #2563fc;

View File

@ -4,12 +4,37 @@
* Released under the AGPL-3.0 License. * Released under the AGPL-3.0 License.
*/ */
import { observable } from 'mobx'; import { observable } from 'mobx';
import { http } from 'libs';
import { message } from 'antd';
class Store { class Store {
@observable record = {nodes: []};
@observable nodes = []; @observable nodes = [];
@observable node = {}; @observable node = {};
@observable actionNode = {}; @observable actionNode = {};
@observable isFetching = true; @observable isFetching = true;
fetchRecords = (id, isFetching) => {
this.isFetching = true;
return http.get('/api/pipline/')
.then(res => this.records = res)
.finally(() => this.isFetching = false)
}
fetchRecord = (id) => {
this.isFetching = true;
return http.get('/api/pipeline/', {params: {id}})
.then(res => this.record = res)
.finally(() => this.isFetching = false)
}
updateRecord = () => {
return http.post('/api/pipeline/', this.record)
.then(res => {
this.record = res
message.success('保存成功')
})
}
} }
export default new Store() export default new Store()

View File

@ -1,3 +1,5 @@
import lds from 'lodash'
let response = [] let response = []
let nodes = {} let nodes = {}
let layer = 0 let layer = 0
@ -9,7 +11,7 @@ function loop(keys) {
const node = nodes[key] const node = nodes[key]
tmp.push(node.id) tmp.push(node.id)
for (let item of node.downstream || []) { for (let item of node.downstream || []) {
downKeys.push(item.id) downKeys.push(item)
} }
} }
response[layer] = tmp response[layer] = tmp
@ -37,8 +39,7 @@ export function transfer(data) {
const node = nodes[currentRow[cIdx]] const node = nodes[currentRow[cIdx]]
if (node.downstream) { if (node.downstream) {
const downRow = response[idx + 1] const downRow = response[idx + 1]
for (let item of node.downstream) { for (let sKey of node.downstream) {
const sKey = item.id
let dIdx = downRow.indexOf(sKey) let dIdx = downRow.indexOf(sKey)
while (dIdx < cIdx) { // 下级在左侧,则在下级前补空 while (dIdx < cIdx) { // 下级在左侧,则在下级前补空
let tIdx = idx + 1 let tIdx = idx + 1
@ -88,5 +89,5 @@ export function transfer(data) {
idx += 2 idx += 2
} }
return response return lds.cloneDeep(response)
} }

View File

@ -39,8 +39,11 @@ import SystemAccount from './pages/system/account';
import SystemRole from './pages/system/role'; import SystemRole from './pages/system/role';
import SystemSetting from './pages/system/setting'; import SystemSetting from './pages/system/setting';
import SystemLogin from './pages/system/login'; import SystemLogin from './pages/system/login';
import SystemCredential from './pages/system/credential';
import WelcomeIndex from './pages/welcome/index'; import WelcomeIndex from './pages/welcome/index';
import WelcomeInfo from './pages/welcome/info'; import WelcomeInfo from './pages/welcome/info';
import PipelineIndex from './pages/pipeline';
import PipelineEditor from './pages/pipeline';
export default [ export default [
{icon: <DesktopOutlined/>, title: '工作台', path: '/home', component: HomeIndex}, {icon: <DesktopOutlined/>, title: '工作台', path: '/home', component: HomeIndex},
@ -66,6 +69,8 @@ export default [
{title: '发布申请', auth: 'deploy.request.view', path: '/deploy/request', component: DeployRequest}, {title: '发布申请', auth: 'deploy.request.view', path: '/deploy/request', component: DeployRequest},
] ]
}, },
{icon: <FlagOutlined/>, title: '流水线', path: '/pipeline', component: PipelineIndex},
{path: '/pipeline/:id', component: PipelineEditor},
{ {
icon: <ScheduleOutlined/>, icon: <ScheduleOutlined/>,
title: '任务计划', title: '任务计划',
@ -92,6 +97,7 @@ export default [
{ {
icon: <SettingOutlined/>, title: '系统管理', auth: "system.account.view|system.role.view|system.setting.view", child: [ icon: <SettingOutlined/>, title: '系统管理', auth: "system.account.view|system.role.view|system.setting.view", child: [
{title: '登录日志', auth: 'system.login.view', path: '/system/login', component: SystemLogin}, {title: '登录日志', auth: 'system.login.view', path: '/system/login', component: SystemLogin},
{title: '凭据管理', auth: 'system.credential.view', path: '/system/credential', component: SystemCredential},
{title: '账户管理', auth: 'system.account.view', path: '/system/account', component: SystemAccount}, {title: '账户管理', auth: 'system.account.view', path: '/system/account', component: SystemAccount},
{title: '角色管理', auth: 'system.role.view', path: '/system/role', component: SystemRole}, {title: '角色管理', auth: 'system.role.view', path: '/system/role', component: SystemRole},
{title: '系统设置', auth: 'system.setting.view', path: '/system/setting', component: SystemSetting}, {title: '系统设置', auth: 'system.setting.view', path: '/system/setting', component: SystemSetting},