mirror of https://github.com/openspug/spug
A 添加文件分发模块
parent
0676b21dc0
commit
8e7c8d8e74
|
@ -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',)
|
||||||
|
|
|
@ -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()}))
|
|
@ -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()),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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),
|
||||||
])
|
])
|
||||||
|
|
|
@ -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__')
|
||||||
|
|
|
@ -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/
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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()
|
|
@ -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}
|
||||||
|
|
|
@ -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},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in New Issue