mirror of https://github.com/openspug/spug
A 优化发布构建展示效果提供终止构建/发布功能
parent
8d8e31b0aa
commit
379b6c1b54
|
|
@ -1,46 +1,25 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
from django.conf import settings
|
||||
from django.template.defaultfilters import filesizeformat
|
||||
from django_redis import get_redis_connection
|
||||
from libs.utils import human_datetime, render_str, str_decode
|
||||
from libs.spug import Notification
|
||||
from apps.host.models import Host
|
||||
from functools import partial
|
||||
from collections import defaultdict
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
class SpugError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Helper:
|
||||
def __init__(self, rds, key):
|
||||
self.rds = rds
|
||||
self.key = key
|
||||
self.callback = []
|
||||
|
||||
@classmethod
|
||||
def make(cls, rds, key, host_ids=None):
|
||||
if host_ids:
|
||||
counter, tmp_key = 0, f'{key}_tmp'
|
||||
data = rds.lrange(key, counter, counter + 9)
|
||||
while data:
|
||||
for item in data:
|
||||
counter += 1
|
||||
print(item)
|
||||
tmp = json.loads(item.decode())
|
||||
if tmp['key'] not in host_ids:
|
||||
rds.rpush(tmp_key, item)
|
||||
data = rds.lrange(key, counter, counter + 9)
|
||||
rds.delete(key)
|
||||
if rds.exists(tmp_key):
|
||||
rds.rename(tmp_key, key)
|
||||
else:
|
||||
rds.delete(key)
|
||||
return cls(rds, key)
|
||||
|
||||
class NotifyMixin:
|
||||
@classmethod
|
||||
def _make_dd_notify(cls, url, action, req, version, host_str):
|
||||
texts = [
|
||||
|
|
@ -224,9 +203,98 @@ class Helper:
|
|||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class KitMixin:
|
||||
regex = re.compile(r'^((\r\n)*)(.*?)((\r\n)*)$', re.DOTALL)
|
||||
|
||||
@classmethod
|
||||
def term_message(cls, message, color_mode='info', with_time=False):
|
||||
prefix = f'{human_datetime()} ' if with_time else ''
|
||||
if color_mode == 'info':
|
||||
mode = '36m'
|
||||
elif color_mode == 'warn':
|
||||
mode = '33m'
|
||||
elif color_mode == 'error':
|
||||
mode = '31m'
|
||||
elif color_mode == 'success':
|
||||
mode = '32m'
|
||||
else:
|
||||
raise TypeError
|
||||
|
||||
return cls.regex.sub(fr'\1\033[{mode}{prefix}\3\033[0m\4', message)
|
||||
|
||||
|
||||
class Helper(NotifyMixin, KitMixin):
|
||||
def __init__(self, rds, rds_key):
|
||||
self.rds = rds
|
||||
self.rds_key = rds_key
|
||||
self.callback = []
|
||||
self.buffers = defaultdict(str)
|
||||
self.flags = defaultdict(bool)
|
||||
self.files = {}
|
||||
self.already_clear = False
|
||||
|
||||
def __del__(self):
|
||||
self.clear()
|
||||
|
||||
@classmethod
|
||||
def make(cls, rds, rds_key, keys):
|
||||
rds.delete(rds_key)
|
||||
instance = cls(rds, rds_key)
|
||||
for key in keys:
|
||||
instance.get_file(key)
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def fill_outputs(cls, outputs, deploy_key):
|
||||
rds = get_redis_connection()
|
||||
key_ttl = rds.ttl(deploy_key)
|
||||
counter, hit_keys = 0, set()
|
||||
if key_ttl > 30 or key_ttl == -1:
|
||||
data = rds.lrange(deploy_key, counter, counter + 9)
|
||||
while data:
|
||||
for item in data:
|
||||
counter += 1
|
||||
item = json.loads(item.decode())
|
||||
key = item['key']
|
||||
if key in outputs:
|
||||
hit_keys.add(key)
|
||||
if 'data' in item:
|
||||
outputs[key]['data'] += item['data']
|
||||
if 'status' in item:
|
||||
outputs[key]['status'] = item['status']
|
||||
data = rds.lrange(deploy_key, counter, counter + 9)
|
||||
|
||||
for key in outputs.keys():
|
||||
if key in hit_keys:
|
||||
continue
|
||||
file_name = os.path.join(settings.DEPLOY_DIR, f'{deploy_key}:{key}')
|
||||
if not os.path.exists(file_name):
|
||||
continue
|
||||
with open(file_name, newline='\r\n') as f:
|
||||
line = f.readline()
|
||||
while line:
|
||||
status, data = line.split(',', 1)
|
||||
if data:
|
||||
outputs[key]['data'] += data
|
||||
if status:
|
||||
outputs[key]['status'] = status
|
||||
line = f.readline()
|
||||
return counter
|
||||
|
||||
def get_file(self, key):
|
||||
if key in self.files:
|
||||
return self.files[key]
|
||||
file = open(os.path.join(settings.DEPLOY_DIR, f'{self.rds_key}:{key}'), 'w')
|
||||
self.files[key] = file
|
||||
return file
|
||||
|
||||
def add_callback(self, func):
|
||||
self.callback.append(func)
|
||||
|
||||
def save_pid(self, pid, key):
|
||||
self.rds.set(f'PID:{self.rds_key}:{key}', pid, 3600)
|
||||
|
||||
def parse_filter_rule(self, data: str, sep='\n', env=None):
|
||||
data, files = data.strip(), []
|
||||
if data:
|
||||
|
|
@ -236,44 +304,89 @@ class Helper:
|
|||
files.append(render_str(line, env))
|
||||
return files
|
||||
|
||||
def _send(self, message):
|
||||
self.rds.rpush(self.key, json.dumps(message))
|
||||
def _send(self, key, data, *, status=''):
|
||||
message = {'key': key, 'data': data}
|
||||
if status:
|
||||
message['status'] = status
|
||||
self.rds.rpush(self.rds_key, json.dumps(message))
|
||||
|
||||
def send_info(self, key, message):
|
||||
if message:
|
||||
self._send({'key': key, 'data': message})
|
||||
for idx, line in enumerate(data.split('\r\n')):
|
||||
if idx != 0:
|
||||
tmp = [status, self.buffers[key] + '\r\n']
|
||||
file = self.get_file(key)
|
||||
file.write(','.join(tmp))
|
||||
file.flush()
|
||||
self.buffers[key] = ''
|
||||
self.flags[key] = False
|
||||
if line:
|
||||
for idx2, item in enumerate(line.split('\r')):
|
||||
if idx2 != 0:
|
||||
self.flags[key] = True
|
||||
if item:
|
||||
if self.flags[key]:
|
||||
self.buffers[key] = item
|
||||
self.flags[key] = False
|
||||
else:
|
||||
self.buffers[key] += item
|
||||
|
||||
def send_clear(self, key):
|
||||
self._send(key, '\033[2J\033[3J\033[1;1H')
|
||||
|
||||
def send_info(self, key, message, status='', with_time=True):
|
||||
message = self.term_message(message, 'info', with_time)
|
||||
self._send(key, message, status=status)
|
||||
|
||||
def send_warn(self, key, message, status=''):
|
||||
message = self.term_message(message, 'warn')
|
||||
self._send(key, message, status=status)
|
||||
|
||||
def send_success(self, key, message, status=''):
|
||||
message = self.term_message(message, 'success')
|
||||
self._send(key, message, status=status)
|
||||
|
||||
def send_error(self, key, message, with_break=True):
|
||||
message = f'\r\n\033[31m{message}\033[0m'
|
||||
self._send({'key': key, 'status': 'error', 'data': message})
|
||||
message = self.term_message(message, 'error')
|
||||
if not message.endswith('\r\n'):
|
||||
message += '\r\n'
|
||||
self._send(key, message, status='error')
|
||||
if with_break:
|
||||
raise SpugError
|
||||
|
||||
def send_step(self, key, step, data):
|
||||
self._send({'key': key, 'step': step, 'data': data})
|
||||
|
||||
def clear(self):
|
||||
self.rds.delete(f'{self.key}_tmp')
|
||||
# save logs for two weeks
|
||||
self.rds.expire(self.key, 14 * 24 * 60 * 60)
|
||||
self.rds.close()
|
||||
# callback
|
||||
for func in self.callback:
|
||||
func()
|
||||
if self.already_clear:
|
||||
return
|
||||
self.already_clear = True
|
||||
for key, value in self.buffers.items():
|
||||
if value:
|
||||
file = self.get_file(key)
|
||||
file.write(f',{value}')
|
||||
for file in self.files.values():
|
||||
file.close()
|
||||
if self.rds.ttl(self.rds_key) == -1:
|
||||
self.rds.expire(self.rds_key, 60)
|
||||
while self.callback:
|
||||
self.callback.pop()()
|
||||
|
||||
def progress_callback(self, key):
|
||||
def func(k, n, t):
|
||||
message = f'\r {filesizeformat(n):<8}/{filesizeformat(t):>8} '
|
||||
self.send_info(k, message)
|
||||
self._send(k, message)
|
||||
|
||||
self.send_info(key, '\r\n')
|
||||
self._send(key, '\r\n')
|
||||
return partial(func, key)
|
||||
|
||||
def local(self, command, env=None):
|
||||
if env:
|
||||
env = dict(env.items())
|
||||
env.update(os.environ)
|
||||
task = subprocess.Popen(command, env=env, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
task = subprocess.Popen(
|
||||
command,
|
||||
env=env,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
preexec_fn=os.setsid)
|
||||
self.save_pid(task.pid, 'local')
|
||||
message = b''
|
||||
while True:
|
||||
output = task.stdout.read(1)
|
||||
|
|
@ -282,7 +395,7 @@ class Helper:
|
|||
if output in (b'\r', b'\n'):
|
||||
message += b'\r\n' if output == b'\n' else b'\r'
|
||||
message = str_decode(message)
|
||||
self.send_info('local', message)
|
||||
self._send('local', message)
|
||||
message = b''
|
||||
else:
|
||||
message += output
|
||||
|
|
@ -292,7 +405,7 @@ class Helper:
|
|||
def remote(self, key, ssh, command, env=None):
|
||||
code = -1
|
||||
for code, out in ssh.exec_command_with_stream(command, environment=env):
|
||||
self.send_info(key, out)
|
||||
self._send(key, out)
|
||||
if code != 0:
|
||||
self.send_error(key, f'exit code: {code}')
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ class DeployRequest(models.Model, ModelMixin):
|
|||
return extra[0] in ('branch', 'tag')
|
||||
return False
|
||||
|
||||
@property
|
||||
def deploy_key(self):
|
||||
return f'{settings.REQUEST_KEY}:{self.id}'
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
super().delete(using, keep_parents)
|
||||
if self.repository_id:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
from django_redis import get_redis_connection
|
||||
from django.conf import settings
|
||||
from django.db import close_old_connections
|
||||
from libs.utils import AttrDict, human_time, render_str
|
||||
from libs.utils import AttrDict, render_str
|
||||
from apps.host.models import Host
|
||||
from apps.config.utils import compose_configs
|
||||
from apps.repository.models import Repository
|
||||
|
|
@ -23,13 +23,14 @@ BUILD_DIR = settings.BUILD_DIR
|
|||
|
||||
def dispatch(req, fail_mode=False):
|
||||
rds = get_redis_connection()
|
||||
rds_key = f'{settings.REQUEST_KEY}:{req.id}'
|
||||
rds_key = req.deploy_key
|
||||
if fail_mode:
|
||||
req.host_ids = req.fail_host_ids
|
||||
req.fail_mode = fail_mode
|
||||
req.host_ids = json.loads(req.host_ids)
|
||||
req.fail_host_ids = req.host_ids[:]
|
||||
helper = Helper.make(rds, rds_key, req.host_ids if fail_mode else None)
|
||||
keys = req.host_ids if fail_mode else req.host_ids + ['local']
|
||||
helper = Helper.make(rds, rds_key, keys)
|
||||
|
||||
try:
|
||||
api_token = uuid.uuid4().hex
|
||||
|
|
@ -61,7 +62,8 @@ def dispatch(req, fail_mode=False):
|
|||
req.status = '3'
|
||||
except Exception as e:
|
||||
req.status = '-3'
|
||||
raise e
|
||||
if not isinstance(e, SpugError):
|
||||
raise e
|
||||
finally:
|
||||
close_old_connections()
|
||||
DeployRequest.objects.filter(pk=req.id).update(
|
||||
|
|
@ -108,7 +110,7 @@ def _ext1_deploy(req, helper, env):
|
|||
if exception:
|
||||
latest_exception = exception
|
||||
if not isinstance(exception, SpugError):
|
||||
helper.send_error(t.h_id, f'Exception: {exception}', False)
|
||||
helper.send_error(t.h_id, f'Exception: {exception}', with_break=False)
|
||||
else:
|
||||
req.fail_host_ids.remove(t.h_id)
|
||||
if latest_exception:
|
||||
|
|
@ -122,9 +124,9 @@ def _ext1_deploy(req, helper, env):
|
|||
_deploy_ext1_host(req, helper, h_id, new_env)
|
||||
req.fail_host_ids.remove(h_id)
|
||||
except Exception as e:
|
||||
helper.send_error(h_id, f'Exception: {e}', False)
|
||||
helper.send_error(h_id, f'Exception: {e}', with_break=False)
|
||||
for h_id in host_ids:
|
||||
helper.send_error(h_id, '终止发布', False)
|
||||
helper.send_error(h_id, '终止发布', with_break=False)
|
||||
raise e
|
||||
|
||||
|
||||
|
|
@ -137,23 +139,32 @@ def _ext2_deploy(req, helper, env):
|
|||
for index, value in enumerate(req.version.split()):
|
||||
env.update({f'SPUG_RELEASE_{index + 1}': value})
|
||||
|
||||
if not req.fail_mode:
|
||||
helper.send_info('local', f'\033[32m完成√\033[0m\r\n')
|
||||
for action in server_actions:
|
||||
helper.send_step('local', step, f'{human_time()} {action["title"]}...\r\n')
|
||||
helper.local(f'cd /tmp && {action["data"]}', env)
|
||||
step += 1
|
||||
|
||||
transfer_action = None
|
||||
for action in host_actions:
|
||||
if action.get('type') == 'transfer':
|
||||
action['src'] = render_str(action.get('src', '').strip().rstrip('/'), env)
|
||||
action['dst'] = render_str(action['dst'].strip().rstrip('/'), env)
|
||||
if action.get('src_mode') == '1': # upload when publish
|
||||
if not req.extra:
|
||||
helper.send_error('local', '\r\n未找到上传的文件信息,请尝试新建发布申请')
|
||||
extra = json.loads(req.extra)
|
||||
if 'name' in extra:
|
||||
action['name'] = extra['name']
|
||||
break
|
||||
helper.send_step('local', step, f'{human_time()} 检测到来源为本地路径的数据传输动作,执行打包... \r\n')
|
||||
else:
|
||||
transfer_action = action
|
||||
break
|
||||
|
||||
if not req.fail_mode:
|
||||
helper.send_success('local', '', status='doing')
|
||||
if server_actions or transfer_action:
|
||||
helper.send_clear('local')
|
||||
for action in server_actions:
|
||||
helper.send_info('local', f'{action["title"]}...\r\n')
|
||||
helper.local(f'cd /tmp && {action["data"]}', env)
|
||||
step += 1
|
||||
if transfer_action:
|
||||
action = transfer_action
|
||||
helper.send_info('local', '检测到来源为本地路径的数据传输动作,执行打包... \r\n')
|
||||
action['src'] = action['src'].rstrip('/ ')
|
||||
action['dst'] = action['dst'].rstrip('/ ')
|
||||
if not action['src'] or not action['dst']:
|
||||
|
|
@ -178,17 +189,16 @@ def _ext2_deploy(req, helper, env):
|
|||
exclude = ' '.join(excludes)
|
||||
tar_gz_file = f'{req.spug_version}.tar.gz'
|
||||
helper.local(f'cd {sp_dir} && tar -zcf {tar_gz_file} {exclude} {contain}')
|
||||
helper.send_info('local', f'{human_time()} \033[32m完成√\033[0m\r\n')
|
||||
helper.send_info('local', '打包完成\r\n')
|
||||
helper.add_callback(partial(os.remove, os.path.join(sp_dir, tar_gz_file)))
|
||||
break
|
||||
helper.send_step('local', 100, '')
|
||||
|
||||
if host_actions:
|
||||
helper.send_success('local', '\r\n** 执行完成 **', status='success')
|
||||
if req.deploy.is_parallel:
|
||||
threads, latest_exception = [], None
|
||||
max_workers = max(10, os.cpu_count() * 5)
|
||||
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for h_id in req.host_ids:
|
||||
for h_id in sorted(req.host_ids, reverse=True):
|
||||
new_env = AttrDict(env.items())
|
||||
t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, new_env, req.spug_version)
|
||||
t.h_id = h_id
|
||||
|
|
@ -198,13 +208,13 @@ def _ext2_deploy(req, helper, env):
|
|||
if exception:
|
||||
latest_exception = exception
|
||||
if not isinstance(exception, SpugError):
|
||||
helper.send_error(t.h_id, f'Exception: {exception}', False)
|
||||
helper.send_error(t.h_id, f'Exception: {exception}', with_break=False)
|
||||
else:
|
||||
req.fail_host_ids.remove(t.h_id)
|
||||
if latest_exception:
|
||||
raise latest_exception
|
||||
else:
|
||||
host_ids = sorted(req.host_ids, reverse=True)
|
||||
host_ids = sorted(req.host_ids)
|
||||
while host_ids:
|
||||
h_id = host_ids.pop()
|
||||
new_env = AttrDict(env.items())
|
||||
|
|
@ -212,17 +222,20 @@ def _ext2_deploy(req, helper, env):
|
|||
_deploy_ext2_host(helper, h_id, host_actions, new_env, req.spug_version)
|
||||
req.fail_host_ids.remove(h_id)
|
||||
except Exception as e:
|
||||
helper.send_error(h_id, f'Exception: {e}', False)
|
||||
if not isinstance(e, SpugError):
|
||||
helper.send_error(h_id, f'Exception: {e}', with_break=False)
|
||||
for h_id in host_ids:
|
||||
helper.send_error(h_id, '终止发布', False)
|
||||
helper.send_clear(h_id)
|
||||
helper.send_error(h_id, '串行模式,终止发布', with_break=False)
|
||||
raise e
|
||||
else:
|
||||
req.fail_host_ids = []
|
||||
helper.send_step('local', 100, f'\r\n{human_time()} ** 发布成功 **')
|
||||
helper.send_success('local', '\r\n** 发布成功 **', status='success')
|
||||
|
||||
|
||||
def _deploy_ext1_host(req, helper, h_id, env):
|
||||
helper.send_step(h_id, 1, f'\033[32m就绪√\033[0m\r\n{human_time()} 数据准备... ')
|
||||
helper.send_clear(h_id)
|
||||
helper.send_info(h_id, '数据准备... ', status='doing')
|
||||
host = Host.objects.filter(pk=h_id).first()
|
||||
if not host:
|
||||
helper.send_error(h_id, 'no such host')
|
||||
|
|
@ -232,13 +245,15 @@ def _deploy_ext1_host(req, helper, h_id, env):
|
|||
extend.dst_repo = render_str(extend.dst_repo, env)
|
||||
env.update(SPUG_DST_DIR=extend.dst_dir)
|
||||
with host.get_ssh(default_env=env) as ssh:
|
||||
helper.save_pid(ssh.get_pid(), h_id)
|
||||
base_dst_dir = os.path.dirname(extend.dst_dir)
|
||||
code, _ = ssh.exec_command_raw(
|
||||
f'mkdir -p {extend.dst_repo} {base_dst_dir} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]')
|
||||
if code == 0:
|
||||
helper.send_error(host.id, f'检测到该主机的发布目录 {extend.dst_dir!r} 已存在,为了数据安全请自行备份后删除该目录,Spug 将会创建并接管该目录。')
|
||||
helper.send_error(host.id,
|
||||
f'\r\n检测到该主机的发布目录 {extend.dst_dir!r} 已存在,为了数据安全请自行备份后删除该目录,Spug 将会创建并接管该目录。')
|
||||
if req.type == '2':
|
||||
helper.send_step(h_id, 1, '\033[33m跳过√\033[0m\r\n')
|
||||
helper.send_warn(h_id, '跳过√\r\n')
|
||||
else:
|
||||
# clean
|
||||
clean_command = f'ls -d {extend.deploy_id}_* 2> /dev/null | sort -t _ -rnk2 | tail -n +{extend.versions + 1} | xargs rm -rf'
|
||||
|
|
@ -257,39 +272,41 @@ def _deploy_ext1_host(req, helper, h_id, env):
|
|||
|
||||
command = f'cd {extend.dst_repo} && rm -rf {req.spug_version} && tar xf {tar_gz_file} && rm -f {req.deploy_id}_*.tar.gz'
|
||||
helper.remote_raw(host.id, ssh, command)
|
||||
helper.send_step(h_id, 1, '\033[32m完成√\033[0m\r\n')
|
||||
helper.send_success(h_id, '完成√\r\n')
|
||||
|
||||
# pre host
|
||||
repo_dir = os.path.join(extend.dst_repo, req.spug_version)
|
||||
if extend.hook_pre_host:
|
||||
helper.send_step(h_id, 2, f'{human_time()} 发布前任务... \r\n')
|
||||
helper.send_info(h_id, '发布前任务... \r\n')
|
||||
command = f'cd {repo_dir} && {extend.hook_pre_host}'
|
||||
helper.remote(host.id, ssh, command)
|
||||
|
||||
# do deploy
|
||||
helper.send_step(h_id, 3, f'{human_time()} 执行发布... ')
|
||||
helper.send_info(h_id, '执行发布... ')
|
||||
helper.remote_raw(host.id, ssh, f'rm -f {extend.dst_dir} && ln -sfn {repo_dir} {extend.dst_dir}')
|
||||
helper.send_step(h_id, 3, '\033[32m完成√\033[0m\r\n')
|
||||
helper.send_success(h_id, '完成√\r\n')
|
||||
|
||||
# post host
|
||||
if extend.hook_post_host:
|
||||
helper.send_step(h_id, 4, f'{human_time()} 发布后任务... \r\n')
|
||||
helper.send_info(h_id, '发布后任务... \r\n')
|
||||
command = f'cd {extend.dst_dir} && {extend.hook_post_host}'
|
||||
helper.remote(host.id, ssh, command)
|
||||
|
||||
helper.send_step(h_id, 100, f'\r\n{human_time()} ** \033[32m发布成功\033[0m **')
|
||||
helper.send_success(h_id, '\r\n** 发布成功 **', status='success')
|
||||
|
||||
|
||||
def _deploy_ext2_host(helper, h_id, actions, env, spug_version):
|
||||
helper.send_info(h_id, '\033[32m就绪√\033[0m\r\n')
|
||||
host = Host.objects.filter(pk=h_id).first()
|
||||
if not host:
|
||||
helper.send_error(h_id, 'no such host')
|
||||
env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname})
|
||||
with host.get_ssh(default_env=env) as ssh:
|
||||
for index, action in enumerate(actions):
|
||||
helper.send_step(h_id, 1 + index, f'{human_time()} {action["title"]}...\r\n')
|
||||
helper.send_clear(h_id)
|
||||
helper.save_pid(ssh.get_pid(), h_id)
|
||||
helper.send_success(h_id, '', status='doing')
|
||||
for index, action in enumerate(actions, start=1):
|
||||
if action.get('type') == 'transfer':
|
||||
helper.send_info(h_id, f'{action["title"]}...')
|
||||
if action.get('src_mode') == '1':
|
||||
try:
|
||||
dst = action['dst']
|
||||
|
|
@ -302,8 +319,8 @@ def _deploy_ext2_host(helper, h_id, actions, env, spug_version):
|
|||
callback = helper.progress_callback(host.id)
|
||||
ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, spug_version), dst, callback)
|
||||
except Exception as e:
|
||||
helper.send_error(host.id, f'Exception: {e}')
|
||||
helper.send_info(host.id, 'transfer completed\r\n')
|
||||
helper.send_error(host.id, f'\r\nException: {e}')
|
||||
helper.send_success(host.id, '完成√\r\n')
|
||||
continue
|
||||
else:
|
||||
sp_dir, sd_dst = os.path.split(action['src'])
|
||||
|
|
@ -312,13 +329,15 @@ def _deploy_ext2_host(helper, h_id, actions, env, spug_version):
|
|||
callback = helper.progress_callback(host.id)
|
||||
ssh.put_file(os.path.join(sp_dir, tar_gz_file), f'/tmp/{tar_gz_file}', callback)
|
||||
except Exception as e:
|
||||
helper.send_error(host.id, f'Exception: {e}')
|
||||
helper.send_error(host.id, f'\r\nException: {e}')
|
||||
|
||||
command = f'mkdir -p /tmp/{spug_version} && tar xf /tmp/{tar_gz_file} -C /tmp/{spug_version}/ '
|
||||
command = f'mkdir -p /tmp/{spug_version} '
|
||||
command += f'&& tar xf /tmp/{tar_gz_file} -C /tmp/{spug_version}/ 2> /dev/null '
|
||||
command += f'&& rm -rf {action["dst"]} && mv /tmp/{spug_version}/{sd_dst} {action["dst"]} '
|
||||
command += f'&& rm -rf /tmp/{spug_version}* && echo "transfer completed"'
|
||||
command += f'&& rm -rf /tmp/{spug_version}* && echo "\033[32m完成√\033[0m"'
|
||||
else:
|
||||
helper.send_info(h_id, f'{action["title"]}...\r\n')
|
||||
command = f'cd /tmp && {action["data"]}'
|
||||
helper.remote(host.id, ssh, command)
|
||||
|
||||
helper.send_step(h_id, 100, f'\r\n{human_time()} ** \033[32m发布成功\033[0m **')
|
||||
helper.send_success(h_id, f'\r\n** 发布成功 **', status='success')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from django.db.models import F
|
|||
from django.conf import settings
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django_redis import get_redis_connection
|
||||
from libs import json_response, JsonParser, Argument, human_datetime, human_time, auth
|
||||
from libs import json_response, JsonParser, Argument, human_datetime, human_time, auth, AttrDict
|
||||
from apps.deploy.models import DeployRequest
|
||||
from apps.app.models import Deploy, DeployExtend2
|
||||
from apps.repository.models import Repository
|
||||
|
|
@ -104,47 +104,29 @@ class RequestDetailView(View):
|
|||
req = DeployRequest.objects.filter(pk=r_id).first()
|
||||
if not req:
|
||||
return json_response(error='未找到指定发布申请')
|
||||
response = AttrDict(status=req.status)
|
||||
hosts = Host.objects.filter(id__in=json.loads(req.host_ids))
|
||||
outputs = {x.id: {'id': x.id, 'title': x.name, 'data': f'{human_time()} 读取数据... '} for x in hosts}
|
||||
response = {'outputs': outputs, 'status': req.status}
|
||||
if req.is_quick_deploy:
|
||||
outputs['local'] = {'id': 'local', 'data': ''}
|
||||
outputs = {x.id: {'id': x.id, 'title': x.name, 'data': ''} for x in hosts}
|
||||
outputs['local'] = {'id': 'local', 'data': ''}
|
||||
if req.deploy.extend == '2':
|
||||
outputs['local'] = {'id': 'local', 'data': f'{human_time()} 读取数据... '}
|
||||
response['s_actions'] = json.loads(req.deploy.extend_obj.server_actions)
|
||||
response['h_actions'] = json.loads(req.deploy.extend_obj.host_actions)
|
||||
if not response['h_actions']:
|
||||
response['outputs'] = {'local': outputs['local']}
|
||||
rds, key, counter = get_redis_connection(), f'{settings.REQUEST_KEY}:{r_id}', 0
|
||||
data = rds.lrange(key, counter, counter + 9)
|
||||
while data:
|
||||
for item in data:
|
||||
counter += 1
|
||||
item = json.loads(item.decode())
|
||||
if item['key'] in outputs:
|
||||
if 'data' in item:
|
||||
outputs[item['key']]['data'] += item['data']
|
||||
if 'step' in item:
|
||||
outputs[item['key']]['step'] = item['step']
|
||||
if 'status' in item:
|
||||
outputs[item['key']]['status'] = item['status']
|
||||
data = rds.lrange(key, counter, counter + 9)
|
||||
response['index'] = counter
|
||||
if counter == 0:
|
||||
for item in outputs:
|
||||
outputs[item]['data'] += '\r\n\r\n未读取到数据,Spug 仅保存最近2周的日志信息。'
|
||||
s_actions = json.loads(req.deploy.extend_obj.server_actions)
|
||||
h_actions = json.loads(req.deploy.extend_obj.host_actions)
|
||||
if not s_actions:
|
||||
outputs.pop('local')
|
||||
if not h_actions:
|
||||
outputs = {'local': outputs['local']}
|
||||
elif not req.is_quick_deploy:
|
||||
outputs.pop('local')
|
||||
|
||||
if req.is_quick_deploy:
|
||||
if outputs['local']['data']:
|
||||
outputs['local']['data'] = f'{human_time()} 读取数据... ' + outputs['local']['data']
|
||||
else:
|
||||
outputs['local'].update(step=100, data=f'{human_time()} 已构建完成忽略执行。')
|
||||
response.index = Helper.fill_outputs(outputs, req.deploy_key)
|
||||
response.token = req.deploy_key
|
||||
response.outputs = outputs
|
||||
return json_response(response)
|
||||
|
||||
@auth('deploy.request.do')
|
||||
def post(self, request, r_id):
|
||||
form, _ = JsonParser(Argument('mode', default='all')).parse(request.body)
|
||||
query = {'pk': r_id}
|
||||
query, is_fail_mode = {'pk': r_id}, form.mode == 'fail'
|
||||
if not request.user.is_supper:
|
||||
perms = request.user.deploy_perms
|
||||
query['deploy__app_id__in'] = perms['apps']
|
||||
|
|
@ -154,32 +136,38 @@ class RequestDetailView(View):
|
|||
return json_response(error='未找到指定发布申请')
|
||||
if req.status not in ('1', '-3'):
|
||||
return json_response(error='该申请单当前状态还不能执行发布')
|
||||
host_ids = req.fail_host_ids if is_fail_mode else req.host_ids
|
||||
|
||||
host_ids = req.fail_host_ids if form.mode == 'fail' else req.host_ids
|
||||
hosts = Host.objects.filter(id__in=json.loads(host_ids))
|
||||
message = f'{human_time()} 等待调度... '
|
||||
outputs = {x.id: {'id': x.id, 'title': x.name, 'step': 0, 'data': message} for x in hosts}
|
||||
req.status = '2'
|
||||
req.do_at = human_datetime()
|
||||
req.do_by = request.user
|
||||
req.save()
|
||||
Thread(target=dispatch, args=(req, form.mode == 'fail')).start()
|
||||
Thread(target=dispatch, args=(req, is_fail_mode)).start()
|
||||
|
||||
hosts = Host.objects.filter(id__in=json.loads(host_ids))
|
||||
message = Helper.term_message('等待调度... ')
|
||||
outputs = {x.id: {'id': x.id, 'title': x.name, 'data': message} for x in hosts}
|
||||
if req.is_quick_deploy:
|
||||
if req.repository_id:
|
||||
outputs['local'] = {'id': 'local', 'step': 100, 'data': f'{human_time()} 已构建完成忽略执行。'}
|
||||
outputs['local'] = {
|
||||
'id': 'local',
|
||||
'status': 'success',
|
||||
'data': Helper.term_message('已构建完成忽略执行', 'warn')
|
||||
}
|
||||
else:
|
||||
outputs['local'] = {'id': 'local', 'step': 0, 'data': f'{human_time()} 建立连接... '}
|
||||
outputs['local'] = {'id': 'local', 'data': Helper.term_message('等待初始化... ')}
|
||||
if req.deploy.extend == '2':
|
||||
outputs['local'] = {'id': 'local', 'step': 0, 'data': f'{human_time()} 建立连接... '}
|
||||
message = Helper.term_message('等待初始化... ')
|
||||
if is_fail_mode:
|
||||
message = Helper.term_message('已完成本地动作忽略执行', 'warn')
|
||||
outputs['local'] = {'id': 'local', 'data': message}
|
||||
s_actions = json.loads(req.deploy.extend_obj.server_actions)
|
||||
h_actions = json.loads(req.deploy.extend_obj.host_actions)
|
||||
for item in h_actions:
|
||||
if item.get('type') == 'transfer' and item.get('src_mode') == '0':
|
||||
s_actions.append({'title': '执行打包'})
|
||||
if not s_actions:
|
||||
outputs.pop('local')
|
||||
if not h_actions:
|
||||
outputs = {'local': outputs['local']}
|
||||
return json_response({'s_actions': s_actions, 'h_actions': h_actions, 'outputs': outputs})
|
||||
return json_response({'outputs': outputs})
|
||||
return json_response({'outputs': outputs, 'token': req.deploy_key})
|
||||
|
||||
@auth('deploy.request.approve')
|
||||
def patch(self, request, r_id):
|
||||
|
|
@ -266,7 +254,8 @@ def post_request_ext1_rollback(request):
|
|||
requests = DeployRequest.objects.filter(deploy=req.deploy, status__in=('3', '-3'))
|
||||
versions = list({x.spug_version: 1 for x in requests}.keys())
|
||||
if req.spug_version not in versions[:req.deploy.extend_obj.versions + 1]:
|
||||
return json_response(error='选择的版本超出了发布配置中设置的版本数量,无法快速回滚,可通过新建发布申请选择构建仓库里的该版本再次发布。')
|
||||
return json_response(
|
||||
error='选择的版本超出了发布配置中设置的版本数量,无法快速回滚,可通过新建发布申请选择构建仓库里的该版本再次发布。')
|
||||
|
||||
form.status = '0' if req.deploy.is_audit else '1'
|
||||
form.host_ids = json.dumps(sorted(form.host_ids))
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class Job:
|
|||
code = -1
|
||||
try:
|
||||
with self.ssh:
|
||||
self.rds.set(self.rds_key, self.ssh.get_pid(), 7200)
|
||||
self.rds.set(self.rds_key, self.ssh.get_pid(), 3600)
|
||||
for code, out in self.ssh.exec_command_with_stream(self.command, self.env):
|
||||
self.send(out)
|
||||
human_time = human_seconds_time(time.time() - flag)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from apps.host.models import Host
|
|||
from apps.account.utils import has_host_perm
|
||||
import uuid
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
class TemplateView(View):
|
||||
|
|
@ -120,19 +121,25 @@ class TaskView(View):
|
|||
return json_response(error=error)
|
||||
|
||||
|
||||
@auth('exec.task.do')
|
||||
@auth('exec.task.do|deploy.request.do')
|
||||
def handle_terminate(request):
|
||||
form, error = JsonParser(
|
||||
Argument('token', help='参数错误'),
|
||||
Argument('host_id', type=int, help='参数错误')
|
||||
Argument('target', help='参数错误')
|
||||
).parse(request.body)
|
||||
if error is None:
|
||||
host = Host.objects.get(pk=form.host_id)
|
||||
rds = get_redis_connection()
|
||||
rds_key = f'PID:{form.token}:{host.id}'
|
||||
rds_key = f'PID:{form.token}:{form.target}'
|
||||
pid = rds.get(rds_key)
|
||||
if pid:
|
||||
if not pid:
|
||||
return json_response(error='未找到关联进程')
|
||||
if form.target.isdigit():
|
||||
host = Host.objects.get(pk=form.target)
|
||||
with host.get_ssh() as ssh:
|
||||
ssh.exec_command_raw(f'kill -9 {pid.decode()}')
|
||||
rds.delete(rds_key)
|
||||
elif form.target == 'local':
|
||||
gid = os.getpgid(int(pid))
|
||||
if gid:
|
||||
os.killpg(gid, 9)
|
||||
rds.delete(rds_key)
|
||||
return json_response(error=error)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ class Repository(models.Model, ModelMixin):
|
|||
def make_spug_version(deploy_id):
|
||||
return f'{deploy_id}_{datetime.now().strftime("%Y%m%d%H%M%S")}'
|
||||
|
||||
@property
|
||||
def deploy_key(self):
|
||||
if self.remarks == 'SPUG AUTO MAKE':
|
||||
req = self.deployrequest_set.last()
|
||||
if req:
|
||||
return f'{settings.REQUEST_KEY}:{req.id}'
|
||||
return f'{settings.BUILD_KEY}:{self.id}'
|
||||
|
||||
def to_view(self):
|
||||
tmp = self.to_dict()
|
||||
tmp['extra'] = json.loads(self.extra)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
from django_redis import get_redis_connection
|
||||
from django.conf import settings
|
||||
from django.db import close_old_connections
|
||||
from libs.utils import AttrDict, human_time, render_str
|
||||
from libs.utils import AttrDict, human_datetime, render_str
|
||||
from apps.repository.models import Repository
|
||||
from apps.app.utils import fetch_repo
|
||||
from apps.config.utils import compose_configs
|
||||
|
|
@ -22,13 +22,11 @@ def dispatch(rep: Repository, helper=None):
|
|||
alone_build = helper is None
|
||||
if not helper:
|
||||
rds = get_redis_connection()
|
||||
rds_key = f'{settings.BUILD_KEY}:{rep.spug_version}'
|
||||
helper = Helper.make(rds, rds_key)
|
||||
helper = Helper.make(rds, rep.deploy_key, ['local'])
|
||||
rep.save()
|
||||
try:
|
||||
api_token = uuid.uuid4().hex
|
||||
helper.rds.setex(api_token, 60 * 60, f'{rep.app_id},{rep.env_id}')
|
||||
helper.send_info('local', f'\033[32m完成√\033[0m\r\n{human_time()} 构建准备... ')
|
||||
env = AttrDict(
|
||||
SPUG_APP_NAME=rep.app.name,
|
||||
SPUG_APP_KEY=rep.app.key,
|
||||
|
|
@ -42,6 +40,21 @@ def dispatch(rep: Repository, helper=None):
|
|||
SPUG_API_TOKEN=api_token,
|
||||
SPUG_REPOS_DIR=REPOS_DIR,
|
||||
)
|
||||
helper.send_clear('local')
|
||||
helper.send_info('local', f'应用名称: {rep.app.name}\r\n', with_time=False)
|
||||
helper.send_info('local', f'执行环境: {rep.env.name}\r\n', with_time=False)
|
||||
extras = json.loads(rep.extra)
|
||||
if extras[0] == 'branch':
|
||||
env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2])
|
||||
helper.send_info('local', f'代码分支: {extras[1]}/{extras[2][:8]}\r\n', with_time=False)
|
||||
else:
|
||||
env.update(SPUG_GIT_TAG=extras[1])
|
||||
helper.send_info('local', f'代码版本: {extras[1]}', with_time=False)
|
||||
helper.send_info('local', f'执行人员: {rep.created_by.nickname}\r\n', with_time=False)
|
||||
helper.send_info('local', f'执行时间: {human_datetime()}\r\n', with_time=False)
|
||||
helper.send_warn('local', '.' * 50 + '\r\n\r\n')
|
||||
helper.send_info('local', '构建准备... ', status='doing')
|
||||
|
||||
# append configs
|
||||
configs = compose_configs(rep.app, rep.env_id)
|
||||
configs_env = {f'_SPUG_{k.upper()}': v for k, v in configs.items()}
|
||||
|
|
@ -65,34 +78,28 @@ def dispatch(rep: Repository, helper=None):
|
|||
|
||||
def _build(rep: Repository, helper, env):
|
||||
extend = rep.deploy.extend_obj
|
||||
extras = json.loads(rep.extra)
|
||||
git_dir = os.path.join(REPOS_DIR, str(rep.deploy_id))
|
||||
build_dir = os.path.join(REPOS_DIR, rep.spug_version)
|
||||
tar_file = os.path.join(BUILD_DIR, f'{rep.spug_version}.tar.gz')
|
||||
if extras[0] == 'branch':
|
||||
tree_ish = extras[2]
|
||||
env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2])
|
||||
else:
|
||||
tree_ish = extras[1]
|
||||
env.update(SPUG_GIT_TAG=extras[1])
|
||||
env.update(SPUG_DST_DIR=render_str(extend.dst_dir, env))
|
||||
fetch_repo(rep.deploy_id, extend.git_repo)
|
||||
helper.send_info('local', '\033[32m完成√\033[0m\r\n')
|
||||
helper.send_success('local', '完成√\r\n')
|
||||
|
||||
if extend.hook_pre_server:
|
||||
helper.send_step('local', 1, f'{human_time()} 检出前任务...\r\n')
|
||||
helper.send_info('local', '检出前任务...\r\n')
|
||||
helper.local(f'cd {git_dir} && {extend.hook_pre_server}', env)
|
||||
|
||||
helper.send_step('local', 2, f'{human_time()} 执行检出... ')
|
||||
helper.send_info('local', '执行检出... ')
|
||||
tree_ish = env.get('SPUG_GIT_COMMIT_ID') or env.get('SPUG_GIT_TAG')
|
||||
command = f'cd {git_dir} && git archive --prefix={rep.spug_version}/ {tree_ish} | (cd .. && tar xf -)'
|
||||
helper.local(command)
|
||||
helper.send_info('local', '\033[32m完成√\033[0m\r\n')
|
||||
helper.send_success('local', '完成√\r\n')
|
||||
|
||||
if extend.hook_post_server:
|
||||
helper.send_step('local', 3, f'{human_time()} 检出后任务...\r\n')
|
||||
helper.send_info('local', '检出后任务...\r\n')
|
||||
helper.local(f'cd {build_dir} && {extend.hook_post_server}', env)
|
||||
|
||||
helper.send_step('local', 4, f'\r\n{human_time()} 执行打包... ')
|
||||
helper.send_info('local', '执行打包... ')
|
||||
filter_rule, exclude, contain = json.loads(extend.filter_rule), '', rep.spug_version
|
||||
files = helper.parse_filter_rule(filter_rule['data'], env=env)
|
||||
if files:
|
||||
|
|
@ -107,5 +114,5 @@ def _build(rep: Repository, helper, env):
|
|||
else:
|
||||
contain = ' '.join(f'{rep.spug_version}/{x}' for x in files)
|
||||
helper.local(f'mkdir -p {BUILD_DIR} && cd {REPOS_DIR} && tar zcf {tar_file} {exclude} {contain}')
|
||||
helper.send_step('local', 5, f'\033[32m完成√\033[0m')
|
||||
helper.send_step('local', 100, f'\r\n\r\n{human_time()} ** \033[32m构建成功\033[0m **')
|
||||
helper.send_success('local', '完成√\r\n')
|
||||
helper.send_success('local', '\r\n** 构建成功 **\r\n', status='success')
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ from django.views.generic import View
|
|||
from django.db.models import F
|
||||
from django.conf import settings
|
||||
from django_redis import get_redis_connection
|
||||
from libs import json_response, JsonParser, Argument, human_time, AttrDict, auth
|
||||
from libs import json_response, JsonParser, Argument, AttrDict, auth
|
||||
from apps.repository.models import Repository
|
||||
from apps.deploy.models import DeployRequest
|
||||
from apps.repository.utils import dispatch
|
||||
from apps.deploy.helper import Helper
|
||||
from apps.app.models import Deploy
|
||||
from threading import Thread
|
||||
import json
|
||||
|
|
@ -109,31 +110,14 @@ def get_detail(request, r_id):
|
|||
repository = Repository.objects.filter(pk=r_id).first()
|
||||
if not repository:
|
||||
return json_response(error='未找到指定构建记录')
|
||||
rds, counter = get_redis_connection(), 0
|
||||
if repository.remarks == 'SPUG AUTO MAKE':
|
||||
req = repository.deployrequest_set.last()
|
||||
key = f'{settings.REQUEST_KEY}:{req.id}'
|
||||
else:
|
||||
key = f'{settings.BUILD_KEY}:{repository.spug_version}'
|
||||
data = rds.lrange(key, counter, counter + 9)
|
||||
response = AttrDict(data='', step=0, s_status='process', status=repository.status)
|
||||
while data:
|
||||
for item in data:
|
||||
counter += 1
|
||||
item = json.loads(item.decode())
|
||||
if item['key'] == 'local':
|
||||
if 'data' in item:
|
||||
response.data += item['data']
|
||||
if 'step' in item:
|
||||
response.step = item['step']
|
||||
if 'status' in item:
|
||||
response.status = item['status']
|
||||
data = rds.lrange(key, counter, counter + 9)
|
||||
response.index = counter
|
||||
deploy_key = repository.deploy_key
|
||||
response = AttrDict(status=repository.status, token=deploy_key)
|
||||
output = {'data': ''}
|
||||
response.index = Helper.fill_outputs({'local': output}, deploy_key)
|
||||
|
||||
if repository.status in ('0', '1'):
|
||||
response.data = f'{human_time()} 建立连接... ' + response.data
|
||||
elif not response.data:
|
||||
response.data = f'{human_time()} 读取数据... \r\n\r\n未读取到数据,Spug 仅保存最近2周的构建日志。'
|
||||
else:
|
||||
response.data = f'{human_time()} 读取数据... ' + response.data
|
||||
output['data'] = Helper.term_message('等待初始化...') + output['data']
|
||||
elif not output['data']:
|
||||
output['data'] = Helper.term_message('未读取到数据,可能已被清理', 'warn', with_time=False)
|
||||
response.output = output
|
||||
return json_response(response)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ BUILD_KEY = 'spug:build'
|
|||
REPOS_DIR = os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'repos')
|
||||
BUILD_DIR = os.path.join(REPOS_DIR, 'build')
|
||||
TRANSFER_DIR = os.path.join(BASE_DIR, 'storage', 'transfer')
|
||||
DEPLOY_DIR = os.path.join(BASE_DIR, 'storage', 'deploy')
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||
|
|
|
|||
|
|
@ -5,33 +5,41 @@
|
|||
*/
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { FullscreenOutlined, FullscreenExitOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
FullscreenOutlined,
|
||||
FullscreenExitOutlined,
|
||||
LoadingOutlined,
|
||||
StopOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { Terminal } from 'xterm';
|
||||
import { Modal, Steps, Spin } from 'antd';
|
||||
import { Modal, Spin, Tooltip } from 'antd';
|
||||
import { X_TOKEN, http } from 'libs';
|
||||
import styles from './index.module.less';
|
||||
import gStore from 'gStore';
|
||||
import store from './store';
|
||||
import lds from 'lodash';
|
||||
|
||||
export default observer(function Console() {
|
||||
const el = useRef()
|
||||
const [term] = useState(new Terminal({disableStdin: true}))
|
||||
const [token, setToken] = useState();
|
||||
const [status, setStatus] = useState();
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [step, setStep] = useState(0);
|
||||
const [status, setStatus] = useState('process');
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let socket;
|
||||
initialTerm()
|
||||
http.get(`/api/repository/${store.record.id}/`)
|
||||
.then(res => {
|
||||
term.write(res.data)
|
||||
setStep(res.step)
|
||||
setToken(res.token)
|
||||
setStatus(res.output.status)
|
||||
term.write(res.output.data)
|
||||
if (res.status === '1') {
|
||||
socket = _makeSocket(res.index)
|
||||
} else {
|
||||
setStatus('wait')
|
||||
}
|
||||
})
|
||||
.finally(() => setFetching(false))
|
||||
|
|
@ -39,40 +47,13 @@ export default observer(function Console() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function _makeSocket(index = 0) {
|
||||
const token = store.record.spug_version;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/build/${token}/?x-token=${X_TOKEN}`);
|
||||
socket.onopen = () => socket.send(String(index));
|
||||
socket.onmessage = e => {
|
||||
if (e.data === 'pong') {
|
||||
socket.send(String(index))
|
||||
} else {
|
||||
index += 1;
|
||||
const {data, step, status} = JSON.parse(e.data);
|
||||
if (data !== undefined) term.write(data);
|
||||
if (step !== undefined) setStep(step);
|
||||
if (status !== undefined) setStatus(status);
|
||||
}
|
||||
}
|
||||
socket.onerror = () => {
|
||||
setStatus('error')
|
||||
term.reset()
|
||||
term.write('\u001b[31mWebsocket connection failed!\u001b[0m')
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
term.fit && term.fit()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fullscreen])
|
||||
|
||||
function initialTerm() {
|
||||
const fitPlugin = new FitAddon()
|
||||
term.loadAddon(fitPlugin)
|
||||
term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei')
|
||||
term.setOption('theme', {background: '#fafafa', foreground: '#000', selection: '#999'})
|
||||
term.setOption('fontSize', 14)
|
||||
term.setOption('lineHeight', 1.2)
|
||||
term.setOption('fontFamily', gStore.terminal.fontFamily)
|
||||
term.setOption('theme', {background: '#2b2b2b', foreground: '#A9B7C6', cursor: '#2b2b2b'})
|
||||
term.attachCustomKeyEventHandler((arg) => {
|
||||
if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {
|
||||
document.execCommand('copy')
|
||||
|
|
@ -83,19 +64,47 @@ export default observer(function Console() {
|
|||
term.open(el.current)
|
||||
term.fit = () => fitPlugin.fit()
|
||||
fitPlugin.fit()
|
||||
const resize = () => fitPlugin.fit();
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
return () => window.removeEventListener('resize', resize);
|
||||
}, [])
|
||||
|
||||
function _makeSocket(index = 0) {
|
||||
const token = store.record.id;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/build/${token}/?x-token=${X_TOKEN}`);
|
||||
socket.onopen = () => socket.send(String(index));
|
||||
socket.onmessage = e => {
|
||||
if (e.data === 'pong') {
|
||||
socket.send(String(index))
|
||||
} else {
|
||||
index += 1;
|
||||
const {data, status} = JSON.parse(e.data);
|
||||
if (!lds.isNil(data)) term.write(data);
|
||||
if (!lds.isNil(status)) setStatus(status);
|
||||
}
|
||||
}
|
||||
socket.onerror = () => {
|
||||
term.write('\r\n\x1b[31mWebsocket connection failed!\x1b[0m')
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
term.fit && term.fit()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fullscreen])
|
||||
|
||||
function handleClose() {
|
||||
store.fetchRecords();
|
||||
store.logVisible = false
|
||||
}
|
||||
|
||||
function StepItem(props) {
|
||||
let icon = null;
|
||||
if (props.step === step && status === 'process') {
|
||||
icon = <LoadingOutlined style={{fontSize: 32}}/>
|
||||
}
|
||||
return <Steps.Step {...props} icon={icon}/>
|
||||
function handleTerminate() {
|
||||
setLoading(true)
|
||||
http.post('/api/exec/terminate/', {token, target: 'local'})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -112,16 +121,31 @@ export default observer(function Console() {
|
|||
onCancel={handleClose}
|
||||
className={styles.console}
|
||||
maskClosable={false}>
|
||||
<Steps current={step} status={status}>
|
||||
<StepItem title="构建准备" step={0}/>
|
||||
<StepItem title="检出前任务" step={1}/>
|
||||
<StepItem title="执行检出" step={2}/>
|
||||
<StepItem title="检出后任务" step={3}/>
|
||||
<StepItem title="执行打包" step={4}/>
|
||||
</Steps>
|
||||
<Spin spinning={fetching}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{store.record.version}</div>
|
||||
{status === 'error' ? (
|
||||
<ExclamationCircleOutlined style={{color: 'red'}}/>
|
||||
) : status === 'success' ? (
|
||||
<CheckCircleOutlined style={{color: '#52c41a'}}/>
|
||||
) : (
|
||||
<LoadingOutlined style={{color: '#1890ff'}}/>
|
||||
)}
|
||||
<div style={{flex: 1}}/>
|
||||
{loading ? (
|
||||
<LoadingOutlined className={styles.icon} style={{color: '#faad14'}}/>
|
||||
) : (
|
||||
<Tooltip title="终止构建">
|
||||
{status === 'doing' ? (
|
||||
<StopOutlined style={{color: '#faad14'}} onClick={handleTerminate}/>
|
||||
) : (
|
||||
<StopOutlined style={{color: '#dfdfdf'}}/>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.out}>
|
||||
<div ref={el}/>
|
||||
<div ref={el} className={styles.term}/>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -17,12 +17,33 @@
|
|||
color: #000;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: -12px 0 12px 0;
|
||||
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.out {
|
||||
margin-top: 24px;
|
||||
padding: 8px 0 8px 15px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background-color: #fafafa;
|
||||
background-color: #2b2b2b;
|
||||
|
||||
.term {
|
||||
width: 100%;
|
||||
height: calc(100vh - 370px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* Released under the AGPL-3.0 License.
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { observer, useLocalStore } from 'mobx-react';
|
||||
import { Tooltip, Modal, Spin, Card, Progress } from 'antd';
|
||||
import {
|
||||
LoadingOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
CodeOutlined,
|
||||
StopOutlined,
|
||||
ShrinkOutlined,
|
||||
ClockCircleOutlined, CloseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { Terminal } from 'xterm';
|
||||
import styles from './console.module.less';
|
||||
import { clsNames, http, X_TOKEN } from 'libs';
|
||||
import store from './store';
|
||||
import gStore from 'gStore';
|
||||
import lds from 'lodash';
|
||||
|
||||
let gCurrent;
|
||||
|
||||
function Console(props) {
|
||||
const el = useRef()
|
||||
const outputs = useLocalStore(() => ({}));
|
||||
const [term] = useState(new Terminal());
|
||||
const [fitPlugin] = useState(new FitAddon());
|
||||
const [token, setToken] = useState();
|
||||
const [current, setCurrent] = useState();
|
||||
const [sides, setSides] = useState([]);
|
||||
const [miniMode, setMiniMode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
|
||||
useEffect(props.request.mode === 'read' ? readDeploy : doDeploy, [])
|
||||
|
||||
useEffect(() => {
|
||||
gCurrent = current
|
||||
term.setOption('disableStdin', true)
|
||||
term.setOption('fontSize', 14)
|
||||
term.setOption('lineHeight', 1.2)
|
||||
term.setOption('fontFamily', gStore.terminal.fontFamily)
|
||||
term.setOption('theme', {background: '#2b2b2b', foreground: '#A9B7C6', cursor: '#2b2b2b'})
|
||||
term.attachCustomKeyEventHandler((arg) => {
|
||||
if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {
|
||||
document.execCommand('copy')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
term.loadAddon(fitPlugin)
|
||||
term.open(el.current)
|
||||
fitPlugin.fit()
|
||||
// term.write('\x1b[36m### WebSocket connecting ...\x1b[0m')
|
||||
const resize = () => fitPlugin.fit();
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
return () => window.removeEventListener('resize', resize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function readDeploy() {
|
||||
let socket;
|
||||
http.get(`/api/deploy/request/${props.request.id}/`)
|
||||
.then(res => {
|
||||
_handleResponse(res)
|
||||
if (res.status === '2') {
|
||||
socket = _makeSocket(res.index)
|
||||
}
|
||||
})
|
||||
return () => socket && socket.close()
|
||||
}
|
||||
|
||||
function doDeploy() {
|
||||
let socket;
|
||||
http.post(`/api/deploy/request/${props.request.id}/`, {mode: props.request.mode})
|
||||
.then(res => {
|
||||
_handleResponse(res)
|
||||
socket = _makeSocket()
|
||||
store.fetchInfo(props.request.id)
|
||||
})
|
||||
return () => socket && socket.close()
|
||||
}
|
||||
|
||||
function _handleResponse(res) {
|
||||
Object.assign(outputs, res.outputs)
|
||||
let tmp = Object.values(res.outputs).map(x => lds.pick(x, ['id', 'title']))
|
||||
tmp = lds.reverse(lds.sortBy(tmp, [x => String(x.id)]))
|
||||
setToken(res.token)
|
||||
setSides(tmp)
|
||||
setTimeout(() => {
|
||||
setFetching(false)
|
||||
handleSwitch(tmp[0]?.id)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function _makeSocket(index = 0) {
|
||||
const token = props.request.id;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/request/${token}/?x-token=${X_TOKEN}`);
|
||||
socket.onopen = () => socket.send(String(index));
|
||||
socket.onmessage = e => {
|
||||
if (e.data === 'pong') {
|
||||
socket.send(String(index))
|
||||
} else {
|
||||
index += 1;
|
||||
const {key, data, status} = JSON.parse(e.data);
|
||||
if (!outputs[key]) return
|
||||
if (!lds.isNil(data)) {
|
||||
outputs[key].data += data
|
||||
if (key === gCurrent) term.write(data)
|
||||
}
|
||||
if (!lds.isNil(status)) outputs[key].status = status;
|
||||
}
|
||||
}
|
||||
socket.onerror = () => {
|
||||
for (let key of Object.keys(store.outputs)) {
|
||||
if (outputs[key].status === -2) {
|
||||
outputs[key].status = -1
|
||||
}
|
||||
outputs[key].data += '\r\n\x1b[31mWebsocket connection failed!\x1b[0m'
|
||||
term.write('\r\n\x1b[31mWebsocket connection failed!\x1b[0m')
|
||||
}
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
function handleSwitch(key) {
|
||||
if (key === current) return
|
||||
setCurrent(key)
|
||||
gCurrent = key
|
||||
term.reset()
|
||||
term.write(outputs[key].data)
|
||||
}
|
||||
|
||||
function handleTerminate() {
|
||||
setLoading(true)
|
||||
http.post('/api/exec/terminate/', {token, target: current})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
function openTerminal() {
|
||||
window.open(`/ssh?id=${current}`)
|
||||
}
|
||||
|
||||
const cItem = outputs[current] || {}
|
||||
const localTitle = props.request.app_extend === '2' ? '本地动作' : '构建'
|
||||
return (
|
||||
<React.Fragment>
|
||||
{miniMode && (
|
||||
<Card
|
||||
className={styles.miniCard}
|
||||
bodyStyle={{padding: 0}}
|
||||
onClick={() => setMiniMode(false)}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{props.request.name}</div>
|
||||
<CloseOutlined onClick={() => store.showConsole(props.request, true)}/>
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{sides.map(item => (
|
||||
<div key={item.id} className={clsNames(styles.item, item.id === current && styles.active)}
|
||||
onClick={() => handleSwitch(item.id)}>
|
||||
{outputs[item.id]?.status === 'error' ? (
|
||||
<ExclamationCircleOutlined style={{color: 'red'}}/>
|
||||
) : outputs[item.id]?.status === 'success' ? (
|
||||
<CheckCircleOutlined style={{color: '#52c41a'}}/>
|
||||
) : outputs[item.id]?.status === 'doing' ? (
|
||||
<LoadingOutlined style={{color: '#1890ff'}}/>
|
||||
) : (
|
||||
<ClockCircleOutlined style={{color: '#faad14'}}/>
|
||||
)}
|
||||
{item.id === 'local' ? (
|
||||
<div className={styles.text} style={{color: '#0E989A'}}>{localTitle}</div>
|
||||
) : (
|
||||
<div className={styles.text}>{item.title}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
visible={!miniMode}
|
||||
width="80%"
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
className={styles.container}
|
||||
bodyStyle={{padding: 0}}
|
||||
onCancel={() => store.showConsole(props.request, true)}
|
||||
title={[
|
||||
<span key="1">{props.request.name}</span>,
|
||||
<div key="2" className={styles.miniIcon} onClick={() => setMiniMode(true)}>
|
||||
<ShrinkOutlined/>
|
||||
</div>
|
||||
]}>
|
||||
<Spin spinning={fetching}>
|
||||
<div className={styles.output}>
|
||||
<div className={styles.side}>
|
||||
<div className={styles.title}>任务列表</div>
|
||||
<div className={styles.list}>
|
||||
{sides.map(item => (
|
||||
<div key={item.id} className={clsNames(styles.item, item.id === current && styles.active)}
|
||||
onClick={() => handleSwitch(item.id)}>
|
||||
{outputs[item.id]?.status === 'error' ? (
|
||||
<ExclamationCircleOutlined style={{color: 'red'}}/>
|
||||
) : outputs[item.id]?.status === 'success' ? (
|
||||
<CheckCircleOutlined style={{color: '#52c41a'}}/>
|
||||
) : outputs[item.id]?.status === 'doing' ? (
|
||||
<LoadingOutlined style={{color: '#1890ff'}}/>
|
||||
) : (
|
||||
<ClockCircleOutlined style={{color: '#faad14'}}/>
|
||||
)}
|
||||
{item.id === 'local' ? (
|
||||
<div className={styles.text} style={{color: '#0E989A'}}>{localTitle}</div>
|
||||
) : (
|
||||
<div className={styles.text}>{item.title}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{cItem.id === 'local' ? localTitle : cItem.title}</div>
|
||||
{loading ? (
|
||||
<LoadingOutlined className={styles.icon} style={{color: '#faad14'}}/>
|
||||
) : (
|
||||
<Tooltip title="终止发布">
|
||||
{cItem.status === 'doing' ? (
|
||||
<StopOutlined className={styles.icon} style={{color: '#faad14'}} onClick={handleTerminate}/>
|
||||
) : (
|
||||
<StopOutlined className={styles.icon} style={{color: '#dfdfdf'}}/>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="打开web终端">
|
||||
<CodeOutlined className={styles.icon} onClick={() => openTerminal(current)}/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.termContainer}>
|
||||
<div ref={el} className={styles.term}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Console)
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* Released under the AGPL-3.0 License.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { observer, useLocalStore } from 'mobx-react';
|
||||
import { Card, Progress, Modal, Collapse, Steps, Skeleton } from 'antd';
|
||||
import { ShrinkOutlined, LoadingOutlined, CloseOutlined, CodeOutlined } from '@ant-design/icons';
|
||||
import OutView from './OutView';
|
||||
import { http, X_TOKEN } from 'libs';
|
||||
import styles from './index.module.less';
|
||||
import store from './store';
|
||||
|
||||
function Ext1Console(props) {
|
||||
const outputs = useLocalStore(() => ({}));
|
||||
const terms = useLocalStore(() => ({}));
|
||||
const [mini, setMini] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
|
||||
useEffect(props.request.mode === 'read' ? readDeploy : doDeploy, [])
|
||||
|
||||
function readDeploy() {
|
||||
let socket;
|
||||
http.get(`/api/deploy/request/${props.request.id}/`)
|
||||
.then(res => {
|
||||
Object.assign(outputs, res.outputs)
|
||||
setTimeout(() => setFetching(false), 100)
|
||||
if (res.status === '2') {
|
||||
socket = _makeSocket(res.index)
|
||||
}
|
||||
})
|
||||
return () => socket && socket.close()
|
||||
}
|
||||
|
||||
function doDeploy() {
|
||||
let socket;
|
||||
http.post(`/api/deploy/request/${props.request.id}/`, {mode: props.request.mode})
|
||||
.then(res => {
|
||||
Object.assign(outputs, res.outputs)
|
||||
setTimeout(() => setFetching(false), 100)
|
||||
socket = _makeSocket()
|
||||
store.fetchInfo(props.request.id)
|
||||
})
|
||||
return () => socket && socket.close()
|
||||
}
|
||||
|
||||
function _makeSocket(index = 0) {
|
||||
const token = props.request.id;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/request/${token}/?x-token=${X_TOKEN}`);
|
||||
socket.onopen = () => socket.send(String(index));
|
||||
socket.onmessage = e => {
|
||||
if (e.data === 'pong') {
|
||||
socket.send(String(index))
|
||||
} else {
|
||||
index += 1;
|
||||
const {key, data, step, status} = JSON.parse(e.data);
|
||||
if (!outputs[key]) return
|
||||
if (data !== undefined) {
|
||||
outputs[key].data += data
|
||||
if (terms[key]) terms[key].write(data)
|
||||
}
|
||||
if (step !== undefined) outputs[key].step = step;
|
||||
if (status !== undefined) outputs[key].status = status;
|
||||
}
|
||||
}
|
||||
socket.onerror = () => {
|
||||
for (let key of Object.keys(outputs)) {
|
||||
outputs[key]['status'] = 'error'
|
||||
outputs[key].data = '\u001b[31mWebsocket connection failed!\u001b[0m'
|
||||
if (terms[key]) {
|
||||
terms[key].reset()
|
||||
terms[key].write('\u001b[31mWebsocket connection failed!\u001b[0m')
|
||||
}
|
||||
}
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
function StepItem(props) {
|
||||
let icon = null;
|
||||
if (props.step === props.item.step && props.item.status !== 'error') {
|
||||
icon = <LoadingOutlined/>
|
||||
}
|
||||
return <Steps.Step {...props} icon={icon}/>
|
||||
}
|
||||
|
||||
function switchMiniMode() {
|
||||
setMini(true)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
function handleSetTerm(term, key) {
|
||||
if (outputs[key] && outputs[key].data) {
|
||||
term.write(outputs[key].data)
|
||||
}
|
||||
terms[key] = term
|
||||
}
|
||||
|
||||
function openTerminal(e, item) {
|
||||
e.stopPropagation()
|
||||
window.open(`/ssh?id=${item.id}`)
|
||||
}
|
||||
|
||||
let {local, ...hosts} = outputs;
|
||||
return (
|
||||
<div>
|
||||
{mini && (
|
||||
<Card
|
||||
className={styles.item}
|
||||
bodyStyle={{padding: '8px 12px'}}
|
||||
onClick={() => setVisible(true)}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{props.request.name}</div>
|
||||
<CloseOutlined onClick={() => store.showConsole(props.request, true)}/>
|
||||
</div>
|
||||
{local && (
|
||||
<Progress
|
||||
percent={(local.step + 1) * 18}
|
||||
status={local.step === 100 ? 'success' : outputs.local.status === 'error' ? 'exception' : 'active'}/>
|
||||
)}
|
||||
{Object.values(hosts).map(item => (
|
||||
<Progress
|
||||
key={item.id}
|
||||
percent={(item.step + 1) * 18}
|
||||
status={item.step === 100 ? 'success' : item.status === 'error' ? 'exception' : 'active'}/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
<Modal
|
||||
visible={visible}
|
||||
width="70%"
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
className={styles.console}
|
||||
onCancel={() => store.showConsole(props.request, true)}
|
||||
title={[
|
||||
<span key="1">{props.request.name}</span>,
|
||||
<div key="2" className={styles.miniIcon} onClick={switchMiniMode}>
|
||||
<ShrinkOutlined/>
|
||||
</div>
|
||||
]}>
|
||||
<Skeleton loading={fetching} active>
|
||||
{local && (
|
||||
<Collapse defaultActiveKey={['0']} className={styles.collapse} style={{marginBottom: 24}}>
|
||||
<Collapse.Panel header={(
|
||||
<div className={styles.header}>
|
||||
<b className={styles.title}/>
|
||||
<Steps size="small" className={styles.step} current={local.step} status={local.status} style={{margin: 0}}>
|
||||
<StepItem title="构建准备" item={local} step={0}/>
|
||||
<StepItem title="检出前任务" item={local} step={1}/>
|
||||
<StepItem title="执行检出" item={local} step={2}/>
|
||||
<StepItem title="检出后任务" item={local} step={3}/>
|
||||
<StepItem title="执行打包" item={local} step={4}/>
|
||||
</Steps>
|
||||
</div>
|
||||
)}>
|
||||
<OutView setTerm={term => handleSetTerm(term, 'local')}/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
<Collapse defaultActiveKey="0" className={styles.collapse}>
|
||||
{Object.entries(hosts).map(([key, item], index) => (
|
||||
<Collapse.Panel
|
||||
key={index}
|
||||
header={
|
||||
<div className={styles.header}>
|
||||
<b className={styles.title}>{item.title}</b>
|
||||
<Steps size="small" className={styles.step} current={item.step} status={item.status}>
|
||||
<StepItem title="等待调度" item={item} step={0}/>
|
||||
<StepItem title="数据准备" item={item} step={1}/>
|
||||
<StepItem title="发布前任务" item={item} step={2}/>
|
||||
<StepItem title="执行发布" item={item} step={3}/>
|
||||
<StepItem title="发布后任务" item={item} step={4}/>
|
||||
</Steps>
|
||||
<CodeOutlined className={styles.codeIcon} onClick={e => openTerminal(e, item)}/>
|
||||
</div>}>
|
||||
<OutView setTerm={term => handleSetTerm(term, key)}/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
</Skeleton>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default observer(Ext1Console)
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* Released under the AGPL-3.0 License.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { observer, useLocalStore } from 'mobx-react';
|
||||
import { Card, Progress, Modal, Collapse, Steps, Skeleton } from 'antd';
|
||||
import { ShrinkOutlined, LoadingOutlined, CloseOutlined, CodeOutlined } from '@ant-design/icons';
|
||||
import OutView from './OutView';
|
||||
import { http, X_TOKEN } from 'libs';
|
||||
import styles from './index.module.less';
|
||||
import store from './store';
|
||||
|
||||
function Ext2Console(props) {
|
||||
const terms = useLocalStore(() => ({}));
|
||||
const outputs = useLocalStore(() => ({local: {id: 'local'}}));
|
||||
const [sActions, setSActions] = useState([]);
|
||||
const [hActions, setHActions] = useState([]);
|
||||
const [mini, setMini] = useState(false);
|
||||
const [visible, setVisible] = useState(true);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
|
||||
useEffect(props.request.mode === 'read' ? readDeploy : doDeploy, [])
|
||||
|
||||
function readDeploy() {
|
||||
let socket;
|
||||
http.get(`/api/deploy/request/${props.request.id}/`)
|
||||
.then(res => {
|
||||
setSActions(res.s_actions);
|
||||
setHActions(res.h_actions);
|
||||
Object.assign(outputs, res.outputs);
|
||||
setTimeout(() => setFetching(false), 100)
|
||||
if (res.status === '2') {
|
||||
socket = _makeSocket(res.index)
|
||||
}
|
||||
})
|
||||
return () => socket && socket.close()
|
||||
}
|
||||
|
||||
function doDeploy() {
|
||||
let socket;
|
||||
http.post(`/api/deploy/request/${props.request.id}/`, {mode: props.request.mode})
|
||||
.then(res => {
|
||||
setSActions(res.s_actions);
|
||||
setHActions(res.h_actions);
|
||||
Object.assign(outputs, res.outputs)
|
||||
setTimeout(() => setFetching(false), 100)
|
||||
socket = _makeSocket()
|
||||
store.fetchInfo(props.request.id)
|
||||
})
|
||||
return () => socket && socket.close()
|
||||
}
|
||||
|
||||
function _makeSocket(index = 0) {
|
||||
const token = props.request.id;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/request/${token}/?x-token=${X_TOKEN}`);
|
||||
socket.onopen = () => socket.send(String(index));
|
||||
socket.onmessage = e => {
|
||||
if (e.data === 'pong') {
|
||||
socket.send(String(index))
|
||||
} else {
|
||||
index += 1;
|
||||
const {key, data, step, status} = JSON.parse(e.data);
|
||||
if (!outputs[key]) return
|
||||
if (data !== undefined) {
|
||||
outputs[key].data += data
|
||||
if (terms[key]) terms[key].write(data)
|
||||
}
|
||||
if (step !== undefined) outputs[key].step = step;
|
||||
if (status !== undefined) outputs[key].status = status;
|
||||
}
|
||||
}
|
||||
socket.onerror = () => {
|
||||
for (let key of Object.keys(outputs)) {
|
||||
outputs[key]['status'] = 'error'
|
||||
outputs[key].data = '\u001b[31mWebsocket connection failed!\u001b[0m'
|
||||
if (terms[key]) {
|
||||
terms[key].reset()
|
||||
terms[key].write('\u001b[31mWebsocket connection failed!\u001b[0m')
|
||||
}
|
||||
}
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
function StepItem(props) {
|
||||
let icon = null;
|
||||
if (props.step === props.item.step && props.item.status !== 'error') {
|
||||
if (props.item.id === 'local' || outputs.local.step === 100) {
|
||||
icon = <LoadingOutlined/>
|
||||
}
|
||||
}
|
||||
return <Steps.Step {...props} icon={icon}/>
|
||||
}
|
||||
|
||||
function switchMiniMode() {
|
||||
setMini(true)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
function handleSetTerm(term, key) {
|
||||
if (outputs[key] && outputs[key].data) {
|
||||
term.write(outputs[key].data)
|
||||
}
|
||||
terms[key] = term
|
||||
}
|
||||
|
||||
function openTerminal(e, item) {
|
||||
e.stopPropagation()
|
||||
window.open(`/ssh?id=${item.id}`)
|
||||
}
|
||||
|
||||
const hostOutputs = Object.values(outputs).filter(x => x.id !== 'local');
|
||||
return (
|
||||
<div>
|
||||
{mini && (
|
||||
<Card
|
||||
className={styles.item}
|
||||
bodyStyle={{padding: '8px 12px'}}
|
||||
onClick={() => setVisible(true)}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{props.request.name}</div>
|
||||
<CloseOutlined onClick={() => store.showConsole(props.request, true)}/>
|
||||
</div>
|
||||
<Progress percent={(outputs.local.step + 1) * (90 / (1 + sActions.length)).toFixed(0)}
|
||||
status={outputs.local.step === 100 ? 'success' : outputs.local.status === 'error' ? 'exception' : 'active'}/>
|
||||
{Object.values(outputs).filter(x => x.id !== 'local').map(item => (
|
||||
<Progress
|
||||
key={item.id}
|
||||
percent={item.step * (90 / (hActions.length).toFixed(0))}
|
||||
status={item.step === 100 ? 'success' : item.status === 'error' ? 'exception' : 'active'}/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
<Modal
|
||||
visible={visible}
|
||||
width={1000}
|
||||
footer={null}
|
||||
maskClosable={false}
|
||||
className={styles.console}
|
||||
onCancel={() => store.showConsole(props.request, true)}
|
||||
title={[
|
||||
<span key="1">{props.request.name}</span>,
|
||||
<div key="2" className={styles.miniIcon} onClick={switchMiniMode}>
|
||||
<ShrinkOutlined/>
|
||||
</div>
|
||||
]}>
|
||||
<Skeleton loading={fetching} active>
|
||||
{sActions.length > 0 && (
|
||||
<Collapse defaultActiveKey={['0']} className={styles.collapse}>
|
||||
<Collapse.Panel header={(
|
||||
<div className={styles.header}>
|
||||
<b className={styles.title}/>
|
||||
<Steps size="small" className={styles.step} current={outputs.local.step}
|
||||
status={outputs.local.status}>
|
||||
<StepItem title="建立连接" item={outputs.local} step={0}/>
|
||||
{sActions.map((item, index) => (
|
||||
<StepItem key={index} title={item.title} item={outputs.local} step={index + 1}/>
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
)}>
|
||||
<OutView setTerm={term => handleSetTerm(term, 'local')}/>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
{hostOutputs.length > 0 && (
|
||||
<Collapse
|
||||
accordion
|
||||
defaultActiveKey="0"
|
||||
className={styles.collapse}
|
||||
style={{marginTop: sActions.length > 0 ? 24 : 0}}>
|
||||
{hostOutputs.map((item, index) => (
|
||||
<Collapse.Panel
|
||||
key={index}
|
||||
header={
|
||||
<div className={styles.header}>
|
||||
<b className={styles.title}>{item.title}</b>
|
||||
<Steps size="small" className={styles.step} current={item.step} status={item.status}>
|
||||
<StepItem title="等待调度" item={item} step={0}/>
|
||||
{hActions.map((action, index) => (
|
||||
<StepItem key={index} title={action.title} item={item} step={index + 1}/>
|
||||
))}
|
||||
</Steps>
|
||||
<CodeOutlined className={styles.codeIcon} onClick={e => openTerminal(e, item)}/>
|
||||
</div>}>
|
||||
<OutView setTerm={term => handleSetTerm(term, item.id)}/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
)}
|
||||
</Skeleton>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(Ext2Console)
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
.container {
|
||||
.miniIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, .45);
|
||||
margin-right: 56px;
|
||||
|
||||
&:hover {
|
||||
color: #000000bf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.miniCard {
|
||||
width: 180px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, .2);
|
||||
border-radius: 5px;
|
||||
border: 1px solid #d9d9d9;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
padding: 8px 12px;
|
||||
|
||||
.title {
|
||||
width: 120px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
max-height: 90px;
|
||||
overflow: auto;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.output {
|
||||
display: flex;
|
||||
background-color: #fff;
|
||||
height: calc(100vh - 218px);
|
||||
overflow: hidden;
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 250px;
|
||||
border-right: 1px solid #dfdfdf;
|
||||
|
||||
.title {
|
||||
margin: 16px 16px 12px 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-bottom: 8px;
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
:global(.anticon) {
|
||||
margin-right: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(100% - 250px);
|
||||
padding: 16px 20px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.termContainer {
|
||||
background-color: #2b2b2b;
|
||||
padding: 8px 0 4px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
.term {
|
||||
width: 100%;
|
||||
height: calc(100vh - 296px);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,8 +12,7 @@ import Ext1Form from './Ext1Form';
|
|||
import Ext2Form from './Ext2Form';
|
||||
import Approve from './Approve';
|
||||
import ComTable from './Table';
|
||||
import Ext1Console from './Ext1Console';
|
||||
import Ext2Console from './Ext2Console';
|
||||
import Console from './Console';
|
||||
import BatchDelete from './BatchDelete';
|
||||
import Rollback from './Rollback';
|
||||
import { includes } from 'libs';
|
||||
|
|
@ -90,12 +89,9 @@ function Index() {
|
|||
{store.rollbackVisible && <Rollback/>}
|
||||
{store.tabs.length > 0 && (
|
||||
<Space className={styles.miniConsole}>
|
||||
{store.tabs.map(item => item.id ?
|
||||
item.app_extend === '1' ? (
|
||||
<Ext1Console key={item.id} request={item}/>
|
||||
) : (
|
||||
<Ext2Console key={item.id} request={item}/>
|
||||
) : null)}
|
||||
{store.tabs.map(item => (
|
||||
<Console key={item.id} request={item}/>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</AuthDiv>
|
||||
|
|
|
|||
|
|
@ -14,95 +14,6 @@
|
|||
right: 24px;
|
||||
align-items: flex-end;
|
||||
z-index: 999;
|
||||
|
||||
.item {
|
||||
width: 180px;
|
||||
box-shadow: 0 0 4px rgba(0, 0, 0, .3);
|
||||
border-radius: 5px;
|
||||
|
||||
:global(.ant-progress-text) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.title {
|
||||
width: 120px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, .45);
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.console {
|
||||
.miniIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, .45);
|
||||
margin-right: 56px;
|
||||
|
||||
:hover {
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: 1;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.codeIcon {
|
||||
font-size: 22px;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collapse {
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header-text) {
|
||||
flex: 1
|
||||
}
|
||||
}
|
||||
|
||||
.upload {
|
||||
|
|
|
|||
|
|
@ -132,14 +132,12 @@ class Store {
|
|||
showConsole = (info, isClose) => {
|
||||
const index = lds.findIndex(this.tabs, x => x.id === info.id);
|
||||
if (isClose) {
|
||||
if (index !== -1) {
|
||||
this.tabs[index] = {}
|
||||
}
|
||||
if (index !== -1) this.tabs.splice(index, 1)
|
||||
this.fetchInfo(info.id)
|
||||
} else if (index === -1) {
|
||||
this.tabs.push(info)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
readConsole = (info) => {
|
||||
const index = lds.findIndex(this.tabs, x => x.id === info.id);
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function OutView(props) {
|
|||
|
||||
function handleTerminate() {
|
||||
setLoading(true)
|
||||
http.post('/api/exec/terminate/', {token: store.token, host_id: current})
|
||||
http.post('/api/exec/terminate/', {token: store.token, target: current})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -213,7 +213,8 @@
|
|||
}
|
||||
|
||||
:global(.anticon) {
|
||||
margin-right: 4px;
|
||||
margin-right: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.text {
|
||||
|
|
|
|||
Loading…
Reference in New Issue