diff --git a/spug_api/apps/account/management/commands/user.py b/spug_api/apps/account/management/commands/user.py index 0d1be0f..b01d28b 100644 --- a/spug_api/apps/account/management/commands/user.py +++ b/spug_api/apps/account/management/commands/user.py @@ -37,7 +37,7 @@ class Command(BaseCommand): if not all((options['u'], options['p'], options['n'])): self.echo_error('缺少参数') self.print_help() - elif User.objects.filter(username=options['u'], deleted_by_id__isnull=True).exists(): + elif User.objects.filter(username=options['u'], is_deleted=False).exists(): self.echo_error(f'已存在登录名为【{options["u"]}】的用户') else: User.objects.create( diff --git a/spug_api/apps/account/models.py b/spug_api/apps/account/models.py index b5bc60c..a886073 100644 --- a/spug_api/apps/account/models.py +++ b/spug_api/apps/account/models.py @@ -3,7 +3,7 @@ # Released under the AGPL-3.0 License. from django.db import models from django.core.cache import cache -from libs import ModelMixin, human_datetime +from libs import ModelMixin from django.contrib.auth.hashers import make_password, check_password import json @@ -15,24 +15,21 @@ class User(models.Model, ModelMixin): type = models.CharField(max_length=20, default='default') is_supper = models.BooleanField(default=False) is_active = models.BooleanField(default=True) + is_deleted = models.BooleanField(default=False) access_token = models.CharField(max_length=32) token_expired = models.IntegerField(null=True) last_login = models.CharField(max_length=20) last_ip = models.CharField(max_length=50) wx_token = models.CharField(max_length=50, null=True) roles = models.ManyToManyField('Role', db_table='user_role_rel') - - created_at = models.CharField(max_length=20, default=human_datetime) - created_by = models.ForeignKey('User', models.PROTECT, related_name='+', null=True) - deleted_at = models.CharField(max_length=20, null=True) - deleted_by = models.ForeignKey('User', models.PROTECT, related_name='+', null=True) + created_at = models.DateTimeField(auto_now_add=True) @staticmethod - def make_password(plain_password: str) -> str: - return make_password(plain_password, hasher='pbkdf2_sha256') + def make_password(password): + return make_password(password, hasher='pbkdf2_sha256') - def verify_password(self, plain_password: str) -> bool: - return check_password(plain_password, self.password_hash) + def verify_password(self, password): + return check_password(password, self.password_hash) def get_perms_cache(self): return cache.get(f'perms_{self.id}', set()) @@ -89,26 +86,22 @@ class User(models.Model, ModelMixin): class Role(models.Model, ModelMixin): name = models.CharField(max_length=50) desc = models.CharField(max_length=255, null=True) - page_perms = models.TextField(null=True) - deploy_perms = models.TextField(null=True) - group_perms = models.TextField(null=True) - created_at = models.CharField(max_length=20, default=human_datetime) - created_by = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+') + page_perms = models.JSONField(default=dict) + deploy_perms = models.JSONField(default=dict) + group_perms = models.JSONField(default=list) + created_at = models.DateTimeField(auto_now_add=True) def to_dict(self, *args, **kwargs): tmp = super().to_dict(*args, **kwargs) - tmp['page_perms'] = json.loads(self.page_perms) if self.page_perms else {} - tmp['deploy_perms'] = json.loads(self.deploy_perms) if self.deploy_perms else {} - tmp['group_perms'] = json.loads(self.group_perms) if self.group_perms else [] tmp['used'] = self.user_set.filter(deleted_by_id__isnull=True).count() return tmp def add_deploy_perm(self, target, value): perms = {'apps': [], 'envs': []} if self.deploy_perms: - perms.update(json.loads(self.deploy_perms)) + perms.update(self.deploy_perms) perms[target].append(value) - self.deploy_perms = json.dumps(perms) + self.deploy_perms = perms self.save() def clear_perms_cache(self): @@ -130,7 +123,7 @@ class History(models.Model, ModelMixin): agent = models.CharField(max_length=255, null=True) message = models.CharField(max_length=255, null=True) is_success = models.BooleanField(default=True) - created_at = models.CharField(max_length=20, default=human_datetime) + created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'login_histories' diff --git a/spug_api/apps/account/urls.py b/spug_api/apps/account/urls.py index cad7bc1..e66040d 100644 --- a/spug_api/apps/account/urls.py +++ b/spug_api/apps/account/urls.py @@ -1,16 +1,16 @@ # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # Released under the AGPL-3.0 License. -from django.conf.urls import url +from django.urls import path from apps.account.views import * from apps.account.history import * urlpatterns = [ - url(r'^login/$', login), - url(r'^logout/$', logout), - url(r'^user/$', UserView.as_view()), - url(r'^role/$', RoleView.as_view()), - url(r'^self/$', SelfView.as_view()), - url(r'^login/history/$', HistoryView.as_view()) + path('login/', login), + path('logout/', logout), + path('user/', UserView.as_view()), + path('role/', RoleView.as_view()), + path('self/', SelfView.as_view()), + path('login/history/', HistoryView.as_view()) ] diff --git a/spug_api/apps/account/views.py b/spug_api/apps/account/views.py index d9608e4..76f8b16 100644 --- a/spug_api/apps/account/views.py +++ b/spug_api/apps/account/views.py @@ -3,6 +3,7 @@ # Released under the AGPL-3.0 License. from django.core.cache import cache from django.conf import settings +from django.http.response import HttpResponse from libs.mixins import AdminView, View from libs import JsonParser, Argument, human_datetime, json_response from libs.utils import get_request_real_ip, generate_random_str @@ -39,7 +40,7 @@ class UserView(AdminView): Argument('wx_token', required=False), ).parse(request.body) if error is None: - user = User.objects.filter(username=form.username, deleted_by_id__isnull=True).first() + user = User.objects.filter(username=form.username, is_deleted=False).first() if user and (not form.id or form.id != user.id): return json_response(error=f'已存在登录名为【{form.username}】的用户') @@ -50,11 +51,8 @@ class UserView(AdminView): else: if not verify_password(password): return json_response(error='请设置至少8位包含数字、小写和大写字母的新密码') - user = User.objects.create( - password_hash=User.make_password(password), - created_by=request.user, - **form - ) + form.password_hash = User.make_password(password) + user = User.objects.create(**form) user.roles.set(role_ids) user.set_perms_cache() return json_response(error=error) @@ -71,7 +69,7 @@ class UserView(AdminView): if not verify_password(form.password): return json_response(error='请设置至少8位包含数字、小写和大写字母的新密码') user.token_expired = 0 - user.password_hash = User.make_password(form.pop('password')) + user.password_hash = User.make_password(form.password) if form.is_active is not None: user.is_active = form.is_active cache.delete(user.username) @@ -90,8 +88,7 @@ class UserView(AdminView): if user.id == request.user.id: return json_response(error='无法删除当前登录账户') user.is_active = True - user.deleted_at = human_datetime() - user.deleted_by = request.user + user.is_deleted = True user.roles.clear() user.save() return json_response(error=error) @@ -127,12 +124,12 @@ class RoleView(AdminView): if not role: return json_response(error='未找到指定角色') if form.page_perms is not None: - role.page_perms = json.dumps(form.page_perms) + role.page_perms = form.page_perms role.clear_perms_cache() if form.deploy_perms is not None: - role.deploy_perms = json.dumps(form.deploy_perms) + role.deploy_perms = form.deploy_perms if form.group_perms is not None: - role.group_perms = json.dumps(form.group_perms) + role.group_perms = form.group_perms role.user_set.update(token_expired=0) role.save() return json_response(error=error) @@ -193,7 +190,7 @@ def login(request): ).parse(request.body) if error is None: handle_response = partial(handle_login_record, request, form.username, form.type) - user = User.objects.filter(username=form.username, type=form.type).first() + user = User.objects.filter(username=form.username, type=form.type, is_deleted=False).first() if user and not user.is_active: return handle_response(error="账户已被系统禁用") if form.type == 'ldap': @@ -209,9 +206,8 @@ def login(request): elif message: return handle_response(error=message) else: - if user and user.deleted_by is None: - if user.verify_password(form.password): - return handle_user_info(handle_response, request, user, form.captcha) + if user and user.verify_password(form.password): + return handle_user_info(handle_response, request, user, form.captcha) value = cache.get_or_set(form.username, 0, 86400) if value >= 3: diff --git a/spug_api/apps/apis/deploy.py b/spug_api/apps/apis/deploy.py index 1c96f60..cb7d65e 100644 --- a/spug_api/apps/apis/deploy.py +++ b/spug_api/apps/apis/deploy.py @@ -3,9 +3,6 @@ # Released under the AGPL-3.0 License. from django.http.response import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse from apps.setting.utils import AppSetting -from apps.deploy.models import Deploy, DeployRequest -from apps.repository.models import Repository -from apps.deploy.utils import dispatch as deploy_dispatch from libs.utils import human_datetime from threading import Thread import hashlib diff --git a/spug_api/apps/config/views.py b/spug_api/apps/config/views.py index d009313..6c8a1f2 100644 --- a/spug_api/apps/config/views.py +++ b/spug_api/apps/config/views.py @@ -5,7 +5,6 @@ from django.views.generic import View from django.db.models import F from libs import json_response, JsonParser, Argument, auth from apps.app.models import Deploy, App -from apps.repository.models import Repository from apps.config.models import * import json import re @@ -71,8 +70,6 @@ class EnvironmentView(View): if error is None: if Deploy.objects.filter(env_id=form.id).exists(): return json_response(error='该环境已关联了发布配置,请删除相关发布配置后再尝试删除') - if Repository.objects.filter(env_id=form.id).exists(): - return json_response(error='该环境关联了构建记录,请在删除应用发布/构建仓库中相关记录后再尝试') # auto delete configs Config.objects.filter(env_id=form.id).delete() ConfigHistory.objects.filter(env_id=form.id).delete() diff --git a/spug_api/apps/deploy/__init__.py b/spug_api/apps/deploy/__init__.py deleted file mode 100644 index 89f622a..0000000 --- a/spug_api/apps/deploy/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. diff --git a/spug_api/apps/deploy/ext1.py b/spug_api/apps/deploy/ext1.py deleted file mode 100644 index 810318b..0000000 --- a/spug_api/apps/deploy/ext1.py +++ /dev/null @@ -1,140 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django.conf import settings -from libs.utils import AttrDict, render_str, human_seconds_time -from apps.host.models import Host -from apps.repository.models import Repository -from apps.repository.utils import dispatch as build_repository -from apps.deploy.helper import SpugError -from concurrent import futures -import json -import time -import os - -BUILD_DIR = settings.BUILD_DIR - - -def ext1_deploy(req, helper, env): - if not req.repository_id: - rep = Repository( - app_id=req.deploy.app_id, - env_id=req.deploy.env_id, - deploy_id=req.deploy_id, - version=req.version, - spug_version=req.spug_version, - extra=req.extra, - remarks='SPUG AUTO MAKE', - created_by_id=req.created_by_id - ) - build_repository(rep, helper) - req.repository = rep - env.update(SPUG_BUILD_ID=str(req.repository_id)) - env.update(helper.get_cross_env(req.spug_version)) - extras = json.loads(req.extra) - if extras[0] == 'repository': - extras = extras[1:] - if extras[0] == 'branch': - env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2]) - else: - env.update(SPUG_GIT_TAG=extras[1]) - 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 helper.deploy_host_ids: - new_env = AttrDict(env.items()) - t = executor.submit(_deploy_ext1_host, req, helper, h_id, new_env) - t.h_id = h_id - threads.append(t) - for t in futures.as_completed(threads): - exception = t.exception() - if exception: - helper.set_deploy_fail(t.h_id) - latest_exception = exception - if not isinstance(exception, SpugError): - helper.send_error(t.h_id, f'Exception: {exception}', with_break=False) - else: - helper.set_deploy_success(t.h_id) - if latest_exception: - raise latest_exception - else: - host_ids = sorted(helper.deploy_host_ids, reverse=True) - while host_ids: - h_id = host_ids.pop() - new_env = AttrDict(env.items()) - try: - _deploy_ext1_host(req, helper, h_id, new_env) - helper.set_deploy_success(h_id) - except Exception as e: - helper.set_deploy_fail(h_id) - helper.send_error(h_id, f'Exception: {e}', with_break=False) - for h_id in host_ids: - helper.set_deploy_fail(h_id) - helper.send_error(h_id, '终止发布', with_break=False) - raise e - - -def _deploy_ext1_host(req, helper, h_id, env): - flag = time.time() - helper.set_deploy_process(h_id) - 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') - env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname}) - extend = req.deploy.extend_obj - extend.dst_dir = render_str(extend.dst_dir, 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'\r\n检测到该主机的发布目录 {extend.dst_dir!r} 已存在,为了数据安全请自行备份后删除该目录,Spug 将会创建并接管该目录。') - if req.type == '2': - 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' - helper.remote_raw(host.id, ssh, f'cd {extend.dst_repo} && {clean_command}') - # transfer files - tar_gz_file = f'{req.spug_version}.tar.gz' - try: - callback = helper.progress_callback(host.id) - ssh.put_file( - os.path.join(BUILD_DIR, tar_gz_file), - os.path.join(extend.dst_repo, tar_gz_file), - callback - ) - except Exception as e: - helper.send_error(host.id, f'Exception: {e}') - - 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_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_info(h_id, '发布前任务... \r\n') - command = f'cd {repo_dir} && {extend.hook_pre_host}' - helper.remote(host.id, ssh, command) - - # do deploy - 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_success(h_id, '完成√\r\n') - - # post host - if extend.hook_post_host: - helper.send_info(h_id, '发布后任务... \r\n') - command = f'cd {extend.dst_dir} && {extend.hook_post_host}' - helper.remote(host.id, ssh, command) - - human_time = human_seconds_time(time.time() - flag) - helper.send_success(h_id, f'\r\n** 发布成功,耗时:{human_time} **', status='success') diff --git a/spug_api/apps/deploy/ext2.py b/spug_api/apps/deploy/ext2.py deleted file mode 100644 index db635ed..0000000 --- a/spug_api/apps/deploy/ext2.py +++ /dev/null @@ -1,175 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django.conf import settings -from libs.utils import AttrDict, render_str, human_seconds_time -from libs.executor import Executor -from apps.host.models import Host -from apps.deploy.helper import SpugError -from concurrent import futures -import json -import time -import os - -REPOS_DIR = settings.REPOS_DIR - - -def ext2_deploy(req, helper, env, with_local): - flag = time.time() - extend, step = req.deploy.extend_obj, 1 - host_actions = json.loads(extend.host_actions) - server_actions = json.loads(extend.server_actions) - env.update({'SPUG_RELEASE': req.version}) - if req.version: - for index, value in enumerate(req.version.split()): - env.update({f'SPUG_RELEASE_{index + 1}': value}) - - 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'] - else: - transfer_action = action - break - - if with_local or True: - with Executor(env) as et: - helper.save_pid(et.pid, 'local') - helper.set_deploy_process('local') - 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(et, f'cd /tmp && {action["data"]}') - 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']: - helper.send_error('local', f'Invalid path for transfer, src: {action["src"]} dst: {action["dst"]}') - if not os.path.exists(action['src']): - helper.send_error('local', f'No such file or directory: {action["src"]}') - is_dir, exclude = os.path.isdir(action['src']), '' - sp_dir, sd_dst = os.path.split(action['src']) - contain = sd_dst - if action['mode'] != '0' and is_dir: - files = helper.parse_filter_rule(action['rule'], ',', env) - if files: - if action['mode'] == '1': - contain = ' '.join(f'{sd_dst}/{x}' for x in files) - else: - excludes = [] - for x in files: - if x.startswith('/'): - excludes.append(f'--exclude={sd_dst}{x}') - else: - excludes.append(f'--exclude={x}') - exclude = ' '.join(excludes) - tar_gz_file = os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, f'{req.spug_version}.tar.gz') - helper.local_raw(f'cd {sp_dir} && tar -zcf {tar_gz_file} {exclude} {contain}') - helper.send_info('local', '打包完成\r\n') - helper.set_cross_env(req.spug_version, et.get_envs()) - helper.set_deploy_success('local') - human_time = human_seconds_time(time.time() - flag) - helper.send_success('local', f'\r\n** 执行完成,耗时:{human_time} **', status='success') - - if host_actions: - env.update(helper.get_cross_env(req.spug_version)) - 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 sorted(helper.deploy_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 - threads.append(t) - for t in futures.as_completed(threads): - exception = t.exception() - if exception: - helper.set_deploy_fail(t.h_id) - latest_exception = exception - if not isinstance(exception, SpugError): - helper.send_error(t.h_id, f'Exception: {exception}', with_break=False) - else: - helper.set_deploy_success(t.h_id) - if latest_exception: - raise latest_exception - else: - host_ids = sorted(helper.deploy_host_ids) - while host_ids: - h_id = host_ids.pop() - new_env = AttrDict(env.items()) - try: - _deploy_ext2_host(helper, h_id, host_actions, new_env, req.spug_version) - helper.set_deploy_success(h_id) - except Exception as e: - helper.set_deploy_fail(h_id) - if not isinstance(e, SpugError): - helper.send_error(h_id, f'Exception: {e}', with_break=False) - for h_id in host_ids: - helper.set_deploy_fail(h_id) - helper.send_clear(h_id) - helper.send_error(h_id, '串行模式,终止发布', with_break=False) - raise e - - -def _deploy_ext2_host(helper, h_id, actions, env, spug_version): - flag = time.time() - helper.set_deploy_process(h_id) - 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: - 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'] - command = f'[ -e {dst} ] || mkdir -p $(dirname {dst}); [ -d {dst} ]' - code, _ = ssh.exec_command_raw(command) - if code == 0: # is dir - if not action.get('name'): - raise RuntimeError('internal error 1002') - dst = dst.rstrip('/') + '/' + action['name'] - 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'\r\nException: {e}') - helper.send_success(host.id, '完成√\r\n') - else: - _, sd_dst = os.path.split(action['src']) - tar_gz_file = f'{spug_version}.tar.gz' - src_file = os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, tar_gz_file) - try: - callback = helper.progress_callback(host.id) - ssh.put_file(src_file, f'/tmp/{tar_gz_file}', callback) - except Exception as e: - helper.send_error(host.id, f'\r\nException: {e}') - helper.send_success(host.id, '完成√\r\n') - 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}*' - helper.remote(host.id, ssh, command) - else: - helper.send_info(h_id, f'{action["title"]}...\r\n') - command = f'cd /tmp && {action["data"]}' - helper.remote(host.id, ssh, command) - human_time = human_seconds_time(time.time() - flag) - helper.send_success(h_id, f'\r\n** 发布成功,耗时:{human_time} **', status='success') diff --git a/spug_api/apps/deploy/helper.py b/spug_api/apps/deploy/helper.py deleted file mode 100644 index 867d5dd..0000000 --- a/spug_api/apps/deploy/helper.py +++ /dev/null @@ -1,453 +0,0 @@ -# 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 SpugError, human_datetime, render_str, str_decode -from libs.spug import Notification -from apps.host.models import Host -from apps.config.utils import update_config_by_var -from functools import partial -from collections import defaultdict -import subprocess -import json -import os -import re - - -class NotifyMixin: - @classmethod - def _make_dd_notify(cls, url, action, req, version, host_str): - texts = [ - f'**申请标题:** {req.name}', - f'**应用名称:** {req.deploy.app.name}', - f'**应用版本:** {version}', - f'**发布环境:** {req.deploy.env.name}', - f'**发布主机:** {host_str}', - ] - if action == 'approve_req': - texts.insert(0, '## %s ## ' % '发布审核申请') - texts.extend([ - f'**申请人员:** {req.created_by.nickname}', - f'**申请时间:** {human_datetime()}', - '> 来自 Spug运维平台' - ]) - elif action == 'approve_rst': - color, text = ('#008000', '通过') if req.status == '1' else ('#f90202', '驳回') - texts.insert(0, '## %s ## ' % '发布审核结果') - texts.extend([ - f'**审核人员:** {req.approve_by.nickname}', - f'**审核结果:** {text}', - f'**审核意见:** {req.reason or ""}', - f'**审核时间:** {human_datetime()}', - '> 来自 Spug运维平台' - ]) - else: - color, text = ('#008000', '成功') if req.status == '3' else ('#f90202', '失败') - texts.insert(0, '## %s ## ' % '发布结果通知') - if req.approve_at: - texts.append(f'**审核人员:** {req.approve_by.nickname}') - do_user = req.do_by.nickname if req.type != '3' else 'Webhook' - texts.extend([ - f'**执行人员:** {do_user}', - f'**发布结果:** {text}', - f'**发布时间:** {human_datetime()}', - '> 来自 Spug运维平台' - ]) - data = { - 'msgtype': 'markdown', - 'markdown': { - 'title': 'Spug 发布消息通知', - 'text': '\n\n'.join(texts) - }, - 'at': { - 'isAtAll': True - } - } - Notification.handle_request(url, data, 'dd') - - @classmethod - def _make_wx_notify(cls, url, action, req, version, host_str): - texts = [ - f'申请标题: {req.name}', - f'应用名称: {req.deploy.app.name}', - f'应用版本: {version}', - f'发布环境: {req.deploy.env.name}', - f'发布主机: {host_str}', - ] - - if action == 'approve_req': - texts.insert(0, '## %s' % '发布审核申请') - texts.extend([ - f'申请人员: {req.created_by.nickname}', - f'申请时间: {human_datetime()}', - '> 来自 Spug运维平台' - ]) - elif action == 'approve_rst': - color, text = ('info', '通过') if req.status == '1' else ('warning', '驳回') - texts.insert(0, '## %s' % '发布审核结果') - texts.extend([ - f'审核人员: {req.approve_by.nickname}', - f'审核结果: {text}', - f'审核意见: {req.reason or ""}', - f'审核时间: {human_datetime()}', - '> 来自 Spug运维平台' - ]) - else: - color, text = ('info', '成功') if req.status == '3' else ('warning', '失败') - texts.insert(0, '## %s' % '发布结果通知') - if req.approve_at: - texts.append(f'审核人员: {req.approve_by.nickname}') - do_user = req.do_by.nickname if req.type != '3' else 'Webhook' - texts.extend([ - f'执行人员: {do_user}', - f'发布结果: {text}', - f'发布时间: {human_datetime()}', - '> 来自 Spug运维平台' - ]) - data = { - 'msgtype': 'markdown', - 'markdown': { - 'content': '\n'.join(texts) - } - } - Notification.handle_request(url, data, 'wx') - - @classmethod - def _make_fs_notify(cls, url, action, req, version, host_str): - texts = [ - f'申请标题: {req.name}', - f'应用名称: {req.deploy.app.name}', - f'应用版本: {version}', - f'发布环境: {req.deploy.env.name}', - f'发布主机: {host_str}', - ] - - if action == 'approve_req': - title = '发布审核申请' - texts.extend([ - f'申请人员: {req.created_by.nickname}', - f'申请时间: {human_datetime()}', - ]) - elif action == 'approve_rst': - title = '发布审核结果' - text = '通过' if req.status == '1' else '驳回' - texts.extend([ - f'审核人员: {req.approve_by.nickname}', - f'审核结果: {text}', - f'审核意见: {req.reason or ""}', - f'审核时间: {human_datetime()}', - ]) - else: - title = '发布结果通知' - text = '成功 ✅' if req.status == '3' else '失败 ❗' - if req.approve_at: - texts.append(f'审核人员: {req.approve_by.nickname}') - do_user = req.do_by.nickname if req.type != '3' else 'Webhook' - texts.extend([ - f'执行人员: {do_user}', - f'发布结果: {text}', - f'发布时间: {human_datetime()}', - ]) - data = { - 'msg_type': 'post', - 'content': { - 'post': { - 'zh_cn': { - 'title': title, - 'content': [[{'tag': 'text', 'text': x}] for x in texts] + [[{'tag': 'at', 'user_id': 'all'}]] - } - } - } - } - Notification.handle_request(url, data, 'fs') - - @classmethod - def send_deploy_notify(cls, req, action=None): - rst_notify = json.loads(req.deploy.rst_notify) - host_ids = json.loads(req.host_ids) if isinstance(req.host_ids, str) else req.host_ids - if rst_notify['mode'] != '0' and rst_notify.get('value'): - url = rst_notify['value'] - version = req.version - hosts = [{'id': x.id, 'name': x.name} for x in Host.objects.filter(id__in=host_ids)] - host_str = ', '.join(x['name'] for x in hosts[:2]) - if len(hosts) > 2: - host_str += f'等{len(hosts)}台主机' - if rst_notify['mode'] == '1': - cls._make_dd_notify(url, action, req, version, host_str) - elif rst_notify['mode'] == '2': - data = { - 'action': action, - 'req_id': req.id, - 'req_name': req.name, - 'app_id': req.deploy.app_id, - 'app_name': req.deploy.app.name, - 'env_id': req.deploy.env_id, - 'env_name': req.deploy.env.name, - 'status': req.status, - 'reason': req.reason, - 'version': version, - 'targets': hosts, - 'is_success': req.status == '3', - 'created_at': human_datetime() - } - Notification.handle_request(url, data) - elif rst_notify['mode'] == '3': - cls._make_wx_notify(url, action, req, version, host_str) - elif rst_notify['mode'] == '4': - cls._make_fs_notify(url, action, req, version, host_str) - 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.deploy_status = {} - self.deploy_host_ids = [] - 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: - if key != 'local': - instance.deploy_host_ids.append(key) - instance.deploy_status[key] = '0' - 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 set_deploy_process(self, key): - self.deploy_status[key] = '1' - - def set_deploy_success(self, key): - self.deploy_status[key] = '2' - - def set_deploy_fail(self, key): - self.deploy_status[key] = '3' - - 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 get_cross_env(self, key): - file = os.path.join(settings.DEPLOY_DIR, key) - if os.path.exists(file): - with open(file, 'r') as f: - return json.loads(f.read()) - return {} - - def set_cross_env(self, key, envs): - file_envs = {} - for k, v in envs.items(): - if k == 'SPUG_SET': - try: - update_config_by_var(v) - except SpugError as e: - self.send_error('local', f'{e}') - elif k.startswith('SPUG_GEV_'): - file_envs[k] = v - - file = os.path.join(settings.DEPLOY_DIR, key) - with open(file, 'w') as f: - f.write(json.dumps(file_envs)) - - 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: - for line in data.split(sep): - line = line.strip() - if line and not line.startswith('#'): - files.append(render_str(line, env)) - return files - - 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)) - - 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 = 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 clear(self): - 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(k, message) - - self._send(key, '\r\n') - return partial(func, key) - - def local(self, executor, command): - code = -1 - for code, out in executor.exec_command_with_stream(command): - self._send('local', out) - if code != 0: - self.send_error('local', f'exit code: {code}') - - def local_raw(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) - message = b'' - while True: - output = task.stdout.read(1) - if not output: - break - if output in (b'\r', b'\n'): - message += b'\r\n' if output == b'\n' else b'\r' - message = str_decode(message) - self._send('local', message) - message = b'' - else: - message += output - if task.wait() != 0: - self.send_error('local', f'exit code: {task.returncode}') - - def remote(self, key, ssh, command, env=None): - code = -1 - for code, out in ssh.exec_command_with_stream(command, environment=env): - self._send(key, out) - if code != 0: - self.send_error(key, f'exit code: {code}') - - def remote_raw(self, key, ssh, command): - code, out = ssh.exec_command_raw(command) - if code != 0: - self.send_error(key, f'exit code: {code}, {out}') diff --git a/spug_api/apps/deploy/models.py b/spug_api/apps/deploy/models.py deleted file mode 100644 index 27c56ce..0000000 --- a/spug_api/apps/deploy/models.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django.db import models -from django.conf import settings -from libs import ModelMixin, human_datetime -from apps.account.models import User -from apps.app.models import Deploy -from apps.repository.models import Repository -from pathlib import Path -import json - - -class DeployRequest(models.Model, ModelMixin): - STATUS = ( - ('-3', '发布异常'), - ('-1', '已驳回'), - ('0', '待审核'), - ('1', '待发布'), - ('2', '发布中'), - ('3', '发布成功'), - ('4', '灰度成功'), - ) - TYPES = ( - ('1', '正常发布'), - ('2', '回滚'), - ('3', '自动发布'), - ) - deploy = models.ForeignKey(Deploy, on_delete=models.CASCADE) - repository = models.ForeignKey(Repository, null=True, on_delete=models.SET_NULL) - name = models.CharField(max_length=100) - type = models.CharField(max_length=2, choices=TYPES, default='1') - extra = models.TextField() - host_ids = models.TextField() - desc = models.CharField(max_length=255, null=True) - status = models.CharField(max_length=2, choices=STATUS) - reason = models.CharField(max_length=255, null=True) - version = models.CharField(max_length=100, null=True) - spug_version = models.CharField(max_length=50, null=True) - plan = models.DateTimeField(null=True) - deploy_status = models.TextField(default='{}') - created_at = models.CharField(max_length=20, default=human_datetime) - created_by = models.ForeignKey(User, models.PROTECT, related_name='+') - approve_at = models.CharField(max_length=20, null=True) - approve_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True) - do_at = models.CharField(max_length=20, null=True) - do_by = models.ForeignKey(User, models.PROTECT, related_name='+', null=True) - - @property - def is_quick_deploy(self): - if self.type in ('1', '3') and self.deploy.extend == '1' and self.extra: - extra = json.loads(self.extra) - 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): - if self.repository_id: - if not self.repository.deployrequest_set.exclude(id=self.id).exists(): - self.repository.delete() - if self.deploy.extend == '2': - for p in Path(settings.REPOS_DIR, str(self.deploy_id)).glob(f'{self.spug_version}*'): - try: - p.unlink() - except FileNotFoundError: - pass - for p in Path(settings.DEPLOY_DIR).glob(f'{self.deploy_key}:*'): - try: - p.unlink() - except FileNotFoundError: - pass - try: - Path(settings.DEPLOY_DIR, self.spug_version).unlink() - except FileNotFoundError: - pass - super().delete(using, keep_parents) - - def __repr__(self): - return f'' - - class Meta: - db_table = 'deploy_requests' - ordering = ('-id',) diff --git a/spug_api/apps/deploy/urls.py b/spug_api/apps/deploy/urls.py deleted file mode 100644 index 424dd04..0000000 --- a/spug_api/apps/deploy/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django.urls import path - -from .views import * - -urlpatterns = [ - path('request/', RequestView.as_view()), - path('request/info/', get_request_info), - path('request/ext1/', post_request_ext1), - path('request/ext1/rollback/', post_request_ext1_rollback), - path('request/ext2/', post_request_ext2), - path('request/upload/', do_upload), - path('request//', RequestDetailView.as_view()), -] diff --git a/spug_api/apps/deploy/utils.py b/spug_api/apps/deploy/utils.py deleted file mode 100644 index 6f85ad3..0000000 --- a/spug_api/apps/deploy/utils.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django_redis import get_redis_connection -from django.conf import settings -from django.db import close_old_connections -from libs.utils import AttrDict -from apps.config.utils import compose_configs -from apps.deploy.models import DeployRequest -from apps.deploy.helper import Helper, SpugError -from apps.deploy.ext1 import ext1_deploy -from apps.deploy.ext2 import ext2_deploy -import json -import uuid - -REPOS_DIR = settings.REPOS_DIR - - -def dispatch(req, deploy_host_ids, with_local): - rds = get_redis_connection() - rds_key = req.deploy_key - keys = deploy_host_ids + ['local'] if with_local else deploy_host_ids - helper = Helper.make(rds, rds_key, keys) - - try: - api_token = uuid.uuid4().hex - rds.setex(api_token, 60 * 60, f'{req.deploy.app_id},{req.deploy.env_id}') - env = AttrDict( - SPUG_APP_NAME=req.deploy.app.name, - SPUG_APP_KEY=req.deploy.app.key, - SPUG_APP_ID=str(req.deploy.app_id), - SPUG_REQUEST_ID=str(req.id), - SPUG_REQUEST_NAME=req.name, - SPUG_DEPLOY_ID=str(req.deploy.id), - SPUG_ENV_ID=str(req.deploy.env_id), - SPUG_ENV_KEY=req.deploy.env.key, - SPUG_VERSION=req.version, - SPUG_BUILD_VERSION=req.spug_version, - SPUG_DEPLOY_TYPE=req.type, - SPUG_API_TOKEN=api_token, - SPUG_REPOS_DIR=REPOS_DIR, - ) - # append configs - configs = compose_configs(req.deploy.app, req.deploy.env_id) - configs_env = {f'_SPUG_{k.upper()}': v for k, v in configs.items()} - env.update(configs_env) - - if req.deploy.extend == '1': - ext1_deploy(req, helper, env) - else: - ext2_deploy(req, helper, env, with_local) - req.status = '3' - except Exception as e: - req.status = '-3' - if not isinstance(e, SpugError): - raise e - finally: - close_old_connections() - request = DeployRequest.objects.get(pk=req.id) - deploy_status = json.loads(request.deploy_status) - deploy_status.update({str(k): v for k, v in helper.deploy_status.items()}) - values = [v for k, v in deploy_status.items() if k != 'local'] - if all([x == '2' for x in values]): - if len(values) == len(json.loads(request.host_ids)): - request.status = '3' - else: - request.status = '4' - else: - request.status = '-3' - request.repository = req.repository - request.deploy_status = json.dumps(deploy_status) - request.save() - helper.clear() - Helper.send_deploy_notify(req) diff --git a/spug_api/apps/deploy/views.py b/spug_api/apps/deploy/views.py deleted file mode 100644 index d6096df..0000000 --- a/spug_api/apps/deploy/views.py +++ /dev/null @@ -1,369 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django.views.generic import View -from django.db.models import F -from django.conf import settings -from django.http.response import HttpResponseBadRequest -from libs import json_response, JsonParser, Argument, human_datetime, auth, AttrDict -from apps.deploy.models import DeployRequest -from apps.app.models import Deploy, DeployExtend2 -from apps.repository.models import Repository -from apps.deploy.utils import dispatch, Helper -from apps.host.models import Host -from collections import defaultdict -from threading import Thread -from datetime import datetime -import subprocess -import json -import os - - -class RequestView(View): - @auth('deploy.request.view') - def get(self, request): - data, query, counter = [], {}, {} - if not request.user.is_supper: - perms = request.user.deploy_perms - query['deploy__app_id__in'] = perms['apps'] - query['deploy__env_id__in'] = perms['envs'] - for item in DeployRequest.objects.filter(**query).annotate( - env_id=F('deploy__env_id'), - env_name=F('deploy__env__name'), - app_id=F('deploy__app_id'), - app_name=F('deploy__app__name'), - app_host_ids=F('deploy__host_ids'), - app_extend=F('deploy__extend'), - rep_extra=F('repository__extra'), - do_by_user=F('do_by__nickname'), - approve_by_user=F('approve_by__nickname'), - created_by_user=F('created_by__nickname')): - tmp = item.to_dict() - tmp['env_id'] = item.env_id - tmp['env_name'] = item.env_name - tmp['app_id'] = item.app_id - tmp['app_name'] = item.app_name - tmp['app_extend'] = item.app_extend - tmp['host_ids'] = json.loads(item.host_ids) - tmp['deploy_status'] = json.loads(item.deploy_status) - tmp['extra'] = json.loads(item.extra) if item.extra else None - tmp['rep_extra'] = json.loads(item.rep_extra) if item.rep_extra else None - tmp['app_host_ids'] = json.loads(item.app_host_ids) - tmp['status_alias'] = item.get_status_display() - tmp['created_by_user'] = item.created_by_user - tmp['approve_by_user'] = item.approve_by_user - tmp['do_by_user'] = item.do_by_user - if item.app_extend == '1': - tmp['visible_rollback'] = item.deploy_id not in counter - counter[item.deploy_id] = True - data.append(tmp) - return json_response(data) - - @auth('deploy.request.del') - def delete(self, request): - form, error = JsonParser( - Argument('id', type=int, required=False), - Argument('mode', filter=lambda x: x in ('count', 'expire', 'deploy'), required=False, help='参数错误'), - Argument('value', required=False), - ).parse(request.GET) - if error is None: - if form.id: - deploy = DeployRequest.objects.get(pk=form.id) - deploy.delete() - return json_response() - - count = 0 - if form.mode == 'count': - if not str(form.value).isdigit() or int(form.value) < 1: - return json_response(error='请输入正确的保留数量') - counter, form.value = defaultdict(int), int(form.value) - for item in DeployRequest.objects.all(): - counter[item.deploy_id] += 1 - if counter[item.deploy_id] > form.value: - count += 1 - item.delete() - elif form.mode == 'expire': - for item in DeployRequest.objects.filter(created_at__lt=form.value): - count += 1 - item.delete() - elif form.mode == 'deploy': - app_id, env_id = str(form.value).split(',') - for item in DeployRequest.objects.filter(deploy__app_id=app_id, deploy__env_id=env_id): - count += 1 - item.delete() - return json_response(count) - return json_response(error=error) - - -class RequestDetailView(View): - @auth('deploy.request.view') - def get(self, request, r_id): - 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': ''} for x in hosts} - outputs['local'] = {'id': 'local', 'data': ''} - if req.deploy.extend == '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') - - 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, error = JsonParser( - Argument('mode', filter=lambda x: x in ('fail', 'gray', 'all'), help='参数错误'), - Argument('host_ids', type=list, required=False) - ).parse(request.body) - if error is None: - 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'] - query['deploy__env_id__in'] = perms['envs'] - req = DeployRequest.objects.filter(**query).first() - if not req: - return json_response(error='未找到指定发布申请') - if req.status not in ('1', '-3', '4'): - return json_response(error='该申请单当前状态还不能执行发布') - - deploy_status = json.loads(req.deploy_status) - if form.mode == 'gray': - if not form.host_ids: - return json_response(error='请选择灰度发布的主机') - host_ids = form.host_ids - elif form.mode == 'fail': - host_ids = [int(k) for k, v in deploy_status.items() if v != '2' and k != 'local'] - else: - host_ids = json.loads(req.host_ids) - - with_local = False - hosts = Host.objects.filter(id__in=host_ids) - message = Helper.term_message('等待调度... ') - outputs = {x.id: {'id': x.id, 'title': x.name, 'data': message} for x in hosts} - if req.deploy.extend == '1': - if req.repository_id: - if req.is_quick_deploy: - outputs['local'] = { - 'id': 'local', - 'status': 'success', - 'data': Helper.term_message('已构建完成忽略执行', 'warn') - } - else: - with_local = True - outputs['local'] = {'id': 'local', 'data': Helper.term_message('等待初始化... ')} - elif req.deploy.extend == '2': - s_actions = json.loads(req.deploy.extend_obj.server_actions) - h_actions = json.loads(req.deploy.extend_obj.host_actions) - if s_actions: - if deploy_status.get('local') == '2': - outputs['local'] = { - 'id': 'local', - 'status': 'success', - 'data': Helper.term_message('已完成本地动作忽略执行', 'warn') - } - else: - with_local = True - outputs['local'] = {'id': 'local', 'data': Helper.term_message('等待初始化... ')} - if not h_actions: - outputs = {'local': outputs['local']} - else: - raise NotImplementedError - - req.status = '2' - req.do_at = human_datetime() - req.do_by = request.user - req.save() - Thread(target=dispatch, args=(req, host_ids, with_local)).start() - return json_response({'outputs': outputs, 'token': req.deploy_key}) - return json_response(error=error) - - @auth('deploy.request.approve') - def patch(self, request, r_id): - form, error = JsonParser( - Argument('reason', required=False), - Argument('is_pass', type=bool, help='参数错误') - ).parse(request.body) - if error is None: - req = DeployRequest.objects.filter(pk=r_id).first() - if not req: - return json_response(error='未找到指定申请') - if not form.is_pass and not form.reason: - return json_response(error='请输入驳回原因') - if req.status != '0': - return json_response(error='该申请当前状态不允许审核') - req.approve_at = human_datetime() - req.approve_by = request.user - req.status = '1' if form.is_pass else '-1' - req.reason = form.reason - req.save() - Thread(target=Helper.send_deploy_notify, args=(req, 'approve_rst')).start() - return json_response(error=error) - - -@auth('deploy.request.add|deploy.request.edit') -def post_request_ext1(request): - form, error = JsonParser( - Argument('id', type=int, required=False), - Argument('deploy_id', type=int, help='参数错误'), - Argument('name', help='请输入申请标题'), - Argument('extra', type=list, help='请选择发布版本'), - Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'), - Argument('type', default='1'), - Argument('plan', required=False), - Argument('desc', required=False), - ).parse(request.body) - if error is None: - deploy = Deploy.objects.get(pk=form.deploy_id) - form.spug_version = Repository.make_spug_version(deploy.id) - if form.extra[0] == 'tag': - if not form.extra[1]: - return json_response(error='请选择要发布的版本') - form.version = form.extra[1] - elif form.extra[0] == 'branch': - if not form.extra[2]: - return json_response(error='请选择要发布的分支及Commit ID') - form.version = f'{form.extra[1]}#{form.extra[2][:6]}' - elif form.extra[0] == 'repository': - if not form.extra[1]: - return json_response(error='请选择要发布的版本') - repository = Repository.objects.get(pk=form.extra[1]) - form.repository_id = repository.id - form.version = repository.version - form.spug_version = repository.spug_version - form.extra = ['repository'] + json.loads(repository.extra) - else: - return json_response(error='参数错误') - - form.extra = json.dumps(form.extra) - form.status = '0' if deploy.is_audit else '1' - form.host_ids = json.dumps(sorted(form.host_ids)) - if form.id: - req = DeployRequest.objects.get(pk=form.id) - is_required_notify = deploy.is_audit and req.status == '-1' - DeployRequest.objects.filter(pk=form.id).update(created_by=request.user, reason=None, **form) - else: - req = DeployRequest.objects.create(created_by=request.user, **form) - is_required_notify = deploy.is_audit - if is_required_notify: - Thread(target=Helper.send_deploy_notify, args=(req, 'approve_req')).start() - return json_response(error=error) - - -@auth('deploy.request.do') -def post_request_ext1_rollback(request): - form, error = JsonParser( - Argument('request_id', type=int, help='请选择要回滚的版本'), - Argument('name', help='请输入申请标题'), - Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'), - Argument('desc', required=False), - ).parse(request.body) - if error is None: - req = DeployRequest.objects.get(pk=form.pop('request_id')) - 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='选择的版本超出了发布配置中设置的版本数量,无法快速回滚,可通过新建发布申请选择构建仓库里的该版本再次发布。') - - form.status = '0' if req.deploy.is_audit else '1' - form.host_ids = json.dumps(sorted(form.host_ids)) - new_req = DeployRequest.objects.create( - deploy_id=req.deploy_id, - repository_id=req.repository_id, - type='2', - extra=req.extra, - version=req.version, - spug_version=req.spug_version, - created_by=request.user, - **form - ) - if req.deploy.is_audit: - Thread(target=Helper.send_deploy_notify, args=(new_req, 'approve_req')).start() - return json_response(error=error) - - -@auth('deploy.request.add|deploy.request.edit') -def post_request_ext2(request): - form, error = JsonParser( - Argument('id', type=int, required=False), - Argument('deploy_id', type=int, help='缺少必要参数'), - Argument('name', help='请输申请标题'), - Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'), - Argument('extra', type=dict, required=False), - Argument('version', default=''), - Argument('type', default='1'), - Argument('plan', required=False), - Argument('desc', required=False), - ).parse(request.body) - if error is None: - deploy = Deploy.objects.filter(pk=form.deploy_id).first() - if not deploy: - return json_response(error='未找到该发布配置') - extra = form.pop('extra') - if DeployExtend2.objects.filter(deploy=deploy, host_actions__contains='"src_mode": "1"').exists(): - if not extra: - return json_response(error='该应用的发布配置中使用了数据传输动作且设置为发布时上传,请上传要传输的数据') - form.spug_version = extra['path'] - form.extra = json.dumps(extra) - else: - form.spug_version = Repository.make_spug_version(deploy.id) - form.name = form.name.replace("'", '') - form.status = '0' if deploy.is_audit else '1' - form.host_ids = json.dumps(form.host_ids) - if form.id: - req = DeployRequest.objects.get(pk=form.id) - is_required_notify = deploy.is_audit and req.status == '-1' - form.update(created_by=request.user, reason=None) - req.update_by_dict(form) - else: - req = DeployRequest.objects.create(created_by=request.user, **form) - is_required_notify = deploy.is_audit - if is_required_notify: - Thread(target=Helper.send_deploy_notify, args=(req, 'approve_req')).start() - return json_response(error=error) - - -@auth('deploy.request.view') -def get_request_info(request): - form, error = JsonParser( - Argument('id', type=int, help='参数错误') - ).parse(request.GET) - if error is None: - req = DeployRequest.objects.get(pk=form.id) - response = req.to_dict(selects=('status', 'reason')) - response['deploy_status'] = json.loads(req.deploy_status) - response['status_alias'] = req.get_status_display() - return json_response(response) - return json_response(error=error) - - -@auth('deploy.request.add') -def do_upload(request): - repos_dir = settings.REPOS_DIR - file = request.FILES['file'] - deploy_id = request.POST.get('deploy_id') - if file and deploy_id: - dir_name = os.path.join(repos_dir, deploy_id) - file_name = datetime.now().strftime("%Y%m%d%H%M%S") - command = f'mkdir -p {dir_name} && cd {dir_name} && ls | sort -rn | tail -n +11 | xargs rm -rf' - code, outputs = subprocess.getstatusoutput(command) - if code != 0: - return json_response(error=outputs) - with open(os.path.join(dir_name, file_name), 'wb') as f: - for chunk in file.chunks(): - f.write(chunk) - return json_response(file_name) - else: - return HttpResponseBadRequest() diff --git a/spug_api/apps/exec/urls.py b/spug_api/apps/exec/urls.py index 9ad212b..be48bf8 100644 --- a/spug_api/apps/exec/urls.py +++ b/spug_api/apps/exec/urls.py @@ -1,14 +1,14 @@ # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # Released under the AGPL-3.0 License. -from django.conf.urls import url +from django.urls import path from apps.exec.views import TemplateView, TaskView, handle_terminate from apps.exec.transfer import TransferView urlpatterns = [ - url(r'template/$', TemplateView.as_view()), - url(r'do/$', TaskView.as_view()), - url(r'transfer/$', TransferView.as_view()), - url(r'terminate/$', handle_terminate), + path('template/', TemplateView.as_view()), + path('do/', TaskView.as_view()), + path('transfer/', TransferView.as_view()), + path('terminate/', handle_terminate), ] diff --git a/spug_api/apps/home/views.py b/spug_api/apps/home/views.py index 22938d9..41e36ab 100644 --- a/spug_api/apps/home/views.py +++ b/spug_api/apps/home/views.py @@ -6,7 +6,6 @@ from apps.host.models import Host from apps.schedule.models import Task from apps.monitor.models import Detection from apps.alarm.models import Alarm -from apps.deploy.models import Deploy, DeployRequest from apps.account.utils import get_host_perms from libs.utils import json_response, human_date, parse_time from libs.parser import JsonParser, Argument diff --git a/spug_api/apps/repository/__init__.py b/spug_api/apps/repository/__init__.py deleted file mode 100644 index 89f622a..0000000 --- a/spug_api/apps/repository/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. diff --git a/spug_api/apps/repository/models.py b/spug_api/apps/repository/models.py deleted file mode 100644 index c4908fb..0000000 --- a/spug_api/apps/repository/models.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django.db import models -from django.conf import settings -from libs.mixins import ModelMixin -from apps.app.models import App, Environment, Deploy -from apps.account.models import User -from datetime import datetime -from pathlib import Path -import json - - -class Repository(models.Model, ModelMixin): - STATUS = ( - ('0', '未开始'), - ('1', '构建中'), - ('2', '失败'), - ('5', '成功'), - ) - app = models.ForeignKey(App, on_delete=models.PROTECT) - env = models.ForeignKey(Environment, on_delete=models.PROTECT) - deploy = models.ForeignKey(Deploy, on_delete=models.PROTECT) - version = models.CharField(max_length=100) - spug_version = models.CharField(max_length=50) - remarks = models.CharField(max_length=255, null=True) - extra = models.TextField() - status = models.CharField(max_length=2, choices=STATUS, default='0') - created_at = models.DateTimeField(auto_now_add=True) - created_by = models.ForeignKey(User, on_delete=models.PROTECT) - - @staticmethod - 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) - tmp['status_alias'] = self.get_status_display() - if hasattr(self, 'app_name'): - tmp['app_name'] = self.app_name - if hasattr(self, 'env_name'): - tmp['env_name'] = self.env_name - if hasattr(self, 'created_by_user'): - tmp['created_by_user'] = self.created_by_user - return tmp - - def delete(self, using=None, keep_parents=False): - try: - Path(settings.BUILD_DIR, f'{self.spug_version}.tar.gz').unlink() - except FileNotFoundError: - pass - - for p in Path(settings.DEPLOY_DIR).glob(f'{self.deploy_key}:*'): - try: - p.unlink() - except FileNotFoundError: - pass - super().delete(using, keep_parents) - - class Meta: - db_table = 'repositories' - ordering = ('-id',) diff --git a/spug_api/apps/repository/urls.py b/spug_api/apps/repository/urls.py deleted file mode 100644 index cf0a221..0000000 --- a/spug_api/apps/repository/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -from django.urls import path - -from .views import * - -urlpatterns = [ - path('', RepositoryView.as_view()), - path('/', get_detail), - path('request/', get_requests), -] diff --git a/spug_api/apps/repository/utils.py b/spug_api/apps/repository/utils.py deleted file mode 100644 index 1303ef4..0000000 --- a/spug_api/apps/repository/utils.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -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_datetime, render_str, human_seconds_time -from libs.executor import Executor -from apps.repository.models import Repository -from apps.config.utils import compose_configs -from apps.deploy.helper import Helper -import json -import uuid -import time -import os - -REPOS_DIR = settings.REPOS_DIR -BUILD_DIR = settings.BUILD_DIR - - -def dispatch(rep: Repository, helper=None): - rep.status = '1' - alone_build = helper is None - if not helper: - rds = get_redis_connection() - helper = Helper.make(rds, rep.deploy_key, ['local']) - rep.save() - try: - helper.set_deploy_process('local') - api_token = uuid.uuid4().hex - helper.rds.setex(api_token, 60 * 60, f'{rep.app_id},{rep.env_id}') - env = AttrDict( - SPUG_APP_NAME=rep.app.name, - SPUG_APP_KEY=rep.app.key, - SPUG_APP_ID=str(rep.app_id), - SPUG_DEPLOY_ID=str(rep.deploy_id), - SPUG_BUILD_ID=str(rep.id), - SPUG_ENV_ID=str(rep.env_id), - SPUG_ENV_KEY=rep.env.key, - SPUG_VERSION=rep.version, - SPUG_BUILD_VERSION=rep.spug_version, - 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()} - env.update(configs_env) - - _build(rep, helper, env) - rep.status = '5' - helper.set_deploy_success('local') - except Exception as e: - rep.status = '2' - helper.set_deploy_fail('local') - raise e - finally: - helper.local_raw(f'cd {REPOS_DIR} && rm -rf {rep.spug_version}') - close_old_connections() - if alone_build: - helper.clear() - rep.save() - return rep - elif rep.status == '5': - rep.save() - - -def _build(rep: Repository, helper, env): - flag = time.time() - extend = rep.deploy.extend_obj - 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') - env.update(SPUG_DST_DIR=render_str(extend.dst_dir, env)) - helper.send_success('local', '完成√\r\n') - - with Executor(env) as et: - helper.save_pid(et.pid, 'local') - if extend.hook_pre_server: - helper.send_info('local', '检出前任务...\r\n') - helper.local(et, f'cd {git_dir} && {extend.hook_pre_server}') - - 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_raw(command) - helper.send_success('local', '完成√\r\n') - - if extend.hook_post_server: - helper.send_info('local', '检出后任务...\r\n') - helper.local(et, f'cd {build_dir} && {extend.hook_post_server}') - - 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: - if filter_rule['type'] == 'exclude': - excludes = [] - for x in files: - if x.startswith('/'): - excludes.append(f'--exclude={rep.spug_version}{x}') - else: - excludes.append(f'--exclude={x}') - exclude = ' '.join(excludes) - else: - contain = ' '.join(f'{rep.spug_version}/{x}' for x in files) - helper.local_raw(f'mkdir -p {BUILD_DIR} && cd {REPOS_DIR} && tar zcf {tar_file} {exclude} {contain}') - helper.send_success('local', '完成√\r\n') - helper.set_cross_env(rep.spug_version, et.get_envs()) - human_time = human_seconds_time(time.time() - flag) - helper.send_success('local', f'\r\n** 构建成功,耗时:{human_time} **\r\n', status='success') diff --git a/spug_api/apps/repository/views.py b/spug_api/apps/repository/views.py deleted file mode 100644 index f11bfbb..0000000 --- a/spug_api/apps/repository/views.py +++ /dev/null @@ -1,124 +0,0 @@ -# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug -# Copyright: (c) -# Released under the AGPL-3.0 License. -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, 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 - - -class RepositoryView(View): - @auth('deploy.repository.view|deploy.request.add|deploy.request.edit') - def get(self, request): - app_id = request.GET.get('app_id') - apps = request.user.deploy_perms['apps'] - data = Repository.objects.filter(app_id__in=apps).annotate( - app_name=F('app__name'), - env_name=F('env__name'), - created_by_user=F('created_by__nickname')) - if app_id: - data = data.filter(app_id=app_id, status='5') - return json_response([x.to_view() for x in data]) - - response = dict() - for item in data: - if item.app_id in response: - response[item.app_id]['child'].append(item.to_view()) - else: - tmp = item.to_view() - tmp['child'] = [item.to_view()] - response[item.app_id] = tmp - return json_response(list(response.values())) - - @auth('deploy.repository.add') - def post(self, request): - form, error = JsonParser( - Argument('deploy_id', type=int, help='参数错误'), - Argument('version', help='请输入构建版本'), - Argument('extra', type=list, help='参数错误'), - Argument('remarks', required=False) - ).parse(request.body) - if error is None: - deploy = Deploy.objects.filter(pk=form.deploy_id).first() - if not deploy: - return json_response(error='未找到指定发布配置') - form.extra = json.dumps(form.extra) - form.spug_version = Repository.make_spug_version(deploy.id) - rep = Repository.objects.create( - app_id=deploy.app_id, - env_id=deploy.env_id, - created_by=request.user, - **form) - Thread(target=dispatch, args=(rep,)).start() - return json_response(rep.to_view()) - return json_response(error=error) - - @auth('deploy.repository.add|deploy.repository.build') - def patch(self, request): - form, error = JsonParser( - Argument('id', type=int, help='参数错误'), - Argument('action', help='参数错误') - ).parse(request.body) - if error is None: - rep = Repository.objects.filter(pk=form.id).first() - if not rep: - return json_response(error='未找到指定构建记录') - if form.action == 'rebuild': - Thread(target=dispatch, args=(rep,)).start() - return json_response(rep.to_view()) - return json_response(error=error) - - @auth('deploy.repository.del') - def delete(self, request): - form, error = JsonParser( - Argument('id', type=int, help='请指定操作对象') - ).parse(request.GET) - if error is None: - repository = Repository.objects.filter(pk=form.id).first() - if not repository: - return json_response(error='未找到指定构建记录') - if repository.deployrequest_set.exists(): - return json_response(error='已关联发布申请无法删除') - repository.delete() - return json_response(error=error) - - -@auth('deploy.repository.view') -def get_requests(request): - form, error = JsonParser( - Argument('repository_id', type=int, help='参数错误') - ).parse(request.GET) - if error is None: - requests = [] - for item in DeployRequest.objects.filter(repository_id=form.repository_id): - data = item.to_dict(selects=('id', 'name', 'created_at')) - data['host_ids'] = json.loads(item.host_ids) - data['status_alias'] = item.get_status_display() - requests.append(data) - return json_response(requests) - - -@auth('deploy.repository.view') -def get_detail(request, r_id): - repository = Repository.objects.filter(pk=r_id).first() - if not repository: - return json_response(error='未找到指定构建记录') - 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'): - 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/apps/schedule/builtin.py b/spug_api/apps/schedule/builtin.py index 2bf9547..63230a8 100644 --- a/spug_api/apps/schedule/builtin.py +++ b/spug_api/apps/schedule/builtin.py @@ -6,15 +6,10 @@ from django.conf import settings from apps.account.models import History, User from apps.alarm.models import Alarm from apps.schedule.models import Task, History as TaskHistory -from apps.deploy.models import DeployRequest -from apps.app.models import DeployExtend1 from apps.exec.models import ExecHistory, Transfer from apps.notify.models import Notify -from apps.deploy.utils import dispatch -from apps.repository.models import Repository -from libs.utils import parse_time, human_datetime, human_date +from libs.utils import human_date from datetime import datetime, timedelta -from threading import Thread from collections import defaultdict from pathlib import Path import time @@ -28,12 +23,6 @@ def auto_run_by_day(): History.objects.filter(created_at__lt=date_30).delete() Notify.objects.filter(created_at__lt=date_7, unread=False).delete() Alarm.objects.filter(created_at__lt=date_30).delete() - for item in DeployExtend1.objects.all(): - index = 0 - for req in DeployRequest.objects.filter(deploy_id=item.deploy_id, repository_id__isnull=False): - if index > item.versions and req.repository_id: - req.repository.delete() - index += 1 timer = defaultdict(int) for item in ExecHistory.objects.all(): @@ -64,26 +53,3 @@ def auto_run_by_day(): os.system(f'umount -f {transfer_dir} &> /dev/null ; rm -rf {transfer_dir}') finally: connections.close_all() - - -def auto_run_by_minute(): - try: - now = datetime.now() - for req in DeployRequest.objects.filter(status='2'): - if (now - parse_time(req.do_at)).seconds > 3600: - req.status = '-3' - req.save() - - for rep in Repository.objects.filter(status='1'): - if (now - parse_time(rep.created_at)).seconds > 3600: - rep.status = '2' - rep.save() - - for req in DeployRequest.objects.filter(status='1', plan__lte=now): - req.status = '2' - req.do_at = human_datetime() - req.do_by = req.created_by - req.save() - Thread(target=dispatch, args=(req,)).start() - finally: - connections.close_all() diff --git a/spug_api/apps/schedule/scheduler.py b/spug_api/apps/schedule/scheduler.py index ebf12a8..41c0c2c 100644 --- a/spug_api/apps/schedule/scheduler.py +++ b/spug_api/apps/schedule/scheduler.py @@ -10,7 +10,7 @@ from django_redis import get_redis_connection from django.db import connections from django.db.utils import DatabaseError from apps.schedule.models import Task, History -from apps.schedule.builtin import auto_run_by_day, auto_run_by_minute +from apps.schedule.builtin import auto_run_by_day from django.conf import settings from libs import AttrDict, human_datetime import logging @@ -75,7 +75,6 @@ class Scheduler: def _init_builtin_jobs(self): self.scheduler.add_job(auto_run_by_day, 'cron', hour=1, minute=20) - self.scheduler.add_job(auto_run_by_minute, 'interval', minutes=1) def _init(self): self.scheduler.start() diff --git a/spug_api/apps/setting/urls.py b/spug_api/apps/setting/urls.py index 33ca444..3306568 100644 --- a/spug_api/apps/setting/urls.py +++ b/spug_api/apps/setting/urls.py @@ -2,17 +2,17 @@ # Copyright: (c) # Released under the AGPL-3.0 License. # from django.urls import path -from django.conf.urls import url +from django.urls import path from apps.setting.views import * from apps.setting.user import UserSettingView urlpatterns = [ - url(r'^$', SettingView.as_view()), - url(r'^user/$', UserSettingView.as_view()), - url(r'^ldap/$', LDAPUserView.as_view()), - url(r'^ldap_test/$', ldap_test), - url(r'^ldap_import/$', ldap_import), - url(r'^email_test/$', email_test), - url(r'^mfa/$', MFAView.as_view()), - url(r'^about/$', get_about) + path('', SettingView.as_view()), + path('user/', UserSettingView.as_view()), + path('ldap/', LDAPUserView.as_view()), + path('ldap_test/', ldap_test), + path('ldap_import/', ldap_import), + path('email_test/', email_test), + path('mfa/', MFAView.as_view()), + path('about/', get_about) ] diff --git a/spug_api/libs/parser.py b/spug_api/libs/parser.py index 69bcad8..2bf73d9 100644 --- a/spug_api/libs/parser.py +++ b/spug_api/libs/parser.py @@ -1,10 +1,9 @@ # Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # Released under the AGPL-3.0 License. +from libs.utils import AttrDict import json -from .utils import AttrDict - # 自定义的解析异常 class ParseError(BaseException): @@ -13,14 +12,8 @@ class ParseError(BaseException): # 需要校验的参数对象 -class Argument(object): - """ - :param name: name of option - :param default: default value if the argument if absent - :param bool required: is required - """ - - def __init__(self, name, default=None, handler=None, required=True, type=str, filter=None, help=None): +class Argument: + def __init__(self, name, default=None, handler=None, required=True, type=None, filter=None, help=None): self.name = name self.default = default self.type = type @@ -69,6 +62,8 @@ class Argument(object): self.help or 'Value Error: %s filter check failed' % self.name) if self.handler: value = self.handler(value) + if isinstance(value, str): + value = value.strip() return value diff --git a/spug_api/libs/ssh.py b/spug_api/libs/ssh.py index f658f72..202111f 100644 --- a/spug_api/libs/ssh.py +++ b/spug_api/libs/ssh.py @@ -3,84 +3,63 @@ # Released under the AGPL-3.0 License. from paramiko.client import SSHClient, AutoAddPolicy from paramiko.rsakey import RSAKey -from paramiko.ssh_exception import AuthenticationException, SSHException +from paramiko.ssh_exception import AuthenticationException from io import StringIO from uuid import uuid4 import time import re +KILLER = ''' +function kill_tree { + local pid=$1 + local and_self=${2:-0} + local children=$(pgrep -P $pid) + for child in $children; do + kill_tree $child 1 + done + if [ $and_self -eq 1 ]; then + kill $pid + fi +} + +kill_tree %s +''' + class SSH: - def __init__(self, hostname, port=22, username='root', pkey=None, password=None, default_env=None, - connect_timeout=10, term=None): - self.stdout = None + def __init__(self, host, credential, environment=None): self.client = None self.channel = None self.sftp = None self.exec_file = f'/tmp/spug.{uuid4().hex}' - self.term = term or {} self.pid = None + self.environment = environment self.eof = 'Spug EOF 2108111926' - self.default_env = default_env self.regex = re.compile(r'(?> ~/.ssh/authorized_keys && \ - chmod 600 ~/.ssh/authorized_keys' - exit_code, out = self.exec_command_raw(command) - if exit_code != 0: - raise Exception(f'add public key error: {out}') - - def exec_command_raw(self, command, environment=None): - channel = self.client.get_transport().open_session() - if environment: - channel.update_environment(environment) - channel.set_combine_stderr(True) - channel.exec_command(command) - code, output = channel.recv_exit_status(), channel.recv(-1) - return code, self._decode(output) + if credential.type == 'pw': + self.arguments['password'] = credential.secret + elif credential.type == 'pk': + self.arguments['pkey'] = RSAKey.from_private_key(StringIO(credential.secret)) + else: + raise Exception('Invalid credential type for SSH') def exec_command(self, command, environment=None): - channel = self._get_channel() command = self._handle_command(command, environment) - channel.sendall(command) + self.channel.sendall(command) buf_size, exit_code, out = 4096, -1, '' while True: - data = channel.recv(buf_size) + data = self.channel.recv(buf_size) if not data: break - while channel.recv_ready(): - data += channel.recv(buf_size) + while self.channel.recv_ready(): + data += self.channel.recv(buf_size) out += self._decode(data) match = self.regex.search(out) if match: @@ -90,16 +69,15 @@ class SSH: return exit_code, out def exec_command_with_stream(self, command, environment=None): - channel = self._get_channel() command = self._handle_command(command, environment) - channel.sendall(command) + self.channel.sendall(command) buf_size, exit_code, line = 4096, -1, '' while True: - out = channel.recv(buf_size) + out = self.channel.recv(buf_size) if not out: break - while channel.recv_ready(): - out += channel.recv(buf_size) + while self.channel.recv_ready(): + out += self.channel.recv(buf_size) line = self._decode(out) match = self.regex.search(line) if match: @@ -129,26 +107,25 @@ class SSH: sftp = self._get_sftp() sftp.remove(path) - def get_pid(self): - if self.pid: - return self.pid - self._get_channel() - return self.pid + def terminate(self, pid): + command = KILLER % pid + self.exec_command(command) - def _get_channel(self): - if self.channel: - return self.channel + def _initial(self): + self.client = SSHClient() + self.client.set_missing_host_key_policy(AutoAddPolicy) + self.client.connect(**self.arguments) - self.channel = self.client.invoke_shell(term='xterm', **self.term) + self.channel = self.client.invoke_shell(term='xterm') self.channel.settimeout(3600) command = '[ -n "$BASH_VERSION" ] && set +o history\n' - command += '[ -n "$ZSH_VERSION" ] && set +o zle && set -o no_nomatch && HISTFILE=""\n' + command += '[ -n "$ZSH_VERSION" ] && set +o zle && HISTFILE=\n' command += 'export PS1= && stty -echo\n' command += f'trap \'rm -f {self.exec_file}*\' EXIT\n' - command += self._make_env_command(self.default_env) + command += self._make_env_command(self.environment) command += f'echo {self.eof} $$\n' time.sleep(0.2) # compatibility - self.channel.sendall(command) + self.channel.sendall(command.encode()) counter, buf_size = 0, 4096 while True: if self.channel.recv_ready(): @@ -169,7 +146,6 @@ class SSH: else: counter += 1 time.sleep(0.1) - return self.channel def _get_sftp(self): if self.sftp: @@ -205,9 +181,10 @@ class SSH: return content def __enter__(self): - self.get_client() + self._initial() return self def __exit__(self, *args, **kwargs): + self.channel.close() self.client.close() self.client = None diff --git a/spug_api/libs/utils.py b/spug_api/libs/utils.py index 138656b..b09aedd 100644 --- a/spug_api/libs/utils.py +++ b/spug_api/libs/utils.py @@ -94,7 +94,7 @@ def json_response(data='', error=''): content.data = data.to_dict() elif isinstance(data, (list, QuerySet)) and all([hasattr(item, 'to_dict') for item in data]): content.data = [item.to_dict() for item in data] - return HttpResponse(json.dumps(content, cls=DateTimeEncoder), content_type='application/json') + return HttpResponse(json.dumps(content, cls=SpugEncoder, ensure_ascii=False), content_type='application/json') # 继承自dict,实现可以通过.来操作元素 @@ -112,8 +112,7 @@ class AttrDict(dict): self.__delitem__(item) -# 日期json序列化 -class DateTimeEncoder(json.JSONEncoder): +class SpugEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, datetime): return o.strftime('%Y-%m-%d %H:%M:%S') @@ -121,6 +120,8 @@ class DateTimeEncoder(json.JSONEncoder): return o.strftime('%Y-%m-%d') elif isinstance(o, Decimal): return float(o) + elif isinstance(o, set): + return list(o) return json.JSONEncoder.default(self, o) diff --git a/spug_api/requirements.txt b/spug_api/requirements.txt index 24a14f3..333134f 100644 --- a/spug_api/requirements.txt +++ b/spug_api/requirements.txt @@ -1,7 +1,12 @@ -apscheduler==3.10.1 -Django==4.2.2 -paramiko==3.2.0 -django-redis==5.2.0 -requests==2.31.0 +apscheduler==3.10.4 +Django >= 4.2.0, < 4.3.0 +paramiko==3.4.0 +channels >= 4.0.0, < 5.0.0 +channels-redis >= 4.1.0, < 5.0.0 +django_redis >= 5.4.0, < 6.0.0 +asgiref==3.7.2 +requests >= 2.31.0, < 3.0.0 +python-ldap==3.4.3 +GitPython==3.1.40 openpyxl==3.1.2 -user_agents==2.2.0 \ No newline at end of file +user_agents==2.2.0 diff --git a/spug_api/spug/asgi.py b/spug_api/spug/asgi.py index b5e4986..4ab180d 100644 --- a/spug_api/spug/asgi.py +++ b/spug_api/spug/asgi.py @@ -2,15 +2,15 @@ # Copyright: (c) # Released under the AGPL-3.0 License. """ -ASGI entrypoint. Configures Django and then runs the application -defined in the ASGI_APPLICATION setting. +ASGI config for spug project. """ +import os import os -import django -from channels.routing import get_default_application + +from django.core.asgi import get_asgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'spug.settings') -django.setup() -application = get_default_application() + +application = get_asgi_application() diff --git a/spug_api/spug/settings.py b/spug_api/spug/settings.py index 978e686..294a133 100644 --- a/spug_api/spug/settings.py +++ b/spug_api/spug/settings.py @@ -14,14 +14,14 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/2.2/ref/settings/ """ -import os +from pathlib import Path import re -# Build paths inside the project like this: os.path.join(BASE_DIR, ...) -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = 'vk0do47)egwzz!uk49%(y3s(fpx4+ha@ugt-hcv&%&d@hwr&p7' @@ -43,9 +43,7 @@ INSTALLED_APPS = [ 'apps.alarm', 'apps.config', 'apps.app', - 'apps.deploy', 'apps.notify', - 'apps.repository', 'apps.home', 'apps.credential', 'apps.pipeline', @@ -57,31 +55,30 @@ MIDDLEWARE = [ 'django.middleware.common.CommonMiddleware', 'libs.middleware.AuthenticationMiddleware', 'libs.middleware.HandleExceptionMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'spug.urls' WSGI_APPLICATION = 'spug.wsgi.application' ASGI_APPLICATION = 'spug.routing.application' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Database -# https://docs.djangoproject.com/en/2.2/ref/settings/#databases +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases DATABASES = { 'default': { - 'ATOMIC_REQUESTS': True, 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + 'NAME': BASE_DIR / 'db.sqlite3', } } CACHES = { "default": { - "BACKEND": "django_redis.cache.RedisCache", + "BACKEND": "django.core.cache.backends.redis.RedisCache", "LOCATION": "redis://127.0.0.1:6379/1", - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - } + "KEY_PREFIX": "spug", } } @@ -90,20 +87,13 @@ CHANNEL_LAYERS = { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { "hosts": [("127.0.0.1", 6379)], + "prefix": "spug:channel", "capacity": 1000, "expiry": 120, }, }, } -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': False, - }, -] - TOKEN_TTL = 8 * 3600 SCHEDULE_KEY = 'spug:schedule' SCHEDULE_WORKER_KEY = 'spug:schedule:worker' @@ -113,10 +103,8 @@ EXEC_WORKER_KEY = 'spug:exec:worker' REQUEST_KEY = 'spug:request' BUILD_KEY = 'spug:build' PIPELINE_KEY = 'spug:pipeline' -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') +TRANSFER_DIR = BASE_DIR / 'storage' / 'transfer' +DEPLOY_DIR = BASE_DIR / 'storage' / 'deploy' # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ @@ -137,7 +125,7 @@ AUTHENTICATION_EXCLUDES = ( re.compile('/apis/.*'), ) -SPUG_VERSION = 'v3.2.4' +SPUG_VERSION = 'v4.0.0' # override default config try: diff --git a/spug_api/spug/urls.py b/spug_api/spug/urls.py index ad98bf6..3c89a66 100644 --- a/spug_api/spug/urls.py +++ b/spug_api/spug/urls.py @@ -28,8 +28,6 @@ urlpatterns = [ path('setting/', include('apps.setting.urls')), path('config/', include('apps.config.urls')), path('app/', include('apps.app.urls')), - path('deploy/', include('apps.deploy.urls')), - path('repository/', include('apps.repository.urls')), path('home/', include('apps.home.urls')), path('notify/', include('apps.notify.urls')), path('file/', include('apps.file.urls')), diff --git a/spug_web2/.eslintrc.cjs b/spug_web2/.eslintrc.cjs new file mode 100644 index 0000000..e424d5e --- /dev/null +++ b/spug_web2/.eslintrc.cjs @@ -0,0 +1,24 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, + settings: { react: { version: '18.2' } }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 'off', + }, + globals: { + t: 'readonly', + } +} diff --git a/spug_web2/.gitignore b/spug_web2/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/spug_web2/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/spug_web2/README.md b/spug_web2/README.md new file mode 100644 index 0000000..f768e33 --- /dev/null +++ b/spug_web2/README.md @@ -0,0 +1,8 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh diff --git a/spug_web2/index.html b/spug_web2/index.html new file mode 100644 index 0000000..0c589ec --- /dev/null +++ b/spug_web2/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + + +
+ + + diff --git a/spug_web2/package.json b/spug_web2/package.json new file mode 100644 index 0000000..fe441d5 --- /dev/null +++ b/spug_web2/package.json @@ -0,0 +1,37 @@ +{ + "name": "spug", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "antd": "^5.14.1", + "dayjs": "^1.11.10", + "i18next": "^23.7.7", + "mobx": "^6.12.0", + "mobx-react-lite": "^4.0.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-i18next": "^13.5.0", + "react-icons": "^4.12.0", + "react-router-dom": "^6.20.1", + "swr": "^2.2.4", + "use-immer": "^0.9.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "eslint": "^8.53.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "sass": "^1.69.5", + "vite": "^5.0.2" + } +} \ No newline at end of file diff --git a/spug_web2/public/vite.svg b/spug_web2/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/spug_web2/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/spug_web2/src/App.jsx b/spug_web2/src/App.jsx new file mode 100644 index 0000000..2754fe4 --- /dev/null +++ b/spug_web2/src/App.jsx @@ -0,0 +1,53 @@ +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import { ConfigProvider, App as AntdApp, theme } from 'antd' +import { IconContext } from 'react-icons' +import zhCN from 'antd/locale/zh_CN' +import enUS from 'antd/locale/en_US' +import dayjs from 'dayjs' +import routes from './routes.jsx' +import { app, SContext } from '@/libs' +import { useImmer } from 'use-immer' +import './i18n.js' + +dayjs.locale(app.lang) + +const router = createBrowserRouter(routes) + +function App() { + const [S, updateS] = useImmer({ theme: app.theme }) + + return ( + + + + + + + + + + ) +} + +export default App \ No newline at end of file diff --git a/spug_web2/src/assets/spug-default.png b/spug_web2/src/assets/spug-default.png new file mode 100644 index 0000000..e4c98e7 Binary files /dev/null and b/spug_web2/src/assets/spug-default.png differ diff --git a/spug_web2/src/components/SModal/index.jsx b/spug_web2/src/components/SModal/index.jsx new file mode 100644 index 0000000..d6e39f0 --- /dev/null +++ b/spug_web2/src/components/SModal/index.jsx @@ -0,0 +1,11 @@ +import { Modal } from 'antd' +import { clsNames } from '@/libs' +import css from './index.module.scss' + +function SModal(props) { + return ( + + ) +} + +export default SModal \ No newline at end of file diff --git a/spug_web2/src/components/SModal/index.module.scss b/spug_web2/src/components/SModal/index.module.scss new file mode 100644 index 0000000..3bc5fde --- /dev/null +++ b/spug_web2/src/components/SModal/index.module.scss @@ -0,0 +1,23 @@ +.modal { + :global(.ant-modal-content) { + padding-top: 68px; + } + + :global(.ant-modal-close) { + z-index: 999; + } + + :global(.ant-modal-header) { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 48px; + display: flex; + flex-direction: row; + align-items: center; + padding: 0 24px; + border-bottom: 1px solid #f0f0f0; + background: transparent; + } +} \ No newline at end of file diff --git a/spug_web2/src/components/STable/Setting.jsx b/spug_web2/src/components/STable/Setting.jsx new file mode 100644 index 0000000..3c5fa7e --- /dev/null +++ b/spug_web2/src/components/STable/Setting.jsx @@ -0,0 +1,62 @@ +import { useState, useEffect } from 'react' +import { Button, Checkbox, Flex, Popover } from 'antd' +import { IoSettingsOutline } from 'react-icons/io5' +import { app, clsNames } from '@/libs' + + +function Setting(props) { + const { skey, columns, setCols } = props + const [state, setState] = useState(app.getStable(skey)) + + useEffect(() => { + const newColumns = [] + for (const item of columns) { + if (state[item.title] ?? !item.hidden) { + newColumns.push(item) + } + } + setCols(newColumns) + }, [columns, state]); + + function handleChange(e) { + const { value, checked } = e.target + const newState = { ...state, [value]: checked } + setState(newState) + app.updateStable(skey, newState) + } + + function handleReset() { + setState({}) + app.updateStable(skey, null) + } + + return ( + +
{t('展示字段')}
+ + )} + trigger="click" + placement="bottomRight" + content={( + + {columns.map((item, index) => ( + + {item.title} + + ))} + + )}> +
+ +
+
+ ) +} + +export default Setting diff --git a/spug_web2/src/components/STable/index.jsx b/spug_web2/src/components/STable/index.jsx new file mode 100644 index 0000000..0b9357e --- /dev/null +++ b/spug_web2/src/components/STable/index.jsx @@ -0,0 +1,152 @@ +import { useRef, useState, useEffect } from 'react' +import { Card, Table, Flex, Divider, Checkbox, Button, Input, Tag } from 'antd' +import { IoExpand, IoContract, IoReloadOutline } from 'react-icons/io5' +import { useImmer } from 'use-immer' +import { clsNames, includes } from '@/libs' +import Setting from './Setting.jsx' +import css from './index.module.scss' + +function STable(props) { + const { skey, loading, columns, dataSource, actions, pagination } = props + const ref = useRef() + const sMap = useRef({}) + const [sColumns, setSColumns] = useState([]) + const [cols, setCols] = useState([]) + const [isFull, setIsFull] = useState(false) + const [filters, updateFilters] = useImmer({}) + + if (!skey) throw new Error('skey is required') + + useEffect(() => { + const newColumns = [] + for (const item of columns) { + const key = item.dataIndex + if (item.filterKey) { + let inputRef = null + item.onFilter = (value, record) => includes(record[key], value) + item.filterDropdown = (x) => { + sMap.current[key] = x + return ( +
+ inputRef = ref} + onChange={e => x.setSelectedKeys(e.target.value ? [e.target.value] : [])} + onSearch={v => handleSearch(key, v)} + /> +
+ ) + } + item.onFilterDropdownOpenChange = (visible) => { + if (visible) { + setTimeout(() => inputRef.focus(), 100) + } + } + } else if (item.filterItems) { + item.onFilter = (value, record) => record[key] === value + item.filterDropdown = (x) => { + sMap.current[key] = x + return ( +
+ + + + + + +
+ ) + } + } + newColumns.push(item) + } + setSColumns(newColumns) + }, [columns]) + + function handleSearch(key, v) { + const x = sMap.current[key] + updateFilters(draft => { + if (Array.isArray(v)) { + v.length ? draft[key] = v : delete draft[key] + } else { + v ? draft[key] = v : delete draft[key] + } + }) + if (!v) x.setSelectedKeys([]) + x.confirm() + } + + function handleFullscreen() { + if (ref.current && document.fullscreenEnabled) { + if (document.fullscreenElement) { + document.exitFullscreen() + setIsFull(false) + } else { + ref.current.requestFullscreen() + setIsFull(true) + } + } + } + + function SearchItem(props) { + const { cKey, value } = props + const column = columns.find(item => item.dataIndex === cKey) + + return ( + handleSearch(cKey, '')} className={css.search}> + {column.title}: {Array.isArray(value) ? value.join(' | ') : value} + + ) + } + + return ( + + + {Object.keys(filters).length ? ( + + {Object.entries(filters).map(([key, value]) => ( + + ))} + + ) : ( +
{props.title}
+ )} + + {actions} + {actions.length ? : null} + + + {isFull ? ( + + ) : ( + + )} + +
+ + + ) +} + +STable.defaultProps = { + sKey: null, + loading: false, + actions: [], + defaultFields: [], + pagination: { + showSizeChanger: true, + showLessItems: true, + showTotal: total => t('page', { total }), + pageSizeOptions: ['10', '20', '50', '100'] + }, + onReload: () => { + }, +} + +export default STable \ No newline at end of file diff --git a/spug_web2/src/components/STable/index.module.scss b/spug_web2/src/components/STable/index.module.scss new file mode 100644 index 0000000..85f4ee6 --- /dev/null +++ b/spug_web2/src/components/STable/index.module.scss @@ -0,0 +1,37 @@ +.stable { + :global(.ant-pagination) { + margin: 16px 0 0 !important; + } +} + +.search { + line-height: 28px; +} + +.toolbar { + margin-bottom: 12px; + + .title { + font-size: 16px; + font-weight: bold; + } + + .icon { + font-size: 18px; + cursor: pointer; + } +} + +.filterItems { + min-width: 150px; + + :global(.ant-checkbox-group) { + display: flex; + flex-direction: column; + padding: 8px 16px; + } + + .action { + padding: 8px 16px 8px 8px; + } +} \ No newline at end of file diff --git a/spug_web2/src/components/index.js b/spug_web2/src/components/index.js new file mode 100644 index 0000000..f065198 --- /dev/null +++ b/spug_web2/src/components/index.js @@ -0,0 +1,7 @@ +import STable from './STable' +import SModal from './SModal' + +export { + STable, + SModal, +} \ No newline at end of file diff --git a/spug_web2/src/error-page.jsx b/spug_web2/src/error-page.jsx new file mode 100644 index 0000000..a52f7f5 --- /dev/null +++ b/spug_web2/src/error-page.jsx @@ -0,0 +1,16 @@ +import {useRouteError} from 'react-router-dom' + +export default function ErrorPage() { + const error = useRouteError() + + return ( +
+

Oops!

+

Sorry, an unexpected error has occurred.

+

+ {error.statusText || error.message} +

+
+ ) +} + diff --git a/spug_web2/src/i18n.js b/spug_web2/src/i18n.js new file mode 100644 index 0000000..a89b29d --- /dev/null +++ b/spug_web2/src/i18n.js @@ -0,0 +1,34 @@ +import i18n from 'i18next' +import {initReactI18next} from 'react-i18next' +import {app} from '@/libs' + +i18n.use(initReactI18next).init({ + lng: app.lang, + resources: { + en: { + translation: { + '首页': 'Home', + '工作台': 'Work', + '主机管理': 'Hosts', + '批量执行': 'Batch', + '执行任务': 'Task', + '文件分发': 'Transfer', + '重置': 'Reset', + '展示字段': 'Columns Display', + '年龄': 'Age', + // buttons + '新建': 'Add', + 'page': 'Total {{total}} items', + } + }, + zh: { + translation: { + 'page': '共 {{total}} 条', + } + } + } +}) + +window.t = i18n.t + +export default i18n diff --git a/spug_web2/src/index.css b/spug_web2/src/index.css new file mode 100644 index 0000000..75ed8fa --- /dev/null +++ b/spug_web2/src/index.css @@ -0,0 +1,20 @@ +:root { + font-family: pingfang SC, helvetica neue, arial, hiragino sans gb, microsoft yahei ui, microsoft yahei, simsun, sans-serif; + line-height: 1.5; + font-weight: 400; + font-size: 14px; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} diff --git a/spug_web2/src/layout/Header.jsx b/spug_web2/src/layout/Header.jsx new file mode 100644 index 0000000..528a8a1 --- /dev/null +++ b/spug_web2/src/layout/Header.jsx @@ -0,0 +1,57 @@ +import {useContext, useEffect} from 'react' +import {Dropdown, Flex, Layout, theme as antdTheme} from 'antd' +import {IoMoon, IoSunny, IoLanguage} from 'react-icons/io5' +import {SContext} from '@/libs' +import css from './index.module.scss' +import i18n from '@/i18n.js' +import logo from "@/assets/spug-default.png"; + +function Header() { + const {S: {theme}, updateS} = useContext(SContext) + const {token} = antdTheme.useToken() + + useEffect(() => { + document.body.style.backgroundColor = token.colorBgLayout + }, [theme]) + + + function handleThemeChange() { + const newTheme = theme === 'light' ? 'dark' : 'light' + localStorage.setItem('theme', newTheme) + updateS(draft => { + draft.theme = newTheme + }) + } + + function handleLangChange({key}) { + localStorage.setItem('lang', key) + window.location.reload() + } + + const locales = [{ + label: '🇨🇳 简体中文', + key: 'zh', + }, { + label: '🇺🇸 English', + key: 'en', + }] + + return ( + + logo + +
admin
+ +
+ +
+
+
+ {theme === 'light' ? : } +
+
+
+ ) +} + +export default Header \ No newline at end of file diff --git a/spug_web2/src/layout/index.jsx b/spug_web2/src/layout/index.jsx new file mode 100644 index 0000000..ce1c31b --- /dev/null +++ b/spug_web2/src/layout/index.jsx @@ -0,0 +1,44 @@ +import {useState} from 'react' +import {Outlet, useMatches, useNavigate} from 'react-router-dom' +import {Layout, Flex, Menu, theme} from 'antd' +import Header from './Header.jsx' +import {menus} from '@/routes' +import css from './index.module.scss' + + +function LayoutIndex() { + const [collapsed, setCollapsed] = useState(false); + const {token: {colorTextTertiary}} = theme.useToken() + const navigate = useNavigate() + const matches = useMatches() + + function handleMenuClick({key}) { + navigate(key) + } + + const selectedKey = matches[matches.length - 1]?.pathname + return ( + +
+ +
+ + + + +
+ +
+ + + Copyright © 2023 OpenSpug All Rights Reserved. + + +
+ + + ) +} + +export default LayoutIndex \ No newline at end of file diff --git a/spug_web2/src/layout/index.module.scss b/spug_web2/src/layout/index.module.scss new file mode 100644 index 0000000..fe016dd --- /dev/null +++ b/spug_web2/src/layout/index.module.scss @@ -0,0 +1,53 @@ +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 999; + padding: 0 8px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--ant-color-border); + background: var(--ant-color-bg-container); + + .logo { + margin-left: 16px; + height: 30px; + } + + .item { + height: 47px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.1s ease-in-out; + + &:hover { + background: var(--ant-color-fill-secondary); + } + } +} + +.sider { + position: fixed !important; + top: 48px; + left: 0; + bottom: 0; + + :global(.ant-menu) { + height: 100%; + } + + :global(.ant-layout-sider-trigger) { + border-inline-end: 1px solid var(--ant-color-split); + } +} + +.menu { + border-inline-end: none; +} \ No newline at end of file diff --git a/spug_web2/src/libs/app.js b/spug_web2/src/libs/app.js new file mode 100644 index 0000000..802d103 --- /dev/null +++ b/spug_web2/src/libs/app.js @@ -0,0 +1,46 @@ +import { isSubArray, loadJSONStorage } from "@/libs/utils.js"; + +class App { + constructor() { + this.lang = localStorage.getItem('lang') || 'zh'; + this.theme = localStorage.getItem('theme') || 'light'; + this.stable = loadJSONStorage('stable', {}); + this.session = loadJSONStorage('session', {}); + } + + get access_token() { + return this.session['access_token'] || ''; + } + + get nickname() { + return this.session['nickname']; + } + + hasPermission(code) { + const { isSuper, permissions } = this.session; + if (!code || isSuper) return true; + for (let item of code.split('|')) { + if (isSubArray(permissions, item.split('&'))) { + return true + } + } + return false + } + + updateSession(data) { + Object.assign(this.session, data); + localStorage.setItem('session', JSON.stringify(this.session)); + } + + getStable(key) { + return this.stable[key] ?? {}; + } + + updateStable(key, data) { + this.stable[key] = data; + if (data === null) delete this.stable[key]; + localStorage.setItem('stable', JSON.stringify(this.stable)); + } +} + +export default new App(); \ No newline at end of file diff --git a/spug_web2/src/libs/http.js b/spug_web2/src/libs/http.js new file mode 100644 index 0000000..3b31fb2 --- /dev/null +++ b/spug_web2/src/libs/http.js @@ -0,0 +1,53 @@ +import useSWR from 'swr' +import { message } from 'antd' +import app from '@/libs/app.js' +import { redirect } from 'react-router-dom' + +function fetcher(resource, init) { + return fetch(resource, init) + .then(res => { + if (res.status === 200) { + return res.json() + } else if (res.status === 401) { + redirect('/login') + throw new Error('会话过期,请重新登录') + } else { + throw new Error(`请求失败: ${res.status} ${res.statusText}`) + } + }) + .then(res => { + if (res.error) { + throw new Error(res.error) + } + return res.data + }) + .catch(err => { + message.error(err.message) + throw err + }) +} + +function SWRGet(url, params) { + if (params) url = `${url}?${new URLSearchParams(params).toString()}` + return useSWR(url, () => fetcher(url)) +} + +function request(method, url, params) { + const init = { method, headers: { 'X-Token': app.access_token } } + if (['GET', 'DELETE'].includes(method)) { + if (params) url = `${url}?${new URLSearchParams(params).toString()}` + return fetcher(url, init) + } + init.headers['Content-Type'] = 'application/json' + init.body = JSON.stringify(params) + return fetcher(url, init) +} + +export default { + swrGet: SWRGet, + get: (url, params) => request('GET', url, params), + post: (url, body) => request('POST', url, body), + put: (url, body) => request('PUT', url, body), + patch: (url, body) => request('PATCH', url, body), + delete: (url, params) => request('DELETE', url, params), +} \ No newline at end of file diff --git a/spug_web2/src/libs/index.js b/spug_web2/src/libs/index.js new file mode 100644 index 0000000..0867c20 --- /dev/null +++ b/spug_web2/src/libs/index.js @@ -0,0 +1,11 @@ +import React from 'react' +import http from './http' +import app from './app.js' + +const SContext = React.createContext({}) +export * from './utils.js' +export { + app, + http, + SContext, +} diff --git a/spug_web2/src/libs/utils.js b/spug_web2/src/libs/utils.js new file mode 100644 index 0000000..522b843 --- /dev/null +++ b/spug_web2/src/libs/utils.js @@ -0,0 +1,68 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ + +// 数组包含关系判断 +export function isSubArray(parent, child) { + for (let item of child) { + if (!parent.includes(item.trim())) { + return false + } + } + return true +} + +export function clsNames(...args) { + return args.filter(x => x).join(' ') +} + +function isInclude(s, keys) { + if (!s) return false + if (Array.isArray(keys)) { + for (let k of keys) { + k = k.toLowerCase() + if (s.toLowerCase().includes(k)) return true + } + return false + } else { + let k = keys.toLowerCase() + return s.toLowerCase().includes(k) + } +} + +// 字符串包含判断 +export function includes(s, keys) { + if (Array.isArray(s)) { + for (let i of s) { + if (isInclude(i, keys)) return true + } + return false + } else { + return isInclude(s, keys) + } +} + +export function loadJSONStorage(key, defaultValue = null) { + const tmp = localStorage.getItem(key) + if (tmp) { + try { + return JSON.parse(tmp) + } catch (e) { + localStorage.removeItem(key) + } + } + return defaultValue +} + +// 递归查找树节点 +export function findNodeByKey(array, key) { + for (let item of array) { + if (item.key === key) return item + if (item.children) { + let tmp = findNodeByKey(item.children, key) + if (tmp) return tmp + } + } +} \ No newline at end of file diff --git a/spug_web2/src/main.jsx b/spug_web2/src/main.jsx new file mode 100644 index 0000000..92babb7 --- /dev/null +++ b/spug_web2/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/spug_web2/src/pages/home/index.jsx b/spug_web2/src/pages/home/index.jsx new file mode 100644 index 0000000..167d977 --- /dev/null +++ b/spug_web2/src/pages/home/index.jsx @@ -0,0 +1,17 @@ +import {useEffect} from 'react' + + +function Home() { + useEffect(() => { + console.log('now in home page') + }, []) + + return ( +
+

{t('你好')}

+
+
+ ) +} + +export default Home diff --git a/spug_web2/src/pages/host/Form.jsx b/spug_web2/src/pages/host/Form.jsx new file mode 100644 index 0000000..e69de29 diff --git a/spug_web2/src/pages/host/Group.jsx b/spug_web2/src/pages/host/Group.jsx new file mode 100644 index 0000000..ee5cb3c --- /dev/null +++ b/spug_web2/src/pages/host/Group.jsx @@ -0,0 +1,160 @@ +import { useRef, useState, useEffect } from 'react' +import { Card, Tree, Dropdown, Input, Spin } from 'antd' +import { FaServer } from 'react-icons/fa6' +import { IoMdMore } from 'react-icons/io' +import { AiOutlineFolder, AiOutlineFolderAdd, AiOutlineEdit, AiOutlineFileAdd, AiOutlineScissor, AiOutlineClose, AiOutlineDelete } from 'react-icons/ai' +import { useImmer } from 'use-immer' +import { http, findNodeByKey } from '@/libs' +import css from './index.module.scss' + +let clickNode = null +let rawTreeData = [] + +function Group() { + const inputRef = useRef(null) + const [expandedKeys, setExpandedKeys] = useState([]) + const [treeData, updateTreeData] = useImmer([]) + const [loading, setLoading] = useState(false) + + const menuItems = [ + { label: '新建根分组', key: 'newRoot', icon: }, + { label: '新建子分组', key: 'newChild', icon: }, + { label: '重命名', key: 'rename', icon: }, + { type: 'divider' }, + { label: '添加主机', key: 'addHost', icon: }, + { label: '移动主机', key: 'moveHost', icon: }, + { label: '删除主机', key: 'deleteHost', icon: }, + { type: 'divider' }, + { label: '删除此分组', key: 'deleteGroup', danger: true, icon: }, + ] + + useEffect(() => { + fetchData() + // eslint-disable-next-line + }, []) + + function fetchData() { + setLoading(true) + http.get('/api/host/group/') + .then(res => { + rawTreeData = res.treeData + updateTreeData(res.treeData) + }) + .finally(() => setLoading(false)) + } + + function handleNodeClick(e, node) { + e.stopPropagation() + clickNode = node + } + + function handleMenuClick({ key, domEvent }) { + domEvent.stopPropagation() + switch (key) { + case 'newRoot': + updateTreeData(draft => { + draft.unshift({ key, action: key, selectable: false }) + }) + break + case 'newChild': + updateTreeData(draft => { + const node = findNodeByKey(draft, clickNode.key) + if (!node) return + if (!node.children) node.children = [] + node.children.unshift({ key, action: key, selectable: false }) + }) + if (![expandedKeys].includes(clickNode.key)) { + setExpandedKeys([...expandedKeys, clickNode.key]) + } + break + case 'rename': + updateTreeData(draft => { + const node = findNodeByKey(draft, clickNode.key) + if (!node) return + node.action = key + node.selectable = false + }) + break + case 'addHost': + console.log('添加主机') + break + case 'moveHost': + console.log('移动主机') + break + case 'deleteHost': + console.log('删除主机') + break + case 'deleteGroup': + setLoading(true) + http.delete('/api/host/group/', { id: clickNode.key }) + .then(() => fetchData(), () => setLoading(false)) + break + default: + break + } + if (['newRoot', 'newChild', 'rename'].includes(key)) { + setTimeout(() => { + inputRef.current.focus() + }, 300) + } + } + + function handleInputSubmit(e, node) { + const value = e.target.value.trim() + if (value) { + let form = { name: value } + if (node.action === 'newChild') { + form.parent_id = clickNode.key + } else if (node.action === 'rename') { + form.id = node.key + } + setLoading(true) + http.post('/api/host/group/', form) + .then(() => fetchData(), () => setLoading(false)) + } else { + updateTreeData(rawTreeData) + } + } + + function titleRender(node) { + return ['newRoot', 'newChild', 'rename'].includes(node.action) ? ( + handleInputSubmit(e, node)} + onBlur={e => handleInputSubmit(e, node)} + placeholder="请输入" /> + ) : ( +
+ +
{node.title}
+
handleNodeClick(e, node)}> + +
+ +
+
+
+
+ ) + } + + return ( + + + setExpandedKeys(keys)} + /> + + + ) +} + +export default Group \ No newline at end of file diff --git a/spug_web2/src/pages/host/index.jsx b/spug_web2/src/pages/host/index.jsx new file mode 100644 index 0000000..1c20c28 --- /dev/null +++ b/spug_web2/src/pages/host/index.jsx @@ -0,0 +1,13 @@ +import {useEffect} from 'react' + +function Host() { + useEffect(() => { + console.log('now in host page') + }, []) + + return ( +
host page
+ ) +} + +export default Host \ No newline at end of file diff --git a/spug_web2/src/pages/host/index.module.scss b/spug_web2/src/pages/host/index.module.scss new file mode 100644 index 0000000..d984138 --- /dev/null +++ b/spug_web2/src/pages/host/index.module.scss @@ -0,0 +1,42 @@ +.group { + flex: 1; + min-width: 200px; + max-width: 300px; + margin-right: -1px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + :global(.ant-card-head) { + height: 58px; + } + + .tree { + min-height: 200px; + } + + .treeTitle { + display: flex; + flex-direction: row; + align-items: center; + + .title { + flex: 1; + margin-left: 6px; + } + + .more { + width: 30px; + height: 30px; + display: flex; + justify-content: center; + align-items: center; + margin-right: -4px; + } + } +} + +.table { + flex: 3; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} \ No newline at end of file diff --git a/spug_web2/src/pages/login/bg.svg b/spug_web2/src/pages/login/bg.svg new file mode 100644 index 0000000..89c2597 --- /dev/null +++ b/spug_web2/src/pages/login/bg.svg @@ -0,0 +1,69 @@ + + + + Group 21 + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spug_web2/src/pages/login/index.jsx b/spug_web2/src/pages/login/index.jsx new file mode 100644 index 0000000..ee59da8 --- /dev/null +++ b/spug_web2/src/pages/login/index.jsx @@ -0,0 +1,144 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import {useState, useEffect} from 'react'; +import {useNavigate} from 'react-router-dom' +import {Form, Input, Button, Tabs, Modal, message} from 'antd'; +import {AiOutlineUser, AiOutlineLock, AiOutlineCopyright, AiOutlineGithub, AiOutlineMail} from 'react-icons/ai' +import styles from './login.module.css'; +import {http, app} from '@/libs'; +import logo from '@/assets/spug-default.png'; + +export default function Login() { + const navigate = useNavigate(); + const [form] = Form.useForm(); + const [counter, setCounter] = useState(0); + const [loading, setLoading] = useState(false); + const [loginType, setLoginType] = useState(localStorage.getItem('login_type') || 'default'); + const [codeVisible, setCodeVisible] = useState(false); + const [codeLoading, setCodeLoading] = useState(false); + + useEffect(() => { + setTimeout(() => { + if (counter > 0) { + setCounter(counter - 1) + } + }, 1000) + }, [counter]) + + function handleSubmit() { + const formData = form.getFieldsValue(); + if (codeVisible && !formData.captcha) return message.error('请输入验证码'); + setLoading(true); + formData['type'] = loginType; + http.post('/api/account/login/', formData) + .then(data => { + if (data['required_mfa']) { + setCodeVisible(true); + setCounter(30); + setLoading(false) + } else if (!data['has_real_ip']) { + Modal.warning({ + title: '安全警告', + className: styles.tips, + content:
+ 未能获取到访问者的真实IP,无法提供基于请求来源IP的合法性验证,详细信息请参考 + 官方文档。 +
, + onOk: () => doLogin(data) + }) + } else { + doLogin(data) + } + }, () => setLoading(false)) + } + + function doLogin(data) { + localStorage.setItem('login_type', loginType); + app.updateSession(data) + navigate('/home', {replace: true}) + } + + function handleCaptcha() { + setCodeLoading(true); + const formData = form.getFieldsValue(['username', 'password']); + formData['type'] = loginType; + http.post('/api/account/login/', formData) + .then(() => setCounter(30)) + .finally(() => setCodeLoading(false)) + } + + return ( +
+
+
logo
+
灵活、强大、易用的开源运维平台
+
+
+ setLoginType(v)}> + + + +
+ + }/> + + + }/> + + + + + +
+ +
+
+ 官网 + + 文档 +
+
Copyright {new Date().getFullYear()} By OpenSpug +
+
+
+ ) +} diff --git a/spug_web2/src/pages/login/login.module.css b/spug_web2/src/pages/login/login.module.css new file mode 100644 index 0000000..eade152 --- /dev/null +++ b/spug_web2/src/pages/login/login.module.css @@ -0,0 +1,76 @@ +.container { + background-image: url("./bg.svg"); + background-repeat: no-repeat; + background-position: center 110px; + background-size: 100%; + background-color: #f0f2f5; + height: 100vh; + display: flex; + flex-direction: column; +} + +.titleContainer { + padding-top: 70px; + text-align: center; + font-size: 33px; + font-weight: 600; +} + +.titleContainer .logo { + height: 35px; + margin-right: 15px; +} + +.titleContainer .desc { + margin-top: 12px; + margin-bottom: 40px; + color: rgba(0, 0, 0, .45); + font-size: 14px; + font-weight: 400; + +} + +.formContainer { + width: 368px; + margin: 0 auto; + flex: 1; +} + +.formContainer .tabs { + margin-bottom: 10px; +} + +.formContainer .formItem { + margin-bottom: 24px; +} + +.formContainer .icon { + color: rgba(0, 0, 0, .25); + margin-right: 4px; +} + +.formContainer .button { + margin-top: 10px; +} + +.footerZone { + width: 100%; + bottom: 0; + padding: 20px; + font-size: 14px; + text-align: center; + display: flex; + flex-direction: column; +} + +.footerZone .linksZone { + margin-bottom: 7px; +} + +.footerZone .links { + margin-right: 40px; +} + +.tips { + top: 230px +} diff --git a/spug_web2/src/routes.jsx b/spug_web2/src/routes.jsx new file mode 100644 index 0000000..3994486 --- /dev/null +++ b/spug_web2/src/routes.jsx @@ -0,0 +1,75 @@ +import {FaDesktop, FaServer, FaSitemap} from 'react-icons/fa6' +import Layout from './layout/index.jsx' +import ErrorPage from './error-page.jsx' +import LoginIndex from './pages/login/index.jsx' +import HomeIndex from './pages/home/index.jsx' +import HostIndex from './pages/host/index.jsx' +import './index.css' + +let routes = [ + { + path: '/', + element: , + errorElement: , + children: [ + { + path: 'home', + element: , + title: t('工作台'), + icon: , + }, + { + path: 'host', + element: , + title: t('主机管理'), + icon: + }, + { + path: 'exec', + title: t('批量执行'), + icon: , + children: [ + { + path: 'task', + title: t('执行任务'), + }, + { + path: 'transfer', + title: t('文件分发'), + } + ] + } + ] + }, + { + path: '/login', + element: , + }, +] + + +function routes2menu(routes, parentPath = '') { + const menu = [] + for (const route of routes) { + if (!route.title) continue + const path = `${parentPath}/${route.path}` + if (route.children) { + menu.push({ + key: path, + label: route.title, + icon: route.icon, + children: routes2menu(route.children, path) + }) + } else { + menu.push({ + key: path, + label: route.title, + icon: route.icon, + }) + } + } + return menu +} + +export const menus = routes2menu(routes[0].children) +export default routes \ No newline at end of file diff --git a/spug_web2/vite.config.js b/spug_web2/vite.config.js new file mode 100644 index 0000000..d77b21a --- /dev/null +++ b/spug_web2/vite.config.js @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react() + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +})