mirror of https://github.com/openspug/spug
A 主机excel导入增加密码字段并优化导入体验
parent
51efc9591e
commit
e026ce09bf
|
@ -248,12 +248,14 @@ def fetch_host_extend(ssh):
|
||||||
return response
|
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()
|
private_key, public_key = AppSetting.get_ssh_key()
|
||||||
threads, latest_exception, rds = [], None, get_redis_connection()
|
threads, latest_exception, rds = [], None, get_redis_connection()
|
||||||
max_workers = max(10, os.cpu_count() * 5)
|
max_workers = max(10, os.cpu_count() * 5)
|
||||||
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
|
if hasattr(host, 'password'):
|
||||||
|
password = host.password
|
||||||
t = executor.submit(_sync_host_extend, host, private_key, public_key, password)
|
t = executor.submit(_sync_host_extend, host, private_key, public_key, password)
|
||||||
t.host = host
|
t.host = host
|
||||||
threads.append(t)
|
threads.append(t)
|
||||||
|
|
|
@ -129,31 +129,40 @@ class HostView(View):
|
||||||
def post_import(request):
|
def post_import(request):
|
||||||
group_id = request.POST.get('group_id')
|
group_id = request.POST.get('group_id')
|
||||||
file = request.FILES['file']
|
file = request.FILES['file']
|
||||||
|
hosts = []
|
||||||
ws = load_workbook(file, read_only=True)['Sheet1']
|
ws = load_workbook(file, read_only=True)['Sheet1']
|
||||||
summary = {'invalid': [], 'skip': [], 'repeat': [], 'success': []}
|
summary = {'fail': 0, 'success': 0, 'invalid': [], 'skip': [], 'repeat': []}
|
||||||
for i, row in enumerate(ws.rows):
|
for i, row in enumerate(ws.rows, start=1):
|
||||||
if i == 0: # 第1行是表头 略过
|
if i == 1: # 第1行是表头 略过
|
||||||
continue
|
continue
|
||||||
if not all([row[x].value for x in range(4)]):
|
if not all([row[x].value for x in range(4)]):
|
||||||
summary['invalid'].append(i)
|
summary['invalid'].append(i)
|
||||||
|
summary['fail'] += 1
|
||||||
continue
|
continue
|
||||||
data = AttrDict(
|
data = AttrDict(
|
||||||
name=row[0].value,
|
name=row[0].value,
|
||||||
hostname=row[1].value,
|
hostname=row[1].value,
|
||||||
port=row[2].value,
|
port=row[2].value,
|
||||||
username=row[3].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():
|
if Host.objects.filter(hostname=data.hostname, port=data.port, username=data.username).exists():
|
||||||
summary['skip'].append(i)
|
summary['skip'].append(i)
|
||||||
|
summary['fail'] += 1
|
||||||
continue
|
continue
|
||||||
if Host.objects.filter(name=data.name).exists():
|
if Host.objects.filter(name=data.name).exists():
|
||||||
summary['repeat'].append(i)
|
summary['repeat'].append(i)
|
||||||
|
summary['fail'] += 1
|
||||||
continue
|
continue
|
||||||
host = Host.objects.create(created_by=request.user, **data)
|
host = Host.objects.create(created_by=request.user, **data)
|
||||||
host.groups.add(group_id)
|
host.groups.add(group_id)
|
||||||
summary['success'].append(i)
|
summary['success'] += 1
|
||||||
return json_response(summary)
|
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')
|
@auth('host.host.add')
|
||||||
|
|
Binary file not shown.
|
@ -3,42 +3,20 @@
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
import { Modal, Form, Input, Button, Radio } from 'antd';
|
import { Modal, Form, Input, Button, Radio } from 'antd';
|
||||||
import { LoadingOutlined } from '@ant-design/icons';
|
import Sync from './Sync';
|
||||||
import { http, X_TOKEN } from 'libs';
|
import { http } from 'libs';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
|
||||||
export default observer(function () {
|
export default observer(function () {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [password, setPassword] = useState();
|
const [password, setPassword] = useState();
|
||||||
const [range, setRange] = useState('2');
|
const [range, setRange] = useState('2');
|
||||||
const [hosts, setHosts] = useState({});
|
const [hosts, setHosts] = useState();
|
||||||
const [token, setToken] = 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() {
|
function handleSubmit() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
http.post('/api/host/valid/', {password, range})
|
http.post('/api/host/valid/', {password, range})
|
||||||
|
@ -82,15 +60,9 @@ export default observer(function () {
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Form hidden={!token} labelCol={{span: 8}} wrapperCol={{span: 14}}>
|
{token && hosts ? (
|
||||||
{Object.entries(hosts).map(([key, item]) => (
|
<Sync token={token} hosts={hosts}/>
|
||||||
<Form.Item key={key} label={item.name} extra={item.message}>
|
) : null}
|
||||||
{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>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,8 +5,9 @@
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { observer } from 'mobx-react';
|
import { observer } from 'mobx-react';
|
||||||
|
import { Modal, Form, Upload, Button, Tooltip, Divider, Cascader, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
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 http from 'libs/http';
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
|
||||||
|
@ -14,6 +15,9 @@ export default observer(function () {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [fileList, setFileList] = useState([]);
|
const [fileList, setFileList] = useState([]);
|
||||||
const [groupId, setGroupId] = useState([]);
|
const [groupId, setGroupId] = useState([]);
|
||||||
|
const [summary, setSummary] = useState({});
|
||||||
|
const [token, setToken] = useState();
|
||||||
|
const [hosts, setHosts] = useState();
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (groupId.length === 0) return message.error('请选择要导入的分组');
|
if (groupId.length === 0) return message.error('请选择要导入的分组');
|
||||||
|
@ -23,25 +27,9 @@ export default observer(function () {
|
||||||
formData.append('group_id', groupId[groupId.length - 1]);
|
formData.append('group_id', groupId[groupId.length - 1]);
|
||||||
http.post('/api/host/import/', formData, {timeout: 120000})
|
http.post('/api/host/import/', formData, {timeout: 120000})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
Modal.info({
|
setToken(res.token)
|
||||||
title: '导入结果',
|
setHosts(res.hosts)
|
||||||
content: <Form labelCol={{span: 7}} wrapperCol={{span: 14}}>
|
setSummary(res.summary)
|
||||||
<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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
|
@ -54,22 +42,20 @@ export default observer(function () {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
store.importVisible = false;
|
||||||
|
store.fetchRecords()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible
|
visible
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
title="批量导入"
|
title="批量导入"
|
||||||
okText="导入"
|
okText="导入"
|
||||||
onCancel={() => store.importVisible = false}
|
onCancel={handleClose}
|
||||||
confirmLoading={loading}
|
footer={null}>
|
||||||
okButtonProps={{disabled: !fileList.length}}
|
<Form hidden={token} labelCol={{span: 6}} wrapperCol={{span: 14}}>
|
||||||
onOk={handleSubmit}>
|
|
||||||
<Alert
|
|
||||||
showIcon
|
|
||||||
type="info"
|
|
||||||
style={{width: 365, margin: '0 auto 20px'}}
|
|
||||||
message="导入或输入的密码仅作首次验证使用,不会存储。"/>
|
|
||||||
<Form labelCol={{span: 6}} wrapperCol={{span: 14}}>
|
|
||||||
<Form.Item label="模板下载" extra="请下载使用该模板填充数据后导入">
|
<Form.Item label="模板下载" extra="请下载使用该模板填充数据后导入">
|
||||||
<a href="/resource/主机导入模板.xlsx">主机导入模板.xlsx</a>
|
<a href="/resource/主机导入模板.xlsx">主机导入模板.xlsx</a>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
@ -81,7 +67,7 @@ export default observer(function () {
|
||||||
fieldNames={{label: 'title'}}
|
fieldNames={{label: 'title'}}
|
||||||
placeholder="请选择"/>
|
placeholder="请选择"/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item required label="导入数据" extra="导入完成后可通过验证功能进行批量验证。">
|
<Form.Item required label="导入数据" extra="Spug使用密钥认证连接服务器,导入或输入的密码仅作首次验证使用,不会存储。">
|
||||||
<Upload
|
<Upload
|
||||||
name="file"
|
name="file"
|
||||||
accept=".xls, .xlsx"
|
accept=".xls, .xlsx"
|
||||||
|
@ -93,7 +79,34 @@ export default observer(function () {
|
||||||
)}
|
)}
|
||||||
</Upload>
|
</Upload>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item wrapperCol={{span: 14, offset: 6}}>
|
||||||
|
<Button loading={loading} disabled={!fileList.length} type="primary" onClick={handleSubmit}>导入主机</Button>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</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>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -69,3 +69,16 @@
|
||||||
white-space: nowrap;
|
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