A 主机excel导入增加密码字段并优化导入体验

pull/467/head
vapao 2022-03-31 00:52:40 +08:00
parent 51efc9591e
commit e026ce09bf
7 changed files with 128 additions and 73 deletions

View File

@ -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)

View File

@ -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')

View File

@ -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>
);
})

View File

@ -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>
);
})

View File

@ -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>
)
}

View File

@ -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;
}
}