A 添加文件分发模块

pull/517/head
vapao 2022-06-26 18:07:54 +08:00
parent 0676b21dc0
commit 8e7c8d8e74
16 changed files with 860 additions and 15 deletions

View File

@ -56,3 +56,22 @@ class ExecHistory(models.Model, ModelMixin):
class Meta: class Meta:
db_table = 'exec_histories' db_table = 'exec_histories'
ordering = ('-updated_at',) ordering = ('-updated_at',)
class Transfer(models.Model, ModelMixin):
user = models.ForeignKey(User, on_delete=models.CASCADE)
digest = models.CharField(max_length=32, db_index=True)
host_id = models.IntegerField(null=True)
src_dir = models.CharField(max_length=255)
dst_dir = models.CharField(max_length=255)
host_ids = models.TextField()
updated_at = models.CharField(max_length=20, default=human_datetime)
def to_view(self):
tmp = self.to_dict()
tmp['host_ids'] = json.loads(self.host_ids)
return tmp
class Meta:
db_table = 'exec_transfer'
ordering = ('-id',)

View File

@ -0,0 +1,118 @@
# 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.conf import settings
from django_redis import get_redis_connection
from apps.exec.models import Transfer
from apps.account.utils import has_host_perm
from apps.host.models import Host
from apps.setting.utils import AppSetting
from libs import json_response, JsonParser, Argument, auth
from concurrent import futures
import subprocess
import tempfile
import uuid
import json
import os
class TransferView(View):
@auth('exec.task.do')
def get(self, request):
records = Transfer.objects.filter(user=request.user)
return json_response([x.to_view() for x in records])
@auth('exec.transfer.do')
def post(self, request):
data = request.POST.get('data')
form, error = JsonParser(
Argument('host', required=False),
Argument('dst_dir', help='请输入目标路径'),
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择目标主机'),
).parse(data)
if error is None:
if not has_host_perm(request.user, form.host_ids):
return json_response(error='无权访问主机,请联系管理员')
host_id = None
token = uuid.uuid4().hex
base_dir = os.path.join(settings.TRANSFER_DIR, token)
os.makedirs(base_dir)
if form.host:
host_id, path = json.loads(form.host)
host = Host.objects.get(pk=host_id)
with tempfile.NamedTemporaryFile(mode='w') as fp:
fp.write(host.pkey or AppSetting.get('private_key'))
fp.flush()
target = f'{host.username}@{host.hostname}:{path}'
command = f'sshfs -o ro -o ssh_command="ssh -p {host.port} -i {fp.name}" {target} {base_dir}'
task = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
if task.returncode != 0:
return json_response(error=task.stdout.decode())
else:
index = 0
while True:
file = request.FILES.get(f'file{index}')
if not file:
break
with open(os.path.join(base_dir, file.name), 'wb') as f:
for chunk in file.chunks():
f.write(chunk)
index += 1
Transfer.objects.create(
user=request.user,
digest=token,
host_id=host_id,
src_dir=base_dir,
dst_dir=form.dst_dir,
host_ids=json.dumps(form.host_ids),
)
return json_response(token)
return json_response(error=error)
@auth('exec.transfer.do')
def patch(self, request):
form, error = JsonParser(
Argument('token', help='参数错误')
).parse(request.body)
if error is None:
rds = get_redis_connection()
task = Transfer.objects.get(digest=form.token)
threads = []
max_workers = max(10, os.cpu_count() * 5)
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for host in Host.objects.filter(id__in=json.loads(task.host_ids)):
t = executor.submit(_do_sync, rds, task, host)
t.token = task.digest
t.key = host.id
threads.append(t)
for t in futures.as_completed(threads):
exc = t.exception()
if exc:
rds.publish(t.token, json.dumps({'key': t.key, 'status': -1, 'data': f'Exception: {exc}'}))
if task.host_id:
command = f'umount -f {task.src_dir} && rm -rf {task.src_dir}'
else:
command = f'rm -rf {task.src_dir}'
subprocess.run(command, shell=True)
return json_response(error=error)
def _do_sync(rds, task, host):
token = task.digest
rds.publish(token, json.dumps({'key': host.id, 'data': '\r\n\x1b[36m### Executing ...\x1b[0m\r\n'}))
with tempfile.NamedTemporaryFile(mode='w') as fp:
fp.write(host.pkey or AppSetting.get('private_key'))
fp.flush()
options = '-azv' if task.host_id else '-rzv'
target = f'{host.username}@{host.hostname}:{task.dst_dir}'
command = f'rsync {options} -h -e "ssh -p {host.port} -i {fp.name}" {task.src_dir}/ {target}'
task = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
while True:
message = task.stdout.readline()
if not message:
break
message = message.decode().rstrip('\r\n')
rds.publish(token, json.dumps({'key': host.id, 'data': message + '\r\n'}))
rds.publish(token, json.dumps({'key': host.id, 'status': task.wait()}))

