improve app deploy

pull/289/head
vapao 2021-03-17 16:01:15 +08:00
parent 402250f7b1
commit b81b1f66ac
18 changed files with 469 additions and 248 deletions

View File

@ -5,6 +5,7 @@ from django.db import models
from libs import ModelMixin, human_datetime
from apps.account.models import User
from apps.app.models import Deploy
from apps.repository.models import Repository
class DeployRequest(models.Model, ModelMixin):
@ -21,6 +22,7 @@ class DeployRequest(models.Model, ModelMixin):
('2', '回滚')
)
deploy = models.ForeignKey(Deploy, on_delete=models.CASCADE)
repository = models.ForeignKey(Repository, null=True, on_delete=models.SET_NULL)
name = models.CharField(max_length=50)
type = models.CharField(max_length=2, choices=TYPES, default='1')
extra = models.TextField()
@ -29,6 +31,7 @@ class DeployRequest(models.Model, ModelMixin):
status = models.CharField(max_length=2, choices=STATUS)
reason = models.CharField(max_length=255, null=True)
version = models.CharField(max_length=50, null=True)
spug_version = models.CharField(max_length=50, null=True)
created_at = models.CharField(max_length=20, default=human_datetime)
created_by = models.ForeignKey(User, models.PROTECT, related_name='+')

View File

@ -7,6 +7,7 @@ from .views import *
urlpatterns = [
path('request/', RequestView.as_view()),
path('request/1/', post_request_1),
path('request/upload/', do_upload),
path('request/<int:r_id>/', RequestDetailView.as_view()),
]

View File

