mirror of https://github.com/openspug/spug
add pipeline module
parent
65b5e806b9
commit
50030d544a
|
@ -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.
|
|
@ -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',)
|
|
@ -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()),
|
||||||
|
]
|
|
@ -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)
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -47,6 +47,8 @@ INSTALLED_APPS = [
|
||||||
'apps.notify',
|
'apps.notify',
|
||||||
'apps.repository',
|
'apps.repository',
|
||||||
'apps.home',
|
'apps.home',
|
||||||
|
'apps.credential',
|
||||||
|
'apps.pipeline',
|
||||||
'channels',
|
'channels',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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')),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,10 @@
|
||||||
|
.formTips {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
color: #ff4d4f;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
|
@ -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},
|
||||||
|
|
Loading…
Reference in New Issue