A 优化发布构建展示效果提供终止构建/发布功能

4.0
vapao 2022-10-16 22:03:11 +08:00
parent 8d8e31b0aa
commit 379b6c1b54
22 changed files with 868 additions and 745 deletions

View File

@ -1,46 +1,25 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com> # Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License. # Released under the AGPL-3.0 License.
from django.conf import settings
from django.template.defaultfilters import filesizeformat 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.utils import human_datetime, render_str, str_decode
from libs.spug import Notification from libs.spug import Notification
from apps.host.models import Host from apps.host.models import Host
from functools import partial from functools import partial
from collections import defaultdict
import subprocess import subprocess
import json import json
import os import os
import re
class SpugError(Exception): class SpugError(Exception):
pass pass
class Helper: class NotifyMixin:
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)
@classmethod @classmethod
def _make_dd_notify(cls, url, action, req, version, host_str): def _make_dd_notify(cls, url, action, req, version, host_str):
texts = [ texts = [
@ -224,9 +203,98 @@ class Helper:
else: else:
raise NotImplementedError 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): def add_callback(self, func):
self.callback.append(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): def parse_filter_rule(self, data: str, sep='\n', env=None):
data, files = data.strip(), [] data, files = data.strip(), []
if data: if data:
@ -236,44 +304,89 @@ class Helper:
files.append(render_str(line, env)) files.append(render_str(line, env))
return files return files
def _send(self, message): def _send(self, key, data, *, status=''):
self.rds.rpush(self.key, json.dumps(message)) 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): for idx, line in enumerate(data.split('\r\n')):
if message: if idx != 0:
self._send({'key': key, 'data': message}) 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): def send_error(self, key, message, with_break=True):
message = f'\r\n\033[31m{message}\033[0m' message = self.term_message(message, 'error')
self._send({'key': key, 'status': 'error', 'data': message}) if not message.endswith('\r\n'):
message += '\r\n'
self._send(key, message, status='error')
if with_break: if with_break:
raise SpugError raise SpugError
def send_step(self, key, step, data):
self._send({'key': key, 'step': step, 'data': data})
def clear(self): def clear(self):
self.rds.delete(f'{self.key}_tmp') if self.already_clear:
# save logs for two weeks return
self.rds.expire(self.key, 14 * 24 * 60 * 60) self.already_clear = True
self.rds.close() for key, value in self.buffers.items():
# callback if value:
for func in self.callback: file = self.get_file(key)
func() 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 progress_callback(self, key):
def func(k, n, t): def func(k, n, t):
message = f'\r {filesizeformat(n):<8}/{filesizeformat(t):>8} ' 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) return partial(func, key)
def local(self, command, env=None): def local(self, command, env=None):
if env: if env:
env = dict(env.items()) env = dict(env.items())
env.update(os.environ) 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'' message = b''
while True: while True:
output = task.stdout.read(1) output = task.stdout.read(1)
@ -282,7 +395,7 @@ class Helper:
if output in (b'\r', b'\n'): if output in (b'\r', b'\n'):
message += b'\r\n' if output == b'\n' else b'\r' message += b'\r\n' if output == b'\n' else b'\r'
message = str_decode(message) message = str_decode(message)
self.send_info('local', message) self._send('local', message)
message = b'' message = b''
else: else:
message += output message += output
@ -292,7 +405,7 @@ class Helper:
def remote(self, key, ssh, command, env=None): def remote(self, key, ssh, command, env=None):
code = -1 code = -1
for code, out in ssh.exec_command_with_stream(command, environment=env): for code, out in ssh.exec_command_with_stream(command, environment=env):
self.send_info(key, out) self._send(key, out)
if code != 0: if code != 0:
self.send_error(key, f'exit code: {code}') self.send_error(key, f'exit code: {code}')

View File

@ -53,6 +53,10 @@ class DeployRequest(models.Model, ModelMixin):
return extra[0] in ('branch', 'tag') return extra[0] in ('branch', 'tag')
return False return False
@property
def deploy_key(self):
return f'{settings.REQUEST_KEY}:{self.id}'
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):
super().delete(using, keep_parents) super().delete(using, keep_parents)
if self.repository_id: if self.repository_id:

