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 pkey = pkey or self.private_key
return SSH(self.hostname, self.port, self.username, pkey) 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): def __repr__(self):
return '<Host %r>' % self.name return '<Host %r>' % self.name
class Meta: class Meta:
db_table = 'hosts' db_table = 'hosts'
ordering = ('-id',) 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. # Released under the AGPL-3.0 License.
from django.urls import path from django.urls import path
from .views import * from apps.host.views import *
from apps.host.group import GroupView
urlpatterns = [ urlpatterns = [
path('', HostView.as_view()), path('', HostView.as_view()),
path('group/', GroupView.as_view()),
path('import/', post_import), path('import/', post_import),
path('parse/', post_parse), path('parse/', post_parse),
] ]

View File

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

View File

@ -4,10 +4,31 @@
* Released under the AGPL-3.0 License. * Released under the AGPL-3.0 License.
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Table, Space, Divider, Popover, Checkbox, Button } from 'antd'; import { Table, Space, Divider, Popover, Checkbox, Button, Input, Select } from 'antd';
import { ReloadOutlined, SettingOutlined, FullscreenOutlined } from '@ant-design/icons'; import { ReloadOutlined, SettingOutlined, FullscreenOutlined, SearchOutlined } from '@ant-design/icons';
import styles from './index.module.less'; 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) { function Footer(props) {
const actions = props.actions || []; const actions = props.actions || [];
const length = props.selected.length; const length = props.selected.length;
@ -145,4 +166,5 @@ function TableCard(props) {
) )
} }
TableCard.Search = Search;
export default TableCard 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() { 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 ( return (
<TableCard <TableCard
rowKey="id" rowKey="id"
title="主机列表" title={<TableCard.Search keys={['f_name/主机名称', 'f_host/连接地址']} onChange={(k, v) => store[k] = v}/>}
loading={store.isFetching} loading={store.isFetching}
dataSource={data} dataSource={store.dataSource}
onReload={store.fetchRecords} onReload={store.fetchRecords}
actions={[ actions={[
<AuthButton <AuthButton
@ -72,11 +62,10 @@ class ComTable extends React.Component {
showTotal: total => `${total}`, showTotal: total => `${total}`,
pageSizeOptions: ['10', '20', '50', '100'] pageSizeOptions: ['10', '20', '50', '100']
}}> }}>
<Table.Column title="类别" dataIndex="zone"/> <Table.Column showSorterTooltip={false} title="主机名称" dataIndex="name" sorter={(a, b) => a.name.localeCompare(b.name)}/>
<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 title="连接地址" dataIndex="hostname" sorter={(a, b) => a.name.localeCompare(b.name)}/>
<Table.Column width={100} title="端口" dataIndex="port"/> <Table.Column hide width={100} title="端口" dataIndex="port"/>
<Table.Column ellipsis title="备注信息" dataIndex="desc"/> <Table.Column hide ellipsis title="备注信息" dataIndex="desc"/>
{hasPermission('host.host.edit|host.host.del|host.host.console') && ( {hasPermission('host.host.edit|host.host.del|host.host.console') && (
<Table.Column width={200} title="操作" render={info => ( <Table.Column width={200} title="操作" render={info => (
<Action> <Action>

View File

@ -5,8 +5,9 @@
*/ */
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { Input, Select } from 'antd'; import { Row, Col } from 'antd';
import { SearchForm, AuthDiv, Breadcrumb } from 'components'; import { AuthDiv, Breadcrumb } from 'components';
import Group from './Group';
import ComTable from './Table'; import ComTable from './Table';
import ComForm from './Form'; import ComForm from './Form';
import ComImport from './Import'; import ComImport from './Import';
@ -19,22 +20,16 @@ export default observer(function () {
<Breadcrumb.Item>首页</Breadcrumb.Item> <Breadcrumb.Item>首页</Breadcrumb.Item>
<Breadcrumb.Item>主机管理</Breadcrumb.Item> <Breadcrumb.Item>主机管理</Breadcrumb.Item>
</Breadcrumb> </Breadcrumb>
<SearchForm>
<SearchForm.Item span={6} title="主机类别"> <Row gutter={12}>
<Select allowClear placeholder="请选择" value={store.f_zone} onChange={v => store.f_zone = v}> <Col span={6}>
{store.zones.map(item => ( <Group/>
<Select.Option value={item} key={item}>{item}</Select.Option> </Col>
))} <Col span={18}>
</Select> <ComTable/>
</SearchForm.Item> </Col>
<SearchForm.Item span={6} title="主机别名"> </Row>
<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/>
{store.formVisible && <ComForm/>} {store.formVisible && <ComForm/>}
{store.importVisible && <ComImport/>} {store.importVisible && <ComImport/>}
</AuthDiv> </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> * Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License. * Released under the AGPL-3.0 License.
*/ */
import { observable } from "mobx"; import { observable, computed } from 'mobx';
import http from 'libs/http'; import http from 'libs/http';
class Store { class Store {
@observable records = []; @observable records = [];
@observable zones = []; @observable treeData = [];
@observable permRecords = []; @observable groups = [];
@observable group = {};
@observable record = {}; @observable record = {};
@observable idMap = {}; @observable idMap = {};
@observable grpFetching = true;
@observable isFetching = false; @observable isFetching = false;
@observable formVisible = false; @observable formVisible = false;
@observable importVisible = false; @observable importVisible = false;
@observable f_name; @observable f_name;
@observable f_zone;
@observable f_host; @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 = () => { fetchRecords = () => {
this.isFetching = true; this.isFetching = true;
return http.get('/api/host/') return http.get('/api/host/')
.then(({hosts, zones, perms}) => { .then(res => {
this.records = hosts; this.records = res;
this.zones = zones; for (let item of this.records) {
this.permRecords = hosts.filter(item => perms.includes(item.id));
for (let item of hosts) {
this.idMap[item.id] = item this.idMap[item.id] = item
} }
this._updateGroupCount()
}) })
.finally(() => this.isFetching = false) .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 = {}) => { showForm = (info = {}) => {
this.formVisible = true; this.formVisible = true;
this.record = info 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() export default new Store()