View File

@ -3,10 +3,12 @@
# Released under the AGPL-3.0 License. # Released under the AGPL-3.0 License.
from django.conf.urls import url from django.conf.urls import url
from .views import * from apps.exec.views import *
from apps.exec.transfer import TransferView
urlpatterns = [ urlpatterns = [
url(r'template/$', TemplateView.as_view()), url(r'template/$', TemplateView.as_view()),
url(r'history/$', get_histories), url(r'history/$', get_histories),
url(r'do/$', do_task), url(r'do/$', do_task),
url(r'transfer/$', TransferView.as_view()),
] ]

View File

@ -2,17 +2,22 @@
# Copyright: (c) <spug.dev@gmail.com> # Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License. # Released under the AGPL-3.0 License.
from django.db import connections from django.db import connections
from apps.account.models import History from django.conf import settings
from apps.account.models import History, User
from apps.alarm.models import Alarm from apps.alarm.models import Alarm
from apps.schedule.models import Task, History as TaskHistory from apps.schedule.models import Task, History as TaskHistory
from apps.deploy.models import DeployRequest from apps.deploy.models import DeployRequest
from apps.app.models import DeployExtend1 from apps.app.models import DeployExtend1
from apps.exec.models import ExecHistory from apps.exec.models import ExecHistory, Transfer
from apps.notify.models import Notify from apps.notify.models import Notify
from apps.deploy.utils import dispatch from apps.deploy.utils import dispatch
from libs.utils import parse_time, human_datetime, human_date from libs.utils import parse_time, human_datetime, human_date
from datetime import datetime, timedelta from datetime import datetime, timedelta
from threading import Thread from threading import Thread
from collections import defaultdict
from pathlib import Path
import time
import os
def auto_run_by_day(): def auto_run_by_day():
@ -28,17 +33,33 @@ def auto_run_by_day():
if index > item.versions and req.repository_id: if index > item.versions and req.repository_id:
req.repository.delete() req.repository.delete()
index += 1 index += 1
try:
record = ExecHistory.objects.all()[50] timer = defaultdict(int)
ExecHistory.objects.filter(id__lt=record.id).delete() for item in ExecHistory.objects.all():
except IndexError: if timer[item.user_id] >= 10:
pass item.delete()
else:
timer[item.user_id] += 1
timer = defaultdict(int)
for item in Transfer.objects.all():
if timer[item.user_id] >= 10:
item.delete()
else:
timer[item.user_id] += 1
for task in Task.objects.all(): for task in Task.objects.all():
try: try:
record = TaskHistory.objects.filter(task_id=task.id)[50] record = TaskHistory.objects.filter(task_id=task.id)[50]
TaskHistory.objects.filter(task_id=task.id, id__lt=record.id).delete() TaskHistory.objects.filter(task_id=task.id, id__lt=record.id).delete()
except IndexError: except IndexError:
pass pass
timestamp = time.time() - 24 * 3600
for item in Path(settings.TRANSFER_DIR).iterdir():
if item.name != '.gitkeep':
if item.stat().st_atime < timestamp:
os.system(f'rm -rf {item.absolute()}')
finally: finally:
connections.close_all() connections.close_all()

View File

