mirror of https://github.com/openspug/spug
add host group migrate
parent
8917b1fe76
commit
cc22de094b
|
@ -18,8 +18,9 @@ def fetch_children(data):
|
|||
|
||||
|
||||
def merge_children(data, prefix, childes):
|
||||
prefix = f'{prefix}/' if prefix else ''
|
||||
for item in childes:
|
||||
name = f'{prefix}/{item["title"]}'
|
||||
name = f'{prefix}{item["title"]}'
|
||||
item['name'] = name
|
||||
if item['children']:
|
||||
merge_children(data, name, item['children'])
|
||||
|
|
|
@ -46,7 +46,7 @@ class Group(models.Model, ModelMixin):
|
|||
name = models.CharField(max_length=20)
|
||||
parent_id = models.IntegerField(default=0)
|
||||
sort_id = models.IntegerField(default=0)
|
||||
hosts = models.ManyToManyField(Host, through='HostGroupRel', related_name='groups')
|
||||
hosts = models.ManyToManyField(Host, related_name='groups')
|
||||
|
||||
def to_view(self):
|
||||
return {
|
||||
|
@ -59,11 +59,3 @@ class Group(models.Model, ModelMixin):
|
|||
class Meta:
|
||||
db_table = 'host_groups'
|
||||
ordering = ('-sort_id',)
|
||||
|
||||
|
||||
class HostGroupRel(models.Model):
|
||||
group = models.ForeignKey(Group, on_delete=models.CASCADE)
|
||||
host = models.ForeignKey(Host, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
db_table = 'host_group_rel'
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.db.models import F
|
|||
from django.http.response import HttpResponseBadRequest
|
||||
from libs import json_response, JsonParser, Argument
|
||||
from apps.setting.utils import AppSetting
|
||||
from apps.host.models import Host, HostGroupRel
|
||||
from apps.host.models import Host, Group
|
||||
from apps.app.models import Deploy
|
||||
from apps.schedule.models import Task
|
||||
from apps.monitor.models import Detection
|
||||
|
@ -26,7 +26,7 @@ class HostView(View):
|
|||
return json_response(error='无权访问该主机,请联系管理员')
|
||||
return json_response(Host.objects.get(pk=host_id))
|
||||
hosts = {x.id: x.to_view() for x in Host.objects.filter(deleted_by_id__isnull=True)}
|
||||
for rel in HostGroupRel.objects.all():
|
||||
for rel in Group.hosts.through.objects.all():
|
||||
hosts[rel.host_id]['group_ids'].append(rel.group_id)
|
||||
return json_response(list(hosts.values()))
|
||||
|
||||
|
@ -61,15 +61,17 @@ class HostView(View):
|
|||
|
||||
def patch(self, request):
|
||||
form, error = JsonParser(
|
||||
Argument('id', type=int, required=False),
|
||||
Argument('zone', help='请输入主机类别')
|
||||
Argument('host_ids', type=list, filter=lambda x: len(x), help='请选择主机'),
|
||||
Argument('s_group_id', type=int, help='参数错误'),
|
||||
Argument('t_group_id', type=int, help='参数错误'),
|
||||
Argument('is_copy', type=bool, help='参数错误'),
|
||||
).parse(request.body)
|
||||
if error is None:
|
||||
host = Host.objects.filter(pk=form.id).first()
|
||||
if not host:
|
||||
return json_response(error='未找到指定主机')
|
||||
count = Host.objects.filter(zone=host.zone, deleted_by_id__isnull=True).update(zone=form.zone)
|
||||
return json_response(count)
|
||||
s_group = Group.objects.get(pk=form.s_group_id)
|
||||
t_group = Group.objects.get(pk=form.t_group_id)
|
||||
t_group.hosts.add(*form.host_ids)
|
||||
if not form.is_copy:
|
||||
s_group.hosts.remove(*form.host_ids)
|
||||
return json_response(error=error)
|
||||
|
||||
def delete(self, request):
|
||||
|
|
|
@ -1,17 +1,31 @@
|
|||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Drawer} from 'antd';
|
||||
import { Drawer, Descriptions, List } from 'antd';
|
||||
import store from './store';
|
||||
|
||||
export default observer(function () {
|
||||
const host = store.record;
|
||||
return (
|
||||
<Drawer
|
||||
width={500}
|
||||
title={store.record.name}
|
||||
title={host.name}
|
||||
placement="right"
|
||||
onClose={() => store.detailVisible = false}
|
||||
visible={store.detailVisible}>
|
||||
|
||||
<Descriptions bordered size="small" title={<span style={{fontWeight: 500}}>基本信息</span>} column={1}>
|
||||
<Descriptions.Item label="主机名称">{host.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="连接地址">{host.username}@{host.hostname}</Descriptions.Item>
|
||||
<Descriptions.Item label="连接端口">{host.port}</Descriptions.Item>
|
||||
<Descriptions.Item label="独立密钥">{host.pkey ? '是' : '否'}</Descriptions.Item>
|
||||
<Descriptions.Item label="描述信息">{host.desc}</Descriptions.Item>
|
||||
<Descriptions.Item label="所属分组">
|
||||
<List >
|
||||
<List.Item>腾讯云/华北区</List.Item>
|
||||
<List.Item>腾讯云/测试环境/电商商城系统</List.Item>
|
||||
<List.Item>腾讯云/测试环境/订单后台系统</List.Item>
|
||||
</List>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Drawer>
|
||||
)
|
||||
})
|
|
@ -6,6 +6,14 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Input, Card, Tree, Dropdown, Menu, Switch, message } from 'antd';
|
||||
import {
|
||||
FolderOutlined,
|
||||
FolderAddOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
CopyOutlined,
|
||||
ScissorOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import styles from './index.module.css';
|
||||
import { http } from 'libs';
|
||||
|
@ -27,10 +35,14 @@ export default observer(function () {
|
|||
|
||||
const menus = (
|
||||
<Menu onClick={() => setVisible(false)}>
|
||||
<Menu.Item key="0" onClick={handleAddRoot}>新建根分组</Menu.Item>
|
||||
<Menu.Item key="1" onClick={handleAdd}>新建子分组</Menu.Item>
|
||||
<Menu.Item key="2" onClick={() => setAction('edit')}>重命名</Menu.Item>
|
||||
<Menu.Item key="3" danger onClick={handleRemove}>删除分组</Menu.Item>
|
||||
<Menu.Item key="0" icon={<FolderOutlined/>} onClick={handleAddRoot}>新建根分组</Menu.Item>
|
||||
<Menu.Item key="1" icon={<FolderAddOutlined/>} onClick={handleAdd}>新建子分组</Menu.Item>
|
||||
<Menu.Item key="2" icon={<EditOutlined/>} onClick={() => setAction('edit')}>重命名</Menu.Item>
|
||||
<Menu.Divider/>
|
||||
<Menu.Item key="3" icon={<CopyOutlined/>} onClick={() => store.showSelector(true)}>添加至分组</Menu.Item>
|
||||
<Menu.Item key="4" icon={<ScissorOutlined/>} onClick={() => store.showSelector(false)}>移动至分组</Menu.Item>
|
||||
<Menu.Divider/>
|
||||
<Menu.Item key="5" icon={<DeleteOutlined/>} danger onClick={handleRemove}>删除此分组</Menu.Item>
|
||||
</Menu>
|
||||
)
|
||||
|
||||
|
@ -132,7 +144,7 @@ export default observer(function () {
|
|||
className={styles.dragBox}
|
||||
autoExpandParent
|
||||
draggable={draggable}
|
||||
treeData={store.treeData}
|
||||
treeData={store.allTreeData}
|
||||
titleRender={treeRender}
|
||||
expandedKeys={expands}
|
||||
selectedKeys={[store.group.key]}
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Modal, Row, Col, Tree, Table } from 'antd';
|
||||
import styles from './index.module.css';
|
||||
import store from './store';
|
||||
|
||||
export default observer(function (props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [group, setGroup] = useState({});
|
||||
const [dataSource, setDataSource] = useState([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.selfTreeData.length) {
|
||||
store.fetchRecords()
|
||||
store.fetchGroups()
|
||||
.then(() => setGroup(store.selfTreeData[0]))
|
||||
} else {
|
||||
setGroup(store.selfTreeData[0])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (group.key) {
|
||||
const records = store.records.filter(x => group.host_ids.includes(x.id));
|
||||
setDataSource(records)
|
||||
}
|
||||
}, [group])
|
||||
|
||||
function treeRender(nodeData) {
|
||||
return (
|
||||
<span style={{lineHeight: '24px'}}>
|
||||
{nodeData.title}{nodeData.host_ids && nodeData.host_ids.length ? `(${nodeData.host_ids.length})` : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function handleClickRow(record) {
|
||||
const index = selectedRowKeys.indexOf(record.id);
|
||||
if (index !== -1) {
|
||||
selectedRowKeys.splice(index, 1)
|
||||
} else {
|
||||
selectedRowKeys.push(record.id)
|
||||
}
|
||||
setSelectedRowKeys([...selectedRowKeys])
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (props.onOk) {
|
||||
setLoading(true)
|
||||
props.onOk(group, selectedRowKeys)
|
||||
.then(props.onCancel, () => setLoading(false))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
width="70%"
|
||||
title={props.title || '主机列表'}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
onCancel={props.onCancel}>
|
||||
<Row gutter={12}>
|
||||
<Col span={6}>
|
||||
<Tree.DirectoryTree
|
||||
selectedKeys={[group.key]}
|
||||
className={styles.dragBox}
|
||||
treeData={store.selfTreeData}
|
||||
titleRender={treeRender}
|
||||
onSelect={(_, {node}) => setGroup(node)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={dataSource}
|
||||
onRow={record => {
|
||||
return {
|
||||
onClick: () => handleClickRow(record)
|
||||
}
|
||||
}}
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys)
|
||||
}}>
|
||||
<Table.Column title="主机名称" dataIndex="name" sorter={(a, b) => a.name.localeCompare(b.name)}/>
|
||||
<Table.Column title="连接地址" dataIndex="hostname" sorter={(a, b) => a.name.localeCompare(b.name)}/>
|
||||
<Table.Column hide ellipsis title="备注信息" dataIndex="desc"/>
|
||||
</Table>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
)
|
||||
})
|
|
@ -12,6 +12,7 @@ import ComTable from './Table';
|
|||
import ComForm from './Form';
|
||||
import ComImport from './Import';
|
||||
import Detail from './Detail';
|
||||
import Selector from './Selector';
|
||||
import store from './store';
|
||||
|
||||
export default observer(function () {
|
||||
|
@ -34,6 +35,7 @@ export default observer(function () {
|
|||
<Detail/>
|
||||
{store.formVisible && <ComForm/>}
|
||||
{store.importVisible && <ComImport/>}
|
||||
{store.selectorVisible && <Selector onCancel={() => store.selectorVisible = false} onOk={store.updateGroup}/>}
|
||||
</AuthDiv>
|
||||
);
|
||||
})
|
||||
|
|
|
@ -4,7 +4,9 @@
|
|||
* Released under the AGPL-3.0 License.
|
||||
*/
|
||||
import { observable, computed } from 'mobx';
|
||||
import { message } from 'antd';
|
||||
import http from 'libs/http';
|
||||
import lds from 'lodash';
|
||||
|
||||
class Store {
|
||||
@observable records = [];
|
||||
|
@ -13,15 +15,47 @@ class Store {
|
|||
@observable group = {};
|
||||
@observable record = {};
|
||||
@observable idMap = {};
|
||||
@observable addByCopy = true;
|
||||
@observable grpFetching = true;
|
||||
@observable isFetching = false;
|
||||
@observable formVisible = false;
|
||||
@observable importVisible = false;
|
||||
@observable detailVisible = false;
|
||||
@observable selectorVisible = false;
|
||||
|
||||
@observable f_name;
|
||||
@observable f_host;
|
||||
|
||||
@computed get counter() {
|
||||
const counter = {};
|
||||
for (let host of this.records) {
|
||||
for (let id of host.group_ids) {
|
||||
if (counter[id]) {
|
||||
counter[id].push(host.id)
|
||||
} else {
|
||||
counter[id] = [host.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
return counter
|
||||
}
|
||||
|
||||
@computed get selfTreeData() {
|
||||
const treeData = lds.cloneDeep(this.treeData);
|
||||
for (let item of treeData) {
|
||||
this._updateCounter(item, true)
|
||||
}
|
||||
return treeData
|
||||
}
|
||||
|
||||
@computed get allTreeData() {
|
||||
const treeData = lds.cloneDeep(this.treeData);
|
||||
for (let item of treeData) {
|
||||
this._updateCounter(item, false)
|
||||
}
|
||||
return treeData
|
||||
}
|
||||
|
||||
@computed get dataSource() {
|
||||
let records = [];
|
||||
if (this.group.host_ids) records = this.records.filter(x => this.group.host_ids.includes(x.id));
|
||||
|
@ -38,23 +72,30 @@ class Store {
|
|||
for (let item of this.records) {
|
||||
this.idMap[item.id] = item
|
||||
}
|
||||
this._updateGroupCount()
|
||||
})
|
||||
.finally(() => this.isFetching = false)
|
||||
};
|
||||
|
||||
fetchGroups = () => {
|
||||
this.grpFetching = true;
|
||||
http.get('/api/host/group/')
|
||||
return http.get('/api/host/group/')
|
||||
.then(res => {
|
||||
this.treeData = res.treeData;
|
||||
this.groups = res.groups;
|
||||
if (!this.group.key) this.group = this.treeData[0];
|
||||
this._updateGroupCount()
|
||||
})
|
||||
.finally(() => this.grpFetching = false)
|
||||
}
|
||||
|
||||
updateGroup = (group, host_ids) => {
|
||||
const form = {host_ids, s_group_id: group.key, t_group_id: this.group.key, is_copy: this.addByCopy};
|
||||
return http.patch('/api/host/', form)
|
||||
.then(() => {
|
||||
message.success('操作成功');
|
||||
this.fetchRecords()
|
||||
})
|
||||
}
|
||||
|
||||
showForm = (info = {}) => {
|
||||
this.formVisible = true;
|
||||
this.record = info
|
||||
|
@ -65,31 +106,18 @@ class Store {
|
|||
this.detailVisible = true;
|
||||
}
|
||||
|
||||
_updateGroupCount = () => {
|
||||
if (this.treeData.length && this.records.length) {
|
||||
const counter = {};
|
||||
for (let host of this.records) {
|
||||
for (let id of host.group_ids) {
|
||||
if (counter[id]) {
|
||||
counter[id].push(host.id)
|
||||
} else {
|
||||
counter[id] = [host.id]
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let item of this.treeData) {
|
||||
this._updateCount(counter, item)
|
||||
}
|
||||
}
|
||||
showSelector = (addByCopy) => {
|
||||
this.addByCopy = addByCopy;
|
||||
this.selectorVisible = true;
|
||||
}
|
||||
|
||||
_updateCount = (counter, item) => {
|
||||
let host_ids = counter[item.key] || [];
|
||||
_updateCounter = (item, isSelf) => {
|
||||
let host_ids = this.counter[item.key] || [];
|
||||
for (let child of item.children) {
|
||||
host_ids = host_ids.concat(this._updateCount(counter, child))
|
||||
const ids = this._updateCounter(child, isSelf)
|
||||
if (!isSelf) host_ids = host_ids.concat(ids)
|
||||
}
|
||||
item.host_ids = Array.from(new Set(host_ids));
|
||||
if (item.key === this.group.key) this.group = item;
|
||||
return item.host_ids
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue