mirror of https://github.com/openspug/spug
A 主机excel导入增加密码字段并优化导入体验
parent
51efc9591e
commit
e026ce09bf
|
@ -248,12 +248,14 @@ def fetch_host_extend(ssh):
|
|||
return response
|
||||
|
||||
|
||||
def batch_sync_host(token, hosts, password):
|
||||
def batch_sync_host(token, hosts, password=None):
|
||||
private_key, public_key = AppSetting.get_ssh_key()
|
||||
threads, latest_exception, rds = [], None, get_redis_connection()
|
||||
max_workers = max(10, os.cpu_count() * 5)
|
||||
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for host in hosts:
|
||||
if hasattr(host, 'password'):
|
||||
password = host.password
|
||||
t = executor.submit(_sync_host_extend, host, private_key, public_key, password)
|
||||
t.host = host
|
||||
threads.append(t)
|
||||
|
|
|
@ -129,31 +129,40 @@ class HostView(View):
|
|||
def post_import(request):
|
||||
group_id = request.POST.get('group_id')
|
||||
file = request.FILES['file']
|
||||
hosts = []
|
||||
ws = load_workbook(file, read_only=True)['Sheet1']
|
||||
summary = {'invalid': [], 'skip': [], 'repeat': [], 'success': []}
|
||||
for i, row in enumerate(ws.rows):
|
||||
if i == 0: # 第1行是表头 略过
|
||||
summary = {'fail': 0, 'success': 0, 'invalid': [], 'skip': [], 'repeat': []}
|
||||
for i, row in enumerate(ws.rows, start=1):
|
||||
if i == 1: # 第1行是表头 略过
|
||||
continue
|
||||
if not all([row[x].value for x in range(4)]):
|
||||
summary['invalid'].append(i)
|
||||
summary['fail'] += 1
|
||||
continue
|
||||
data = AttrDict(
|
||||
name=row[0].value,
|
||||
hostname=row[1].value,
|
||||
port=row[2].value,
|
||||
username=row[3].value,
|
||||
desc=row[4].value
|
||||
desc=row[5].value
|
||||
)
|
||||
if Host.objects.filter(hostname=data.hostname, port=data.port, username=data.username).exists():
|
||||
summary['skip'].append(i)
|
||||
summary['fail'] += 1
|
||||
continue
|
||||
if Host.objects.filter(name=data.name).exists():
|
||||
summary['repeat'].append(i)
|
||||
summary['fail'] += 1
|
||||
continue
|
||||
host = Host.objects.create(created_by=request.user, **data)
|
||||
host.groups.add(group_id)
|
||||
summary['success'].append(i)
|
||||
return json_response(summary)
|
||||
summary['success'] += 1
|
||||
host.password = row[4].value
|
||||
hosts.append(host)
|
||||
token = uuid.uuid4().hex
|
||||
if hosts:
|
||||
Thread(target=batch_sync_host, args=(token, hosts)).start()
|
||||
return json_response({'summary': summary, 'token': token, 'hosts': {x.id: {'name': x.name} for x in hosts}})
|
||||
|
||||
|
||||
@auth('host.host.add')
|
||||
|
|
Binary file not shown.
|
@ -3,42 +3,20 @@
|
|||
* Copyright (c) <spug.dev@gmail.com>
|
||||
* Released under the AGPL-3.0 License.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Modal, Form, Input, Button, Radio } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { http, X_TOKEN } from 'libs';
|
||||
import Sync from './Sync';
|
||||
import { http } from 'libs';
|
||||
import store from './store';
|
||||
|
||||
export default observer(function () {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [password, setPassword] = useState();
|
||||
const [range, setRange] = useState('2');
|
||||
const [hosts, setHosts] = useState({});
|
||||
const [hosts, setHosts] = useState();
|
||||
const [token, setToken] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
let index = 0;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/host/${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, status, message} = JSON.parse(e.data);
|
||||
hosts[key]['status'] = status;
|
||||
hosts[key]['message'] = message;
|
||||
setHosts({...hosts})
|
||||
}
|
||||
}
|
||||
return () => socket && socket.close()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token])
|
||||
|
||||
function handleSubmit() {
|
||||
setLoading(true);
|
||||
http.post('/api/host/valid/', {password, range})
|
||||
|
@ -82,15 +60,9 @@ export default observer(function () {
|
|||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Form hidden={!token} labelCol={{span: 8}} wrapperCol={{span: 14}}>
|
||||
{Object.entries(hosts).map(([key, item]) => (
|
||||
<Form.Item key={key} label={item.name} extra={item.message}>
|
||||
{item.status === 'ok' && <span style={{color: "#52c41a"}}>成功</span>}
|
||||
{item.status === 'fail' && <span style={{color: "red"}}>失败</span>}
|
||||
{item.status === undefined && <LoadingOutlined style={{fontSize: 20}}/>}
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
{token && hosts ? (
|
||||
<Sync token={token} hosts={hosts}/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
})
|
||||
|
|
|
@ -5,8 +5,9 @@
|
|||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Modal, Form, Upload, Button, Tooltip, Divider, Cascader, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { Modal, Form, Upload, Button, Tooltip, Alert, Cascader, message } from 'antd';
|
||||
import Sync from './Sync';
|
||||
import http from 'libs/http';
|
||||
import store from './store';
|
||||
|
||||
|
@ -14,6 +15,9 @@ export default observer(function () {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [fileList, setFileList] = useState([]);
|
||||
const [groupId, setGroupId] = useState([]);
|
||||
const [summary, setSummary] = useState({});
|
||||
const [token, setToken] = useState();
|
||||
const [hosts, setHosts] = useState();
|
||||
|
||||
function handleSubmit() {
|
||||
if (groupId.length === 0) return message.error('请选择要导入的分组');
|
||||
|
@ -23,25 +27,9 @@ export default observer(function () {
|
|||
formData.append('group_id', groupId[groupId.length - 1]);
|
||||
http.post('/api/host/import/', formData, {timeout: 120000})
|
||||
.then(res => {
|
||||
Modal.info({
|
||||
title: '导入结果',
|
||||
content: <Form labelCol={{span: 7}} wrapperCol={{span: 14}}>
|
||||
<Form.Item style={{margin: 0}} label="导入成功">{res.success.length}</Form.Item>
|
||||
{res['skip'].length > 0 && <Form.Item style={{margin: 0, color: '#1890ff'}} label="重复数据">
|
||||
<Tooltip title={`相关行:${res['skip'].join(', ')}`}>{res['skip'].length}</Tooltip>
|
||||
</Form.Item>}
|
||||
{res['invalid'].length > 0 && <Form.Item style={{margin: 0, color: '#1890ff'}} label="无效数据">
|
||||
<Tooltip title={`相关行:${res['invalid'].join(', ')}`}>{res['invalid'].length}</Tooltip>
|
||||
</Form.Item>}
|
||||
{res['repeat'].length > 0 && <Form.Item style={{margin: 0, color: '#1890ff'}} label="重复主机名">
|
||||
<Tooltip title={`相关行:${res['repeat'].join(', ')}`}>{res['repeat'].length}</Tooltip>
|
||||
</Form.Item>}
|
||||
</Form>,
|
||||
onOk: () => {
|
||||
store.fetchRecords();
|
||||
store.importVisible = false
|
||||
}
|
||||
})
|
||||
setToken(res.token)
|
||||
setHosts(res.hosts)
|
||||
setSummary(res.summary)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
@ -54,22 +42,20 @@ export default observer(function () {
|
|||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
store.importVisible = false;
|
||||
store.fetchRecords()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
maskClosable={false}
|
||||
title="批量导入"
|
||||
okText="导入"
|
||||
onCancel={() => store.importVisible = false}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{disabled: !fileList.length}}
|
||||
onOk={handleSubmit}>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
style={{width: 365, margin: '0 auto 20px'}}
|
||||
message="导入或输入的密码仅作首次验证使用,不会存储。"/>
|
||||
<Form labelCol={{span: 6}} wrapperCol={{span: 14}}>
|
||||
onCancel={handleClose}
|
||||
footer={null}>
|
||||
<Form hidden={token} labelCol={{span: 6}} wrapperCol={{span: 14}}>
|
||||
<Form.Item label="模板下载" extra="请下载使用该模板填充数据后导入">
|
||||
<a href="/resource/主机导入模板.xlsx">主机导入模板.xlsx</a>
|
||||
</Form.Item>
|
||||
|
@ -81,7 +67,7 @@ export default observer(function () {
|
|||
fieldNames={{label: 'title'}}
|
||||
placeholder="请选择"/>
|
||||
</Form.Item>
|
||||
<Form.Item required label="导入数据" extra="导入完成后可通过验证功能进行批量验证。">
|
||||
<Form.Item required label="导入数据" extra="Spug使用密钥认证连接服务器,导入或输入的密码仅作首次验证使用,不会存储。">
|
||||
<Upload
|
||||
name="file"
|
||||
accept=".xls, .xlsx"
|
||||
|
@ -93,7 +79,34 @@ export default observer(function () {
|
|||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item wrapperCol={{span: 14, offset: 6}}>
|
||||
<Button loading={loading} disabled={!fileList.length} type="primary" onClick={handleSubmit}>导入主机</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{token && hosts ? (
|
||||
<div>
|
||||
<Divider>导入结果</Divider>
|
||||
<div style={{display: 'flex', justifyContent: 'space-around'}}>
|
||||
<div>成功:{summary.success}</div>
|
||||
<div>失败:{summary.fail > 0 ? (
|
||||
<Tooltip style={{color: '#1890ff'}} title={(
|
||||
<div>
|
||||
{summary.skip.map(x => <div key={x}>第 {x} 行,重复的服务器信息</div>)}
|
||||
{summary.repeat.map(x => <div key={x}>第 {x} 行,重复的主机名称</div>)}
|
||||
{summary.invalid.map(x => <div key={x}>第 {x} 行,无效的数据</div>)}
|
||||
</div>
|
||||
)}><span style={{color: '#1890ff'}}>{summary.fail}</span></Tooltip>
|
||||
) : 0}</div>
|
||||
</div>
|
||||
{Object.keys(hosts).length > 0 && (
|
||||
<>
|
||||
<Divider>验证及同步</Divider>
|
||||
<Sync token={token} hosts={hosts} style={{maxHeight: 'calc(100vh - 400px)'}}/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
})
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* 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 { Form } from 'antd';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { X_TOKEN } from 'libs';
|
||||
import styles from './index.module.less';
|
||||
|
||||
export default function (props) {
|
||||
const [hosts, setHosts] = useState(props.hosts);
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/ws/host/${props.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, status, message} = JSON.parse(e.data);
|
||||
hosts[key]['status'] = status;
|
||||
hosts[key]['message'] = message;
|
||||
setHosts({...hosts})
|
||||
}
|
||||
}
|
||||
return () => socket && socket.close()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Form labelCol={{span: 8}} wrapperCol={{span: 14}} className={styles.batchSync} style={props.style}>
|
||||
{Object.entries(hosts).map(([key, item]) => (
|
||||
<Form.Item key={key} label={item.name} extra={item.message}>
|
||||
{item.status === 'ok' && <span style={{color: "#52c41a"}}>成功</span>}
|
||||
{item.status === 'fail' && <span style={{color: "red"}}>失败</span>}
|
||||
{item.status === undefined && <LoadingOutlined/>}
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
)
|
||||
}
|
|
@ -68,4 +68,17 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.batchSync {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow: auto;
|
||||
|
||||
:global(.ant-form-item) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
:global(.ant-form-item-extra) {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue