diff --git a/spug_api/apps/exec/models.py b/spug_api/apps/exec/models.py index d084d52..990e397 100644 --- a/spug_api/apps/exec/models.py +++ b/spug_api/apps/exec/models.py @@ -56,3 +56,22 @@ class ExecHistory(models.Model, ModelMixin): class Meta: db_table = 'exec_histories' 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',) diff --git a/spug_api/apps/exec/transfer.py b/spug_api/apps/exec/transfer.py new file mode 100644 index 0000000..b4e55b2 --- /dev/null +++ b/spug_api/apps/exec/transfer.py @@ -0,0 +1,118 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# 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()})) diff --git a/spug_api/apps/exec/urls.py b/spug_api/apps/exec/urls.py index 01aeb60..3c99a19 100644 --- a/spug_api/apps/exec/urls.py +++ b/spug_api/apps/exec/urls.py @@ -3,10 +3,12 @@ # Released under the AGPL-3.0 License. from django.conf.urls import url -from .views import * +from apps.exec.views import * +from apps.exec.transfer import TransferView urlpatterns = [ url(r'template/$', TemplateView.as_view()), url(r'history/$', get_histories), url(r'do/$', do_task), + url(r'transfer/$', TransferView.as_view()), ] diff --git a/spug_api/apps/schedule/builtin.py b/spug_api/apps/schedule/builtin.py index a4bd8d3..32790e2 100644 --- a/spug_api/apps/schedule/builtin.py +++ b/spug_api/apps/schedule/builtin.py @@ -2,17 +2,22 @@ # Copyright: (c) # Released under the AGPL-3.0 License. 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.schedule.models import Task, History as TaskHistory from apps.deploy.models import DeployRequest from apps.app.models import DeployExtend1 -from apps.exec.models import ExecHistory +from apps.exec.models import ExecHistory, Transfer from apps.notify.models import Notify from apps.deploy.utils import dispatch from libs.utils import parse_time, human_datetime, human_date from datetime import datetime, timedelta from threading import Thread +from collections import defaultdict +from pathlib import Path +import time +import os def auto_run_by_day(): @@ -28,17 +33,33 @@ def auto_run_by_day(): if index > item.versions and req.repository_id: req.repository.delete() index += 1 - try: - record = ExecHistory.objects.all()[50] - ExecHistory.objects.filter(id__lt=record.id).delete() - except IndexError: - pass + + timer = defaultdict(int) + for item in ExecHistory.objects.all(): + if timer[item.user_id] >= 10: + 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(): try: record = TaskHistory.objects.filter(task_id=task.id)[50] TaskHistory.objects.filter(task_id=task.id, id__lt=record.id).delete() except IndexError: 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: connections.close_all() diff --git a/spug_api/consumer/consumers.py b/spug_api/consumer/consumers.py index 3fd2938..3c92d4e 100644 --- a/spug_api/consumer/consumers.py +++ b/spug_api/consumer/consumers.py @@ -153,3 +153,27 @@ class NotifyConsumer(WebsocketConsumer): def notify_message(self, 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') diff --git a/spug_api/consumer/routing.py b/spug_api/consumer/routing.py index d869671..7055c0a 100644 --- a/spug_api/consumer/routing.py +++ b/spug_api/consumer/routing.py @@ -10,6 +10,7 @@ ws_router = AuthMiddleware( URLRouter([ path('ws/exec//', ExecConsumer), path('ws/ssh//', SSHConsumer), + path('ws/subscribe//', PubSubConsumer), path('ws///', ComConsumer), path('ws/notify/', NotifyConsumer), ]) diff --git a/spug_api/libs/parser.py b/spug_api/libs/parser.py index f7c0ad5..69bcad8 100644 --- a/spug_api/libs/parser.py +++ b/spug_api/libs/parser.py @@ -118,7 +118,6 @@ class JsonParser(BaseParser): def _init(self, data): try: if isinstance(data, (str, bytes)): - data = data.decode('utf-8') self.__data = json.loads(data) if data else {} else: assert hasattr(data, '__contains__') diff --git a/spug_api/spug/settings.py b/spug_api/spug/settings.py index 1830955..d1938f2 100644 --- a/spug_api/spug/settings.py +++ b/spug_api/spug/settings.py @@ -111,6 +111,7 @@ REQUEST_KEY = 'spug:request' BUILD_KEY = 'spug:build' REPOS_DIR = os.path.join(os.path.dirname(os.path.dirname(BASE_DIR)), 'repos') BUILD_DIR = os.path.join(REPOS_DIR, 'build') +TRANSFER_DIR = os.path.join(BASE_DIR, 'storage', 'transfer') # Internationalization # https://docs.djangoproject.com/en/2.2/topics/i18n/ diff --git a/spug_api/storage/transfer/.gitkeep b/spug_api/storage/transfer/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/spug_web/src/pages/exec/task/index.js b/spug_web/src/pages/exec/task/index.js index 3ca8b8a..66f4f47 100644 --- a/spug_web/src/pages/exec/task/index.js +++ b/spug_web/src/pages/exec/task/index.js @@ -119,7 +119,7 @@ function TaskIndex() {
执行记录 - +
diff --git a/spug_web/src/pages/exec/transfer/Output.js b/spug_web/src/pages/exec/transfer/Output.js new file mode 100644 index 0000000..08f05e6 --- /dev/null +++ b/spug_web/src/pages/exec/transfer/Output.js @@ -0,0 +1,164 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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 ( +
+
+ +
+
store.updateTag('0')}> + +
{counter['0']}
+
+
store.updateTag('1')}> + +
{counter['1']}
+
+
store.updateTag('2')}> + +
{counter['2']}
+
+
+ +
+ {items.map(([key, item]) => ( +
handleSwitch(key)}> + {item.status === -2 ? ( + + ) : item.status === 0 ? ( + + ) : ( + + )} +
{item.title}
+
+ ))} +
+
+
+
+
{store.outputs[current].title}
+ openTerminal(current)}/> +
+
+
+
+
+
+ ) +} + +export default observer(OutView) \ No newline at end of file diff --git a/spug_web/src/pages/exec/transfer/index.js b/spug_web/src/pages/exec/transfer/index.js new file mode 100644 index 0000000..3627b35 --- /dev/null +++ b/spug_web/src/pages/exec/transfer/index.js @@ -0,0 +1,175 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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 ( + + 首页 + 批量执行 + 文件分发 + + + +
+
+ 执行记录 + + + +
+
+ {histories.map((item, index) => (
+ {item.host_id ? ( + + ) : ( + + )} +
{item.interpreter}
+
{item.host_ids.length}
+
{item.dst_dir}
+
{moment(item.updated_at).format('MM.DD HH:mm')}
+
))} +
+
+
+ + {token ? setToken()}/> : null} + ) +} + +export default observer(TransferIndex) diff --git a/spug_web/src/pages/exec/transfer/index.module.less b/spug_web/src/pages/exec/transfer/index.module.less new file mode 100644 index 0000000..c0eba59 --- /dev/null +++ b/spug_web/src/pages/exec/transfer/index.module.less @@ -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); + } + } + } +} \ No newline at end of file diff --git a/spug_web/src/pages/exec/transfer/store.js b/spug_web/src/pages/exec/transfer/store.js new file mode 100644 index 0000000..11a615d --- /dev/null +++ b/spug_web/src/pages/exec/transfer/store.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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() diff --git a/spug_web/src/pages/host/Selector.js b/spug_web/src/pages/host/Selector.js index c3d5601..3b57ab5 100644 --- a/spug_web/src/pages/host/Selector.js +++ b/spug_web/src/pages/host/Selector.js @@ -46,19 +46,28 @@ export default observer(function (props) { } function handleClickRow(record) { - const index = selectedRowKeys.indexOf(record.id); + let tmp = [...selectedRowKeys] + const index = tmp.indexOf(record.id); if (index !== -1) { - selectedRowKeys.splice(index, 1) + tmp.splice(index, 1) + } else if (props.onlyOne) { + tmp = [record.id] } else { - selectedRowKeys.push(record.id) + tmp.push(record.id) } - setSelectedRowKeys([...selectedRowKeys]) + setSelectedRowKeys(tmp) } function handleSubmit() { if (props.onOk) { 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) { res.then(props.onCancel, () => setLoading(false)) } else { @@ -80,11 +89,14 @@ export default observer(function (props) { className={styles.selector} title={props.title || '主机列表'} onOk={handleSubmit} + okButtonProps={{disabled: selectedRowKeys.length === 0}} confirmLoading={loading} onCancel={props.onCancel}> , title: '批量执行', auth: 'exec.task.do|exec.template.view', child: [ {title: '执行任务', auth: 'exec.task.do', path: '/exec/task', component: ExecTask}, {title: '模板管理', auth: 'exec.template.view', path: '/exec/template', component: ExecTemplate}, + {title: '文件分发', auth: 'exec.transfer.view', path: '/exec/transfer', component: ExecTransfer}, ] }, {