From 74e064002a61be34516fcbbb19113a26cf524624 Mon Sep 17 00:00:00 2001 From: vapao Date: Thu, 7 Dec 2023 00:40:08 +0800 Subject: [PATCH 1/8] update --- .../apps/account/management/commands/user.py | 2 +- spug_api/apps/account/models.py | 35 +- spug_api/apps/account/urls.py | 14 +- spug_api/apps/account/views.py | 28 +- spug_api/apps/apis/deploy.py | 3 - spug_api/apps/config/views.py | 3 - spug_api/apps/deploy/__init__.py | 3 - spug_api/apps/deploy/ext1.py | 140 ------ spug_api/apps/deploy/ext2.py | 175 ------- spug_api/apps/deploy/helper.py | 453 ------------------ spug_api/apps/deploy/models.py | 86 ---- spug_api/apps/deploy/urls.py | 16 - spug_api/apps/deploy/utils.py | 74 --- spug_api/apps/deploy/views.py | 369 -------------- spug_api/apps/exec/urls.py | 10 +- spug_api/apps/home/views.py | 1 - spug_api/apps/repository/__init__.py | 3 - spug_api/apps/repository/models.py | 71 --- spug_api/apps/repository/urls.py | 12 - spug_api/apps/repository/utils.py | 126 ----- spug_api/apps/repository/views.py | 124 ----- spug_api/apps/schedule/builtin.py | 36 +- spug_api/apps/schedule/scheduler.py | 3 +- spug_api/apps/setting/urls.py | 18 +- spug_api/libs/parser.py | 15 +- spug_api/libs/ssh.py | 121 ++--- spug_api/libs/utils.py | 7 +- spug_api/requirements.txt | 16 +- spug_api/spug/asgi.py | 12 +- spug_api/spug/settings.py | 40 +- spug_api/spug/urls.py | 2 - spug_web2/.eslintrc.cjs | 23 + spug_web2/.gitignore | 24 + spug_web2/README.md | 8 + spug_web2/index.html | 13 + spug_web2/package.json | 34 ++ spug_web2/public/vite.svg | 1 + spug_web2/src/App.jsx | 11 + spug_web2/src/assets/logo-spug-txt.png | Bin 0 -> 14694 bytes spug_web2/src/assets/logo-spug-white.png | Bin 0 -> 3262 bytes spug_web2/src/assets/logo-white.png | Bin 0 -> 2833 bytes spug_web2/src/error-page.jsx | 16 + spug_web2/src/i18n.js | 22 + spug_web2/src/index.css | 20 + spug_web2/src/layout/Header.jsx | 37 ++ spug_web2/src/layout/header.module.scss | 18 + spug_web2/src/layout/index.jsx | 52 ++ spug_web2/src/libs/http.js | 53 ++ spug_web2/src/libs/index.js | 7 + spug_web2/src/libs/session.js | 41 ++ spug_web2/src/libs/utils.js | 45 ++ spug_web2/src/main.jsx | 30 ++ spug_web2/src/pages/home/index.jsx | 17 + spug_web2/src/pages/host/index.jsx | 13 + spug_web2/src/pages/login/bg.svg | 69 +++ spug_web2/src/pages/login/index.jsx | 144 ++++++ spug_web2/src/pages/login/login.module.css | 76 +++ spug_web2/src/routes.jsx | 88 ++++ spug_web2/vite.config.js | 24 + 59 files changed, 1024 insertions(+), 1880 deletions(-) delete mode 100644 spug_api/apps/deploy/__init__.py delete mode 100644 spug_api/apps/deploy/ext1.py delete mode 100644 spug_api/apps/deploy/ext2.py delete mode 100644 spug_api/apps/deploy/helper.py delete mode 100644 spug_api/apps/deploy/models.py delete mode 100644 spug_api/apps/deploy/urls.py delete mode 100644 spug_api/apps/deploy/utils.py delete mode 100644 spug_api/apps/deploy/views.py delete mode 100644 spug_api/apps/repository/__init__.py delete mode 100644 spug_api/apps/repository/models.py delete mode 100644 spug_api/apps/repository/urls.py delete mode 100644 spug_api/apps/repository/utils.py delete mode 100644 spug_api/apps/repository/views.py create mode 100644 spug_web2/.eslintrc.cjs create mode 100644 spug_web2/.gitignore create mode 100644 spug_web2/README.md create mode 100644 spug_web2/index.html create mode 100644 spug_web2/package.json create mode 100644 spug_web2/public/vite.svg create mode 100644 spug_web2/src/App.jsx create mode 100644 spug_web2/src/assets/logo-spug-txt.png create mode 100644 spug_web2/src/assets/logo-spug-white.png create mode 100644 spug_web2/src/assets/logo-white.png create mode 100644 spug_web2/src/error-page.jsx create mode 100644 spug_web2/src/i18n.js create mode 100644 spug_web2/src/index.css create mode 100644 spug_web2/src/layout/Header.jsx create mode 100644 spug_web2/src/layout/header.module.scss create mode 100644 spug_web2/src/layout/index.jsx create mode 100644 spug_web2/src/libs/http.js create mode 100644 spug_web2/src/libs/index.js create mode 100644 spug_web2/src/libs/session.js create mode 100644 spug_web2/src/libs/utils.js create mode 100644 spug_web2/src/main.jsx create mode 100644 spug_web2/src/pages/home/index.jsx create mode 100644 spug_web2/src/pages/host/index.jsx create mode 100644 spug_web2/src/pages/login/bg.svg create mode 100644 spug_web2/src/pages/login/index.jsx create mode 100644 spug_web2/src/pages/login/login.module.css create mode 100644 spug_web2/src/routes.jsx create mode 100644 spug_web2/vite.config.js 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..9206711 100644 --- a/spug_api/requirements.txt +++ b/spug_api/requirements.txt @@ -1,7 +1,11 @@ -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.3.1 +channels >= 4.0.0, < 5.0.0 +channels-redis >= 4.1.0, < 5.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..ced1608 --- /dev/null +++ b/spug_web2/.eslintrc.cjs @@ -0,0 +1,23 @@ +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 }, + ], + }, + 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..595852f --- /dev/null +++ b/spug_web2/package.json @@ -0,0 +1,34 @@ +{ + "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.11.5", + "i18next": "^23.7.7", + "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" + } +} 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..383583d --- /dev/null +++ b/spug_web2/src/App.jsx @@ -0,0 +1,11 @@ +import {useTranslation } from 'react-i18next' + + +function App(props) { + const {t} = useTranslation() + window.t = t + + return props.children +} + +export default App \ No newline at end of file diff --git a/spug_web2/src/assets/logo-spug-txt.png b/spug_web2/src/assets/logo-spug-txt.png new file mode 100644 index 0000000000000000000000000000000000000000..e4c98e7c783ef0f89b1e33ab95e093c09e720fea GIT binary patch literal 14694 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 literal 0 HcmV?d00001 diff --git a/spug_web2/src/assets/logo-white.png b/spug_web2/src/assets/logo-white.png new file mode 100644 index 0000000000000000000000000000000000000000..8e436564fe301e155248ca13fb51e1aa8819f627 GIT binary patch literal 2833 zcmaKu`9IT-1IORYG1n}8@M%Uyl6y?KH6h7zFDuEM$w$u7q{A_HLbNq=B*jNtvE*1A zjg(`KisT5*93SV$5h~KR??3Q)J|3^v^Y_?I20gL?Z z%O=(6-x7*(cQ^q&>x0h&fF#e+&c>5Cx?Chxh{Yc0XpTuw@*`$V(dABy+Q}R|5?o|1 zq1`Eb)M=e{-Q)79>iITNZ9$tuBJ6%ohED2*N`0w${usl!0^vQhTr3b}9u9GdG>5Bey;xGyofmu4k4xCY=Q8 zTY9`S&m5AZRj_V`%Me3$9P>kTFnLvw^7@q#+whLjj0C43$(?kldEzF96XYId6GP5q zv6Da5CAcA)!lOlobS@nds0_wd3`XUpx*~^O^t`v1m2S<=B7IT;6Bh zs*Zl>$O=9qou*JNuqlrbeLN!}t<}niN$jm!Wzo3^?QgNKqrI

>Y_=e%O6wHlX|gqghF7 zD~nyT=DnZkLo;n*z1(=5i`GTG7F8)Ay+qrOLU}8_PJ&5qs!1d9Q3OQZbqK ztmngi*t*Ho!)N1wcs4Ml^BF*@6$)PQ&Q*UC-c1%U4Uz^Icc)(t9gtC{za< z60~YzKQ2*H(KDC7*2nxz&=FDxp2;psIiboENqh(?n96Hi`~J^?6amwEeJLC6Lv%Va z_mOxU{uBjFU=bxLw{p8epZ98ut%{_l^_iX0ajo$Wwm2E)AFJca>VI^wk_A)pXM+Mu zQ`jF>PwHwVEDe3F3I114*y$SmHI!2}W9rP?Ee#=o_ zphO+ohV_Fd%_ND6UtFmX7wW^VxU}@OzZA8rkod0W!t6_(vN4j%frtp{0T=hTFMcpb zb`t~*e{FrA`xd*X!!yOq&VMCr%mryPB)thCrS>|PCv`#iW6^cFWdgE1N zH7wI|L$>sDxJXr#-K|2<>EBihe+GqMGAV%oG)8Eh2~8af9yY)I5x!%?eh{8ckpzg#YMXoGAr#aEFK zyh)w^KTRRDwJWS%+|ThInpvJ=0ChllLO_g*RV4^S7+EO~fTH<(4m@@K_%Q@yMrNrR zft06yEQ~u$vMwgcqWZi)!2*rMqu>k5VET8^R-~%ux&^Kj%oiU0OvyCN@g~ zYE6c%j4aISPaY4x>fByr361OYf%h;&1t7Bo>jKu*Z8oZxwu>l9Zi$d`85#ylHeMPt0cwbF*A&5if zOX2nB!4+$YV^8m5<}|j8Zk+J4Pm5GhkQcF;c2n^~Ag`zvM_fv8IE+SZxe&5^ufJdB zeQ*XBcEl6Ho$y(TewpJrJtH>co`w$h(qZ$j_qr*;V-#NtXFZHhx#pv(Dc5Xy&O5M!%>D6(W*BYSE*NUsz zfndJApc+A1@`9a>23%&(!Fxj?{y|;E2B#*1rX6u1v;>+0glG^&Qt@i@GOQdQbb7f8 zA=P}LZjl0ngSPlPTX1S9;S>hf<4 zcHi7~6KmUNWbjM_XAH+a6zn)BBSl;S^`h}}@gBQTY`2Ymd7jZY-_Us0tY96IzD%w! zYt;BEkX=it6JWyLW^*#C$D6j4!Kasa0Yszp29-kXk^1pIiWSoPu6RiHp@dN#pJa!> zn>mD6m|D-BsnlMp>)p%Xa1L?jc*4O`3Lbsfhgr#q_bdxRrpRx~cRz_0)zo3(aHR-5 zKBZ^NURK`4$3DAl!d!HN$)>Q(4;`CaVZ)QG^)t7>UZt1{z&=PuMs}5A)9#b3#^ZaB zOU*y_ZU1Q9ai9v%5WJGx;(_olE_OPE?Tuh{ZoveDeclmfkcE#@K@*^M6Q_yN?Q5M3LPG z-f1&G2;;H3i7gNy=1rl64#`DcmdpBXs|;MC!xjS>$w5o0SY;sc#fbmUuaBI6fegAp z?mDve4E-ile{s8%kmsDEpS?H$59+JLdz?n;13Lw*L1OefY2&m zL^)YrflEd=6%Pt4i_}tR20QYXdABELbBei(#4GJB9TGOJBGVaAblZzqcX5C~DHFu+ z!d|tW;4qxEBjyz0z#FnPymi19m__r#;&GvM3}=;mb4#AzFC9Rs|3%ub#1L&5K(4#% z^LrBc(0KuXj}0>~Q3KNG`w!2&)gF}7I+z%6h9eo|lT64} z>#7K~juKjRa^^gF*Z@?{SW^!-b}jE?t2cS1+P z4yKn`#HCrx@P`J1HCUQl&yF$!>UiR+EkTLgmq{6=&c7ME6tei4_g~PFKNT%s_ErZ* T(@cMVT)@%Z+3wj1eDePQWn^aa literal 0 HcmV?d00001 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..0d2ddaa --- /dev/null +++ b/spug_web2/src/i18n.js @@ -0,0 +1,22 @@ +import i18n from 'i18next' +import {initReactI18next} from 'react-i18next' + +i18n.use(initReactI18next).init({ + lng: localStorage.getItem('lang') || 'zh', + resources: { + en: { + translation: { + '首页': 'Home', + '工作台': 'Work', + '主机管理': 'Hosts', + '批量执行': 'Batch', + '执行任务': 'Task', + '文件分发': 'Transfer', + } + } + } +}) + +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..8b83401 --- /dev/null +++ b/spug_web2/src/layout/Header.jsx @@ -0,0 +1,37 @@ +import {Layout, Flex, Dropdown, theme} from 'antd' +import {AiOutlineTranslation} from 'react-icons/ai' +import css from './header.module.scss' +import i18n from '@/i18n.js' + +function Header() { + const {token: {colorBgContainer}} = theme.useToken() + + function handleLangChange({key}) { + localStorage.setItem('lang', key) + window.location.reload() + } + + const locales = [{ + label: '🇨🇳 简体中文', + key: 'zh', + }, { + label: '🇺🇸 English', + key: 'en', + }] + + console.log('lang', i18n.language) + return ( + + +
admin
+ +
+ +
+
+
+
+ ) +} + +export default Header \ No newline at end of file diff --git a/spug_web2/src/layout/header.module.scss b/spug_web2/src/layout/header.module.scss new file mode 100644 index 0000000..66f3668 --- /dev/null +++ b/spug_web2/src/layout/header.module.scss @@ -0,0 +1,18 @@ +.header { + padding: 0 8px; + + .item { + height: 48px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.1s ease-in-out; + + &:hover { + background: #f1f1f1; + } + } +} \ 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..e3a3d5e --- /dev/null +++ b/spug_web2/src/layout/index.jsx @@ -0,0 +1,52 @@ +import {useState} from 'react' +import {Outlet, useMatches, useNavigate} from 'react-router-dom' +import {Layout, Breadcrumb, Flex, Menu, theme} from 'antd' +import Header from './Header.jsx' +import {menus} from '@/routes' +import logo1 from '@/assets/logo-spug-white.png' +import logo2 from '@/assets/logo-white.png' + + +function LayoutIndex() { + const [collapsed, setCollapsed] = useState(false) + const {token: {colorBgContainer, colorTextTertiary}} = theme.useToken() + const navigate = useNavigate() + const matches = useMatches() + const crumbs = matches.map(x => ({title: x.handle.crumb, href: x.pathname})) + + function handleMenuClick({key}) { + navigate(key) + } + + const selectedKey = matches[matches.length - 1]?.pathname + return ( + + + + {collapsed ? ( + logo + ) : ( + logo + )} + + + + +
+ + +
+ +
+
+ + + Copyright © 2023 OpenSpug All Rights Reserved. + + + + + ) +} + +export default LayoutIndex \ 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..27cb706 --- /dev/null +++ b/spug_web2/src/libs/http.js @@ -0,0 +1,53 @@ +import useSWR from 'swr' +import {message} from 'antd' +import session from '@/libs/session' +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': session.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..c750766 --- /dev/null +++ b/spug_web2/src/libs/index.js @@ -0,0 +1,7 @@ +import http from './http' +import session from './session' + +export { + http, + session, +} \ No newline at end of file diff --git a/spug_web2/src/libs/session.js b/spug_web2/src/libs/session.js new file mode 100644 index 0000000..d1e6493 --- /dev/null +++ b/spug_web2/src/libs/session.js @@ -0,0 +1,41 @@ +import {isSubArray} from "@/libs/utils.js"; + +class Session { + constructor() { + this._session = {}; + const tmp = localStorage.getItem('session'); + if (tmp) { + try { + this._session = JSON.parse(tmp); + } catch (e) { + localStorage.removeItem('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 + } + + update(data) { + Object.assign(this._session, data); + localStorage.setItem('session', JSON.stringify(this._session)); + } +} + +export default new Session(); \ No newline at end of file diff --git a/spug_web2/src/libs/utils.js b/spug_web2/src/libs/utils.js new file mode 100644 index 0000000..5539857 --- /dev/null +++ b/spug_web2/src/libs/utils.js @@ -0,0 +1,45 @@ +/** + * 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) + } +} \ 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..cb27969 --- /dev/null +++ b/spug_web2/src/main.jsx @@ -0,0 +1,30 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import {createBrowserRouter, RouterProvider} from 'react-router-dom' +import {ConfigProvider, App, theme} from 'antd' +import routes from './routes.jsx' +import './i18n.js' + + +const router = createBrowserRouter(routes) + +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/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/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..05ea8c9 --- /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, session} from '@/libs'; +import logo from '@/assets/logo-spug-txt.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); + session.update(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..37fd665 --- /dev/null +++ b/spug_web2/src/routes.jsx @@ -0,0 +1,88 @@ +import {AiOutlineDesktop, AiOutlineCloudServer, AiOutlineCluster} from 'react-icons/ai' +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: , + title: t('首页'), + 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 +} + +function handle(routes) { + for (const route of routes) { + if (route.children) { + route.children = handle(route.children) + } + route.handle = {crumb: route.title} + } + return routes +} + +routes = handle(routes) + +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/, '') + } + } + } +}) From 41990ef08b94640a09deb4aca4c91eda89439486 Mon Sep 17 00:00:00 2001 From: vapao Date: Tue, 26 Dec 2023 00:30:05 +0800 Subject: [PATCH 2/8] update --- spug_web2/.eslintrc.cjs | 1 + spug_web2/package.json | 4 +- spug_web2/src/App.jsx | 46 ++++++++++++++++-- spug_web2/src/assets/logo-spug-white.png | Bin 3262 -> 0 bytes spug_web2/src/assets/logo-white.png | Bin 2833 -> 0 bytes .../{logo-spug-txt.png => spug-default.png} | Bin spug_web2/src/i18n.js | 12 ++++- spug_web2/src/layout/Header.jsx | 25 ++++++++-- spug_web2/src/layout/header.module.scss | 18 ------- spug_web2/src/layout/index.jsx | 43 ++++++---------- spug_web2/src/layout/index.module.scss | 46 ++++++++++++++++++ spug_web2/src/libs/index.js | 6 ++- spug_web2/src/libs/session.js | 2 + spug_web2/src/main.jsx | 25 +--------- spug_web2/src/routes.jsx | 13 ----- 15 files changed, 146 insertions(+), 95 deletions(-) delete mode 100644 spug_web2/src/assets/logo-spug-white.png delete mode 100644 spug_web2/src/assets/logo-white.png rename spug_web2/src/assets/{logo-spug-txt.png => spug-default.png} (100%) delete mode 100644 spug_web2/src/layout/header.module.scss create mode 100644 spug_web2/src/layout/index.module.scss diff --git a/spug_web2/.eslintrc.cjs b/spug_web2/.eslintrc.cjs index ced1608..e424d5e 100644 --- a/spug_web2/.eslintrc.cjs +++ b/spug_web2/.eslintrc.cjs @@ -16,6 +16,7 @@ module.exports = { 'warn', { allowConstantExport: true }, ], + 'react/prop-types': 'off', }, globals: { t: 'readonly', diff --git a/spug_web2/package.json b/spug_web2/package.json index 595852f..1a2c026 100644 --- a/spug_web2/package.json +++ b/spug_web2/package.json @@ -10,7 +10,9 @@ "preview": "vite preview" }, "dependencies": { - "antd": "^5.11.5", + "@ant-design/icons": "^5.2.6", + "antd": "^5.12.4", + "dayjs": "^1.11.10", "i18next": "^23.7.7", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/spug_web2/src/App.jsx b/spug_web2/src/App.jsx index 383583d..6c210a7 100644 --- a/spug_web2/src/App.jsx +++ b/spug_web2/src/App.jsx @@ -1,11 +1,47 @@ -import {useTranslation } from 'react-i18next' +import {createBrowserRouter, RouterProvider} from 'react-router-dom' +import {ConfigProvider, App as AntdApp, theme} from 'antd' +import zhCN from 'antd/locale/zh_CN' +import enUS from 'antd/locale/en_US' +import dayjs from 'dayjs' +import routes from './routes.jsx' +import {session, SContext} from '@/libs' +import {useImmer} from 'use-immer' +import './i18n.js' +dayjs.locale(session.lang) -function App(props) { - const {t} = useTranslation() - window.t = t +const router = createBrowserRouter(routes) - return props.children +function App() { + const [S, updateS] = useImmer({theme: session.theme}) + + return ( + + + + + + + + ) } export default App \ No newline at end of file diff --git a/spug_web2/src/assets/logo-spug-white.png b/spug_web2/src/assets/logo-spug-white.png deleted file mode 100644 index d8af69b294e115006398e48c67747b8a49e613ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3262 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 diff --git a/spug_web2/src/assets/logo-white.png b/spug_web2/src/assets/logo-white.png deleted file mode 100644 index 8e436564fe301e155248ca13fb51e1aa8819f627..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2833 zcmaKu`9IT-1IORYG1n}8@M%Uyl6y?KH6h7zFDuEM$w$u7q{A_HLbNq=B*jNtvE*1A zjg(`KisT5*93SV$5h~KR??3Q)J|3^v^Y_?I20gL?Z z%O=(6-x7*(cQ^q&>x0h&fF#e+&c>5Cx?Chxh{Yc0XpTuw@*`$V(dABy+Q}R|5?o|1 zq1`Eb)M=e{-Q)79>iITNZ9$tuBJ6%ohED2*N`0w${usl!0^vQhTr3b}9u9GdG>5Bey;xGyofmu4k4xCY=Q8 zTY9`S&m5AZRj_V`%Me3$9P>kTFnLvw^7@q#+whLjj0C43$(?kldEzF96XYId6GP5q zv6Da5CAcA)!lOlobS@nds0_wd3`XUpx*~^O^t`v1m2S<=B7IT;6Bh zs*Zl>$O=9qou*JNuqlrbeLN!}t<}niN$jm!Wzo3^?QgNKqrI

>Y_=e%O6wHlX|gqghF7 zD~nyT=DnZkLo;n*z1(=5i`GTG7F8)Ay+qrOLU}8_PJ&5qs!1d9Q3OQZbqK ztmngi*t*Ho!)N1wcs4Ml^BF*@6$)PQ&Q*UC-c1%U4Uz^Icc)(t9gtC{za< z60~YzKQ2*H(KDC7*2nxz&=FDxp2;psIiboENqh(?n96Hi`~J^?6amwEeJLC6Lv%Va z_mOxU{uBjFU=bxLw{p8epZ98ut%{_l^_iX0ajo$Wwm2E)AFJca>VI^wk_A)pXM+Mu zQ`jF>PwHwVEDe3F3I114*y$SmHI!2}W9rP?Ee#=o_ zphO+ohV_Fd%_ND6UtFmX7wW^VxU}@OzZA8rkod0W!t6_(vN4j%frtp{0T=hTFMcpb zb`t~*e{FrA`xd*X!!yOq&VMCr%mryPB)thCrS>|PCv`#iW6^cFWdgE1N zH7wI|L$>sDxJXr#-K|2<>EBihe+GqMGAV%oG)8Eh2~8af9yY)I5x!%?eh{8ckpzg#YMXoGAr#aEFK zyh)w^KTRRDwJWS%+|ThInpvJ=0ChllLO_g*RV4^S7+EO~fTH<(4m@@K_%Q@yMrNrR zft06yEQ~u$vMwgcqWZi)!2*rMqu>k5VET8^R-~%ux&^Kj%oiU0OvyCN@g~ zYE6c%j4aISPaY4x>fByr361OYf%h;&1t7Bo>jKu*Z8oZxwu>l9Zi$d`85#ylHeMPt0cwbF*A&5if zOX2nB!4+$YV^8m5<}|j8Zk+J4Pm5GhkQcF;c2n^~Ag`zvM_fv8IE+SZxe&5^ufJdB zeQ*XBcEl6Ho$y(TewpJrJtH>co`w$h(qZ$j_qr*;V-#NtXFZHhx#pv(Dc5Xy&O5M!%>D6(W*BYSE*NUsz zfndJApc+A1@`9a>23%&(!Fxj?{y|;E2B#*1rX6u1v;>+0glG^&Qt@i@GOQdQbb7f8 zA=P}LZjl0ngSPlPTX1S9;S>hf<4 zcHi7~6KmUNWbjM_XAH+a6zn)BBSl;S^`h}}@gBQTY`2Ymd7jZY-_Us0tY96IzD%w! zYt;BEkX=it6JWyLW^*#C$D6j4!Kasa0Yszp29-kXk^1pIiWSoPu6RiHp@dN#pJa!> zn>mD6m|D-BsnlMp>)p%Xa1L?jc*4O`3Lbsfhgr#q_bdxRrpRx~cRz_0)zo3(aHR-5 zKBZ^NURK`4$3DAl!d!HN$)>Q(4;`CaVZ)QG^)t7>UZt1{z&=PuMs}5A)9#b3#^ZaB zOU*y_ZU1Q9ai9v%5WJGx;(_olE_OPE?Tuh{ZoveDeclmfkcE#@K@*^M6Q_yN?Q5M3LPG z-f1&G2;;H3i7gNy=1rl64#`DcmdpBXs|;MC!xjS>$w5o0SY;sc#fbmUuaBI6fegAp z?mDve4E-ile{s8%kmsDEpS?H$59+JLdz?n;13Lw*L1OefY2&m zL^)YrflEd=6%Pt4i_}tR20QYXdABELbBei(#4GJB9TGOJBGVaAblZzqcX5C~DHFu+ z!d|tW;4qxEBjyz0z#FnPymi19m__r#;&GvM3}=;mb4#AzFC9Rs|3%ub#1L&5K(4#% z^LrBc(0KuXj}0>~Q3KNG`w!2&)gF}7I+z%6h9eo|lT64} z>#7K~juKjRa^^gF*Z@?{SW^!-b}jE?t2cS1+P z4yKn`#HCrx@P`J1HCUQl&yF$!>UiR+EkTLgmq{6=&c7ME6tei4_g~PFKNT%s_ErZ* T(@cMVT)@%Z+3wj1eDePQWn^aa diff --git a/spug_web2/src/assets/logo-spug-txt.png b/spug_web2/src/assets/spug-default.png similarity index 100% rename from spug_web2/src/assets/logo-spug-txt.png rename to spug_web2/src/assets/spug-default.png diff --git a/spug_web2/src/i18n.js b/spug_web2/src/i18n.js index 0d2ddaa..61ebb85 100644 --- a/spug_web2/src/i18n.js +++ b/spug_web2/src/i18n.js @@ -1,8 +1,9 @@ import i18n from 'i18next' import {initReactI18next} from 'react-i18next' +import {session} from '@/libs' i18n.use(initReactI18next).init({ - lng: localStorage.getItem('lang') || 'zh', + lng: session.lang, resources: { en: { translation: { @@ -12,6 +13,15 @@ i18n.use(initReactI18next).init({ '批量执行': 'Batch', '执行任务': 'Task', '文件分发': 'Transfer', + '重置': 'Reset', + '展示字段': 'Columns Display', + '年龄': 'Age', + 'page': 'Total {{total}} items', + } + }, + zh: { + translation: { + 'page': '共 {{total}} 条', } } } diff --git a/spug_web2/src/layout/Header.jsx b/spug_web2/src/layout/Header.jsx index 8b83401..d962c13 100644 --- a/spug_web2/src/layout/Header.jsx +++ b/spug_web2/src/layout/Header.jsx @@ -1,10 +1,22 @@ -import {Layout, Flex, Dropdown, theme} from 'antd' +import {useContext} from 'react' +import {Layout, Flex, Dropdown} from 'antd' import {AiOutlineTranslation} from 'react-icons/ai' -import css from './header.module.scss' +import {IoSunny, IoMoon} 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 {token: {colorBgContainer}} = theme.useToken() + const {S: {theme}, updateS} = useContext(SContext) + + function handleThemeChange() { + const newTheme = theme === 'light' ? 'dark' : 'light' + localStorage.setItem('theme', newTheme) + updateS(draft => { + draft.theme = newTheme + }) + } function handleLangChange({key}) { localStorage.setItem('lang', key) @@ -19,9 +31,9 @@ function Header() { key: 'en', }] - console.log('lang', i18n.language) return ( - + + logo

admin
@@ -29,6 +41,9 @@ function Header() {
+
+ {theme === 'light' ? : } +
) diff --git a/spug_web2/src/layout/header.module.scss b/spug_web2/src/layout/header.module.scss deleted file mode 100644 index 66f3668..0000000 --- a/spug_web2/src/layout/header.module.scss +++ /dev/null @@ -1,18 +0,0 @@ -.header { - padding: 0 8px; - - .item { - height: 48px; - display: flex; - align-items: center; - justify-content: center; - padding: 0 16px; - font-weight: 600; - cursor: pointer; - transition: all 0.1s ease-in-out; - - &:hover { - background: #f1f1f1; - } - } -} \ No newline at end of file diff --git a/spug_web2/src/layout/index.jsx b/spug_web2/src/layout/index.jsx index e3a3d5e..bc60b49 100644 --- a/spug_web2/src/layout/index.jsx +++ b/spug_web2/src/layout/index.jsx @@ -1,18 +1,14 @@ -import {useState} from 'react' import {Outlet, useMatches, useNavigate} from 'react-router-dom' -import {Layout, Breadcrumb, Flex, Menu, theme} from 'antd' +import {Layout, Flex, Menu, theme} from 'antd' import Header from './Header.jsx' import {menus} from '@/routes' -import logo1 from '@/assets/logo-spug-white.png' -import logo2 from '@/assets/logo-white.png' +import css from './index.module.scss' function LayoutIndex() { - const [collapsed, setCollapsed] = useState(false) - const {token: {colorBgContainer, colorTextTertiary}} = theme.useToken() + const {token: {colorTextTertiary}} = theme.useToken() const navigate = useNavigate() const matches = useMatches() - const crumbs = matches.map(x => ({title: x.handle.crumb, href: x.pathname})) function handleMenuClick({key}) { navigate(key) @@ -21,29 +17,20 @@ function LayoutIndex() { const selectedKey = matches[matches.length - 1]?.pathname return ( - - - {collapsed ? ( - logo - ) : ( - logo - )} - - - +
-
- - -
- -
+ + + + + + + + Copyright © 2023 OpenSpug All Rights Reserved. + + - - - Copyright © 2023 OpenSpug All Rights Reserved. - - ) diff --git a/spug_web2/src/layout/index.module.scss b/spug_web2/src/layout/index.module.scss new file mode 100644 index 0000000..2e92d0d --- /dev/null +++ b/spug_web2/src/layout/index.module.scss @@ -0,0 +1,46 @@ +.header { + position: sticky; + top: 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 { + :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/index.js b/spug_web2/src/libs/index.js index c750766..e0e157e 100644 --- a/spug_web2/src/libs/index.js +++ b/spug_web2/src/libs/index.js @@ -1,7 +1,11 @@ +import React from 'react' import http from './http' import session from './session' +const SContext = React.createContext({}) +export * from './utils.js' export { http, session, -} \ No newline at end of file + SContext, +} diff --git a/spug_web2/src/libs/session.js b/spug_web2/src/libs/session.js index d1e6493..03fa021 100644 --- a/spug_web2/src/libs/session.js +++ b/spug_web2/src/libs/session.js @@ -3,6 +3,8 @@ import {isSubArray} from "@/libs/utils.js"; class Session { constructor() { this._session = {}; + this.lang = localStorage.getItem('lang') || 'zh'; + this.theme = localStorage.getItem('theme') || 'light'; const tmp = localStorage.getItem('session'); if (tmp) { try { diff --git a/spug_web2/src/main.jsx b/spug_web2/src/main.jsx index cb27969..92babb7 100644 --- a/spug_web2/src/main.jsx +++ b/spug_web2/src/main.jsx @@ -1,30 +1,9 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import {createBrowserRouter, RouterProvider} from 'react-router-dom' -import {ConfigProvider, App, theme} from 'antd' -import routes from './routes.jsx' -import './i18n.js' - - -const router = createBrowserRouter(routes) +import App from './App.jsx' ReactDOM.createRoot(document.getElementById('root')).render( - - - - - + ) diff --git a/spug_web2/src/routes.jsx b/spug_web2/src/routes.jsx index 37fd665..f97c77f 100644 --- a/spug_web2/src/routes.jsx +++ b/spug_web2/src/routes.jsx @@ -11,7 +11,6 @@ let routes = [ path: '/', element: , errorElement: , - title: t('首页'), children: [ { path: 'home', @@ -72,17 +71,5 @@ function routes2menu(routes, parentPath = '') { return menu } -function handle(routes) { - for (const route of routes) { - if (route.children) { - route.children = handle(route.children) - } - route.handle = {crumb: route.title} - } - return routes -} - -routes = handle(routes) - export const menus = routes2menu(routes[0].children) export default routes \ No newline at end of file From 3008fbfc86991e85e0b8fa9fd7bf797513020b97 Mon Sep 17 00:00:00 2001 From: vapao Date: Tue, 26 Dec 2023 11:28:45 +0800 Subject: [PATCH 3/8] update --- spug_web2/src/layout/Header.jsx | 12 +++++++++--- spug_web2/src/layout/index.jsx | 13 +++++++++---- spug_web2/src/layout/index.module.scss | 9 ++++++++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/spug_web2/src/layout/Header.jsx b/spug_web2/src/layout/Header.jsx index d962c13..586431c 100644 --- a/spug_web2/src/layout/Header.jsx +++ b/spug_web2/src/layout/Header.jsx @@ -1,7 +1,7 @@ -import {useContext} from 'react' -import {Layout, Flex, Dropdown} from 'antd' +import {useContext, useEffect} from 'react' +import {Dropdown, Flex, Layout, theme as antdTheme} from 'antd' import {AiOutlineTranslation} from 'react-icons/ai' -import {IoSunny, IoMoon} from 'react-icons/io5' +import {IoMoon, IoSunny} from 'react-icons/io5' import {SContext} from '@/libs' import css from './index.module.scss' import i18n from '@/i18n.js' @@ -9,6 +9,12 @@ 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' diff --git a/spug_web2/src/layout/index.jsx b/spug_web2/src/layout/index.jsx index bc60b49..ce1c31b 100644 --- a/spug_web2/src/layout/index.jsx +++ b/spug_web2/src/layout/index.jsx @@ -1,3 +1,4 @@ +import {useState} from 'react' import {Outlet, useMatches, useNavigate} from 'react-router-dom' import {Layout, Flex, Menu, theme} from 'antd' import Header from './Header.jsx' @@ -6,6 +7,7 @@ import css from './index.module.scss' function LayoutIndex() { + const [collapsed, setCollapsed] = useState(false); const {token: {colorTextTertiary}} = theme.useToken() const navigate = useNavigate() const matches = useMatches() @@ -19,12 +21,15 @@ function LayoutIndex() {
- - + + - - + +
+ +
Copyright © 2023 OpenSpug All Rights Reserved. diff --git a/spug_web2/src/layout/index.module.scss b/spug_web2/src/layout/index.module.scss index 2e92d0d..fe016dd 100644 --- a/spug_web2/src/layout/index.module.scss +++ b/spug_web2/src/layout/index.module.scss @@ -1,6 +1,8 @@ .header { - position: sticky; + position: fixed; top: 0; + left: 0; + right: 0; z-index: 999; padding: 0 8px; display: flex; @@ -32,6 +34,11 @@ } .sider { + position: fixed !important; + top: 48px; + left: 0; + bottom: 0; + :global(.ant-menu) { height: 100%; } From 89d8f3057029b02f04c62f4c968200bd29012cab Mon Sep 17 00:00:00 2001 From: vapao Date: Wed, 27 Dec 2023 23:47:26 +0800 Subject: [PATCH 4/8] update --- spug_web2/package.json | 1 - spug_web2/src/App.jsx | 17 ++--- spug_web2/src/components/STable/Setting.jsx | 62 ++++++++++++++++++ spug_web2/src/components/STable/index.jsx | 63 +++++++++++++++++++ .../src/components/STable/index.module.scss | 15 +++++ spug_web2/src/i18n.js | 6 +- spug_web2/src/layout/Header.jsx | 5 +- spug_web2/src/libs/app.js | 45 +++++++++++++ spug_web2/src/libs/http.js | 4 +- spug_web2/src/libs/index.js | 4 +- spug_web2/src/libs/session.js | 43 ------------- spug_web2/src/libs/utils.js | 12 ++++ spug_web2/src/routes.jsx | 8 +-- 13 files changed, 221 insertions(+), 64 deletions(-) create mode 100644 spug_web2/src/components/STable/Setting.jsx create mode 100644 spug_web2/src/components/STable/index.jsx create mode 100644 spug_web2/src/components/STable/index.module.scss create mode 100644 spug_web2/src/libs/app.js delete mode 100644 spug_web2/src/libs/session.js diff --git a/spug_web2/package.json b/spug_web2/package.json index 1a2c026..e773461 100644 --- a/spug_web2/package.json +++ b/spug_web2/package.json @@ -10,7 +10,6 @@ "preview": "vite preview" }, "dependencies": { - "@ant-design/icons": "^5.2.6", "antd": "^5.12.4", "dayjs": "^1.11.10", "i18next": "^23.7.7", diff --git a/spug_web2/src/App.jsx b/spug_web2/src/App.jsx index 6c210a7..4321343 100644 --- a/spug_web2/src/App.jsx +++ b/spug_web2/src/App.jsx @@ -1,24 +1,25 @@ 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 {session, SContext} from '@/libs' +import {app, SContext} from '@/libs' import {useImmer} from 'use-immer' import './i18n.js' -dayjs.locale(session.lang) +dayjs.locale(app.lang) const router = createBrowserRouter(routes) function App() { - const [S, updateS] = useImmer({theme: session.theme}) + const [S, updateS] = useImmer({theme: app.theme}) return ( - - - + + + + + ) diff --git a/spug_web2/src/components/STable/Setting.jsx b/spug_web2/src/components/STable/Setting.jsx new file mode 100644 index 0000000..9b53d99 --- /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.key] ?? !item.hidden) { + newColumns.push(item) + } + } + setCols(newColumns) + }, [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, {}) + } + + 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..a5fa2f1 --- /dev/null +++ b/spug_web2/src/components/STable/index.jsx @@ -0,0 +1,63 @@ +import {useRef, useState} from 'react' +import {Card, Table, Flex, Divider} from 'antd' +import {IoExpand, IoContract, IoReloadOutline} from 'react-icons/io5' +import {clsNames} 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 [cols, setCols] = useState([]) + const [isFull, setIsFull] = useState(false) + + if (!skey) throw new Error('skey is required') + + function handleFullscreen() { + if (ref.current && document.fullscreenEnabled) { + if (document.fullscreenElement) { + document.exitFullscreen() + setIsFull(false) + } else { + ref.current.requestFullscreen() + setIsFull(true) + } + } + } + + return ( + + + + {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..a7b411c --- /dev/null +++ b/spug_web2/src/components/STable/index.module.scss @@ -0,0 +1,15 @@ +.stable { + :global(.ant-pagination) { + margin: 16px 0 0 !important; + } +} + +.toolbar { + margin-bottom: 12px; + + .icon { + font-size: 18px; + cursor: pointer; + } +} + diff --git a/spug_web2/src/i18n.js b/spug_web2/src/i18n.js index 61ebb85..a89b29d 100644 --- a/spug_web2/src/i18n.js +++ b/spug_web2/src/i18n.js @@ -1,9 +1,9 @@ import i18n from 'i18next' import {initReactI18next} from 'react-i18next' -import {session} from '@/libs' +import {app} from '@/libs' i18n.use(initReactI18next).init({ - lng: session.lang, + lng: app.lang, resources: { en: { translation: { @@ -16,6 +16,8 @@ i18n.use(initReactI18next).init({ '重置': 'Reset', '展示字段': 'Columns Display', '年龄': 'Age', + // buttons + '新建': 'Add', 'page': 'Total {{total}} items', } }, diff --git a/spug_web2/src/layout/Header.jsx b/spug_web2/src/layout/Header.jsx index 586431c..528a8a1 100644 --- a/spug_web2/src/layout/Header.jsx +++ b/spug_web2/src/layout/Header.jsx @@ -1,7 +1,6 @@ import {useContext, useEffect} from 'react' import {Dropdown, Flex, Layout, theme as antdTheme} from 'antd' -import {AiOutlineTranslation} from 'react-icons/ai' -import {IoMoon, IoSunny} from 'react-icons/io5' +import {IoMoon, IoSunny, IoLanguage} from 'react-icons/io5' import {SContext} from '@/libs' import css from './index.module.scss' import i18n from '@/i18n.js' @@ -44,7 +43,7 @@ function Header() {
admin
- +
diff --git a/spug_web2/src/libs/app.js b/spug_web2/src/libs/app.js new file mode 100644 index 0000000..ce191a6 --- /dev/null +++ b/spug_web2/src/libs/app.js @@ -0,0 +1,45 @@ +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; + 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 index 27cb706..efc9665 100644 --- a/spug_web2/src/libs/http.js +++ b/spug_web2/src/libs/http.js @@ -1,6 +1,6 @@ import useSWR from 'swr' import {message} from 'antd' -import session from '@/libs/session' +import app from '@/libs/app.js' import {redirect} from 'react-router-dom' function fetcher(resource, init) { @@ -33,7 +33,7 @@ function SWRGet(url, params) { } function request(method, url, params) { - const init = {method, headers: {'X-Token': session.access_token}} + const init = {method, headers: {'X-Token': app.accessToken}} if (['GET', 'DELETE'].includes(method)) { if (params) url = `${url}?${new URLSearchParams(params).toString()}` return fetcher(url, init) diff --git a/spug_web2/src/libs/index.js b/spug_web2/src/libs/index.js index e0e157e..0867c20 100644 --- a/spug_web2/src/libs/index.js +++ b/spug_web2/src/libs/index.js @@ -1,11 +1,11 @@ import React from 'react' import http from './http' -import session from './session' +import app from './app.js' const SContext = React.createContext({}) export * from './utils.js' export { + app, http, - session, SContext, } diff --git a/spug_web2/src/libs/session.js b/spug_web2/src/libs/session.js deleted file mode 100644 index 03fa021..0000000 --- a/spug_web2/src/libs/session.js +++ /dev/null @@ -1,43 +0,0 @@ -import {isSubArray} from "@/libs/utils.js"; - -class Session { - constructor() { - this._session = {}; - this.lang = localStorage.getItem('lang') || 'zh'; - this.theme = localStorage.getItem('theme') || 'light'; - const tmp = localStorage.getItem('session'); - if (tmp) { - try { - this._session = JSON.parse(tmp); - } catch (e) { - localStorage.removeItem('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 - } - - update(data) { - Object.assign(this._session, data); - localStorage.setItem('session', JSON.stringify(this._session)); - } -} - -export default new Session(); \ No newline at end of file diff --git a/spug_web2/src/libs/utils.js b/spug_web2/src/libs/utils.js index 5539857..384e9d6 100644 --- a/spug_web2/src/libs/utils.js +++ b/spug_web2/src/libs/utils.js @@ -42,4 +42,16 @@ export function includes(s, keys) { } 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 } \ No newline at end of file diff --git a/spug_web2/src/routes.jsx b/spug_web2/src/routes.jsx index f97c77f..3994486 100644 --- a/spug_web2/src/routes.jsx +++ b/spug_web2/src/routes.jsx @@ -1,4 +1,4 @@ -import {AiOutlineDesktop, AiOutlineCloudServer, AiOutlineCluster} from 'react-icons/ai' +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' @@ -16,18 +16,18 @@ let routes = [ path: 'home', element: , title: t('工作台'), - icon: , + icon: , }, { path: 'host', element: , title: t('主机管理'), - icon: + icon: }, { path: 'exec', title: t('批量执行'), - icon: , + icon: , children: [ { path: 'task', From 9c0b41ba511052650a9ace2199d985d30dd866c9 Mon Sep 17 00:00:00 2001 From: vapao Date: Tue, 27 Feb 2024 00:09:33 +0800 Subject: [PATCH 5/8] update --- spug_api/requirements.txt | 3 +- spug_web2/package.json | 4 +- spug_web2/src/App.jsx | 18 +-- spug_web2/src/components/STable/Setting.jsx | 28 ++-- spug_web2/src/components/STable/index.jsx | 123 +++++++++++++++--- .../src/components/STable/index.module.scss | 23 ++++ spug_web2/src/libs/app.js | 5 +- spug_web2/src/pages/host/Form.jsx | 0 spug_web2/src/pages/host/Group.jsx | 61 +++++++++ spug_web2/src/pages/host/index.module.scss | 14 ++ 10 files changed, 235 insertions(+), 44 deletions(-) create mode 100644 spug_web2/src/pages/host/Form.jsx create mode 100644 spug_web2/src/pages/host/Group.jsx create mode 100644 spug_web2/src/pages/host/index.module.scss diff --git a/spug_api/requirements.txt b/spug_api/requirements.txt index 9206711..333134f 100644 --- a/spug_api/requirements.txt +++ b/spug_api/requirements.txt @@ -1,8 +1,9 @@ apscheduler==3.10.4 Django >= 4.2.0, < 4.3.0 -paramiko==3.3.1 +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 diff --git a/spug_web2/package.json b/spug_web2/package.json index e773461..cec8806 100644 --- a/spug_web2/package.json +++ b/spug_web2/package.json @@ -13,6 +13,8 @@ "antd": "^5.12.4", "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", @@ -32,4 +34,4 @@ "sass": "^1.69.5", "vite": "^5.0.2" } -} +} \ No newline at end of file diff --git a/spug_web2/src/App.jsx b/spug_web2/src/App.jsx index 4321343..209590d 100644 --- a/spug_web2/src/App.jsx +++ b/spug_web2/src/App.jsx @@ -1,12 +1,12 @@ -import {createBrowserRouter, RouterProvider} from 'react-router-dom' -import {ConfigProvider, App as AntdApp, theme} from 'antd' -import {IconContext} from 'react-icons' +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 { app, SContext } from '@/libs' +import { useImmer } from 'use-immer' import './i18n.js' dayjs.locale(app.lang) @@ -14,10 +14,10 @@ dayjs.locale(app.lang) const router = createBrowserRouter(routes) function App() { - const [S, updateS] = useImmer({theme: app.theme}) + const [S, updateS] = useImmer({ theme: app.theme }) return ( - + - + - + diff --git a/spug_web2/src/components/STable/Setting.jsx b/spug_web2/src/components/STable/Setting.jsx index 9b53d99..3c5fa7e 100644 --- a/spug_web2/src/components/STable/Setting.jsx +++ b/spug_web2/src/components/STable/Setting.jsx @@ -1,33 +1,33 @@ -import {useState, useEffect} from 'react' -import {Button, Checkbox, Flex, Popover} from 'antd' -import {IoSettingsOutline} from 'react-icons/io5' -import {app, clsNames} from '@/libs' +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 { skey, columns, setCols } = props const [state, setState] = useState(app.getStable(skey)) useEffect(() => { const newColumns = [] for (const item of columns) { - if (state[item.key] ?? !item.hidden) { + if (state[item.title] ?? !item.hidden) { newColumns.push(item) } } setCols(newColumns) - }, [state]); + }, [columns, state]); function handleChange(e) { - const {value, checked} = e.target - const newState = {...state, [value]: checked} + const { value, checked } = e.target + const newState = { ...state, [value]: checked } setState(newState) app.updateStable(skey, newState) } function handleReset() { setState({}) - app.updateStable(skey, {}) + app.updateStable(skey, null) } return ( @@ -35,7 +35,7 @@ function Setting(props) { title={(
{t('展示字段')}
- +
)} trigger="click" placement="bottomRight" @@ -43,9 +43,9 @@ function Setting(props) { {columns.map((item, index) => ( {item.title} @@ -53,7 +53,7 @@ function Setting(props) { )}>
- +
) diff --git a/spug_web2/src/components/STable/index.jsx b/spug_web2/src/components/STable/index.jsx index a5fa2f1..0b9357e 100644 --- a/spug_web2/src/components/STable/index.jsx +++ b/spug_web2/src/components/STable/index.jsx @@ -1,18 +1,87 @@ -import {useRef, useState} from 'react' -import {Card, Table, Flex, Divider} from 'antd' -import {IoExpand, IoContract, IoReloadOutline} from 'react-icons/io5' -import {clsNames} from '@/libs' +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(); +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) { @@ -25,27 +94,47 @@ function Stable(props) { } } + 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} - - + {actions.length ? : null} + + {isFull ? ( - + ) : ( - + )}
-
+
) } -Stable.defaultProps = { +STable.defaultProps = { sKey: null, loading: false, actions: [], @@ -53,11 +142,11 @@ Stable.defaultProps = { pagination: { showSizeChanger: true, showLessItems: true, - showTotal: total => t('page', {total}), + showTotal: total => t('page', { total }), pageSizeOptions: ['10', '20', '50', '100'] }, onReload: () => { }, } -export default Stable \ No newline at end of file +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 index a7b411c..1acf433 100644 --- a/spug_web2/src/components/STable/index.module.scss +++ b/spug_web2/src/components/STable/index.module.scss @@ -4,12 +4,35 @@ } } +.search { + height: 28px; + 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/libs/app.js b/spug_web2/src/libs/app.js index ce191a6..802d103 100644 --- a/spug_web2/src/libs/app.js +++ b/spug_web2/src/libs/app.js @@ -1,4 +1,4 @@ -import {isSubArray, loadJSONStorage} from "@/libs/utils.js"; +import { isSubArray, loadJSONStorage } from "@/libs/utils.js"; class App { constructor() { @@ -17,7 +17,7 @@ class App { } hasPermission(code) { - const {isSuper, permissions} = this.session; + const { isSuper, permissions } = this.session; if (!code || isSuper) return true; for (let item of code.split('|')) { if (isSubArray(permissions, item.split('&'))) { @@ -38,6 +38,7 @@ class App { updateStable(key, data) { this.stable[key] = data; + if (data === null) delete this.stable[key]; localStorage.setItem('stable', JSON.stringify(this.stable)); } } 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..a0489e7 --- /dev/null +++ b/spug_web2/src/pages/host/Group.jsx @@ -0,0 +1,61 @@ +import {} from 'react' +import {Card, Tree} from 'antd' +import css from './index.module.scss' + + +function Group() { + const dataSource = [ + { + title: 'parent 1-0', + key: '0-0-0', + children: [ + { + title: 'leaf', + key: '0-0-0-0', + }, + { + title: 'leaf', + key: '0-0-0-1', + }, + { + title: 'leaf', + key: '0-0-0-2', + }, + ], + }, + { + title: 'parent 1-1', + key: '0-0-1', + children: [ + { + title: 'leaf', + key: '0-0-1-0', + }, + ], + }, + { + title: 'parent 1-2', + key: '0-0-2', + children: [ + { + title: 'leaf', + key: '0-0-2-0', + }, + { + title: 'leaf', + key: '0-0-2-1', + }, + ], + }, + ] + + return ( + + + + ) +} + +export default Group \ 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..56454c5 --- /dev/null +++ b/spug_web2/src/pages/host/index.module.scss @@ -0,0 +1,14 @@ +.group { + flex: 1; + min-width: 200px; + max-width: 300px; + margin-right: -1px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.table { + flex: 3; + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} \ No newline at end of file From 756599e2e283c960ef97747a7fff7b49ed614537 Mon Sep 17 00:00:00 2001 From: vapao Date: Tue, 27 Feb 2024 18:16:43 +0800 Subject: [PATCH 6/8] update --- spug_web2/package.json | 2 +- spug_web2/src/App.jsx | 3 + .../src/components/STable/index.module.scss | 1 - spug_web2/src/libs/http.js | 6 +- spug_web2/src/libs/utils.js | 11 ++ spug_web2/src/pages/host/Group.jsx | 164 +++++++++++++----- spug_web2/src/pages/host/index.module.scss | 20 +++ spug_web2/src/pages/login/index.jsx | 6 +- 8 files changed, 159 insertions(+), 54 deletions(-) diff --git a/spug_web2/package.json b/spug_web2/package.json index cec8806..fe441d5 100644 --- a/spug_web2/package.json +++ b/spug_web2/package.json @@ -10,7 +10,7 @@ "preview": "vite preview" }, "dependencies": { - "antd": "^5.12.4", + "antd": "^5.14.1", "dayjs": "^1.11.10", "i18next": "^23.7.7", "mobx": "^6.12.0", diff --git a/spug_web2/src/App.jsx b/spug_web2/src/App.jsx index 209590d..2754fe4 100644 --- a/spug_web2/src/App.jsx +++ b/spug_web2/src/App.jsx @@ -35,6 +35,9 @@ function App() { headerHeight: 48, footerPadding: 16 }, + Tree: { + titleHeight: 30 + } }, }}> diff --git a/spug_web2/src/components/STable/index.module.scss b/spug_web2/src/components/STable/index.module.scss index 1acf433..85f4ee6 100644 --- a/spug_web2/src/components/STable/index.module.scss +++ b/spug_web2/src/components/STable/index.module.scss @@ -5,7 +5,6 @@ } .search { - height: 28px; line-height: 28px; } diff --git a/spug_web2/src/libs/http.js b/spug_web2/src/libs/http.js index efc9665..3b31fb2 100644 --- a/spug_web2/src/libs/http.js +++ b/spug_web2/src/libs/http.js @@ -1,7 +1,7 @@ import useSWR from 'swr' -import {message} from 'antd' +import { message } from 'antd' import app from '@/libs/app.js' -import {redirect} from 'react-router-dom' +import { redirect } from 'react-router-dom' function fetcher(resource, init) { return fetch(resource, init) @@ -33,7 +33,7 @@ function SWRGet(url, params) { } function request(method, url, params) { - const init = {method, headers: {'X-Token': app.accessToken}} + 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) diff --git a/spug_web2/src/libs/utils.js b/spug_web2/src/libs/utils.js index 384e9d6..522b843 100644 --- a/spug_web2/src/libs/utils.js +++ b/spug_web2/src/libs/utils.js @@ -54,4 +54,15 @@ export function loadJSONStorage(key, defaultValue = null) { } } 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/pages/host/Group.jsx b/spug_web2/src/pages/host/Group.jsx index a0489e7..0796fd1 100644 --- a/spug_web2/src/pages/host/Group.jsx +++ b/spug_web2/src/pages/host/Group.jsx @@ -1,58 +1,130 @@ -import {} from 'react' -import {Card, Tree} from 'antd' +import { useRef, useState, useEffect } from 'react' +import { Card, Tree, Dropdown, Input } 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 function Group() { - const dataSource = [ - { - title: 'parent 1-0', - key: '0-0-0', - children: [ - { - title: 'leaf', - key: '0-0-0-0', - }, - { - title: 'leaf', - key: '0-0-0-1', - }, - { - title: 'leaf', - key: '0-0-0-2', - }, - ], - }, - { - title: 'parent 1-1', - key: '0-0-1', - children: [ - { - title: 'leaf', - key: '0-0-1-0', - }, - ], - }, - { - title: 'parent 1-2', - key: '0-0-2', - children: [ - { - title: 'leaf', - key: '0-0-2-0', - }, - { - title: 'leaf', - key: '0-0-2-1', - }, - ], - }, + const inputRef = useRef(null) + const [expandedKeys, setExpandedKeys] = useState([]) + const [treeData, updateTreeData] = useImmer([]) + + 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() { + http.get('/api/host/group/') + .then(res => { + updateTreeData(res.treeData) + }) + } + + function handleNodeClick(e, node) { + e.stopPropagation() + clickNode = node + } + + function handleMenuClick({ key, domEvent }) { + domEvent.stopPropagation() + console.log(key, clickNode.key) + 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': + console.log('删除此分组') + break + default: + break + } + if (['newRoot', 'newChild', 'rename'].includes(key)) { + setTimeout(() => { + inputRef.current.focus() + }, 300) + } + } + + function handleInputSubmit(e) { + console.log('提交: ', e.target.value) + } + + function titleRender(node) { + return ['newRoot', 'newChild', 'rename'].includes(node.action) ? ( + + ) : ( +
+ +
{node.title}
+
handleNodeClick(e, node)}> + +
+ +
+
+
+
+ ) + } + return ( setExpandedKeys(keys)} /> ) diff --git a/spug_web2/src/pages/host/index.module.scss b/spug_web2/src/pages/host/index.module.scss index 56454c5..5883978 100644 --- a/spug_web2/src/pages/host/index.module.scss +++ b/spug_web2/src/pages/host/index.module.scss @@ -5,6 +5,26 @@ margin-right: -1px; border-top-right-radius: 0; border-bottom-right-radius: 0; + + .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 { diff --git a/spug_web2/src/pages/login/index.jsx b/spug_web2/src/pages/login/index.jsx index 05ea8c9..ee59da8 100644 --- a/spug_web2/src/pages/login/index.jsx +++ b/spug_web2/src/pages/login/index.jsx @@ -8,8 +8,8 @@ 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, session} from '@/libs'; -import logo from '@/assets/logo-spug-txt.png'; +import {http, app} from '@/libs'; +import logo from '@/assets/spug-default.png'; export default function Login() { const navigate = useNavigate(); @@ -59,7 +59,7 @@ export default function Login() { function doLogin(data) { localStorage.setItem('login_type', loginType); - session.update(data) + app.updateSession(data) navigate('/home', {replace: true}) } From 1d22adf6f5fedfac37596f61d305f3db5d1ab752 Mon Sep 17 00:00:00 2001 From: vapao Date: Tue, 27 Feb 2024 23:00:34 +0800 Subject: [PATCH 7/8] update --- spug_web2/src/pages/host/Group.jsx | 57 ++++++++++++++++------ spug_web2/src/pages/host/index.module.scss | 8 +++ 2 files changed, 50 insertions(+), 15 deletions(-) diff --git a/spug_web2/src/pages/host/Group.jsx b/spug_web2/src/pages/host/Group.jsx index 0796fd1..ee5cb3c 100644 --- a/spug_web2/src/pages/host/Group.jsx +++ b/spug_web2/src/pages/host/Group.jsx @@ -1,5 +1,5 @@ import { useRef, useState, useEffect } from 'react' -import { Card, Tree, Dropdown, Input } from 'antd' +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' @@ -8,11 +8,13 @@ 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: }, @@ -32,10 +34,13 @@ function Group() { }, []) function fetchData() { + setLoading(true) http.get('/api/host/group/') .then(res => { + rawTreeData = res.treeData updateTreeData(res.treeData) }) + .finally(() => setLoading(false)) } function handleNodeClick(e, node) { @@ -45,7 +50,6 @@ function Group() { function handleMenuClick({ key, domEvent }) { domEvent.stopPropagation() - console.log(key, clickNode.key) switch (key) { case 'newRoot': updateTreeData(draft => { @@ -81,7 +85,9 @@ function Group() { console.log('删除主机') break case 'deleteGroup': - console.log('删除此分组') + setLoading(true) + http.delete('/api/host/group/', { id: clickNode.key }) + .then(() => fetchData(), () => setLoading(false)) break default: break @@ -93,13 +99,31 @@ function Group() { } } - function handleInputSubmit(e) { - console.log('提交: ', e.target.value) + 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="请输入" /> ) : (
@@ -117,15 +141,18 @@ function Group() { return ( - setExpandedKeys(keys)} - /> + + setExpandedKeys(keys)} + /> + ) } diff --git a/spug_web2/src/pages/host/index.module.scss b/spug_web2/src/pages/host/index.module.scss index 5883978..d984138 100644 --- a/spug_web2/src/pages/host/index.module.scss +++ b/spug_web2/src/pages/host/index.module.scss @@ -6,6 +6,14 @@ 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; From 98a10f0d78391810b7fbce7cca5f1d3ad8774512 Mon Sep 17 00:00:00 2001 From: vapao Date: Tue, 27 Feb 2024 23:51:56 +0800 Subject: [PATCH 8/8] update --- spug_web2/src/components/SModal/index.jsx | 11 +++++++++ .../src/components/SModal/index.module.scss | 23 +++++++++++++++++++ spug_web2/src/components/index.js | 7 ++++++ 3 files changed, 41 insertions(+) create mode 100644 spug_web2/src/components/SModal/index.jsx create mode 100644 spug_web2/src/components/SModal/index.module.scss create mode 100644 spug_web2/src/components/index.js 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/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