@ -153,3 +153,27 @@ class NotifyConsumer(WebsocketConsumer):
def notify_message(self, event): def notify_message(self, event):
self.send(text_data=json.dumps(event)) self.send(text_data=json.dumps(event))
class PubSubConsumer(WebsocketConsumer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.token = self.scope['url_route']['kwargs']['token']
self.rds = get_redis_connection()
self.p = self.rds.pubsub(ignore_subscribe_messages=True)
self.p.subscribe(self.token)
def connect(self):
self.accept()
def disconnect(self, code):
self.p.close()
self.rds.close()
def receive(self, **kwargs):
response = self.p.get_message(timeout=10)
while response:
data = response['data'].decode()
self.send(text_data=data)
response = self.p.get_message(timeout=10)
self.send(text_data='pong')

View File

@ -10,6 +10,7 @@ ws_router = AuthMiddleware(
URLRouter([ URLRouter([
path('ws/exec/<str:token>/', ExecConsumer), path('ws/exec/<str:token>/', ExecConsumer),
path('ws/ssh/<int:id>/', SSHConsumer), path('ws/ssh/<int:id>/', SSHConsumer),
path('ws/subscribe/<str:token>/', PubSubConsumer),
path('ws/<str:module>/<str:token>/', ComConsumer), path('ws/<str:module>/<str:token>/', ComConsumer),
path('ws/notify/', NotifyConsumer), path('ws/notify/', NotifyConsumer),
]) ])

View File

@ -118,7 +118,6 @@ class JsonParser(BaseParser):
def _init(self, data): def _init(self, data):
try: try:
if isinstance(data, (str, bytes)): if isinstance(data, (str, bytes)):
data = data.decode('utf-8')
self.__data = json.loads(data) if data else {} self.__data = json.loads(data) if data else {}
else: else:
assert hasattr(data, '__contains__') assert hasattr(data, '__contains__')

View File

@ -111,6 +111,7 @@ REQUEST_KEY = 'spug:request'
BUILD_KEY = 'spug:build' BUILD_KEY = 'spug:build'
REPOS_DIR = os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'repos') REPOS_DIR = os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'repos')
BUILD_DIR = os.path.join(REPOS_DIR, 'build') BUILD_DIR = os.path.join(REPOS_DIR, 'build')
TRANSFER_DIR = os.path.join(BASE_DIR, 'storage', 'transfer')
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/2.2/topics/i18n/ # https://docs.djangoproject.com/en/2.2/topics/i18n/

View File

View File

