add host group

pull/289/head
vapao 2020-12-04 21:18:40 +08:00
parent d11ee1258d
commit 591a4d1659
10 changed files with 387 additions and 51 deletions

View File

@ -0,0 +1,96 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from django.views.generic import View
from django.db.models import F
from libs import json_response, JsonParser, Argument
from apps.host.models import Group
def fetch_children(data):
if data:
sub_data = dict()
for item in Group.objects.filter(parent_id__in=data.keys()):
tmp = item.to_view()
sub_data[item.id] = tmp
data[item.parent_id]['children'].append(tmp)
return fetch_children(sub_data)
def merge_children(data, prefix, childes):
for item in childes:
name = f'{prefix}/{item["title"]}'
if item['children']:
merge_children(data, name, item['children'])
else:
data.append({'id': item['key'], 'name': name})
class GroupView(View):
def get(self, request):
data, data2 = dict(), []
for item in Group.objects.filter(parent_id=0):
data[item.id] = item.to_view()
fetch_children(data)
if not data:
grp = Group.objects.create(name='Default', sort_id=1)
data[grp.id] = grp.to_view()
merge_children(data2, '', data.values())
return json_response({'treeData': list(data.values()), 'groups': data2})
def post(self, request):
form, error = JsonParser(
Argument('id', type=int, required=False),
Argument('parent_id', type=int, default=0),
Argument('name', help='请输入分组名称')
).parse(request.body)
if error is None:
if form.id:
Group.objects.filter(pk=form.id).update(name=form.name)
else:
group = Group.objects.create(**form)
group.sort_id = group.id
group.save()
return json_response(error=error)
def patch(self, request):
form, error = JsonParser(
Argument('s_id', type=int, help='参数错误'),
Argument('d_id', type=int, help='参数错误'),
Argument('action', type=int, help='参数错误')
).parse(request.body)
if error is None:
src = Group.objects.get(pk=form.s_id)
dst = Group.objects.get(pk=form.d_id)
if form.action == 0:
src.parent_id = dst.id
src.save()
return json_response()
src.parent_id = dst.parent_id
if src.sort_id > dst.sort_id:
if form.action == -1:
dst = Group.objects.filter(sort_id__gt=dst.sort_id).last()
Group.objects.filter(sort_id__lt=src.sort_id, sort_id__gte=dst.sort_id).update(sort_id=F('sort_id') + 1)
else:
if form.action == 1:
dst = Group.objects.filter(sort_id__lt=dst.sort_id).first()
Group.objects.filter(sort_id__lte=dst.sort_id, sort_id__gt=src.sort_id).update(sort_id=F('sort_id') - 1)
src.sort_id = dst.sort_id
src.save()
return json_response(error=error)
def delete(self, request):
form, error = JsonParser(
Argument('id', type=int, help='参数错误')
).parse(request.GET)
if error is None:
group = Group.objects.filter(pk=form.id).first()
if not group:
return json_response(error='未找到指定分组')
if Group.objects.filter(parent_id=group.id).exists():
return json_response(error='请移除子分组后再尝试删除')
if group.hosts.exists():
return json_response(error='请移除分组下的主机后再尝试删除')
group.delete()
return json_response(error=error)

View File

@ -30,9 +30,40 @@ class Host(models.Model, ModelMixin):
pkey = pkey or self.private_key
return SSH(self.hostname, self.port, self.username, pkey)
def to_view(self):
tmp = self.to_dict()
tmp['group_ids'] = []
return tmp
def __repr__(self):
return '<Host %r>' % self.name
class Meta:
db_table = 'hosts'
ordering = ('-id',)
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')
def to_view(self):
return {
'key': self.id,
'title': self.name,
'children': []
}
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'

View File

@ -3,10 +3,12 @@
# Released under the AGPL-3.0 License.
from django.urls import path
from .views import *
from apps.host.views import *
from apps.host.group import GroupView
urlpatterns = [
path('', HostView.as_view()),
path('group/', GroupView.as_view()),
path('import/', post_import),
path('parse/', post_parse),
]

View File