@ -22,13 +22,14 @@ class SpugError(Exception):
pass
def deploy_dispatch(request, req, token):
def dispatch(req):
rds = get_redis_connection()
rds_key = f'{settings.REQUEST_KEY}:{req.id}'
try:
api_token = uuid.uuid4().hex
rds.setex(api_token, 60 * 60, f'{req.deploy.app_id},{req.deploy.env_id}')
helper = Helper(rds, token, req.id)
helper.send_step('local', 1, f'完成\r\n{human_time()} 发布准备... ')
helper = Helper(rds, rds_key)
# helper.send_step('local', 1, f'完成\r\n{human_time()} 发布准备... ')
env = AttrDict(
SPUG_APP_NAME=req.deploy.app.name,
SPUG_APP_ID=str(req.deploy.app_id),
@ -43,7 +44,6 @@ def deploy_dispatch(request, req, token):
SPUG_REPOS_DIR=REPOS_DIR,
)
if req.deploy.extend == '1':
env.update(json.loads(req.deploy.extend_obj.custom_envs))
_ext1_deploy(req, helper, env)
else:
_ext2_deploy(req, helper, env)
@ -52,7 +52,7 @@ def deploy_dispatch(request, req, token):
req.status = '-3'
raise e
finally:
rds.expire(token, 5 * 60)
rds.expire(rds_key, 14 * 24 * 60 * 60)
rds.close()
req.save()
Helper.send_deploy_notify(req)
@ -60,55 +60,12 @@ def deploy_dispatch(request, req, token):
def _ext1_deploy(req, helper, env):
extend = req.deploy.extend_obj
extras = json.loads(req.extra)
env.update(SPUG_DST_DIR=extend.dst_dir)
if extras[0] == 'branch':
tree_ish = extras[2]
env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2])
else:
tree_ish = extras[1]
env.update(SPUG_GIT_TAG=extras[1])
if req.type == '2':
helper.send_step('local', 6, f'完成\r\n{human_time()} 回滚发布... 跳过')
else:
helper.local(f'cd {REPOS_DIR} && rm -rf {req.deploy_id}_*')
helper.send_step('local', 1, '完成\r\n')
if extend.hook_pre_server:
helper.send_step('local', 2, f'{human_time()} 检出前任务...\r\n')
helper.local(f'cd /tmp && {extend.hook_pre_server}', env)
helper.send_step('local', 3, f'{human_time()} 执行检出... ')
git_dir = os.path.join(REPOS_DIR, str(req.deploy.id))
command = f'cd {git_dir} && git archive --prefix={env.SPUG_VERSION}/ {tree_ish} | (cd .. && tar xf -)'
helper.local(command)
helper.send_step('local', 3, '完成\r\n')
if extend.hook_post_server:
helper.send_step('local', 4, f'{human_time()} 检出后任务...\r\n')
helper.local(f'cd {os.path.join(REPOS_DIR, env.SPUG_VERSION)} && {extend.hook_post_server}', env)
helper.send_step('local', 5, f'\r\n{human_time()} 执行打包... ')
filter_rule, exclude, contain = json.loads(extend.filter_rule), '', env.SPUG_VERSION
files = helper.parse_filter_rule(filter_rule['data'])
if files:
if filter_rule['type'] == 'exclude':
excludes = []
for x in files:
if x.startswith('/'):
excludes.append(f'--exclude={env.SPUG_VERSION}{x}')
else:
excludes.append(f'--exclude={x}')
exclude = ' '.join(excludes)
else:
contain = ' '.join(f'{env.SPUG_VERSION}/{x}' for x in files)
helper.local(f'cd {REPOS_DIR} && tar zcf {env.SPUG_VERSION}.tar.gz {exclude} {contain}')
helper.send_step('local', 6, f'完成')
threads, latest_exception = [], None
with futures.ThreadPoolExecutor(max_workers=min(10, os.cpu_count() + 5)) as executor:
for h_id in json.loads(req.host_ids):
env = AttrDict(env.items())
t = executor.submit(_deploy_ext1_host, helper, h_id, extend, env)
t = executor.submit(_deploy_ext1_host, req, helper, h_id, env)
t.h_id = h_id
threads.append(t)
for t in futures.as_completed(threads):
@ -188,34 +145,34 @@ def _ext2_deploy(req, helper, env):
helper.send_step('local', 100, f'\r\n{human_time()} ** 发布成功 **')
def _deploy_ext1_host(helper, h_id, extend, env):
helper.send_step(h_id, 1, f'{human_time()} 数据准备... ')
def _deploy_ext1_host(req, helper, h_id, env):
extend = req.deploy.extend_obj
helper.send_step(h_id, 1, f'就绪\r\n{human_time()} 数据准备... ')
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})
ssh = host.get_ssh()
if env.SPUG_DEPLOY_TYPE != '2':
code, _ = ssh.exec_command(
f'mkdir -p {extend.dst_repo} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]')
if code == 0:
helper.send_error(host.id, f'检测到该主机的发布目录 {extend.dst_dir!r} 已存在为了数据安全请自行备份后删除该目录Spug 将会创建并接管该目录。')
# 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(host.id, ssh, f'cd {extend.dst_repo} && rm -rf {env.SPUG_VERSION} && {clean_command}')
# transfer files
tar_gz_file = f'{env.SPUG_VERSION}.tar.gz'
try:
ssh.put_file(os.path.join(REPOS_DIR, tar_gz_file), os.path.join(extend.dst_repo, tar_gz_file))
except Exception as e:
helper.send_error(host.id, f'exception: {e}')
code, _ = ssh.exec_command(
f'mkdir -p {extend.dst_repo} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]')
if code == 0:
helper.send_error(host.id, f'检测到该主机的发布目录 {extend.dst_dir!r} 已存在为了数据安全请自行备份后删除该目录Spug 将会创建并接管该目录。')
# 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(host.id, ssh, f'cd {extend.dst_repo} && rm -rf {req.spug_version} && {clean_command}')
# transfer files
tar_gz_file = f'{req.spug_version}.tar.gz'
try:
ssh.put_file(os.path.join(REPOS_DIR, 'build', tar_gz_file), os.path.join(extend.dst_repo, tar_gz_file))
except Exception as e:
helper.send_error(host.id, f'exception: {e}')
command = f'cd {extend.dst_repo} && tar xf {tar_gz_file} && rm -f {env.SPUG_APP_ID}_*.tar.gz'
helper.remote(host.id, ssh, command)
command = f'cd {extend.dst_repo} && tar xf {tar_gz_file} && rm -f {req.deploy_id}_*.tar.gz'
helper.remote(host.id, ssh, command)
helper.send_step(h_id, 1, '完成\r\n')
# pre host
repo_dir = os.path.join(extend.dst_repo, env.SPUG_VERSION)
repo_dir = os.path.join(extend.dst_repo, req.spug_version)
if extend.hook_pre_host:
helper.send_step(h_id, 2, f'{human_time()} 发布前任务... \r\n')
command = f'cd {repo_dir} ; {extend.hook_pre_host}'
@ -271,11 +228,10 @@ def _deploy_ext2_host(helper, h_id, actions, env):
class Helper:
def __init__(self, rds, token, r_id):
def __init__(self, rds, key):
self.rds = rds
self.token = token
self.log_key = f'{settings.REQUEST_KEY}:{r_id}'
self.rds.delete(self.log_key)
self.key = key
self.rds.delete(self.key)
@classmethod
def _make_dd_notify(cls, action, req, version, host_str):
@ -425,11 +381,11 @@ class Helper:
return files
def _send(self, message):
self.rds.lpush(self.token, json.dumps(message))
self.rds.lpush(self.log_key, json.dumps(message))
self.rds.rpush(self.key, json.dumps(message))
def send_info(self, key, message):
self._send({'key': key, 'status': 'info', 'data': message})
if message:
self._send({'key': key, 'data': message})
def send_error(self, key, message, with_break=True):
message = '\r\n' + message