View File

@ -4,7 +4,7 @@
from django_redis import get_redis_connection from django_redis import get_redis_connection
from django.conf import settings from django.conf import settings
from django.db import close_old_connections 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.host.models import Host
from apps.config.utils import compose_configs from apps.config.utils import compose_configs
from apps.repository.models import Repository from apps.repository.models import Repository
@ -23,13 +23,14 @@ BUILD_DIR = settings.BUILD_DIR
def dispatch(req, fail_mode=False): def dispatch(req, fail_mode=False):
rds = get_redis_connection() rds = get_redis_connection()
rds_key = f'{settings.REQUEST_KEY}:{req.id}' rds_key = req.deploy_key
if fail_mode: if fail_mode:
req.host_ids = req.fail_host_ids req.host_ids = req.fail_host_ids
req.fail_mode = fail_mode req.fail_mode = fail_mode
req.host_ids = json.loads(req.host_ids) req.host_ids = json.loads(req.host_ids)
req.fail_host_ids = 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: try:
api_token = uuid.uuid4().hex api_token = uuid.uuid4().hex
@ -61,7 +62,8 @@ def dispatch(req, fail_mode=False):
req.status = '3' req.status = '3'
except Exception as e: except Exception as e:
req.status = '-3' req.status = '-3'
raise e if not isinstance(e, SpugError):
raise e
finally: finally:
close_old_connections() close_old_connections()
DeployRequest.objects.filter(pk=req.id).update( DeployRequest.objects.filter(pk=req.id).update(
@ -108,7 +110,7 @@ def _ext1_deploy(req, helper, env):
if exception: if exception:
latest_exception = exception latest_exception = exception
if not isinstance(exception, SpugError): 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: else:
req.fail_host_ids.remove(t.h_id) req.fail_host_ids.remove(t.h_id)
if latest_exception: if latest_exception:
@ -122,9 +124,9 @@ def _ext1_deploy(req, helper, env):
_deploy_ext1_host(req, helper, h_id, new_env) _deploy_ext1_host(req, helper, h_id, new_env)
req.fail_host_ids.remove(h_id) req.fail_host_ids.remove(h_id)
except Exception as e: 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: for h_id in host_ids:
helper.send_error(h_id, '终止发布', False) helper.send_error(h_id, '终止发布', with_break=False)
raise e raise e
@ -137,23 +139,32 @@ def _ext2_deploy(req, helper, env):
for index, value in enumerate(req.version.split()): for index, value in enumerate(req.version.split()):
env.update({f'SPUG_RELEASE_{index + 1}': value}) env.update({f'SPUG_RELEASE_{index + 1}': value})
if not req.fail_mode: transfer_action = None
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
for action in host_actions: for action in host_actions:
if action.get('type') == 'transfer': if action.get('type') == 'transfer':
action['src'] = render_str(action.get('src', '').strip().rstrip('/'), env) action['src'] = render_str(action.get('src', '').strip().rstrip('/'), env)
action['dst'] = render_str(action['dst'].strip().rstrip('/'), env) action['dst'] = render_str(action['dst'].strip().rstrip('/'), env)
if action.get('src_mode') == '1': # upload when publish if action.get('src_mode') == '1': # upload when publish
if not req.extra:
helper.send_error('local', '\r\n未找到上传的文件信息,请尝试新建发布申请')
extra = json.loads(req.extra) extra = json.loads(req.extra)
if 'name' in extra: if 'name' in extra:
action['name'] = extra['name'] action['name'] = extra['name']
break else:
helper.send_step('local', step, f'{human_time()} 检测到来源为本地路径的数据传输动作,执行打包... \r\n') 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['src'] = action['src'].rstrip('/ ')
action['dst'] = action['dst'].rstrip('/ ') action['dst'] = action['dst'].rstrip('/ ')
if not action['src'] or not action['dst']: if not action['src'] or not action['dst']:
@ -178,17 +189,16 @@ def _ext2_deploy(req, helper, env):
exclude = ' '.join(excludes) exclude = ' '.join(excludes)
tar_gz_file = f'{req.spug_version}.tar.gz' tar_gz_file = f'{req.spug_version}.tar.gz'
helper.local(f'cd {sp_dir} && tar -zcf {tar_gz_file} {exclude} {contain}') 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))) helper.add_callback(partial(os.remove, os.path.join(sp_dir, tar_gz_file)))
break
helper.send_step('local', 100, '')
if host_actions: if host_actions:
helper.send_success('local', '\r\n** 执行完成 **', status='success')
if req.deploy.is_parallel: if req.deploy.is_parallel:
threads, latest_exception = [], None threads, latest_exception = [], None
max_workers = max(10, os.cpu_count() * 5) max_workers = max(10, os.cpu_count() * 5)
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: 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()) new_env = AttrDict(env.items())
t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, new_env, req.spug_version) t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, new_env, req.spug_version)
t.h_id = h_id t.h_id = h_id
@ -198,13 +208,13 @@ def _ext2_deploy(req, helper, env):
if exception: if exception:
latest_exception = exception latest_exception = exception
if not isinstance(exception, SpugError): 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: else:
req.fail_host_ids.remove(t.h_id) req.fail_host_ids.remove(t.h_id)
if latest_exception: if latest_exception:
raise latest_exception raise latest_exception
else: else:
host_ids = sorted(req.host_ids, reverse=True) host_ids = sorted(req.host_ids)
while host_ids: while host_ids:
h_id = host_ids.pop() h_id = host_ids.pop()
new_env = AttrDict(env.items()) 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) _deploy_ext2_host(helper, h_id, host_actions, new_env, req.spug_version)
req.fail_host_ids.remove(h_id) req.fail_host_ids.remove(h_id)
except Exception as e: 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: 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 raise e
else: else:
req.fail_host_ids = [] 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): 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() host = Host.objects.filter(pk=h_id).first()
if not host: if not host:
helper.send_error(h_id, 'no such 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) extend.dst_repo = render_str(extend.dst_repo, env)
env.update(SPUG_DST_DIR=extend.dst_dir) env.update(SPUG_DST_DIR=extend.dst_dir)
with host.get_ssh(default_env=env) as ssh: 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) base_dst_dir = os.path.dirname(extend.dst_dir)
code, _ = ssh.exec_command_raw( code, _ = ssh.exec_command_raw(
f'mkdir -p {extend.dst_repo} {base_dst_dir} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]') f'mkdir -p {extend.dst_repo} {base_dst_dir} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]')
if code == 0: 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': if req.type == '2':
helper.send_step(h_id, 1, '\033[33m跳过√\033[0m\r\n') helper.send_warn(h_id, '跳过√\r\n')
else: else:
# clean # clean
clean_command = f'ls -d {extend.deploy_id}_* 2> /dev/null | sort -t _ -rnk2 | tail -n +{extend.versions + 1} | xargs rm -rf' 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' 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.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 # pre host
repo_dir = os.path.join(extend.dst_repo, req.spug_version) repo_dir = os.path.join(extend.dst_repo, req.spug_version)
if extend.hook_pre_host: 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}' command = f'cd {repo_dir} && {extend.hook_pre_host}'
helper.remote(host.id, ssh, command) helper.remote(host.id, ssh, command)
# do deploy # 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.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 # post host
if extend.hook_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}' command = f'cd {extend.dst_dir} && {extend.hook_post_host}'
helper.remote(host.id, ssh, command) 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): 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() host = Host.objects.filter(pk=h_id).first()
if not host: if not host:
helper.send_error(h_id, 'no such host') helper.send_error(h_id, 'no such host')
env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname}) env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname})
with host.get_ssh(default_env=env) as ssh: with host.get_ssh(default_env=env) as ssh:
for index, action in enumerate(actions): helper.send_clear(h_id)
helper.send_step(h_id, 1 + index, f'{human_time()} {action["title"]}...\r\n') 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': if action.get('type') == 'transfer':
helper.send_info(h_id, f'{action["title"]}...')
if action.get('src_mode') == '1': if action.get('src_mode') == '1':
try: try:
dst = action['dst'] dst = action['dst']
@ -302,8 +319,8 @@ def _deploy_ext2_host(helper, h_id, actions, env, spug_version):
callback = helper.progress_callback(host.id) callback = helper.progress_callback(host.id)
ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, spug_version), dst, callback) ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, spug_version), dst, callback)
except Exception as e: except Exception as e:
helper.send_error(host.id, f'Exception: {e}') helper.send_error(host.id, f'\r\nException: {e}')
helper.send_info(host.id, 'transfer completed\r\n') helper.send_success(host.id, '完成√\r\n')
continue continue
else: else:
sp_dir, sd_dst = os.path.split(action['src']) 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) callback = helper.progress_callback(host.id)
ssh.put_file(os.path.join(sp_dir, tar_gz_file), f'/tmp/{tar_gz_file}', callback) ssh.put_file(os.path.join(sp_dir, tar_gz_file), f'/tmp/{tar_gz_file}', callback)
except Exception as e: 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 {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: else:
helper.send_info(h_id, f'{action["title"]}...\r\n')
command = f'cd /tmp && {action["data"]}' command = f'cd /tmp && {action["data"]}'
helper.remote(host.id, ssh, command) 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')

View File

@ -6,7 +6,7 @@ from django.db.models import F
from django.conf import settings from django.conf import settings
from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseBadRequest
from django_redis import get_redis_connection 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.deploy.models import DeployRequest
from apps.app.models import Deploy, DeployExtend2 from apps.app.models import Deploy, DeployExtend2
from apps.repository.models import Repository from apps.repository.models import Repository
@ -104,47 +104,29 @@ class RequestDetailView(View):
req = DeployRequest.objects.filter(pk=r_id).first() req = DeployRequest.objects.filter(pk=r_id).first()
if not req: if not req:
return json_response(error='未找到指定发布申请') return json_response(error='未找到指定发布申请')
response = AttrDict(status=req.status)
hosts = Host.objects.filter(id__in=json.loads(req.host_ids)) 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} outputs = {x.id: {'id': x.id, 'title': x.name, 'data': ''} for x in hosts}
response = {'outputs': outputs, 'status': req.status} outputs['local'] = {'id': 'local', 'data': ''}
if req.is_quick_deploy:
outputs['local'] = {'id': 'local', 'data': ''}
if req.deploy.extend == '2': if req.deploy.extend == '2':
outputs['local'] = {'id': 'local', 'data': f'{human_time()} 读取数据... '} s_actions = json.loads(req.deploy.extend_obj.server_actions)
response['s_actions'] = json.loads(req.deploy.extend_obj.server_actions) h_actions = json.loads(req.deploy.extend_obj.host_actions)
response['h_actions'] = json.loads(req.deploy.extend_obj.host_actions) if not s_actions:
if not response['h_actions']: outputs.pop('local')
response['outputs'] = {'local': outputs['local']} if not h_actions:
rds, key, counter = get_redis_connection(), f'{settings.REQUEST_KEY}:{r_id}', 0 outputs = {'local': outputs['local']}
data = rds.lrange(key, counter, counter + 9) elif not req.is_quick_deploy:
while data: outputs.pop('local')
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周的日志信息。'
if req.is_quick_deploy: response.index = Helper.fill_outputs(outputs, req.deploy_key)
if outputs['local']['data']: response.token = req.deploy_key
outputs['local']['data'] = f'{human_time()} 读取数据... ' + outputs['local']['data'] response.outputs = outputs
else:
outputs['local'].update(step=100, data=f'{human_time()} 已构建完成忽略执行。')
return json_response(response) return json_response(response)
@auth('deploy.request.do') @auth('deploy.request.do')
def post(self, request, r_id): def post(self, request, r_id):
form, _ = JsonParser(Argument('mode', default='all')).parse(request.body) 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: if not request.user.is_supper:
perms = request.user.deploy_perms perms = request.user.deploy_perms
query['deploy__app_id__in'] = perms['apps'] query['deploy__app_id__in'] = perms['apps']
@ -154,32 +136,38 @@ class RequestDetailView(View):
return json_response(error='未找到指定发布申请') return json_response(error='未找到指定发布申请')
if req.status not in ('1', '-3'): if req.status not in ('1', '-3'):
return json_response(error='该申请单当前状态还不能执行发布') 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.status = '2'
req.do_at = human_datetime() req.do_at = human_datetime()
req.do_by = request.user req.do_by = request.user
req.save() 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.is_quick_deploy:
if req.repository_id: 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: else:
outputs['local'] = {'id': 'local', 'step': 0, 'data': f'{human_time()} 建立连接... '} outputs['local'] = {'id': 'local', 'data': Helper.term_message('等待初始化... ')}
if req.deploy.extend == '2': 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) s_actions = json.loads(req.deploy.extend_obj.server_actions)
h_actions = json.loads(req.deploy.extend_obj.host_actions) h_actions = json.loads(req.deploy.extend_obj.host_actions)
for item in h_actions: if not s_actions:
if item.get('type') == 'transfer' and item.get('src_mode') == '0': outputs.pop('local')
s_actions.append({'title': '执行打包'})
if not h_actions: if not h_actions:
outputs = {'local': outputs['local']} outputs = {'local': outputs['local']}
return json_response({'s_actions': s_actions, 'h_actions': h_actions, 'outputs': outputs}) return json_response({'outputs': outputs, 'token': req.deploy_key})
return json_response({'outputs': outputs})
@auth('deploy.request.approve') @auth('deploy.request.approve')
def patch(self, request, r_id): 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')) requests = DeployRequest.objects.filter(deploy=req.deploy, status__in=('3', '-3'))
versions = list({x.spug_version: 1 for x in requests}.keys()) versions = list({x.spug_version: 1 for x in requests}.keys())
if req.spug_version not in versions[:req.deploy.extend_obj.versions + 1]: 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.status = '0' if req.deploy.is_audit else '1'
form.host_ids = json.dumps(sorted(form.host_ids)) form.host_ids = json.dumps(sorted(form.host_ids))

