diff --git a/spug_api/apps/host/group.py b/spug_api/apps/host/group.py new file mode 100644 index 0000000..39e74ef --- /dev/null +++ b/spug_api/apps/host/group.py @@ -0,0 +1,96 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# 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) diff --git a/spug_api/apps/host/models.py b/spug_api/apps/host/models.py index e79bb11..74ab617 100644 --- a/spug_api/apps/host/models.py +++ b/spug_api/apps/host/models.py @@ -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 '' % 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' diff --git a/spug_api/apps/host/urls.py b/spug_api/apps/host/urls.py index 0cb30ea..71dd57a 100644 --- a/spug_api/apps/host/urls.py +++ b/spug_api/apps/host/urls.py @@ -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), ] diff --git a/spug_api/apps/host/views.py b/spug_api/apps/host/views.py index 70e5ca8..a2cb17f 100644 --- a/spug_api/apps/host/views.py +++ b/spug_api/apps/host/views.py @@ -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( diff --git a/spug_web/src/components/TableCard.js b/spug_web/src/components/TableCard.js index 48c70c6..bf6a649 100644 --- a/spug_web/src/components/TableCard.js +++ b/spug_web/src/components/TableCard.js @@ -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 ( + } + onChange={e => props.onChange(key, e.target.value)} + addonBefore={( + + )}/> + ) +} + 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 \ No newline at end of file diff --git a/spug_web/src/pages/host/Group.js b/spug_web/src/pages/host/Group.js new file mode 100644 index 0000000..743240d --- /dev/null +++ b/spug_web/src/pages/host/Group.js @@ -0,0 +1,150 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * 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 = ( + setVisible(false)}> + 新建根分组 + 新建子分组 + setAction('edit')}>重命名 + 删除分组 + + ) + + 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 : } + 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 + } else { + return ( + + {nodeData.title}{nodeData.host_ids && nodeData.host_ids.length ? `(${nodeData.host_ids.length})` : null} + + ) + } + } + + return ( + }> + v || setVisible(v)}> + store.group = node} + onExpand={handleExpand} + onDrop={handleDrag} + onRightClick={v => { + store.group = v.node; + setVisible(true) + }} + /> + + + ) +}) diff --git a/spug_web/src/pages/host/Table.js b/spug_web/src/pages/host/Table.js index a721d81..b2ab70f 100644 --- a/spug_web/src/pages/host/Table.js +++ b/spug_web/src/pages/host/Table.js @@ -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 ( store[k] = v}/>} loading={store.isFetching} - dataSource={data} + dataSource={store.dataSource} onReload={store.fetchRecords} actions={[ `共 ${total} 条`, pageSizeOptions: ['10', '20', '50', '100'] }}> - - a.name.localeCompare(b.name)}/> + a.name.localeCompare(b.name)}/> a.name.localeCompare(b.name)}/> - - + + {hasPermission('host.host.edit|host.host.del|host.host.console') && ( ( diff --git a/spug_web/src/pages/host/index.js b/spug_web/src/pages/host/index.js index 2f371a3..00c2947 100644 --- a/spug_web/src/pages/host/index.js +++ b/spug_web/src/pages/host/index.js @@ -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 () { 首页 主机管理 - - - - - - store.f_name = e.target.value} placeholder="请输入"/> - - - store.f_host = e.target.value} placeholder="请输入"/> - - - + + + + + + + + + + {store.formVisible && } {store.importVisible && } diff --git a/spug_web/src/pages/host/index.module.css b/spug_web/src/pages/host/index.module.css new file mode 100644 index 0000000..86f1290 --- /dev/null +++ b/spug_web/src/pages/host/index.module.css @@ -0,0 +1,4 @@ +.dragBox :global(.ant-tree-node-content-wrapper) { + border-top: 2px transparent solid; + border-bottom: 2px transparent solid; +} \ No newline at end of file diff --git a/spug_web/src/pages/host/store.js b/spug_web/src/pages/host/store.js index dcddaa3..ec62f1d 100644 --- a/spug_web/src/pages/host/store.js +++ b/spug_web/src/pages/host/store.js @@ -3,41 +3,88 @@ * Copyright (c) * 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()