@ -119,7 +119,7 @@ function TaskIndex() {
<div className={style.right}> <div className={style.right}>
<div className={style.title}> <div className={style.title}>
执行记录 执行记录
<Tooltip title="多次相同的执行记录将会合并展示,每天自动清理,保留最近50条记录。"> <Tooltip title="多次相同的执行记录将会合并展示,每天自动清理,保留最近30条记录。">
<QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/> <QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>
</Tooltip> </Tooltip>
</div> </div>

View File

@ -0,0 +1,164 @@
/**
* 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, useRef, useState } from 'react';
import { observer } from 'mobx-react';
import { PageHeader } from 'antd';
import {
LoadingOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
CodeOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { FitAddon } from 'xterm-addon-fit';
import { Terminal } from 'xterm';
import style from './index.module.less';
import { X_TOKEN, http } from 'libs';
import store from './store';
let gCurrent;
function OutView(props) {
const el = useRef()
const [term] = useState(new Terminal());
const [fitPlugin] = useState(new FitAddon());
const [current, setCurrent] = useState(Object.keys(store.outputs)[0])
useEffect(() => {
store.tag = ''
gCurrent = current
term.setOption('disableStdin', true)
term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei')
term.setOption('theme', {background: '#2b2b2b', foreground: '#A9B7C6', cursor: '#2b2b2b'})
term.attachCustomKeyEventHandler((arg) => {
if (arg.ctrlKey && arg.code === 'KeyC' && arg.type === 'keydown') {
document.execCommand('copy')
return false
}
return true
})
term.loadAddon(fitPlugin)
term.open(el.current)
fitPlugin.fit()
term.write('\x1b[36m### WebSocket connecting ...\x1b[0m')
const resize = () => fitPlugin.fit();
window.addEventListener('resize', resize)
return () => window.removeEventListener('resize', resize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/subscribe/${props.token}/?x-token=${X_TOKEN}`);
socket.onopen = () => {
const message = '\r\x1b[K\x1b[36m### Waiting for scheduling ...\x1b[0m'
for (let key of Object.keys(store.outputs)) {
store.outputs[key].data = message
}
term.write(message)
socket.send('ok');
fitPlugin.fit()
http.patch('/api/exec/transfer/', {token: props.token})
}
socket.onmessage = e => {
if (e.data === 'pong') {
socket.send('ping')
} else {
_handleData(e.data)
}
}
socket.onclose = () => {
for (let key of Object.keys(store.outputs)) {
if (store.outputs[key].status === -2) {
store.outputs[key].status = -1
}
store.outputs[key].data += '\r\n\x1b[31mWebsocket connection failed!\x1b[0m'
term.write('\r\n\x1b[31mWebsocket connection failed!\x1b[0m')
}
}
return () => socket && socket.close()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function _handleData(message) {
const {key, data, status} = JSON.parse(message);
if (status !== undefined) {
store.outputs[key].status = status;
}
if (data) {
store.outputs[key].data += data
if (String(key) === gCurrent) term.write(data)
}
}
function handleSwitch(key) {
setCurrent(key)
gCurrent = key
term.clear()
term.write(store.outputs[key].data)
}
function openTerminal(key) {
window.open(`/ssh?id=${key}`)
}
const {tag, items, counter} = store
return (
<div className={style.output}>
<div className={style.side}>
<PageHeader onBack={props.onBack} title="执行详情"/>
<div className={style.tags}>
<div
className={`${style.item} ${tag === '0' ? style.pendingOn : style.pending}`}
onClick={() => store.updateTag('0')}>
<ClockCircleOutlined/>
<div>{counter['0']}</div>
</div>
<div
className={`${style.item} ${tag === '1' ? style.successOn : style.success}`}
onClick={() => store.updateTag('1')}>
<CheckCircleOutlined/>
<div>{counter['1']}</div>
</div>
<div
className={`${style.item} ${tag === '2' ? style.failOn : style.fail}`}
onClick={() => store.updateTag('2')}>
<ExclamationCircleOutlined/>
<div>{counter['2']}</div>
</div>
</div>
<div className={style.list}>
{items.map(([key, item]) => (
<div key={key} className={[style.item, key === current ? style.active : ''].join(' ')}
onClick={() => handleSwitch(key)}>
{item.status === -2 ? (
<LoadingOutlined style={{color: '#1890ff'}}/>
) : item.status === 0 ? (
<CheckCircleOutlined style={{color: '#52c41a'}}/>
) : (
<ExclamationCircleOutlined style={{color: 'red'}}/>
)}
<div className={style.text}>{item.title}</div>
</div>
))}
</div>
</div>
<div className={style.body}>
<div className={style.header}>
<div className={style.title}>{store.outputs[current].title}</div>
<CodeOutlined className={style.icon} onClick={() => openTerminal(current)}/>
</div>
<div className={style.termContainer}>
<div ref={el} className={style.term}/>
</div>
</div>
</div>
)
}
export default observer(OutView)

View File

@ -0,0 +1,175 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useState, useEffect } from 'react';
import { observer } from 'mobx-react';
import {
PlusOutlined, ThunderboltOutlined, QuestionCircleOutlined, UploadOutlined, CloudServerOutlined
} from '@ant-design/icons';
import { Form, Button, Alert, Tooltip, Space, Card, Table, Input, Upload, message } from 'antd';
import { AuthDiv, Breadcrumb } from 'components';
import Selector from 'pages/host/Selector';
import Output from './Output';
import { http, uniqueId } from 'libs';
import moment from 'moment';
import store from './store';
import style from './index.module.less';
function TransferIndex() {
const [loading, setLoading] = useState(false)
const [files, setFiles] = useState([])
const [dir, setDir] = useState('')
const [hosts, setHosts] = useState([])
const [sProps, setSProps] = useState({visible: false})
const [token, setToken] = useState()
const [histories, setHistories] = useState([])
useEffect(() => {
if (!loading) {
http.get('/api/exec/transfer/')
.then(res => setHistories(res))
}
}, [loading])
function handleSubmit() {
const formData = new FormData();
if (files.length === 0) return message.error('请添加数据源')
if (!dir) return message.error('请输入目标路径')
if (hosts.length === 0) return message.error('请选择目标主机')
const data = {dst_dir: dir, host_ids: hosts.map(x => x.id)}
for (let index in files) {
const item = files[index]
if (item.type === 'host') {
data.host = JSON.stringify([item.host_id, item.path])
} else {
formData.append(`file${index}`, item.path)
}
}
formData.append('data', JSON.stringify(data))
setLoading(true)
http.post('/api/exec/transfer/', formData)
.then(res => {
const tmp = {}
for (let host of hosts) {
tmp[host.id] = {
title: `${host.name}(${host.hostname}:${host.port})`,
data: '\x1b[36m### WebSocket connecting ...\x1b[0m',
status: -2
}
}
store.outputs = tmp
setToken(res)
})
.finally(() => setLoading(false))
}
function _handleAdd(type, name, path, host_id) {
let tmp = []
if (type === 'upload' && files.length > 0 && files[0].type === type) {
tmp = [...files]
}
tmp.push({id: uniqueId(), type, name, path, host_id})
setFiles(tmp)
}
function handleAddHostFile() {
setSProps({
visible: true,
onlyOne: true,
selectedRowKeys: [],
onCancel: () => setSProps({visible: false}),
onOk: (_, __, row) => _handleAdd('host', row.name, '', row.id),
})
}
function handleAddHost() {
setSProps({
visible: true,
selectedRowKeys: hosts.map(x => x.id),
onCancel: () => setSProps({visible: false}),
onOk: (_, __, rows) => setHosts(rows),
})
}
function handleUpload(file) {
_handleAdd('upload', '本地上传', file)
return Upload.LIST_IGNORE
}
function handleRemove(index) {
files.splice(index, 1)
setFiles([...files])
}
return (<AuthDiv auth="exec.task.do">
<Breadcrumb>
<Breadcrumb.Item>首页</Breadcrumb.Item>
<Breadcrumb.Item>批量执行</Breadcrumb.Item>
<Breadcrumb.Item>文件分发</Breadcrumb.Item>
</Breadcrumb>
<div className={style.index} hidden={token}>
<div className={style.left}>
<Card type="inner" title="数据源" extra={(<Space size={24}>
<Upload beforeUpload={handleUpload}><Space className="btn"><UploadOutlined/>上传本地文件</Space></Upload>
<Space className="btn" onClick={handleAddHostFile}><CloudServerOutlined/>添加主机文件</Space>
</Space>)}>
<Table rowKey="id" showHeader={false} pagination={false} size="small" dataSource={files}>
<Table.Column title="文件来源" dataIndex="name"/>
<Table.Column title="文件名称/路径" render={info => info.type === 'upload' ? info.path.name : (
<Input onChange={e => info.path = e.target.value} placeholder="请输入文件路径"/>)}/>
<Table.Column title="操作" render={(_, __, index) => (
<Button danger type="link" onClick={() => handleRemove(index)}>移除</Button>)}/>
</Table>
</Card>
<Card type="inner" title="分发目标" style={{margin: '24px 0'}} bodyStyle={{paddingBottom: 0}}>
<Form>
<Form.Item required label="目标路径">
<Input value={dir} onChange={e => setDir(e.target.value)} placeholder="请输入目标路径"/>
</Form.Item>
<Form.Item required label="目标主机">
{hosts.length > 0 ? (<Alert
type="info"
className={style.area}
message={<div>已选择 <b style={{fontSize: 18, color: '#1890ff'}}>{hosts.length}</b> </div>}
onClick={handleAddHost}/>) : (<Button icon={<PlusOutlined/>} onClick={handleAddHost}>
添加目标主机
</Button>)}
</Form.Item>
</Form>
</Card>
<Button loading={loading} icon={<ThunderboltOutlined/>} type="primary"
onClick={() => handleSubmit()}>开始执行</Button>
</div>
<div className={style.right}>
<div className={style.title}>
执行记录
<Tooltip title="每天自动清理保留最近30条记录。">
<QuestionCircleOutlined style={{color: '#999', marginLeft: 8}}/>
</Tooltip>
</div>
<div className={style.inner}>
{histories.map((item, index) => (<div key={index} className={style.item}>
{item.host_id ? (
<CloudServerOutlined className={style.host}/>
) : (
<UploadOutlined className={style.upload}/>
)}
<div className={style[item.interpreter]}>{item.interpreter}</div>
<div className={style.number}>{item.host_ids.length}</div>
<div className={style.command}>{item.dst_dir}</div>
<div className={style.desc}>{moment(item.updated_at).format('MM.DD HH:mm')}</div>
</div>))}
</div>
</div>
</div>
<Selector {...sProps}/>
{token ? <Output token={token} onBack={() => setToken()}/> : null}
</AuthDiv>)
}
export default observer(TransferIndex)

View File

@ -0,0 +1,259 @@
.index {
display: flex;
height: calc(100vh - 218px);
min-height: 500px;
background-color: #fff;
overflow: hidden;
.left {
padding: 24px;
width: 60%;
.area {
cursor: pointer;
width: 200px;
height: 32px;
}
.tips {
position: absolute;
top: 10px;
left: 180px;
color: #999;
}
.tips:hover {
color: #777;
}
.editor {
height: calc(100vh - 482px) !important;
min-height: 152px;
}
:global(.ant-empty-normal) {
margin: 12px 0;
}
}
.right {
width: 40%;
max-width: 600px;
display: flex;
flex-direction: column;
background-color: #fafafa;
padding: 24px 24px 0 24px;
.title {
font-weight: 500;
margin-bottom: 12px;
}
.inner {
flex: 1;
overflow: auto;
}
.item {
display: flex;
align-items: center;
border-radius: 2px;
padding: 8px 12px;
margin-bottom: 12px;
.host {
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
color: #fff;
border-radius: 2px;
background-color: #1890ff;
}
.upload {
display: flex;
justify-content: center;
align-items: center;
color: #fff;
width: 20px;
height: 20px;
border-radius: 2px;
background-color: #dca900;
}
.number {
width: 24px;
text-align: center;
margin-left: 12px;
border-radius: 2px;
font-weight: 500;
background-color: #dfdfdf;
}
.command {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0 12px;
}
.desc {
color: #999;
}
}
.item:hover {
border-color: #1890ff;
background-color: #e6f7ff;
}
}
}
.output {
display: flex;
background-color: #fff;
height: calc(100vh - 218px);
overflow: hidden;
.side {
display: flex;
flex-direction: column;
width: 300px;
border-right: 1px solid #dfdfdf;
.tags {
padding: 0 24px 24px;
display: flex;
justify-content: space-between;
.item {
width: 70px;
display: flex;
align-items: center;
justify-content: space-around;
border-radius: 35px;
padding: 2px 8px;
cursor: pointer;
background-color: #f3f3f3;
color: #666;
user-select: none;
}
.pendingOn {
background-color: #1890ff;
color: #fff;
}
.pending {
color: #1890ff;
}
.pending:hover {
background-color: #1890ff;
opacity: 0.7;
color: #fff;
}
.successOn {
background-color: #52c41a;
color: #fff;
}
.success {
color: #52c41a;
}
.success:hover {
background-color: #52c41a;
opacity: 0.7;
color: #fff;
}
.failOn {
background-color: red;
color: #fff;
}
.fail {
color: red;
}
.fail:hover {
background-color: red;
opacity: 0.6;
color: #fff;
}
}
.list {
flex: 1;
overflow: auto;
padding-bottom: 8px;
.item {
display: flex;
align-items: center;
padding: 8px 24px;
cursor: pointer;
&.active {
background: #e6f7ff;
}
:global(.anticon) {
margin-right: 4px;
}
.text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
}
.item:hover {
background: #e6f7ff;
}
}
}
.body {
display: flex;
flex-direction: column;
width: calc(100% - 300px);
padding: 22px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.icon {
font-size: 18px;
color: #1890ff;
cursor: pointer;
}
.title {
font-weight: 500;
}
}
.termContainer {
background-color: #2b2b2b;
padding: 8px 0 4px 12px;
border-radius: 4px;
.term {
width: 100%;
height: calc(100vh - 300px);
}
}
}
}

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import { observable, computed } from "mobx";
class Store {
@observable outputs = {};
@observable tag = '';
@computed get items() {
const items = Object.entries(this.outputs)
if (this.tag === '') {
return items
} else if (this.tag === '0') {
return items.filter(([_, x]) => x.status === -2)
} else if (this.tag === '1') {
return items.filter(([_, x]) => x.status === 0)
} else {
return items.filter(([_, x]) => ![-2, 0].includes(x.status))
}
}
@computed get counter() {
const counter = {'0': 0, '1': 0, '2': 0}
for (let item of Object.values(this.outputs)) {
if (item.status === -2) {
counter['0'] += 1
} else if (item.status === 0) {
counter['1'] += 1
} else {
counter['2'] += 1
}
}
return counter
}
updateTag = (tag) => {
if (tag === this.tag) {
this.tag = ''
} else {
this.tag = tag
}
}
}
export default new Store()

View File

@ -46,19 +46,28 @@ export default observer(function (props) {
} }
function handleClickRow(record) { function handleClickRow(record) {
const index = selectedRowKeys.indexOf(record.id); let tmp = [...selectedRowKeys]
const index = tmp.indexOf(record.id);
if (index !== -1) { if (index !== -1) {
selectedRowKeys.splice(index, 1) tmp.splice(index, 1)
} else if (props.onlyOne) {
tmp = [record.id]
} else { } else {
selectedRowKeys.push(record.id) tmp.push(record.id)
} }
setSelectedRowKeys([...selectedRowKeys]) setSelectedRowKeys(tmp)
} }
function handleSubmit() { function handleSubmit() {
if (props.onOk) { if (props.onOk) {
setLoading(true); setLoading(true);
const res = props.onOk(group, selectedRowKeys); let res
const selectedRows = store.records.filter(x => selectedRowKeys.includes(x.id))
if (props.onlyOne) {
res = props.onOk(group, selectedRowKeys[0], selectedRows[0])
} else {
res = props.onOk(group, selectedRowKeys, selectedRows);
}
if (res && res.then) { if (res && res.then) {
res.then(props.onCancel, () => setLoading(false)) res.then(props.onCancel, () => setLoading(false))
} else { } else {
@ -80,11 +89,14 @@ export default observer(function (props) {
className={styles.selector} className={styles.selector}
title={props.title || '主机列表'} title={props.title || '主机列表'}
onOk={handleSubmit} onOk={handleSubmit}
okButtonProps={{disabled: selectedRowKeys.length === 0}}
confirmLoading={loading} confirmLoading={loading}
onCancel={props.onCancel}> onCancel={props.onCancel}>
<Row gutter={12}> <Row gutter={12}>
<Col span={6}> <Col span={6}>
<Tree.DirectoryTree <Tree.DirectoryTree
defaultExpandAll
expandAction="doubleClick"
selectedKeys={[group.key]} selectedKeys={[group.key]}
treeData={store.treeData} treeData={store.treeData}
titleRender={treeRender} titleRender={treeRender}

View File

@ -22,6 +22,7 @@ import DashboardIndex from './pages/dashboard';
import HostIndex from './pages/host'; import HostIndex from './pages/host';
import ExecTask from './pages/exec/task'; import ExecTask from './pages/exec/task';
import ExecTemplate from './pages/exec/template'; import ExecTemplate from './pages/exec/template';
import ExecTransfer from './pages/exec/transfer';
import DeployApp from './pages/deploy/app'; import DeployApp from './pages/deploy/app';
import DeployRepository from './pages/deploy/repository'; import DeployRepository from './pages/deploy/repository';
import DeployRequest from './pages/deploy/request'; import DeployRequest from './pages/deploy/request';
@ -54,6 +55,7 @@ export default [
icon: <CodeOutlined/>, title: '批量执行', auth: 'exec.task.do|exec.template.view', child: [ icon: <CodeOutlined/>, title: '批量执行', auth: 'exec.task.do|exec.template.view', child: [
{title: '执行任务', auth: 'exec.task.do', path: '/exec/task', component: ExecTask}, {title: '执行任务', auth: 'exec.task.do', path: '/exec/task', component: ExecTask},
{title: '模板管理', auth: 'exec.template.view', path: '/exec/template', component: ExecTemplate}, {title: '模板管理', auth: 'exec.template.view', path: '/exec/template', component: ExecTemplate},
{title: '文件分发', auth: 'exec.transfer.view', path: '/exec/transfer', component: ExecTransfer},
] ]
}, },
{ {