View File

@ -55,7 +55,7 @@ class Job:
code = -1 code = -1
try: try:
with self.ssh: 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): for code, out in self.ssh.exec_command_with_stream(self.command, self.env):
self.send(out) self.send(out)
human_time = human_seconds_time(time.time() - flag) human_time = human_seconds_time(time.time() - flag)

View File

@ -10,6 +10,7 @@ from apps.host.models import Host
from apps.account.utils import has_host_perm from apps.account.utils import has_host_perm
import uuid import uuid
import json import json
import os
class TemplateView(View): class TemplateView(View):
@ -120,19 +121,25 @@ class TaskView(View):
return json_response(error=error) return json_response(error=error)
@auth('exec.task.do') @auth('exec.task.do|deploy.request.do')
def handle_terminate(request): def handle_terminate(request):
form, error = JsonParser( form, error = JsonParser(
Argument('token', help='参数错误'), Argument('token', help='参数错误'),
Argument('host_id', type=int, help='参数错误') Argument('target', help='参数错误')
).parse(request.body) ).parse(request.body)
if error is None: if error is None:
host = Host.objects.get(pk=form.host_id)
rds = get_redis_connection() 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) 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: with host.get_ssh() as ssh:
ssh.exec_command_raw(f'kill -9 {pid.decode()}') 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) return json_response(error=error)