@ -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
from apps.host.models import Host, HostGroupRel
from apps.app.models import Deploy
from apps.schedule.models import Task
from apps.monitor.models import Detection
@ -25,10 +25,10 @@ class HostView(View):
if not request.user.has_host_perm(host_id):
return json_response(error='无权访问该主机,请联系管理员')
return json_response(Host.objects.get(pk=host_id))
hosts = Host.objects.filter(deleted_by_id__isnull=True)
zones = [x['zone'] for x in hosts.order_by('zone').values('zone').distinct()]
perms = [x.id for x in hosts] if request.user.is_supper else request.user.host_perms
return json_response({'zones': zones, 'hosts': [x.to_dict() for x in hosts], 'perms': perms})
hosts = {x.id: x.to_view() for x in Host.objects.filter(deleted_by_id__isnull=True)}
for rel in HostGroupRel.objects.all():
hosts[rel.host_id]['group_ids'].append(rel.group_id)
return json_response(list(hosts.values()))
def post(self, request):
form, error = JsonParser(

View File

@ -4,10 +4,31 @@
* Released under the AGPL-3.0 License.
*/
import React, { useState, useEffect, useRef } from 'react';
import { Table, Space, Divider, Popover, Checkbox, Button } from 'antd';
import { ReloadOutlined, SettingOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Table, Space, Divider, Popover, Checkbox, Button, Input, Select } from 'antd';
import { ReloadOutlined, SettingOutlined, FullscreenOutlined, SearchOutlined } from '@ant-design/icons';
import styles from './index.module.less';
function Search(props) {
let keys = props.keys || [''];
keys = keys.map(x => x.split('/'));
const [key, setKey] = useState(keys[0][0]);
return (
<Input
allowClear
style={{width: '280px'}}
placeholder="输入检索"
prefix={<SearchOutlined style={{color: '#c0c0c0'}}/>}
onChange={e => props.onChange(key, e.target.value)}
addonBefore={(
<Select value={key} onChange={setKey}>
{keys.map(item => (
<Select.Option key={item[0]} value={item[0]}>{item[1]}</Select.Option>
))}
</Select>
)}/>
)
}
function Footer(props) {
const actions = props.actions || [];
const length = props.selected.length;
@ -145,4 +166,5 @@ function TableCard(props) {
)
}
TableCard.Search = Search;
export default TableCard

View File

@ -0,0 +1,150 @@
/**
* 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 { observer } from 'mobx-react';
import { Input, Card, Tree, Dropdown, Menu, Switch, message } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import styles from './index.module.css';
import { http } from 'libs';
import store from './store';
import lds from 'lodash';
export default observer(function () {
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const [draggable, setDraggable] = useState(false);
const [action, setAction] = useState('');
const [expands, setExpands] = useState([]);
const [treeData, setTreeData] = useState();
const [bakTreeData, setBakTreeData] = useState();
useEffect(() => {
if (!loading) store.fetchGroups()
}, [loading])
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>
)
function handleSubmit() {
if (!store.group.title) {
return message.error('请输入分组名称')
}
setLoading(true);
const {key, parent_id, title} = store.group;
http.post('/api/host/group/', {id: key || undefined, parent_id, name: title})
.then(() => setAction(''))
.finally(() => setLoading(false))
}
function handleRemove() {
setAction('del');
setLoading(true);
http.delete('/api/host/group/', {params: {id: store.group.key}})
.finally(() => {
setAction('');
setLoading(false)
})
}
function handleAddRoot() {
setBakTreeData(lds.cloneDeep(treeData));
const current = {key: 0, parent_id: 0, title: ''};
treeData.unshift(current);
setTreeData(lds.cloneDeep(treeData));
store.group = current;
setAction('edit')
}
function handleAdd() {
setBakTreeData(lds.cloneDeep(treeData));
const current = {key: 0, parent_id: store.group.key, title: ''};
store.group.children.unshift(current);
setTreeData(lds.cloneDeep(treeData));
if (!expands.includes(store.group.key)) setExpands([store.group.key, ...expands]);
store.group = current;
setAction('edit')
}
function handleDrag(v) {
setLoading(true);
const pos = v.node.pos.split('-');
const dropPosition = v.dropPosition - Number(pos[pos.length - 1]);
http.patch('/api/host/group/', {s_id: v.dragNode.key, d_id: v.node.key, action: dropPosition})
.then(() => setLoading(false))
}
function handleBlur() {
if (store.group.key === 0) {
setTreeData(bakTreeData)
}
setAction('')
}
function handleExpand(keys, {_, node}) {
if (node.children.length > 0) {
setExpands(keys)
}
}
function treeRender(nodeData) {
if (action === 'edit' && nodeData.key === store.group.key) {
return <Input
autoFocus
size="small"
style={{width: 'calc(100% - 24px)'}}
defaultValue={nodeData.title}
suffix={loading ? <LoadingOutlined/> : <span/>}
onClick={e => e.stopPropagation()}
onBlur={handleBlur}
onChange={e => store.group.title = e.target.value}
onPressEnter={handleSubmit}/>
} else if (action === 'del' && nodeData.key === store.group.key) {
return <LoadingOutlined style={{marginLeft: '4px'}}/>
} else {
return (
<span style={{lineHeight: '24px'}}>
{nodeData.title}{nodeData.host_ids && nodeData.host_ids.length ? `${nodeData.host_ids.length}` : null}
</span>
)
}
}
return (
<Card
title="分组列表"
loading={store.grpFetching}
extra={<Switch checked={draggable} onChange={setDraggable} checkedChildren="拖拽" unCheckedChildren="浏览"/>}>
<Dropdown
overlay={menus}
visible={visible}
trigger={['contextMenu']}
onVisibleChange={v => v || setVisible(v)}>
<Tree.DirectoryTree
className={styles.dragBox}
autoExpandParent
draggable={draggable}
treeData={store.treeData}
titleRender={treeRender}
expandedKeys={expands}
selectedKeys={[store.group.key]}
onSelect={(_, {node}) => store.group = node}
onExpand={handleExpand}
onDrop={handleDrag}
onRightClick={v => {
store.group = v.node;
setVisible(true)
}}
/>
</Dropdown>
</Card>
)
})

View File

@ -36,22 +36,12 @@ class ComTable extends React.Component {
};
render() {
let data = store.permRecords;
if (store.f_name) {
data = data.filter(item => item['name'].toLowerCase().includes(store.f_name.toLowerCase()))
}
if (store.f_zone) {
data = data.filter(item => item['zone'].toLowerCase().includes(store.f_zone.toLowerCase()))
}
if (store.f_host) {
data = data.filter(item => item['hostname'].toLowerCase().includes(store.f_host.toLowerCase()))
}
return (
<TableCard
rowKey="id"
title="主机列表"
title={<TableCard.Search keys={['f_name/主机名称', 'f_host/连接地址']} onChange={(k, v) => store[k] = v}/>}
loading={store.isFetching}
dataSource={data}
dataSource={store.dataSource}
onReload={store.fetchRecords}
actions={[
<AuthButton
@ -72,11 +62,10 @@ class ComTable extends React.Component {
showTotal: total => `${total}`,
pageSizeOptions: ['10', '20', '50', '100']
}}>
<Table.Column title="类别" dataIndex="zone"/>
<Table.Column title="主机名称" dataIndex="name" sorter={(a, b) => a.name.localeCompare(b.name)}/>
<Table.Column showSorterTooltip={false} 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 width={100} title="端口" dataIndex="port"/>
<Table.Column ellipsis title="备注信息" dataIndex="desc"/>
<Table.Column hide width={100} title="端口" dataIndex="port"/>
<Table.Column hide ellipsis title="备注信息" dataIndex="desc"/>
{hasPermission('host.host.edit|host.host.del|host.host.console') && (
<Table.Column width={200} title="操作" render={info => (
<Action>

View File

@ -5,8 +5,9 @@
*/
import React from 'react';
import { observer } from 'mobx-react';
import { Input, Select } from 'antd';
import { SearchForm, AuthDiv, Breadcrumb } from 'components';
import { Row, Col } from 'antd';
import { AuthDiv, Breadcrumb } from 'components';
import Group from './Group';
import ComTable from './Table';
import ComForm from './Form';
import ComImport from './Import';
@ -19,22 +20,16 @@ export default observer(function () {
<Breadcrumb.Item>首页</Breadcrumb.Item>
<Breadcrumb.Item>主机管理</Breadcrumb.Item>
</Breadcrumb>
<SearchForm>
<SearchForm.Item span={6} title="主机类别">
<Select allowClear placeholder="请选择" value={store.f_zone} onChange={v => store.f_zone = v}>
{store.zones.map(item => (
<Select.Option value={item} key={item}>{item}</Select.Option>
))}
</Select>
</SearchForm.Item>
<SearchForm.Item span={6} title="主机别名">
<Input allowClear value={store.f_name} onChange={e => store.f_name = e.target.value} placeholder="请输入"/>
</SearchForm.Item>
<SearchForm.Item span={6} title="连接地址">
<Input allowClear value={store.f_host} onChange={e => store.f_host = e.target.value} placeholder="请输入"/>
</SearchForm.Item>
</SearchForm>
<ComTable/>
<Row gutter={12}>
<Col span={6}>
<Group/>
</Col>
<Col span={18}>
<ComTable/>
</Col>
</Row>
{store.formVisible && <ComForm/>}
{store.importVisible && <ComImport/>}
</AuthDiv>

View File

@ -0,0 +1,4 @@
.dragBox :global(.ant-tree-node-content-wrapper) {
border-top: 2px transparent solid;
border-bottom: 2px transparent solid;
}

View File

@ -3,41 +3,88 @@
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import { observable } from "mobx";
import { observable, computed } from 'mobx';
import http from 'libs/http';
class Store {
@observable records = [];
@observable zones = [];
@observable permRecords = [];
@observable treeData = [];
@observable groups = [];
@observable group = {};
@observable record = {};
@observable idMap = {};
@observable grpFetching = true;
@observable isFetching = false;
@observable formVisible = false;
@observable importVisible = false;
@observable f_name;
@observable f_zone;
@observable f_host;
@computed get dataSource() {
let records = [];
if (this.group.host_ids) records = this.records.filter(x => this.group.host_ids.includes(x.id));
if (this.f_name) records = records.filter(x => x.name.toLowerCase().includes(this.f_name.toLowerCase()));
if (this.f_host) records = records.filter(x => x.hostname.toLowerCase().includes(this.f_host.toLowerCase()));
return records
}
fetchRecords = () => {
this.isFetching = true;
return http.get('/api/host/')
.then(({hosts, zones, perms}) => {
this.records = hosts;
this.zones = zones;
this.permRecords = hosts.filter(item => perms.includes(item.id));
for (let item of hosts) {
.then(res => {
this.records = res;
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/')
.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)
}
showForm = (info = {}) => {
this.formVisible = true;
this.record = info
}
_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)
}
}
}
_updateCount = (counter, item) => {
let host_ids = counter[item.key] || [];
for (let child of item.children) {
host_ids = host_ids.concat(this._updateCount(counter, child))
}
item.host_ids = Array.from(new Set(host_ids));
return item.host_ids
}
}
export default new Store()