diff --git a/spug_api/apps/deploy/helper.py b/spug_api/apps/deploy/helper.py index d798ccb..f7a8297 100644 --- a/spug_api/apps/deploy/helper.py +++ b/spug_api/apps/deploy/helper.py @@ -1,46 +1,25 @@ # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # 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}') diff --git a/spug_api/apps/deploy/models.py b/spug_api/apps/deploy/models.py index 15ac935..1d95937 100644 --- a/spug_api/apps/deploy/models.py +++ b/spug_api/apps/deploy/models.py @@ -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: diff --git a/spug_api/apps/deploy/utils.py b/spug_api/apps/deploy/utils.py index a8ac9b9..a2fa2ac 100644 --- a/spug_api/apps/deploy/utils.py +++ b/spug_api/apps/deploy/utils.py @@ -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') diff --git a/spug_api/apps/deploy/views.py b/spug_api/apps/deploy/views.py index 92eb1b8..80dfc61 100644 --- a/spug_api/apps/deploy/views.py +++ b/spug_api/apps/deploy/views.py @@ -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)) diff --git a/spug_api/apps/exec/executors.py b/spug_api/apps/exec/executors.py index a422757..ffa6f02 100644 --- a/spug_api/apps/exec/executors.py +++ b/spug_api/apps/exec/executors.py @@ -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) diff --git a/spug_api/apps/exec/views.py b/spug_api/apps/exec/views.py index a6545c7..90961fd 100644 --- a/spug_api/apps/exec/views.py +++ b/spug_api/apps/exec/views.py @@ -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) diff --git a/spug_api/apps/repository/models.py b/spug_api/apps/repository/models.py index 44f776d..1bc5b1d 100644 --- a/spug_api/apps/repository/models.py +++ b/spug_api/apps/repository/models.py @@ -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) diff --git a/spug_api/apps/repository/utils.py b/spug_api/apps/repository/utils.py index 5c44377..8fe5e3d 100644 --- a/spug_api/apps/repository/utils.py +++ b/spug_api/apps/repository/utils.py @@ -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') diff --git a/spug_api/apps/repository/views.py b/spug_api/apps/repository/views.py index 2b20186..578cb26 100644 --- a/spug_api/apps/repository/views.py +++ b/spug_api/apps/repository/views.py @@ -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) diff --git a/spug_api/spug/settings.py b/spug_api/spug/settings.py index 89cd2c4..4c3a04d 100644 --- a/spug_api/spug/settings.py +++ b/spug_api/spug/settings.py @@ -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/ diff --git a/spug_api/storage/deploy/.gitkeep b/spug_api/storage/deploy/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spug_web/src/pages/deploy/repository/Console.js b/spug_web/src/pages/deploy/repository/Console.js index 40bb3cd..9162927 100644 --- a/spug_web/src/pages/deploy/repository/Console.js +++ b/spug_web/src/pages/deploy/repository/Console.js @@ -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 = - } - return + 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}> - - - - - - - +
+
{store.record.version}
+ {status === 'error' ? ( + + ) : status === 'success' ? ( + + ) : ( + + )} +
+ {loading ? ( + + ) : ( + + {status === 'doing' ? ( + + ) : ( + + )} + + )} +
-
+
diff --git a/spug_web/src/pages/deploy/repository/index.module.less b/spug_web/src/pages/deploy/repository/index.module.less index a629f88..2ff3f59 100644 --- a/spug_web/src/pages/deploy/repository/index.module.less +++ b/spug_web/src/pages/deploy/repository/index.module.less @@ -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); + } } } diff --git a/spug_web/src/pages/deploy/request/Console.js b/spug_web/src/pages/deploy/request/Console.js new file mode 100644 index 0000000..66ac41f --- /dev/null +++ b/spug_web/src/pages/deploy/request/Console.js @@ -0,0 +1,257 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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 ( + + {miniMode && ( + setMiniMode(false)}> +
+
{props.request.name}
+ store.showConsole(props.request, true)}/> +
+
+ {sides.map(item => ( +
handleSwitch(item.id)}> + {outputs[item.id]?.status === 'error' ? ( + + ) : outputs[item.id]?.status === 'success' ? ( + + ) : outputs[item.id]?.status === 'doing' ? ( + + ) : ( + + )} + {item.id === 'local' ? ( +
{localTitle}
+ ) : ( +
{item.title}
+ )} +
+ ))} +
+
+ )} + + store.showConsole(props.request, true)} + title={[ + {props.request.name}, +
setMiniMode(true)}> + +
+ ]}> + +
+
+
任务列表
+
+ {sides.map(item => ( +
handleSwitch(item.id)}> + {outputs[item.id]?.status === 'error' ? ( + + ) : outputs[item.id]?.status === 'success' ? ( + + ) : outputs[item.id]?.status === 'doing' ? ( + + ) : ( + + )} + {item.id === 'local' ? ( +
{localTitle}
+ ) : ( +
{item.title}
+ )} +
+ ))} +
+
+
+
+
{cItem.id === 'local' ? localTitle : cItem.title}
+ {loading ? ( + + ) : ( + + {cItem.status === 'doing' ? ( + + ) : ( + + )} + + )} + + openTerminal(current)}/> + +
+
+
+
+
+
+ + + + ) +} + +export default observer(Console) \ No newline at end of file diff --git a/spug_web/src/pages/deploy/request/Ext1Console.js b/spug_web/src/pages/deploy/request/Ext1Console.js deleted file mode 100644 index b1f3840..0000000 --- a/spug_web/src/pages/deploy/request/Ext1Console.js +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug - * Copyright (c) - * 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 = - } - return - } - - 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 ( -
- {mini && ( - setVisible(true)}> -
-
{props.request.name}
- store.showConsole(props.request, true)}/> -
- {local && ( - - )} - {Object.values(hosts).map(item => ( - - ))} -
- )} - store.showConsole(props.request, true)} - title={[ - {props.request.name}, -
- -
- ]}> - - {local && ( - - - - - - - - - - -
- )}> - handleSetTerm(term, 'local')}/> - - - )} - - - {Object.entries(hosts).map(([key, item], index) => ( - - {item.title} - - - - - - - - openTerminal(e, item)}/> -
}> - handleSetTerm(term, key)}/> - - ))} - - -
-
- ) - -} - -export default observer(Ext1Console) \ No newline at end of file diff --git a/spug_web/src/pages/deploy/request/Ext2Console.js b/spug_web/src/pages/deploy/request/Ext2Console.js deleted file mode 100644 index a3da332..0000000 --- a/spug_web/src/pages/deploy/request/Ext2Console.js +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug - * Copyright (c) - * 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 = - } - } - return - } - - 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 ( -
- {mini && ( - setVisible(true)}> -
-
{props.request.name}
- store.showConsole(props.request, true)}/> -
- - {Object.values(outputs).filter(x => x.id !== 'local').map(item => ( - - ))} -
- )} - store.showConsole(props.request, true)} - title={[ - {props.request.name}, -
- -
- ]}> - - {sActions.length > 0 && ( - - - - - - {sActions.map((item, index) => ( - - ))} - -
- )}> - handleSetTerm(term, 'local')}/> - - - )} - - {hostOutputs.length > 0 && ( - 0 ? 24 : 0}}> - {hostOutputs.map((item, index) => ( - - {item.title} - - - {hActions.map((action, index) => ( - - ))} - - openTerminal(e, item)}/> -
}> - handleSetTerm(term, item.id)}/> - - ))} - - )} - - -
- ) -} - -export default observer(Ext2Console) \ No newline at end of file diff --git a/spug_web/src/pages/deploy/request/console.module.less b/spug_web/src/pages/deploy/request/console.module.less new file mode 100644 index 0000000..6a91e2a --- /dev/null +++ b/spug_web/src/pages/deploy/request/console.module.less @@ -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); + } + } + } +} \ No newline at end of file diff --git a/spug_web/src/pages/deploy/request/index.js b/spug_web/src/pages/deploy/request/index.js index dda6c55..cf91e15 100644 --- a/spug_web/src/pages/deploy/request/index.js +++ b/spug_web/src/pages/deploy/request/index.js @@ -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 && } {store.tabs.length > 0 && ( - {store.tabs.map(item => item.id ? - item.app_extend === '1' ? ( - - ) : ( - - ) : null)} + {store.tabs.map(item => ( + + ))} )} diff --git a/spug_web/src/pages/deploy/request/index.module.less b/spug_web/src/pages/deploy/request/index.module.less index 19d2d2d..766aa8a 100644 --- a/spug_web/src/pages/deploy/request/index.module.less +++ b/spug_web/src/pages/deploy/request/index.module.less @@ -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 { diff --git a/spug_web/src/pages/deploy/request/store.js b/spug_web/src/pages/deploy/request/store.js index 254460e..78eb4c4 100644 --- a/spug_web/src/pages/deploy/request/store.js +++ b/spug_web/src/pages/deploy/request/store.js @@ -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); diff --git a/spug_web/src/pages/exec/task/Output.js b/spug_web/src/pages/exec/task/Output.js index 3a4121f..dac2ca0 100644 --- a/spug_web/src/pages/exec/task/Output.js +++ b/spug_web/src/pages/exec/task/Output.js @@ -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)) } diff --git a/spug_web/src/pages/exec/task/index.module.less b/spug_web/src/pages/exec/task/index.module.less index ecf07ec..e805eee 100644 --- a/spug_web/src/pages/exec/task/index.module.less +++ b/spug_web/src/pages/exec/task/index.module.less @@ -213,7 +213,8 @@ } :global(.anticon) { - margin-right: 4px; + margin-right: 5px; + font-size: 16px; } .text {