View File

@ -9,14 +9,14 @@ from django_redis import get_redis_connection
from libs import json_response, JsonParser, Argument, human_datetime, human_time
from apps.deploy.models import DeployRequest
from apps.app.models import Deploy, DeployExtend2
from apps.deploy.utils import deploy_dispatch, Helper
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 uuid
import os
@ -34,6 +34,7 @@ class RequestView(View):
app_name=F('deploy__app__name'),
app_host_ids=F('deploy__host_ids'),
app_extend=F('deploy__extend'),
rep_extra=F('repository__extra'),
created_by_user=F('created_by__nickname')):
tmp = item.to_dict()
tmp['env_id'] = item.env_id
@ -41,8 +42,8 @@ class RequestView(View):
tmp['app_id'] = item.app_id
tmp['app_name'] = item.app_name
tmp['app_extend'] = item.app_extend
tmp['extra'] = json.loads(item.extra)
tmp['host_ids'] = json.loads(item.host_ids)
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
@ -62,10 +63,6 @@ class RequestView(View):
deploy = Deploy.objects.filter(pk=form.deploy_id).first()
if not deploy:
return json_response(error='未找到该发布配置')
if form.extra[0] == 'tag' and not form.extra[1]:
return json_response(error='请选择要发布的Tag')
if form.extra[0] == 'branch' and not form.extra[2]:
return json_response(error='请选择要发布的分支及Commit ID')
if deploy.extend == '2':
if form.extra[0]:
form.extra[0] = form.extra[0].replace("'", '')
@ -165,28 +162,29 @@ class RequestDetailView(View):
if not req:
return json_response(error='未找到指定发布申请')
hosts = Host.objects.filter(id__in=json.loads(req.host_ids))
targets = [{'id': x.id, 'title': f'{x.name}({x.hostname}:{x.port})'} for x in hosts]
server_actions, host_actions, outputs = [], [], []
server_actions, host_actions = [], []
outputs = {x.id: {'id': x.id, 'title': x.name, 'data': []} for x in hosts}
if req.deploy.extend == '2':
server_actions = json.loads(req.deploy.extend_obj.server_actions)
host_actions = json.loads(req.deploy.extend_obj.host_actions)
if request.GET.get('log'):
rds, key, counter = get_redis_connection(), f'{settings.REQUEST_KEY}:{r_id}', 0
rds, key, counter = get_redis_connection(), f'{settings.REQUEST_KEY}:{r_id}', 0
data = rds.lrange(key, counter, counter + 9)
while data:
counter += 10
for item in data:
item = json.loads(item.decode())
if 'data' in item:
outputs[item['key']]['data'].append(item['data'])
if 'step' in item:
outputs[item['key']]['step'] = item['step']
if 'status' in item:
outputs[item['key']]['status'] = item['status']
data = rds.lrange(key, counter, counter + 9)
while data:
counter += 10
outputs.extend(x.decode() for x in data)
data = rds.lrange(key, counter, counter + 9)
return json_response({
'app_name': req.deploy.app.name,
'env_name': req.deploy.env.name,
'status': req.status,
'type': req.type,
'status_alias': req.get_status_display(),
'targets': targets,
'server_actions': server_actions,
'host_actions': host_actions,
'outputs': outputs
'outputs': outputs,
'status': req.status
})
def post(self, request, r_id):
@ -201,17 +199,14 @@ class RequestDetailView(View):
if req.status not in ('1', '-3'):
return json_response(error='该申请单当前状态还不能执行发布')
hosts = Host.objects.filter(id__in=json.loads(req.host_ids))
token = uuid.uuid4().hex
outputs = {str(x.id): {'data': []} for x in hosts}
outputs.update(local={'data': [f'{human_time()} 建立接连... ']})
message = f'{human_time()} 等待调度... '
outputs = {x.id: {'id': x.id, 'title': x.name, 'step': 0, 'data': [message]} for x in hosts}
req.status = '2'
req.do_at = human_datetime()
req.do_by = request.user
if not req.version:
req.version = f'{req.deploy_id}_{req.id}_{datetime.now().strftime("%Y%m%d%H%M%S")}'
req.save()
Thread(target=deploy_dispatch, args=(request, req, token)).start()
return json_response({'token': token, 'type': req.type, 'outputs': outputs})
Thread(target=dispatch, args=(req,)).start()
return json_response({'type': req.type, 'outputs': outputs})
def patch(self, request, r_id):
form, error = JsonParser(
@ -235,6 +230,36 @@ class RequestDetailView(View):
return json_response(error=error)
def post_request_1(request):
form, error = JsonParser(
Argument('id', type=int, required=False),
Argument('name', help='请输申请标题'),
Argument('repository_id', type=int, help='请选择发布版本'),
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择要部署的主机'),
Argument('desc', required=False),
).parse(request.body)
if error is None:
repository = Repository.objects.filter(pk=form.repository_id).first()
if not repository:
return json_response(error='未找到指定构建版本记录')
form.name = form.name.replace("'", '')
form.status = '0' if repository.deploy.is_audit else '1'
form.version = repository.version
form.spug_version = repository.spug_version
form.deploy_id = repository.deploy_id
form.host_ids = json.dumps(form.host_ids)
if form.id:
req = DeployRequest.objects.get(pk=form.id)
is_required_notify = repository.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 = repository.deploy.is_audit
if is_required_notify:
Thread(target=Helper.send_deploy_notify, args=(req, 'approve_req')).start()
return json_response(error=error)
def do_upload(request):
repos_dir = settings.REPOS_DIR
file = request.FILES['file']

View File

@ -13,10 +13,13 @@ import json
class RepositoryView(View):
def get(self, request):
deploy_id = request.GET.get('deploy_id')
data = Repository.objects.annotate(
app_name=F('app__name'),
env_name=F('env__name'),
created_by_user=F('created_by__nickname'))
if deploy_id:
data = data.filter(deploy_id=deploy_id, status='5')
return json_response([x.to_view() for x in data])
def post(self, request):
@ -41,6 +44,20 @@ class RepositoryView(View):
return json_response(rep.to_view())
return json_response(error=error)
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)
def delete(self, request):
form, error = JsonParser(
Argument('id', type=int, help='请指定操作对象')

View File

@ -43,6 +43,8 @@ class ComConsumer(WebsocketConsumer):
module = self.scope['url_route']['kwargs']['module']
if module == 'build':
self.key = f'{settings.BUILD_KEY}:{token}'
elif module == 'request':
self.key = f'{settings.REQUEST_KEY}:{token}'
else:
raise TypeError(f'unknown module for {module}')
self.rds = get_redis_connection()
@ -69,7 +71,6 @@ class ComConsumer(WebsocketConsumer):
while response:
index += 1
self.send(text_data=response)
time.sleep(1)
response = self.get_response(index)
self.send(text_data='pong')

View File

@ -22,7 +22,7 @@ export default function () {
rel="noopener noreferrer">文档</a>
</div>
<div style={{color: 'rgba(0, 0, 0, .45)'}}>
Copyright <CopyrightOutlined/> 2020 By OpenSpug
Copyright <CopyrightOutlined/> 2021 By OpenSpug
</div>
</div>
</Layout.Footer>

View File

@ -3,7 +3,7 @@
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React from 'react';
import React, { useState } from 'react';
import { observer } from 'mobx-react';
import { Table, Modal, Tag, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
@ -12,6 +12,8 @@ import { http, hasPermission } from 'libs';
import store from './store';
function ComTable() {
const [loading, setLoading] = useState();
function handleDelete(info) {
Modal.confirm({
title: '删除确认',
@ -26,6 +28,27 @@ function ComTable() {
})
}
function handleRebuild(info) {
if (info.status === '5') {
Modal.confirm({
title: '重新构建提示',
content: `当前选择版本 ${info.version} 已完成构建,再次构建将覆盖已有的数据,要再次重新构建吗?`,
onOk: () => _rebuild(info)
})
} else if (info.status === '1') {
return message.error('已在构建中,请点击日志查看详情')
} else {
_rebuild(info)
}
}
function _rebuild(info) {
setLoading(info.id);
http.patch('/api/repository/', {id: info.id, action: 'rebuild'})
.then(() => store.showConsole(info))
.finally(() => setLoading(null))
}
const statusColorMap = {'0': 'cyan', '1': 'blue', '2': 'red', '5': 'green'};
return (
<TableCard
@ -54,11 +77,16 @@ function ComTable() {
<Table.Column ellipsis title="备注" dataIndex="remarks"/>
<Table.Column hide title="构建时间" dataIndex="created_at"/>
<Table.Column hide title="构建人" dataIndex="created_by_user"/>
<Table.Column width={100} title="状态" render={info => <Tag color={statusColorMap[info.status]}>{info.status_alias}</Tag>}/>
<Table.Column width={100} title="状态"
render={info => <Tag color={statusColorMap[info.status]}>{info.status_alias}</Tag>}/>
{hasPermission('config.env.edit|config.env.del') && (
<Table.Column width={150} title="操作" render={info => (
<Table.Column width={180} title="操作" render={info => (
<Action>
<Action.Button auth="config.env.edit" onClick={() => store.showDetail(info)}>详情</Action.Button>
<Action.Button
auth="config.env.del"
loading={loading === info.id}
onClick={() => handleRebuild(info)}>构建</Action.Button>
<Action.Button auth="config.env.del" onClick={() => store.showConsole(info)}>日志</Action.Button>
</Action>
)}/>

View File

@ -9,7 +9,7 @@ import http from 'libs/http';
class Store {
@observable records = [];
@observable record = {};
@observable idMap = {};
@observable deploy = {};
@observable outputs = [];
@observable isFetching = false;
@observable formVisible = false;

View File

@ -8,6 +8,7 @@ import { observer } from 'mobx-react';
import { Modal, Form, Input, Switch, message } from 'antd';
import http from 'libs/http';
import store from './store';
import styles from './index.module.less';
export default observer(function () {
const [form] = Form.useForm();
@ -38,6 +39,7 @@ export default observer(function () {
title="审核发布申请"
onCancel={() => store.approveVisible = false}
confirmLoading={loading}
className={styles.approve}
onOk={handleSubmit}>
<Form form={form} labelCol={{span: 6}} wrapperCol={{span: 14}} onValuesChange={handleChange}>
<Form.Item required name="is_pass" initialValue={true} valuePropName="checked" label="审批结果">

View File

@ -0,0 +1,128 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useEffect } from 'react';
import { observer, useLocalStore } from 'mobx-react';
import { Card, Progress, Modal, Collapse, Steps } from 'antd';
import { ShrinkOutlined, CaretRightOutlined, LoadingOutlined, } from '@ant-design/icons';
import OutView from './OutView';
import { http, X_TOKEN } from 'libs';
import styles from './index.module.less';
import store from './store';
function Ext1Console(props) {
const outputs = useLocalStore(() => ({}));
useEffect(props.request.mode === 'read' ? readDeploy : doDeploy, [])
function readDeploy() {
let socket;
http.get(`/api/deploy/request/${props.request.id}/`)
.then(res => {
Object.assign(outputs, res.outputs)
if (res.status === '2') {
socket = _makeSocket()
}
})
return () => socket && socket.close()
}
function doDeploy() {
let socket;
http.post(`/api/deploy/request/${props.request.id}/`)
.then(res => {
Object.assign(outputs, res.outputs)
socket = _makeSocket()
})
return () => socket && socket.close()
}
function _makeSocket() {
let index = 0;
const token = props.request.id;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/request/${token}/?x-token=${X_TOKEN}`);
socket.onopen = () => socket.send(String(index));
socket.onmessage = e => {
if (e.data === 'pong') {
socket.send(String(index))
} else {
index += 1;
const {key, data, step, status} = JSON.parse(e.data);
if (data !== undefined) outputs[key].data.push(data);
if (step !== undefined) outputs[key].step = step;
if (status !== undefined) outputs[key].status = status;
}
}
return socket
}
function StepItem(props) {
let icon = null;
if (props.step === props.item.step && props.item.status !== 'error') {
icon = <LoadingOutlined/>
}
return <Steps.Step {...props} icon={icon}/>
}
function switchMiniMode() {
const value = store.tabModes[props.request.id];
store.tabModes[props.request.id] = !value
}
return store.tabModes[props.request.id] ? (
<Card className={styles.item} bodyStyle={{padding: '8px 12px'}} onClick={switchMiniMode}>
<div className={styles.header}>
<div className={styles.title}>{props.request.name}</div>
</div>
{Object.values(outputs).map(item => (
<Progress
key={item.id}
percent={(item.step + 1) * 18}
status={item.status === 'error' ? 'exception' : 'active'}/>
))}
</Card>
) : (
<Modal
visible
width={1000}
footer={null}
maskClosable={false}
className={styles.console}
onCancel={() => store.showConsole(props.request, true)}
title={[
<span key="1">{props.request.name}</span>,
<div key="2" className={styles.miniIcon} onClick={switchMiniMode}>
<ShrinkOutlined/>
</div>
]}>
<Collapse
defaultActiveKey={'0'}
className={styles.collapse}
expandIcon={({isActive}) => <CaretRightOutlined style={{fontSize: 16}} rotate={isActive ? 90 : 0}/>}>
{Object.values(outputs).map((item, index) => (
<Collapse.Panel
key={index}
header={
<div className={styles.header}>
<b className={styles.title}>{item.title}</b>
<Steps size="small" className={styles.step} current={item.step} status={item.status}>
<StepItem title="等待调度" item={item} step={0}/>
<StepItem title="数据准备" item={item} step={1}/>
<StepItem title="发布前任务" item={item} step={2}/>
<StepItem title="执行发布" item={item} step={3}/>
<StepItem title="发布后任务" item={item} step={4}/>
</Steps>
</div>}>
<OutView records={item.data}/>
</Collapse.Panel>
))}
</Collapse>
</Modal>
)
}
export default observer(Ext1Console)

View File

@ -5,8 +5,7 @@
*/
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react';
import { LoadingOutlined, SyncOutlined } from '@ant-design/icons';
import { Modal, Form, Input, Select, Button, Tag, message } from 'antd';
import { Modal, Form, Input, Select, Tag, message } from 'antd';
import hostStore from 'pages/host/store';
import http from 'libs/http';
import store from './store';
@ -15,84 +14,29 @@ import lds from 'lodash';
export default observer(function () {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [git_type, setGitType] = useState(lds.get(store.record, 'extra.0', 'branch'));
const [extra1, setExtra1] = useState(lds.get(store.record, 'extra.1'));
const [extra2, setExtra2] = useState(lds.get(store.record, 'extra.2'));
const [versions, setVersions] = useState({});
const [host_ids, setHostIds] = useState(lds.clone(store.record.app_host_ids));
const [versions, setVersions] = useState([]);
const [host_ids, setHostIds] = useState([]);
useEffect(() => {
fetchVersions();
const {deploy_id, app_host_ids, host_ids} = store.record;
setHostIds(lds.clone(host_ids || app_host_ids));
http.get('/api/repository/', {params: {deploy_id}})
.then(res => setVersions(res))
if (hostStore.records.length === 0) {
hostStore.fetchRecords()
}
}, [])
useEffect(() => {
if (extra1 === undefined) {
const {branches, tags} = versions;
let [extra1, extra2] = [undefined, undefined];
if (git_type === 'branch') {
if (branches) {
extra1 = _getDefaultBranch(branches);
extra2 = lds.get(branches[extra1], '0.id')
}
} else {
if (tags) {
extra1 = lds.get(Object.keys(tags), 0)
}
}
setExtra1(extra1)
setExtra2(extra2)
}
}, [versions, git_type, extra1])
function fetchVersions() {
setFetching(true);
http.get(`/api/app/deploy/${store.record.deploy_id}/versions/`, {timeout: 120000})
.then(res => setVersions(res))
.finally(() => setFetching(false))
}
function _getDefaultBranch(branches) {
branches = Object.keys(branches);
let branch = branches[0];
for (let item of store.records) {
if (item['deploy_id'] === store.record['deploy_id']) {
const b = lds.get(item, 'extra.1');
if (branches.includes(b)) {
branch = b
}
break
}
}
return branch
}
function switchType(v) {
setExtra1(undefined);
setGitType(v)
}
function switchExtra1(v) {
setExtra1(v)
if (git_type === 'branch') {
setExtra2(lds.get(versions.branches[v], '0.id'))
}
}
function handleSubmit() {
if (host_ids.length === 0) {
return message.error('请至少选择一个要发布的目标主机')
return message.error('请至少选择一个要发布的主机')
}
setLoading(true);
const formData = form.getFieldsValue();
formData['id'] = store.record.id;
formData['deploy_id'] = store.record.deploy_id;
formData['host_ids'] = host_ids;
formData['extra'] = [git_type, extra1, extra2];
http.post('/api/deploy/request/', formData)
formData['deploy_id'] = store.record.deploy_id;
http.post('/api/deploy/request/1/', formData)
.then(res => {
message.success('操作成功');
store.ext1Visible = false;
@ -111,11 +55,10 @@ export default observer(function () {
}
}
const {branches, tags} = versions;
return (
<Modal
visible
width={800}
width={600}
maskClosable={false}
title="新建发布申请"
onCancel={() => store.ext1Visible = false}
@ -125,55 +68,22 @@ export default observer(function () {
<Form.Item required name="name" label="申请标题">
<Input placeholder="请输入申请标题"/>
</Form.Item>
<Form.Item required label="选择分支/标签/版本" style={{marginBottom: 12}} extra={<span>
根据网络情况首次刷新可能会很慢请耐心等待
<a target="_blank" rel="noopener noreferrer"
href="https://spug.dev/docs/install-error/#%E6%96%B0%E5%BB%BA%E5%B8%B8%E8%A7%84%E5%8F%91%E5%B8%83%E7%94%B3%E8%AF%B7-git-clone-%E9%94%99%E8%AF%AF">clone 失败</a>
</span>}>
<Form.Item style={{display: 'inline-block', marginBottom: 0, width: '450px'}}>
<Input.Group compact>
<Select value={git_type} onChange={switchType} style={{width: 100}}>
<Select.Option value="branch">Branch</Select.Option>
<Select.Option value="tag">Tag</Select.Option>
</Select>
<Select
showSearch
style={{width: 350}}
value={extra1}
placeholder="请稍等"
onChange={switchExtra1}
filterOption={(input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0}>
{git_type === 'branch' ? (
Object.keys(branches || {}).map(b => <Select.Option key={b} value={b}>{b}</Select.Option>)
) : (
Object.entries(tags || {}).map(([tag, info]) => (
<Select.Option key={tag} value={tag}>{`${tag} ${info.author} ${info.message}`}</Select.Option>
))
)}
</Select>
</Input.Group>
</Form.Item>
<Form.Item style={{display: 'inline-block', width: 82, textAlign: 'center', marginBottom: 0}}>
{fetching ? <LoadingOutlined style={{fontSize: 18, color: '#1890ff'}}/> :
<Button type="link" icon={<SyncOutlined/>} disabled={fetching} onClick={fetchVersions}>刷新</Button>
}
</Form.Item>
<Form.Item required name="repository_id" label="发布版本">
<Select placeholder="请选择">
{versions.map(item => (
<Select.Option
key={item.id}
value={item.id}>
{item.remarks ? `${item.version} (${item.remarks})` : item.version}
</Select.Option>
))}
</Select>
</Form.Item>
{git_type === 'branch' && (
<Form.Item required label="选择Commit ID">
<Select value={extra2} placeholder="请选择" onChange={v => setExtra2(v)}>
{extra1 && branches ? branches[extra1].map(item => (
<Select.Option
key={item.id}>{item.id.substr(0, 6)} {item['date']} {item['author']} {item['message']}</Select.Option>
)) : null}
</Select>
</Form.Item>
)}
<Form.Item name="desc" label="备注信息">
<Input placeholder="请输入备注信息"/>
</Form.Item>
<Form.Item required label="发布目标主机" help="通过点击主机名称自由选择本次发布的主机。">
{store.record['app_host_ids'].map(id => (
<Form.Item required label="发布主机" help="通过点击主机名称自由选择本次发布的主机。">
{store.record.app_host_ids.map(id => (
<Tag.CheckableTag key={id} checked={host_ids.includes(id)} onChange={() => handleChange(id)}>
{lds.get(hostStore.idMap, `${id}.name`)}({lds.get(hostStore.idMap, `${id}.hostname`)}:{lds.get(hostStore.idMap, `${id}.port`)})
</Tag.CheckableTag>

View File

@ -0,0 +1,21 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useRef, useEffect } from 'react';
import styles from './index.module.less';
function OutView(props) {
const el = useRef()
useEffect(() => {
if (el) el.current.scrollTop = el.current.scrollHeight
})
return (
<pre ref={el} className={styles.out}>{props.records}</pre>
)
}
export default OutView

View File

@ -6,7 +6,7 @@
import React from 'react';
import { observer } from 'mobx-react';
import { BranchesOutlined, BuildOutlined, TagOutlined, PlusOutlined } from '@ant-design/icons';
import { Radio, Modal, Popover, Tag, message } from 'antd';
import { Radio, Modal, Popover, Tag, Popconfirm, message } from 'antd';
import { http, hasPermission } from 'libs';
import { Action, AuthButton, TableCard } from 'components';
import store from './store';
@ -42,20 +42,12 @@ class ComTable extends React.Component {
title: '版本',
render: info => {
if (info['app_extend'] === '1') {
const [type, ext1, ext2] = info.extra;
if (type === 'branch') {
return (
<React.Fragment>
<BranchesOutlined/> {ext1}#{ext2.substr(0, 6)}
</React.Fragment>
)
} else {
return (
<React.Fragment>
<TagOutlined/> {ext1}
</React.Fragment>
)
}
const [ext1] = info.rep_extra;
return (
<React.Fragment>
{ext1 === 'branch' ? <BranchesOutlined/> : <TagOutlined/>} {info.version}
</React.Fragment>
)
} else {
return (
<React.Fragment>
@ -88,15 +80,15 @@ class ComTable extends React.Component {
}, {
title: '申请人',
dataIndex: 'created_by_user',
hide: true
}, {
title: '申请时间',
dataIndex: 'created_at',
sorter: (a, b) => a['created_at'].localeCompare(b['created_at'])
sorter: (a, b) => a['created_at'].localeCompare(b['created_at']),
hide: true
}, {
title: '备注',
dataIndex: 'desc',
ellipsis: true,
hide: true
}, {
title: '操作',
className: hasPermission('deploy.request.do|deploy.request.edit|deploy.request.approve|deploy.request.del') ? null : 'none',
@ -104,10 +96,10 @@ class ComTable extends React.Component {
switch (info.status) {
case '-3':
return <Action>
<Action.Link
auth="deploy.request.do"
to={`/deploy/do/ext${info['app_extend']}/${info.id}/1`}>查看</Action.Link>
<Action.Link auth="deploy.request.do" to={`/deploy/do/ext${info['app_extend']}/${info.id}`}>发布</Action.Link>
<Action.Button auth="deploy.request.do" onClick={() => store.readConsole(info)}>查看</Action.Button>
<Popconfirm title="确认要执行该发布申请?" onConfirm={() => store.showConsole(info)}>
<Action.Button auth="deploy.request.do">发布</Action.Button>
</Popconfirm>
<Action.Button
auth="deploy.request.do"
disabled={info.type === '2'}
@ -138,7 +130,7 @@ class ComTable extends React.Component {
</Action>;
case '1':
return <Action>
<Action.Link auth="deploy.request.do" to={`/deploy/do/ext${info['app_extend']}/${info.id}`}>发布</Action.Link>
<Action.Button auth="deploy.request.do" onClick={() => store.showConsole(info)}>发布</Action.Button>
<Action.Button auth="deploy.request.del" onClick={() => this.handleDelete(info)}>删除</Action.Button>
</Action>;
case '2':

View File

@ -6,17 +6,19 @@
import React from 'react';
import { observer } from 'mobx-react';
import { ExclamationCircleOutlined, DeleteOutlined } from '@ant-design/icons';
import { Form, Select, DatePicker, Modal, Input, message } from 'antd';
import { Form, Select, DatePicker, Modal, Input, Row, Col, message } from 'antd';
import { SearchForm, AuthDiv, AuthButton, Breadcrumb, AppSelector } from 'components';
import Ext1Form from './Ext1Form';
import Ext2Form from './Ext2Form';
import Approve from './Approve';
import ComTable from './Table';
import Ext1Console from './Ext1Console';
import { http, includes } from 'libs';
import envStore from 'pages/config/environment/store';
import appStore from 'pages/config/app/store';
import store from './store';
import moment from 'moment';
import styles from './index.module.less';
@observer
class Index extends React.Component {
@ -120,6 +122,15 @@ class Index extends React.Component {
{store.ext1Visible && <Ext1Form/>}
{store.ext2Visible && <Ext2Form/>}
{store.approveVisible && <Approve/>}
{store.tabs.length > 0 && (
<Row gutter={12} className={styles.miniConsole}>
{store.tabs.map(item => (
<Col key={item.id}>
<Ext1Console request={item}/>
</Col>
))}
</Row>
)}
</AuthDiv>
)
}

View File

@ -0,0 +1,101 @@
.approve {
:global(.ant-switch) {
background: #faad14;
}
:global(.ant-switch-checked) {
background: #389e0d;
}
}
.miniConsole {
position: fixed;
bottom: 12px;
right: 24px;
align-items: flex-end;
.item {
width: 180px;
box-shadow: 0 0 4px rgba(0, 0, 0, .3);
border-radius: 5px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
margin-bottom: 4px;
.title {
width: 120px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.icon {
font-size: 16px;
color: rgba(0, 0, 0, .45);
}
.icon:hover {
color: #000;
}
}
}
}
.console {
.miniIcon {
position: absolute;
top: 0;
right: 0;
display: block;
width: 56px;
height: 56px;
line-height: 56px;
text-align: center;
cursor: pointer;
color: rgba(0, 0, 0, .45);
margin-right: 56px;
:hover {
color: #000;
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
.title {
width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-weight: 600;
}
.step {
width: 600px;
margin-right: 16px;
}
.icon {
font-size: 22px;
font-weight: 300;
color: #1890ff;
}
}
.out {
min-height: 40px;
max-height: 400px;
padding: 10px 15px;
}
}
.collapse :global(.ant-collapse-content-box) {
padding: 0;
}

View File

@ -5,11 +5,14 @@
*/
import { observable } from "mobx";
import http from 'libs/http';
import lds from 'lodash';
class Store {
@observable records = [];
@observable record = {};
@observable counter = {};
@observable tabs = [];
@observable tabModes = {};
@observable isFetching = false;
@observable addVisible = false;
@observable ext1Visible = false;
@ -82,6 +85,28 @@ class Store {
showApprove = (info) => {
this.record = info;
this.approveVisible = true;
};
showConsole = (info, isClose) => {
const index = lds.findIndex(this.tabs, x => x.id === info.id);
if (isClose) {
if (index !== -1) {
this.tabs.splice(index, 1)
delete this.tabModes[info.id]
}
} else if (index === -1) {
this.tabModes[info.id] = true
this.tabs.push(info)
}
};
readConsole = (info) => {
this.tabModes[info.id] = false
const index = lds.findIndex(this.tabs, x => x.id === info.id);
if (index === -1) {
info = Object.assign({}, info, {mode: 'read'})
this.tabs.push(info)
}
}
}

View File

@ -145,7 +145,7 @@ export default function () {
<a title="文档" href="https://www.spug.dev/docs/about-spug/" target="_blank"
rel="noopener noreferrer">文档</a>
</div>
<div style={{color: 'rgba(0, 0, 0, .45)'}}>Copyright <CopyrightOutlined/> 2020 By OpenSpug</div>
<div style={{color: 'rgba(0, 0, 0, .45)'}}>Copyright <CopyrightOutlined/> 2021 By OpenSpug</div>
</div>
</div>
)