mirror of https://github.com/openspug/spug
commit
080a6956e4
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
from django.conf.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())
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
from django.conf import settings
|
||||
from 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')
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
from django.conf import settings
|
||||
from 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')
|
||||
|
|
@ -1,453 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
from django.conf import settings
|
||||
from django.template.defaultfilters import filesizeformat
|
||||
from django_redis import get_redis_connection
|
||||
from libs.utils import 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'**审核结果:** <font color="{color}">{text}</font>',
|
||||
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'**发布结果:** <font color="{color}">{text}</font>',
|
||||
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'审核结果: <font color="{color}">{text}</font>',
|
||||
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'发布结果: <font color="{color}">{text}</font>',
|
||||
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}')
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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'<DeployRequest name={self.name}>'
|
||||
|
||||
class Meta:
|
||||
db_table = 'deploy_requests'
|
||||
ordering = ('-id',)
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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/<int:r_id>/', RequestDetailView.as_view()),
|
||||
]
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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)
|
||||
|
|
@ -1,369 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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()
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
from django.conf.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),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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',)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# Released under the AGPL-3.0 License.
|
||||
from django.urls import path
|
||||
|
||||
from .views import *
|
||||
|
||||
urlpatterns = [
|
||||
path('', RepositoryView.as_view()),
|
||||
path('<int:r_id>/', get_detail),
|
||||
path('request/', get_requests),
|
||||
]
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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')
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -2,17 +2,17 @@
|
|||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'(?<!echo )Spug EOF 2108111926 (-?\d+)[\r\n]?')
|
||||
self.arguments = {
|
||||
'hostname': hostname,
|
||||
'port': port,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'pkey': RSAKey.from_private_key(StringIO(pkey)) if isinstance(pkey, str) else pkey,
|
||||
'timeout': connect_timeout,
|
||||
'hostname': host.hostname,
|
||||
'port': host.port,
|
||||
'username': credential.username,
|
||||
'allow_agent': False,
|
||||
'look_for_keys': False,
|
||||
'banner_timeout': 30
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_key():
|
||||
key_obj = StringIO()
|
||||
key = RSAKey.generate(2048)
|
||||
key.write_private_key(key_obj)
|
||||
return key_obj.getvalue(), 'ssh-rsa ' + key.get_base64()
|
||||
|
||||
def get_client(self):
|
||||
if self.client is not None:
|
||||
return self.client
|
||||
self.client = SSHClient()
|
||||
self.client.set_missing_host_key_policy(AutoAddPolicy)
|
||||
self.client.connect(**self.arguments)
|
||||
return self.client
|
||||
|
||||
def ping(self):
|
||||
return True
|
||||
|
||||
def add_public_key(self, public_key):
|
||||
command = f'mkdir -p -m 700 ~/.ssh && \
|
||||
echo {public_key!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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
apscheduler==3.10.1
|
||||
Django==4.2.2
|
||||
paramiko==3.2.0
|
||||
django-redis==5.2.0
|
||||
requests==2.31.0
|
||||
apscheduler==3.10.4
|
||||
Django >= 4.2.0, < 4.3.0
|
||||
paramiko==3.4.0
|
||||
channels >= 4.0.0, < 5.0.0
|
||||
channels-redis >= 4.1.0, < 5.0.0
|
||||
django_redis >= 5.4.0, < 6.0.0
|
||||
asgiref==3.7.2
|
||||
requests >= 2.31.0, < 3.0.0
|
||||
python-ldap==3.4.3
|
||||
GitPython==3.1.40
|
||||
openpyxl==3.1.2
|
||||
user_agents==2.2.0
|
||||
user_agents==2.2.0
|
||||
|
|
|
|||
|
|
@ -2,15 +2,15 @@
|
|||
# Copyright: (c) <spug.dev@gmail.com>
|
||||
# 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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
settings: { react: { version: '18.2' } },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'react/prop-types': 'off',
|
||||
},
|
||||
globals: {
|
||||
t: 'readonly',
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "spug",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"antd": "^5.14.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"i18next": "^23.7.7",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react-lite": "^4.0.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-i18next": "^13.5.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"swr": "^2.2.4",
|
||||
"use-immer": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"sass": "^1.69.5",
|
||||
"vite": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -0,0 +1,53 @@
|
|||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import { ConfigProvider, App as AntdApp, theme } from 'antd'
|
||||
import { IconContext } from 'react-icons'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import enUS from 'antd/locale/en_US'
|
||||
import dayjs from 'dayjs'
|
||||
import routes from './routes.jsx'
|
||||
import { app, SContext } from '@/libs'
|
||||
import { useImmer } from 'use-immer'
|
||||
import './i18n.js'
|
||||
|
||||
dayjs.locale(app.lang)
|
||||
|
||||
const router = createBrowserRouter(routes)
|
||||
|
||||
function App() {
|
||||
const [S, updateS] = useImmer({ theme: app.theme })
|
||||
|
||||
return (
|
||||
<SContext.Provider value={{ S, updateS }}>
|
||||
<ConfigProvider
|
||||
locale={app.lang === 'en' ? enUS : zhCN}
|
||||
theme={{
|
||||
cssVar: true,
|
||||
hashed: false,
|
||||
algorithm: S.theme === 'dark' ? theme.darkAlgorithm : null,
|
||||
token: {
|
||||
borderRadius: 4,
|
||||
padding: 12,
|
||||
paddingLG: 16,
|
||||
controlHeight: 30,
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerHeight: 48,
|
||||
footerPadding: 16
|
||||
},
|
||||
Tree: {
|
||||
titleHeight: 30
|
||||
}
|
||||
},
|
||||
}}>
|
||||
<IconContext.Provider value={{ className: 'anticon' }}>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</IconContext.Provider>
|
||||
</ConfigProvider>
|
||||
</SContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,11 @@
|
|||
import { Modal } from 'antd'
|
||||
import { clsNames } from '@/libs'
|
||||
import css from './index.module.scss'
|
||||
|
||||
function SModal(props) {
|
||||
return (
|
||||
<Modal {...props} className={clsNames(css.modal, props.className)} />
|
||||
)
|
||||
}
|
||||
|
||||
export default SModal
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Button, Checkbox, Flex, Popover } from 'antd'
|
||||
import { IoSettingsOutline } from 'react-icons/io5'
|
||||
import { app, clsNames } from '@/libs'
|
||||
|
||||
|
||||
function Setting(props) {
|
||||
const { skey, columns, setCols } = props
|
||||
const [state, setState] = useState(app.getStable(skey))
|
||||
|
||||
useEffect(() => {
|
||||
const newColumns = []
|
||||
for (const item of columns) {
|
||||
if (state[item.title] ?? !item.hidden) {
|
||||
newColumns.push(item)
|
||||
}
|
||||
}
|
||||
setCols(newColumns)
|
||||
}, [columns, state]);
|
||||
|
||||
function handleChange(e) {
|
||||
const { value, checked } = e.target
|
||||
const newState = { ...state, [value]: checked }
|
||||
setState(newState)
|
||||
app.updateStable(skey, newState)
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setState({})
|
||||
app.updateStable(skey, null)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
title={(
|
||||
<Flex justify="space-between" align="center">
|
||||
<div>{t('展示字段')}</div>
|
||||
<Button type="link" style={{ padding: 0 }} onClick={handleReset}>{t('重置')}</Button>
|
||||
</Flex>)}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={(
|
||||
<Flex vertical gap="small">
|
||||
{columns.map((item, index) => (
|
||||
<Checkbox
|
||||
value={item.title}
|
||||
key={index}
|
||||
checked={state[item.title] ?? !item.hidden}
|
||||
onChange={handleChange}>
|
||||
{item.title}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Flex>
|
||||
)}>
|
||||
<div className={clsNames('anticon', props.className)}>
|
||||
<IoSettingsOutline />
|
||||
</div>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default Setting
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { useRef, useState, useEffect } from 'react'
|
||||
import { Card, Table, Flex, Divider, Checkbox, Button, Input, Tag } from 'antd'
|
||||
import { IoExpand, IoContract, IoReloadOutline } from 'react-icons/io5'
|
||||
import { useImmer } from 'use-immer'
|
||||
import { clsNames, includes } from '@/libs'
|
||||
import Setting from './Setting.jsx'
|
||||
import css from './index.module.scss'
|
||||
|
||||
function STable(props) {
|
||||
const { skey, loading, columns, dataSource, actions, pagination } = props
|
||||
const ref = useRef()
|
||||
const sMap = useRef({})
|
||||
const [sColumns, setSColumns] = useState([])
|
||||
const [cols, setCols] = useState([])
|
||||
const [isFull, setIsFull] = useState(false)
|
||||
const [filters, updateFilters] = useImmer({})
|
||||
|
||||
if (!skey) throw new Error('skey is required')
|
||||
|
||||
useEffect(() => {
|
||||
const newColumns = []
|
||||
for (const item of columns) {
|
||||
const key = item.dataIndex
|
||||
if (item.filterKey) {
|
||||
let inputRef = null
|
||||
item.onFilter = (value, record) => includes(record[key], value)
|
||||
item.filterDropdown = (x) => {
|
||||
sMap.current[key] = x
|
||||
return (
|
||||
<div style={{ padding: 8, width: 200 }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton
|
||||
placeholder="请输入"
|
||||
value={x.selectedKeys[0] ?? ''}
|
||||
ref={ref => inputRef = ref}
|
||||
onChange={e => x.setSelectedKeys(e.target.value ? [e.target.value] : [])}
|
||||
onSearch={v => handleSearch(key, v)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<div className={css.filterItems}>
|
||||
<Checkbox.Group
|
||||
options={item.filterItems}
|
||||
value={x.selectedKeys}
|
||||
onChange={x.setSelectedKeys} />
|
||||
<Divider style={{ margin: '0' }} />
|
||||
<Flex justify="space-between" className={css.action}>
|
||||
<Button size="small" type="link" disabled={x.selectedKeys.length === 0} onClick={() => x.setSelectedKeys([])}>{t('重置')}</Button>
|
||||
<Button size="small" type="primary" onClick={() => handleSearch(key, x.selectedKeys)}>{t('确定')}</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
newColumns.push(item)
|
||||
}
|
||||
setSColumns(newColumns)
|
||||
}, [columns])
|
||||
|
||||
function handleSearch(key, v) {
|
||||
const x = sMap.current[key]
|
||||
updateFilters(draft => {
|
||||
if (Array.isArray(v)) {
|
||||
v.length ? draft[key] = v : delete draft[key]
|
||||
} else {
|
||||
v ? draft[key] = v : delete draft[key]
|
||||
}
|
||||
})
|
||||
if (!v) x.setSelectedKeys([])
|
||||
x.confirm()
|
||||
}
|
||||
|
||||
function handleFullscreen() {
|
||||
if (ref.current && document.fullscreenEnabled) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen()
|
||||
setIsFull(false)
|
||||
} else {
|
||||
ref.current.requestFullscreen()
|
||||
setIsFull(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function SearchItem(props) {
|
||||
const { cKey, value } = props
|
||||
const column = columns.find(item => item.dataIndex === cKey)
|
||||
|
||||
return (
|
||||
<Tag closable bordered={false} color="blue" onClose={() => handleSearch(cKey, '')} className={css.search}>
|
||||
{column.title}: {Array.isArray(value) ? value.join(' | ') : value}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card ref={ref} className={clsNames(css.stable, props.className)} style={props.style}>
|
||||
<Flex align="center" justify="space-between" className={css.toolbar}>
|
||||
{Object.keys(filters).length ? (
|
||||
<Flex>
|
||||
{Object.entries(filters).map(([key, value]) => (
|
||||
<SearchItem key={key} cKey={key} value={value} />
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<div className={css.title}>{props.title}</div>
|
||||
)}
|
||||
<Flex gap="middle" align="center">
|
||||
{actions}
|
||||
{actions.length ? <Divider type="vertical" /> : null}
|
||||
<IoReloadOutline className={css.icon} onClick={props.onReload} />
|
||||
<Setting className={css.icon} skey={skey} columns={sColumns} setCols={setCols} />
|
||||
{isFull ? (
|
||||
<IoContract className={css.icon} onClick={handleFullscreen} />
|
||||
) : (
|
||||
<IoExpand className={css.icon} onClick={handleFullscreen} />
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Table loading={loading} columns={cols} dataSource={dataSource} pagination={pagination} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
.stable {
|
||||
:global(.ant-pagination) {
|
||||
margin: 16px 0 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.filterItems {
|
||||
min-width: 150px;
|
||||
|
||||
:global(.ant-checkbox-group) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.action {
|
||||
padding: 8px 16px 8px 8px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import STable from './STable'
|
||||
import SModal from './SModal'
|
||||
|
||||
export {
|
||||
STable,
|
||||
SModal,
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import {useRouteError} from 'react-router-dom'
|
||||
|
||||
export default function ErrorPage() {
|
||||
const error = useRouteError()
|
||||
|
||||
return (
|
||||
<div className="error-page">
|
||||
<h1>Oops!</h1>
|
||||
<p>Sorry, an unexpected error has occurred.</p>
|
||||
<p>
|
||||
<i>{error.statusText || error.message}</i>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import i18n from 'i18next'
|
||||
import {initReactI18next} from 'react-i18next'
|
||||
import {app} from '@/libs'
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
lng: app.lang,
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
'首页': 'Home',
|
||||
'工作台': 'Work',
|
||||
'主机管理': 'Hosts',
|
||||
'批量执行': 'Batch',
|
||||
'执行任务': 'Task',
|
||||
'文件分发': 'Transfer',
|
||||
'重置': 'Reset',
|
||||
'展示字段': 'Columns Display',
|
||||
'年龄': 'Age',
|
||||
// buttons
|
||||
'新建': 'Add',
|
||||
'page': 'Total {{total}} items',
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
translation: {
|
||||
'page': '共 {{total}} 条',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
window.t = i18n.t
|
||||
|
||||
export default i18n
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import {useContext, useEffect} from 'react'
|
||||
import {Dropdown, Flex, Layout, theme as antdTheme} from 'antd'
|
||||
import {IoMoon, IoSunny, IoLanguage} from 'react-icons/io5'
|
||||
import {SContext} from '@/libs'
|
||||
import css from './index.module.scss'
|
||||
import i18n from '@/i18n.js'
|
||||
import logo from "@/assets/spug-default.png";
|
||||
|
||||
function Header() {
|
||||
const {S: {theme}, updateS} = useContext(SContext)
|
||||
const {token} = antdTheme.useToken()
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.backgroundColor = token.colorBgLayout
|
||||
}, [theme])
|
||||
|
||||
|
||||
function handleThemeChange() {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme', newTheme)
|
||||
updateS(draft => {
|
||||
draft.theme = newTheme
|
||||
})
|
||||
}
|
||||
|
||||
function handleLangChange({key}) {
|
||||
localStorage.setItem('lang', key)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const locales = [{
|
||||
label: '🇨🇳 简体中文',
|
||||
key: 'zh',
|
||||
}, {
|
||||
label: '🇺🇸 English',
|
||||
key: 'en',
|
||||
}]
|
||||
|
||||
return (
|
||||
<Layout.Header theme="light" className={css.header}>
|
||||
<img src={logo} alt="logo" className={css.logo}/>
|
||||
<Flex justify="flex-end" align="center" gap="small" style={{height: 48}}>
|
||||
<div className={css.item}>admin</div>
|
||||
<Dropdown menu={{items: locales, selectable: true, onClick: handleLangChange, selectedKeys: [i18n.language]}}>
|
||||
<div className={css.item}>
|
||||
<IoLanguage size={16}/>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<div className={css.item} onClick={handleThemeChange}>
|
||||
{theme === 'light' ? <IoMoon size={16}/> : <IoSunny size={18}/>}
|
||||
</div>
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import {useState} from 'react'
|
||||
import {Outlet, useMatches, useNavigate} from 'react-router-dom'
|
||||
import {Layout, Flex, Menu, theme} from 'antd'
|
||||
import Header from './Header.jsx'
|
||||
import {menus} from '@/routes'
|
||||
import css from './index.module.scss'
|
||||
|
||||
|
||||
function LayoutIndex() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const {token: {colorTextTertiary}} = theme.useToken()
|
||||
const navigate = useNavigate()
|
||||
const matches = useMatches()
|
||||
|
||||
function handleMenuClick({key}) {
|
||||
navigate(key)
|
||||
}
|
||||
|
||||
const selectedKey = matches[matches.length - 1]?.pathname
|
||||
return (
|
||||
<Layout style={{minHeight: '100vh'}}>
|
||||
<Header/>
|
||||
<Layout>
|
||||
<div style={{width: collapsed ? 80 : 200, transition: 'all 0.2s'}}/>
|
||||
<Layout.Sider theme="light" collapsible className={css.sider} collapsed={collapsed} onCollapse={setCollapsed}>
|
||||
<Menu mode="inline" items={menus} selectedKeys={[selectedKey]}
|
||||
onClick={handleMenuClick}/>
|
||||
</Layout.Sider>
|
||||
<Layout.Content style={{margin: '64px 16px 0 16px'}}>
|
||||
<div style={{minHeight: 'calc(100vh - 64px - 54px'}}>
|
||||
<Outlet/>
|
||||
</div>
|
||||
<Layout.Footer>
|
||||
<Flex justify="center" align="center" style={{color: colorTextTertiary}}>
|
||||
Copyright © 2023 OpenSpug All Rights Reserved.
|
||||
</Flex>
|
||||
</Layout.Footer>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutIndex
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 999;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--ant-color-border);
|
||||
background: var(--ant-color-bg-container);
|
||||
|
||||
.logo {
|
||||
margin-left: 16px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.item {
|
||||
height: 47px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: var(--ant-color-fill-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sider {
|
||||
position: fixed !important;
|
||||
top: 48px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
:global(.ant-menu) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:global(.ant-layout-sider-trigger) {
|
||||
border-inline-end: 1px solid var(--ant-color-split);
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { isSubArray, loadJSONStorage } from "@/libs/utils.js";
|
||||
|
||||
class App {
|
||||
constructor() {
|
||||
this.lang = localStorage.getItem('lang') || 'zh';
|
||||
this.theme = localStorage.getItem('theme') || 'light';
|
||||
this.stable = loadJSONStorage('stable', {});
|
||||
this.session = loadJSONStorage('session', {});
|
||||
}
|
||||
|
||||
get access_token() {
|
||||
return this.session['access_token'] || '';
|
||||
}
|
||||
|
||||
get nickname() {
|
||||
return this.session['nickname'];
|
||||
}
|
||||
|
||||
hasPermission(code) {
|
||||
const { isSuper, permissions } = this.session;
|
||||
if (!code || isSuper) return true;
|
||||
for (let item of code.split('|')) {
|
||||
if (isSubArray(permissions, item.split('&'))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
updateSession(data) {
|
||||
Object.assign(this.session, data);
|
||||
localStorage.setItem('session', JSON.stringify(this.session));
|
||||
}
|
||||
|
||||
getStable(key) {
|
||||
return this.stable[key] ?? {};
|
||||
}
|
||||
|
||||
updateStable(key, data) {
|
||||
this.stable[key] = data;
|
||||
if (data === null) delete this.stable[key];
|
||||
localStorage.setItem('stable', JSON.stringify(this.stable));
|
||||
}
|
||||
}
|
||||
|
||||
export default new App();
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import useSWR from 'swr'
|
||||
import { message } from 'antd'
|
||||
import app from '@/libs/app.js'
|
||||
import { redirect } from 'react-router-dom'
|
||||
|
||||
function fetcher(resource, init) {
|
||||
return fetch(resource, init)
|
||||
.then(res => {
|
||||
if (res.status === 200) {
|
||||
return res.json()
|
||||
} else if (res.status === 401) {
|
||||
redirect('/login')
|
||||
throw new Error('会话过期,请重新登录')
|
||||
} else {
|
||||
throw new Error(`请求失败: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
})
|
||||
.then(res => {
|
||||
if (res.error) {
|
||||
throw new Error(res.error)
|
||||
}
|
||||
return res.data
|
||||
})
|
||||
.catch(err => {
|
||||
message.error(err.message)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
function SWRGet(url, params) {
|
||||
if (params) url = `${url}?${new URLSearchParams(params).toString()}`
|
||||
return useSWR(url, () => fetcher(url))
|
||||
}
|
||||
|
||||
function request(method, url, params) {
|
||||
const init = { method, headers: { 'X-Token': app.access_token } }
|
||||
if (['GET', 'DELETE'].includes(method)) {
|
||||
if (params) url = `${url}?${new URLSearchParams(params).toString()}`
|
||||
return fetcher(url, init)
|
||||
}
|
||||
init.headers['Content-Type'] = 'application/json'
|
||||
init.body = JSON.stringify(params)
|
||||
return fetcher(url, init)
|
||||
}
|
||||
|
||||
export default {
|
||||
swrGet: SWRGet,
|
||||
get: (url, params) => request('GET', url, params),
|
||||
post: (url, body) => request('POST', url, body),
|
||||
put: (url, body) => request('PUT', url, body),
|
||||
patch: (url, body) => request('PATCH', url, body),
|
||||
delete: (url, params) => request('DELETE', url, params),
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react'
|
||||
import http from './http'
|
||||
import app from './app.js'
|
||||
|
||||
const SContext = React.createContext({})
|
||||
export * from './utils.js'
|
||||
export {
|
||||
app,
|
||||
http,
|
||||
SContext,
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* Released under the AGPL-3.0 License.
|
||||
*/
|
||||
|
||||
// 数组包含关系判断
|
||||
export function isSubArray(parent, child) {
|
||||
for (let item of child) {
|
||||
if (!parent.includes(item.trim())) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function clsNames(...args) {
|
||||
return args.filter(x => x).join(' ')
|
||||
}
|
||||
|
||||
function isInclude(s, keys) {
|
||||
if (!s) return false
|
||||
if (Array.isArray(keys)) {
|
||||
for (let k of keys) {
|
||||
k = k.toLowerCase()
|
||||
if (s.toLowerCase().includes(k)) return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
let k = keys.toLowerCase()
|
||||
return s.toLowerCase().includes(k)
|
||||
}
|
||||
}
|
||||
|
||||
// 字符串包含判断
|
||||
export function includes(s, keys) {
|
||||
if (Array.isArray(s)) {
|
||||
for (let i of s) {
|
||||
if (isInclude(i, keys)) return true
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
return isInclude(s, keys)
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJSONStorage(key, defaultValue = null) {
|
||||
const tmp = localStorage.getItem(key)
|
||||
if (tmp) {
|
||||
try {
|
||||
return JSON.parse(tmp)
|
||||
} catch (e) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// 递归查找树节点
|
||||
export function findNodeByKey(array, key) {
|
||||
for (let item of array) {
|
||||
if (item.key === key) return item
|
||||
if (item.children) {
|
||||
let tmp = findNodeByKey(item.children, key)
|
||||
if (tmp) return tmp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import {useEffect} from 'react'
|
||||
|
||||
|
||||
function Home() {
|
||||
useEffect(() => {
|
||||
console.log('now in home page')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t('你好')}</h1>
|
||||
<div className="i-ant-design:desktop-outlined"/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Home
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { useRef, useState, useEffect } from 'react'
|
||||
import { Card, Tree, Dropdown, Input, Spin } from 'antd'
|
||||
import { FaServer } from 'react-icons/fa6'
|
||||
import { IoMdMore } from 'react-icons/io'
|
||||
import { AiOutlineFolder, AiOutlineFolderAdd, AiOutlineEdit, AiOutlineFileAdd, AiOutlineScissor, AiOutlineClose, AiOutlineDelete } from 'react-icons/ai'
|
||||
import { useImmer } from 'use-immer'
|
||||
import { http, findNodeByKey } from '@/libs'
|
||||
import css from './index.module.scss'
|
||||
|
||||
let clickNode = null
|
||||
let rawTreeData = []
|
||||
|
||||
function Group() {
|
||||
const inputRef = useRef(null)
|
||||
const [expandedKeys, setExpandedKeys] = useState([])
|
||||
const [treeData, updateTreeData] = useImmer([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const menuItems = [
|
||||
{ label: '新建根分组', key: 'newRoot', icon: <AiOutlineFolder size={18} /> },
|
||||
{ label: '新建子分组', key: 'newChild', icon: <AiOutlineFolderAdd size={18} /> },
|
||||
{ label: '重命名', key: 'rename', icon: <AiOutlineEdit size={18} /> },
|
||||
{ type: 'divider' },
|
||||
{ label: '添加主机', key: 'addHost', icon: <AiOutlineFileAdd size={18} /> },
|
||||
{ label: '移动主机', key: 'moveHost', icon: <AiOutlineScissor size={18} /> },
|
||||
{ label: '删除主机', key: 'deleteHost', icon: <AiOutlineClose size={18} /> },
|
||||
{ type: 'divider' },
|
||||
{ label: '删除此分组', key: 'deleteGroup', danger: true, icon: <AiOutlineDelete size={18} /> },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
// eslint-disable-next-line
|
||||
}, [])
|
||||
|
||||
function fetchData() {
|
||||
setLoading(true)
|
||||
http.get('/api/host/group/')
|
||||
.then(res => {
|
||||
rawTreeData = res.treeData
|
||||
updateTreeData(res.treeData)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
function handleNodeClick(e, node) {
|
||||
e.stopPropagation()
|
||||
clickNode = node
|
||||
}
|
||||
|
||||
function handleMenuClick({ key, domEvent }) {
|
||||
domEvent.stopPropagation()
|
||||
switch (key) {
|
||||
case 'newRoot':
|
||||
updateTreeData(draft => {
|
||||
draft.unshift({ key, action: key, selectable: false })
|
||||
})
|
||||
break
|
||||
case 'newChild':
|
||||
updateTreeData(draft => {
|
||||
const node = findNodeByKey(draft, clickNode.key)
|
||||
if (!node) return
|
||||
if (!node.children) node.children = []
|
||||
node.children.unshift({ key, action: key, selectable: false })
|
||||
})
|
||||
if (![expandedKeys].includes(clickNode.key)) {
|
||||
setExpandedKeys([...expandedKeys, clickNode.key])
|
||||
}
|
||||
break
|
||||
case 'rename':
|
||||
updateTreeData(draft => {
|
||||
const node = findNodeByKey(draft, clickNode.key)
|
||||
if (!node) return
|
||||
node.action = key
|
||||
node.selectable = false
|
||||
})
|
||||
break
|
||||
case 'addHost':
|
||||
console.log('添加主机')
|
||||
break
|
||||
case 'moveHost':
|
||||
console.log('移动主机')
|
||||
break
|
||||
case 'deleteHost':
|
||||
console.log('删除主机')
|
||||
break
|
||||
case 'deleteGroup':
|
||||
setLoading(true)
|
||||
http.delete('/api/host/group/', { id: clickNode.key })
|
||||
.then(() => fetchData(), () => setLoading(false))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (['newRoot', 'newChild', 'rename'].includes(key)) {
|
||||
setTimeout(() => {
|
||||
inputRef.current.focus()
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputSubmit(e, node) {
|
||||
const value = e.target.value.trim()
|
||||
if (value) {
|
||||
let form = { name: value }
|
||||
if (node.action === 'newChild') {
|
||||
form.parent_id = clickNode.key
|
||||
} else if (node.action === 'rename') {
|
||||
form.id = node.key
|
||||
}
|
||||
setLoading(true)
|
||||
http.post('/api/host/group/', form)
|
||||
.then(() => fetchData(), () => setLoading(false))
|
||||
} else {
|
||||
updateTreeData(rawTreeData)
|
||||
}
|
||||
}
|
||||
|
||||
function titleRender(node) {
|
||||
return ['newRoot', 'newChild', 'rename'].includes(node.action) ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
defaultValue={node.title}
|
||||
onPressEnter={e => handleInputSubmit(e, node)}
|
||||
onBlur={e => handleInputSubmit(e, node)}
|
||||
placeholder="请输入" />
|
||||
) : (
|
||||
<div className={css.treeTitle}>
|
||||
<FaServer />
|
||||
<div className={css.title}>{node.title}</div>
|
||||
<div onClick={e => handleNodeClick(e, node)}>
|
||||
<Dropdown menu={{ items: menuItems, onClick: handleMenuClick }} trigger={['click']}>
|
||||
<div className={css.more}>
|
||||
<IoMdMore />
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="分组列表" className={css.group}>
|
||||
<Spin spinning={loading}>
|
||||
<Tree.DirectoryTree
|
||||
className={css.tree}
|
||||
defaultExpandParent
|
||||
showIcon={false}
|
||||
treeData={treeData}
|
||||
expandedKeys={expandedKeys}
|
||||
expandAction="doubleClick"
|
||||
titleRender={titleRender}
|
||||
onExpand={keys => setExpandedKeys(keys)}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default Group
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import {useEffect} from 'react'
|
||||
|
||||
function Host() {
|
||||
useEffect(() => {
|
||||
console.log('now in host page')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>host page</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Host
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
.group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
margin-right: -1px;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
|
||||
:global(.ant-card-head) {
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.tree {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.treeTitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.more {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
flex: 3;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="1361px" height="609px" viewBox="0 0 1361 609" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
|
||||
<title>Group 21</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<g id="Ant-Design-Pro-3.0" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="账户密码登录-校验" transform="translate(-79.000000, -82.000000)">
|
||||
<g id="Group-21" transform="translate(77.000000, 73.000000)">
|
||||
<g id="Group-18" opacity="0.8" transform="translate(74.901416, 569.699158) rotate(-7.000000) translate(-74.901416, -569.699158) translate(4.901416, 525.199158)">
|
||||
<ellipse id="Oval-11" fill="#CFDAE6" opacity="0.25" cx="63.5748792" cy="32.468367" rx="21.7830479" ry="21.766008"></ellipse>
|
||||
<ellipse id="Oval-3" fill="#CFDAE6" opacity="0.599999964" cx="5.98746479" cy="13.8668601" rx="5.2173913" ry="5.21330997"></ellipse>
|
||||
<path d="M38.1354514,88.3520215 C43.8984227,88.3520215 48.570234,83.6838647 48.570234,77.9254015 C48.570234,72.1669383 43.8984227,67.4987816 38.1354514,67.4987816 C32.3724801,67.4987816 27.7006688,72.1669383 27.7006688,77.9254015 C27.7006688,83.6838647 32.3724801,88.3520215 38.1354514,88.3520215 Z" id="Oval-3-Copy" fill="#CFDAE6" opacity="0.45"></path>
|
||||
<path d="M64.2775582,33.1704963 L119.185836,16.5654915" id="Path-12" stroke="#CFDAE6" stroke-width="1.73913043" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M42.1431708,26.5002681 L7.71190162,14.5640702" id="Path-16" stroke="#E0B4B7" stroke-width="0.702678964" opacity="0.7" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
|
||||
<path d="M63.9262187,33.521561 L43.6721326,69.3250951" id="Path-15" stroke="#BACAD9" stroke-width="0.702678964" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="1.405357899873153,2.108036953469981"></path>
|
||||
<g id="Group-17" transform="translate(126.850922, 13.543654) rotate(30.000000) translate(-126.850922, -13.543654) translate(117.285705, 4.381889)" fill="#CFDAE6">
|
||||
<ellipse id="Oval-4" opacity="0.45" cx="9.13482653" cy="9.12768076" rx="9.13482653" ry="9.12768076"></ellipse>
|
||||
<path d="M18.2696531,18.2553615 C18.2696531,13.2142826 14.1798519,9.12768076 9.13482653,9.12768076 C4.08980114,9.12768076 0,13.2142826 0,18.2553615 L18.2696531,18.2553615 Z" id="Oval-4" transform="translate(9.134827, 13.691521) scale(-1, -1) translate(-9.134827, -13.691521) "></path>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Group-14" transform="translate(216.294700, 123.725600) rotate(-5.000000) translate(-216.294700, -123.725600) translate(106.294700, 35.225600)">
|
||||
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.25" cx="29.1176471" cy="29.1402439" rx="29.1176471" ry="29.1402439"></ellipse>
|
||||
<ellipse id="Oval-2" fill="#CFDAE6" opacity="0.3" cx="29.1176471" cy="29.1402439" rx="21.5686275" ry="21.5853659"></ellipse>
|
||||
<ellipse id="Oval-2-Copy" stroke="#CFDAE6" opacity="0.4" cx="179.019608" cy="138.146341" rx="23.7254902" ry="23.7439024"></ellipse>
|
||||
<ellipse id="Oval-2" fill="#BACAD9" opacity="0.5" cx="29.1176471" cy="29.1402439" rx="10.7843137" ry="10.7926829"></ellipse>
|
||||
<path d="M29.1176471,39.9329268 L29.1176471,18.347561 C23.1616351,18.347561 18.3333333,23.1796097 18.3333333,29.1402439 C18.3333333,35.1008781 23.1616351,39.9329268 29.1176471,39.9329268 Z" id="Oval-2" fill="#BACAD9"></path>
|
||||
<g id="Group-9" opacity="0.45" transform="translate(172.000000, 131.000000)" fill="#E6A1A6">
|
||||
<ellipse id="Oval-2-Copy-2" cx="7.01960784" cy="7.14634146" rx="6.47058824" ry="6.47560976"></ellipse>
|
||||
<path d="M0.549019608,13.6219512 C4.12262681,13.6219512 7.01960784,10.722722 7.01960784,7.14634146 C7.01960784,3.56996095 4.12262681,0.670731707 0.549019608,0.670731707 L0.549019608,13.6219512 Z" id="Oval-2-Copy-2" transform="translate(3.784314, 7.146341) scale(-1, 1) translate(-3.784314, -7.146341) "></path>
|
||||
</g>
|
||||
<ellipse id="Oval-10" fill="#CFDAE6" cx="218.382353" cy="138.685976" rx="1.61764706" ry="1.61890244"></ellipse>
|
||||
<ellipse id="Oval-10-Copy-2" fill="#E0B4B7" opacity="0.35" cx="179.558824" cy="175.381098" rx="1.61764706" ry="1.61890244"></ellipse>
|
||||
<ellipse id="Oval-10-Copy" fill="#E0B4B7" opacity="0.35" cx="180.098039" cy="102.530488" rx="2.15686275" ry="2.15853659"></ellipse>
|
||||
<path d="M28.9985381,29.9671598 L171.151018,132.876024" id="Path-11" stroke="#CFDAE6" opacity="0.8"></path>
|
||||
</g>
|
||||
<g id="Group-10" opacity="0.799999952" transform="translate(1054.100635, 36.659317) rotate(-11.000000) translate(-1054.100635, -36.659317) translate(1026.600635, 4.659317)">
|
||||
<ellipse id="Oval-7" stroke="#CFDAE6" stroke-width="0.941176471" cx="43.8135593" cy="32" rx="11.1864407" ry="11.2941176"></ellipse>
|
||||
<g id="Group-12" transform="translate(34.596774, 23.111111)" fill="#BACAD9">
|
||||
<ellipse id="Oval-7" opacity="0.45" cx="9.18534718" cy="8.88888889" rx="8.47457627" ry="8.55614973"></ellipse>
|
||||
<path d="M9.18534718,17.4450386 C13.8657264,17.4450386 17.6599235,13.6143199 17.6599235,8.88888889 C17.6599235,4.16345787 13.8657264,0.332739156 9.18534718,0.332739156 L9.18534718,17.4450386 Z" id="Oval-7"></path>
|
||||
</g>
|
||||
<path d="M34.6597385,24.809694 L5.71666084,4.76878945" id="Path-2" stroke="#CFDAE6" stroke-width="0.941176471"></path>
|
||||
<ellipse id="Oval" stroke="#CFDAE6" stroke-width="0.941176471" cx="3.26271186" cy="3.29411765" rx="3.26271186" ry="3.29411765"></ellipse>
|
||||
<ellipse id="Oval-Copy" fill="#F7E1AD" cx="2.79661017" cy="61.1764706" rx="2.79661017" ry="2.82352941"></ellipse>
|
||||
<path d="M34.6312443,39.2922712 L5.06366663,59.785082" id="Path-10" stroke="#CFDAE6" stroke-width="0.941176471"></path>
|
||||
</g>
|
||||
<g id="Group-19" opacity="0.33" transform="translate(1282.537219, 446.502867) rotate(-10.000000) translate(-1282.537219, -446.502867) translate(1142.537219, 327.502867)">
|
||||
<g id="Group-17" transform="translate(141.333539, 104.502742) rotate(275.000000) translate(-141.333539, -104.502742) translate(129.333539, 92.502742)" fill="#BACAD9">
|
||||
<circle id="Oval-4" opacity="0.45" cx="11.6666667" cy="11.6666667" r="11.6666667"></circle>
|
||||
<path d="M23.3333333,23.3333333 C23.3333333,16.8900113 18.1099887,11.6666667 11.6666667,11.6666667 C5.22334459,11.6666667 0,16.8900113 0,23.3333333 L23.3333333,23.3333333 Z" id="Oval-4" transform="translate(11.666667, 17.500000) scale(-1, -1) translate(-11.666667, -17.500000) "></path>
|
||||
</g>
|
||||
<circle id="Oval-5-Copy-6" fill="#CFDAE6" cx="201.833333" cy="87.5" r="5.83333333"></circle>
|
||||
<path d="M143.5,88.8126685 L155.070501,17.6038544" id="Path-17" stroke="#BACAD9" stroke-width="1.16666667"></path>
|
||||
<path d="M17.5,37.3333333 L127.466252,97.6449735" id="Path-18" stroke="#BACAD9" stroke-width="1.16666667"></path>
|
||||
<polyline id="Path-19" stroke="#CFDAE6" stroke-width="1.16666667" points="143.902597 120.302281 174.935455 231.571342 38.5 147.510847 126.366941 110.833333"></polyline>
|
||||
<path d="M159.833333,99.7453842 L195.416667,89.25" id="Path-20" stroke="#E0B4B7" stroke-width="1.16666667" opacity="0.6"></path>
|
||||
<path d="M205.333333,82.1372105 L238.719406,36.1666667" id="Path-24" stroke="#BACAD9" stroke-width="1.16666667"></path>
|
||||
<path d="M266.723424,132.231988 L207.083333,90.4166667" id="Path-25" stroke="#CFDAE6" stroke-width="1.16666667"></path>
|
||||
<circle id="Oval-5" fill="#C1D1E0" cx="156.916667" cy="8.75" r="8.75"></circle>
|
||||
<circle id="Oval-5-Copy-3" fill="#C1D1E0" cx="39.0833333" cy="148.75" r="5.25"></circle>
|
||||
<circle id="Oval-5-Copy-2" fill-opacity="0.6" fill="#D1DEED" cx="8.75" cy="33.25" r="8.75"></circle>
|
||||
<circle id="Oval-5-Copy-4" fill-opacity="0.6" fill="#D1DEED" cx="243.833333" cy="30.3333333" r="5.83333333"></circle>
|
||||
<circle id="Oval-5-Copy-5" fill="#E0B4B7" cx="175.583333" cy="232.75" r="5.25"></circle>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
|
||||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* Released under the AGPL-3.0 License.
|
||||
*/
|
||||
import {useState, useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom'
|
||||
import {Form, Input, Button, Tabs, Modal, message} from 'antd';
|
||||
import {AiOutlineUser, AiOutlineLock, AiOutlineCopyright, AiOutlineGithub, AiOutlineMail} from 'react-icons/ai'
|
||||
import styles from './login.module.css';
|
||||
import {http, app} from '@/libs';
|
||||
import logo from '@/assets/spug-default.png';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [counter, setCounter] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loginType, setLoginType] = useState(localStorage.getItem('login_type') || 'default');
|
||||
const [codeVisible, setCodeVisible] = useState(false);
|
||||
const [codeLoading, setCodeLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (counter > 0) {
|
||||
setCounter(counter - 1)
|
||||
}
|
||||
}, 1000)
|
||||
}, [counter])
|
||||
|
||||
function handleSubmit() {
|
||||
const formData = form.getFieldsValue();
|
||||
if (codeVisible && !formData.captcha) return message.error('请输入验证码');
|
||||
setLoading(true);
|
||||
formData['type'] = loginType;
|
||||
http.post('/api/account/login/', formData)
|
||||
.then(data => {
|
||||
if (data['required_mfa']) {
|
||||
setCodeVisible(true);
|
||||
setCounter(30);
|
||||
setLoading(false)
|
||||
} else if (!data['has_real_ip']) {
|
||||
Modal.warning({
|
||||
title: '安全警告',
|
||||
className: styles.tips,
|
||||
content: <div>
|
||||
未能获取到访问者的真实IP,无法提供基于请求来源IP的合法性验证,详细信息请参考
|
||||
<a target="_blank"
|
||||
href="https://spug.cc/docs/practice/"
|
||||
rel="noopener noreferrer">官方文档</a>。
|
||||
</div>,
|
||||
onOk: () => doLogin(data)
|
||||
})
|
||||
} else {
|
||||
doLogin(data)
|
||||
}
|
||||
}, () => setLoading(false))
|
||||
}
|
||||
|
||||
function doLogin(data) {
|
||||
localStorage.setItem('login_type', loginType);
|
||||
app.updateSession(data)
|
||||
navigate('/home', {replace: true})
|
||||
}
|
||||
|
||||
function handleCaptcha() {
|
||||
setCodeLoading(true);
|
||||
const formData = form.getFieldsValue(['username', 'password']);
|
||||
formData['type'] = loginType;
|
||||
http.post('/api/account/login/', formData)
|
||||
.then(() => setCounter(30))
|
||||
.finally(() => setCodeLoading(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div><img className={styles.logo} src={logo} alt="logo"/></div>
|
||||
<div className={styles.desc}>灵活、强大、易用的开源运维平台</div>
|
||||
</div>
|
||||
<div className={styles.formContainer}>
|
||||
<Tabs activeKey={loginType} className={styles.tabs} onTabClick={v => setLoginType(v)}>
|
||||
<Tabs.TabPane tab="普通登录" key="default"/>
|
||||
<Tabs.TabPane tab="LDAP登录" key="ldap"/>
|
||||
</Tabs>
|
||||
<Form form={form}>
|
||||
<Form.Item name="username" className={styles.formItem}>
|
||||
<Input
|
||||
size="large"
|
||||
autoComplete="off"
|
||||
placeholder="请输入账户"
|
||||
prefix={<AiOutlineUser className={styles.icon}/>}/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" className={styles.formItem}>
|
||||
<Input.Password
|
||||
size="large"
|
||||
autoComplete="off"
|
||||
placeholder="请输入密码"
|
||||
onPressEnter={handleSubmit}
|
||||
prefix={<AiOutlineLock className={styles.icon}/>}/>
|
||||
</Form.Item>
|
||||
<Form.Item hidden={!codeVisible} name="captcha" className={styles.formItem}>
|
||||
<div style={{display: 'flex'}}>
|
||||
<Form.Item noStyle name="captcha">
|
||||
<Input
|
||||
size="large"
|
||||
autoComplete="off"
|
||||
placeholder="请输入验证码"
|
||||
prefix={<AiOutlineMail className={styles.icon}/>}/>
|
||||
</Form.Item>
|
||||
{counter > 0 ? (
|
||||
<Button disabled size="large" style={{marginLeft: 8}}>{counter} 秒后重新获取</Button>
|
||||
) : (
|
||||
<Button size="large" loading={codeLoading} style={{marginLeft: 8}}
|
||||
onClick={handleCaptcha}>获取验证码</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
type="primary"
|
||||
className={styles.button}
|
||||
loading={loading}
|
||||
onClick={handleSubmit}>登录</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.footerZone}>
|
||||
<div className={styles.linksZone}>
|
||||
<a className={styles.links} title="官网" href="https://spug.cc" target="_blank"
|
||||
rel="noopener noreferrer">官网</a>
|
||||
<a className={styles.links} title="Github" href="https://github.com/openspug/spug" target="_blank"
|
||||
rel="noopener noreferrer"><AiOutlineGithub/></a>
|
||||
<a title="文档" href="https://spug.cc/docs/about-spug/" target="_blank"
|
||||
rel="noopener noreferrer">文档</a>
|
||||
</div>
|
||||
<div style={{color: 'rgba(0, 0, 0, .45)'}}>Copyright <AiOutlineCopyright/> {new Date().getFullYear()} By OpenSpug
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import {FaDesktop, FaServer, FaSitemap} from 'react-icons/fa6'
|
||||
import Layout from './layout/index.jsx'
|
||||
import ErrorPage from './error-page.jsx'
|
||||
import LoginIndex from './pages/login/index.jsx'
|
||||
import HomeIndex from './pages/home/index.jsx'
|
||||
import HostIndex from './pages/host/index.jsx'
|
||||
import './index.css'
|
||||
|
||||
let routes = [
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout/>,
|
||||
errorElement: <ErrorPage/>,
|
||||
children: [
|
||||
{
|
||||
path: 'home',
|
||||
element: <HomeIndex/>,
|
||||
title: t('工作台'),
|
||||
icon: <FaDesktop/>,
|
||||
},
|
||||
{
|
||||
path: 'host',
|
||||
element: <HostIndex/>,
|
||||
title: t('主机管理'),
|
||||
icon: <FaServer/>
|
||||
},
|
||||
{
|
||||
path: 'exec',
|
||||
title: t('批量执行'),
|
||||
icon: <FaSitemap/>,
|
||||
children: [
|
||||
{
|
||||
path: 'task',
|
||||
title: t('执行任务'),
|
||||
},
|
||||
{
|
||||
path: 'transfer',
|
||||
title: t('文件分发'),
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginIndex/>,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
function routes2menu(routes, parentPath = '') {
|
||||
const menu = []
|
||||
for (const route of routes) {
|
||||
if (!route.title) continue
|
||||
const path = `${parentPath}/${route.path}`
|
||||
if (route.children) {
|
||||
menu.push({
|
||||
key: path,
|
||||
label: route.title,
|
||||
icon: route.icon,
|
||||
children: routes2menu(route.children, path)
|
||||
})
|
||||
} else {
|
||||
menu.push({
|
||||
key: path,
|
||||
label: route.title,
|
||||
icon: route.icon,
|
||||
})
|
||||
}
|
||||
}
|
||||
return menu
|
||||
}
|
||||
|
||||
export const menus = routes2menu(routes[0].children)
|
||||
export default routes
|
||||
|
|
@ -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/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in New Issue