View File

@ -33,6 +33,14 @@ class Repository(models.Model, ModelMixin):
def make_spug_version(deploy_id): def make_spug_version(deploy_id):
return f'{deploy_id}_{datetime.now().strftime("%Y%m%d%H%M%S")}' 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): def to_view(self):
tmp = self.to_dict() tmp = self.to_dict()
tmp['extra'] = json.loads(self.extra) tmp['extra'] = json.loads(self.extra)

View File

@ -4,7 +4,7 @@
from django_redis import get_redis_connection from django_redis import get_redis_connection
from django.conf import settings from django.conf import settings
from django.db import close_old_connections 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.repository.models import Repository
from apps.app.utils import fetch_repo from apps.app.utils import fetch_repo
from apps.config.utils import compose_configs from apps.config.utils import compose_configs
@ -22,13 +22,11 @@ def dispatch(rep: Repository, helper=None):
alone_build = helper is None alone_build = helper is None
if not helper: if not helper:
rds = get_redis_connection() rds = get_redis_connection()
rds_key = f'{settings.BUILD_KEY}:{rep.spug_version}' helper = Helper.make(rds, rep.deploy_key, ['local'])
helper = Helper.make(rds, rds_key)
rep.save() rep.save()
try: try:
api_token = uuid.uuid4().hex api_token = uuid.uuid4().hex
helper.rds.setex(api_token, 60 * 60, f'{rep.app_id},{rep.env_id}') 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( env = AttrDict(
SPUG_APP_NAME=rep.app.name, SPUG_APP_NAME=rep.app.name,
SPUG_APP_KEY=rep.app.key, SPUG_APP_KEY=rep.app.key,
@ -42,6 +40,21 @@ def dispatch(rep: Repository, helper=None):
SPUG_API_TOKEN=api_token, SPUG_API_TOKEN=api_token,
SPUG_REPOS_DIR=REPOS_DIR, 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 # append configs
configs = compose_configs(rep.app, rep.env_id) configs = compose_configs(rep.app, rep.env_id)
configs_env = {f'_SPUG_{k.upper()}': v for k, v in configs.items()} 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): def _build(rep: Repository, helper, env):
extend = rep.deploy.extend_obj extend = rep.deploy.extend_obj
extras = json.loads(rep.extra)
git_dir = os.path.join(REPOS_DIR, str(rep.deploy_id)) git_dir = os.path.join(REPOS_DIR, str(rep.deploy_id))
build_dir = os.path.join(REPOS_DIR, rep.spug_version) build_dir = os.path.join(REPOS_DIR, rep.spug_version)
tar_file = os.path.join(BUILD_DIR, f'{rep.spug_version}.tar.gz') 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)) env.update(SPUG_DST_DIR=render_str(extend.dst_dir, env))
fetch_repo(rep.deploy_id, extend.git_repo) 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: 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.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 -)' command = f'cd {git_dir} && git archive --prefix={rep.spug_version}/ {tree_ish} | (cd .. && tar xf -)'
helper.local(command) helper.local(command)
helper.send_info('local', '\033[32m完成√\033[0m\r\n') helper.send_success('local', '完成√\r\n')
if extend.hook_post_server: 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.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 filter_rule, exclude, contain = json.loads(extend.filter_rule), '', rep.spug_version
files = helper.parse_filter_rule(filter_rule['data'], env=env) files = helper.parse_filter_rule(filter_rule['data'], env=env)
if files: if files:
@ -107,5 +114,5 @@ def _build(rep: Repository, helper, env):
else: else:
contain = ' '.join(f'{rep.spug_version}/{x}' for x in files) 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.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_success('local', '完成√\r\n')
helper.send_step('local', 100, f'\r\n\r\n{human_time()} ** \033[32m构建成功\033[0m **') helper.send_success('local', '\r\n** 构建成功 **\r\n', status='success')

