mirror of https://github.com/openspug/spug
add host group
parent
d11ee1258d
commit
591a4d1659
|
@ -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)
|
|
@ -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'
|
||||
|
|
|
@ -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),
|
||||
]
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
})
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
.dragBox :global(.ant-tree-node-content-wrapper) {
|
||||
border-top: 2px transparent solid;
|
||||
border-bottom: 2px transparent solid;
|
||||
}
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue