Merge pull request #661 from vapao/4.0

4.0 update
4.0
vapao 2024-02-27 23:53:46 +08:00 committed by GitHub
commit 080a6956e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 1622 additions and 1880 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

24
spug_web2/.eslintrc.cjs Normal file
View File

@ -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',
}
}

24
spug_web2/.gitignore vendored Normal file
View File

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

8
spug_web2/README.md Normal file
View File

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

13
spug_web2/index.html Normal file
View File

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

37
spug_web2/package.json Normal file
View File

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

View File

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

53
spug_web2/src/App.jsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import STable from './STable'
import SModal from './SModal'
export {
STable,
SModal,
}

View File

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

34
spug_web2/src/i18n.js Normal file
View File

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

20
spug_web2/src/index.css Normal file
View File

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

View File

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

View File

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

View File

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

46
spug_web2/src/libs/app.js Normal file
View File

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

View File

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

View File

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

View File

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

9
spug_web2/src/main.jsx Normal file
View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

75
spug_web2/src/routes.jsx Normal file
View File

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

24
spug_web2/vite.config.js Normal file
View File

@ -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/, '')
}
}
}
})