View File

@ -5,10 +5,11 @@ from django.views.generic import View
from django.db.models import F from django.db.models import F
from django.conf import settings from django.conf import settings
from django_redis import get_redis_connection 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.repository.models import Repository
from apps.deploy.models import DeployRequest from apps.deploy.models import DeployRequest
from apps.repository.utils import dispatch from apps.repository.utils import dispatch
from apps.deploy.helper import Helper
from apps.app.models import Deploy from apps.app.models import Deploy
from threading import Thread from threading import Thread
import json import json
@ -109,31 +110,14 @@ def get_detail(request, r_id):
repository = Repository.objects.filter(pk=r_id).first() repository = Repository.objects.filter(pk=r_id).first()
if not repository: if not repository:
return json_response(error='未找到指定构建记录') return json_response(error='未找到指定构建记录')
rds, counter = get_redis_connection(), 0 deploy_key = repository.deploy_key
if repository.remarks == 'SPUG AUTO MAKE': response = AttrDict(status=repository.status, token=deploy_key)
req = repository.deployrequest_set.last() output = {'data': ''}
key = f'{settings.REQUEST_KEY}:{req.id}' response.index = Helper.fill_outputs({'local': output}, deploy_key)
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
if repository.status in ('0', '1'): if repository.status in ('0', '1'):
response.data = f'{human_time()} 建立连接... ' + response.data output['data'] = Helper.term_message('等待初始化...') + output['data']
elif not response.data: elif not output['data']:
response.data = f'{human_time()} 读取数据... \r\n\r\n未读取到数据Spug 仅保存最近2周的构建日志。' output['data'] = Helper.term_message('未读取到数据,可能已被清理', 'warn', with_time=False)
else: response.output = output
response.data = f'{human_time()} 读取数据... ' + response.data
return json_response(response) return json_response(response)

