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) <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}')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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