View File

@ -113,6 +113,7 @@ BUILD_KEY = 'spug:build'
REPOS_DIR = os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'repos') REPOS_DIR = os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'repos')
BUILD_DIR = os.path.join(REPOS_DIR, 'build') BUILD_DIR = os.path.join(REPOS_DIR, 'build')
TRANSFER_DIR = os.path.join(BASE_DIR, 'storage', 'transfer') TRANSFER_DIR = os.path.join(BASE_DIR, 'storage', 'transfer')
DEPLOY_DIR = os.path.join(BASE_DIR, 'storage', 'deploy')
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/ # https://docs.djangoproject.com/en/2.2/topics/i18n/

View File

View File

@ -5,33 +5,41 @@
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { observer } from 'mobx-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 { FitAddon } from 'xterm-addon-fit';
import { Terminal } from 'xterm'; import { Terminal } from 'xterm';
import { Modal, Steps, Spin } from 'antd'; import { Modal, Spin, Tooltip } from 'antd';
import { X_TOKEN, http } from 'libs'; import { X_TOKEN, http } from 'libs';
import styles from './index.module.less'; import styles from './index.module.less';
import gStore from 'gStore';
import store from './store'; import store from './store';
import lds from 'lodash';
export default observer(function Console() { export default observer(function Console() {
const el = useRef() const el = useRef()
const [term] = useState(new Terminal({disableStdin: true})) const [term] = useState(new Terminal({disableStdin: true}))
const [token, setToken] = useState();
const [status, setStatus] = useState();
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const [step, setStep] = useState(0);
const [status, setStatus] = useState('process');
const [fetching, setFetching] = useState(true); const [fetching, setFetching] = useState(true);
const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
let socket; let socket;
initialTerm()
http.get(`/api/repository/${store.record.id}/`) http.get(`/api/repository/${store.record.id}/`)
.then(res => { .then(res => {
term.write(res.data) setToken(res.token)
setStep(res.step) setStatus(res.output.status)
term.write(res.output.data)
if (res.status === '1') { if (res.status === '1') {
socket = _makeSocket(res.index) socket = _makeSocket(res.index)
} else {
setStatus('wait')
} }
}) })
.finally(() => setFetching(false)) .finally(() => setFetching(false))
@ -39,40 +47,13 @@ export default observer(function Console() {
// eslint-disable-next-line react-hooks/exhaustive-deps // 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(() => { useEffect(() => {
term.fit && term.fit()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fullscreen])
function initialTerm() {
const fitPlugin = new FitAddon() const fitPlugin = new FitAddon()
term.loadAddon(fitPlugin) term.loadAddon(fitPlugin)
term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei') term.setOption('fontSize', 14)
term.setOption('theme', {background: '#fafafa', foreground: '#000', selection: '#999'}) term.setOption('lineHeight', 1.2)
term.setOption('fontFamily', gStore.terminal.fontFamily)
term.setOption('theme', {background: '#2b2b2b', foreground: '#A9B7C6', cursor: '#2b2b2b'})
term.attachCustomKeyEventHandler((arg) => { term.attachCustomKeyEventHandler((arg) => {
if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') { if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {
document.execCommand('copy') document.execCommand('copy')
@ -83,19 +64,47 @@ export default observer(function Console() {
term.open(el.current) term.open(el.current)
term.fit = () => fitPlugin.fit() term.fit = () => fitPlugin.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() { function handleClose() {
store.fetchRecords(); store.fetchRecords();
store.logVisible = false store.logVisible = false
} }
function StepItem(props) { function handleTerminate() {
let icon = null; setLoading(true)
if (props.step === step && status === 'process') { http.post('/api/exec/terminate/', {token, target: 'local'})
icon = <LoadingOutlined style={{fontSize: 32}}/> .finally(() => setLoading(false))
}
return <Steps.Step {...props} icon={icon}/>
} }
return ( return (
@ -112,16 +121,31 @@ export default observer(function Console() {
onCancel={handleClose} onCancel={handleClose}
className={styles.console} className={styles.console}
maskClosable={false}> 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}> <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 className={styles.out}>
<div ref={el}/> <div ref={el} className={styles.term}/>
</div> </div>
</Spin> </Spin>
</Modal> </Modal>

View File

@ -17,12 +17,33 @@
color: #000; 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 { .out {
margin-top: 24px;
padding: 8px 0 8px 15px; padding: 8px 0 8px 15px;
border: 1px solid #d9d9d9;
border-radius: 4px; border-radius: 4px;
background-color: #fafafa; background-color: #2b2b2b;
.term {
width: 100%;
height: calc(100vh - 370px);
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -12,8 +12,7 @@ import Ext1Form from './Ext1Form';
import Ext2Form from './Ext2Form'; import Ext2Form from './Ext2Form';
import Approve from './Approve'; import Approve from './Approve';
import ComTable from './Table'; import ComTable from './Table';
import Ext1Console from './Ext1Console'; import Console from './Console';
import Ext2Console from './Ext2Console';
import BatchDelete from './BatchDelete'; import BatchDelete from './BatchDelete';
import Rollback from './Rollback'; import Rollback from './Rollback';
import { includes } from 'libs'; import { includes } from 'libs';
@ -90,12 +89,9 @@ function Index() {
{store.rollbackVisible && <Rollback/>} {store.rollbackVisible && <Rollback/>}
{store.tabs.length > 0 && ( {store.tabs.length > 0 && (
<Space className={styles.miniConsole}> <Space className={styles.miniConsole}>
{store.tabs.map(item => item.id ? {store.tabs.map(item => (
item.app_extend === '1' ? ( <Console key={item.id} request={item}/>
<Ext1Console key={item.id} request={item}/> ))}
) : (
<Ext2Console key={item.id} request={item}/>
) : null)}
</Space> </Space>
)} )}
</AuthDiv> </AuthDiv>

View File

@ -14,95 +14,6 @@
right: 24px; right: 24px;
align-items: flex-end; align-items: flex-end;
z-index: 999; 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 { .upload {

View File

@ -132,14 +132,12 @@ class Store {
showConsole = (info, isClose) => { showConsole = (info, isClose) => {
const index = lds.findIndex(this.tabs, x => x.id === info.id); const index = lds.findIndex(this.tabs, x => x.id === info.id);
if (isClose) { if (isClose) {
if (index !== -1) { if (index !== -1) this.tabs.splice(index, 1)
this.tabs[index] = {}
}
this.fetchInfo(info.id) this.fetchInfo(info.id)
} else if (index === -1) { } else if (index === -1) {
this.tabs.push(info) this.tabs.push(info)
} }
}; }
readConsole = (info) => { readConsole = (info) => {
const index = lds.findIndex(this.tabs, x => x.id === info.id); const index = lds.findIndex(this.tabs, x => x.id === info.id);

View File

@ -111,7 +111,7 @@ function OutView(props) {
function handleTerminate() { function handleTerminate() {
setLoading(true) 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)) .finally(() => setLoading(false))
} }

View File

@ -213,7 +213,8 @@
} }
:global(.anticon) { :global(.anticon) {
margin-right: 4px; margin-right: 5px;
font-size: 16px;
} }
.text { .text {