diff --git a/spug_api/apps/host/extend.py b/spug_api/apps/host/extend.py new file mode 100644 index 0000000..d04a958 --- /dev/null +++ b/spug_api/apps/host/extend.py @@ -0,0 +1,81 @@ +# 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 libs import json_response, JsonParser, Argument, human_datetime +from apps.host.models import Host, HostExtend +from apps.host.utils import check_os_type +import ipaddress +import json + + +class ExtendView(View): + def get(self, request): + form, error = JsonParser( + Argument('host_id', type=int, help='参数错误') + ).parse(request.GET) + if error is None: + host = Host.objects.filter(pk=form.host_id).first() + if not host: + return json_response(error='未找到指定主机') + if not host.is_verified: + return json_response(error='该主机还未验证') + cli = host.get_ssh() + commands = [ + "lscpu | grep '^CPU(s)' | awk '{print $2}'", + "free -m | awk 'NR==2{print $2}'", + "hostname -I", + "cat /etc/os-release | grep PRETTY_NAME | awk -F \\\" '{print $2}'", + "fdisk -l | grep '^Disk /' | awk '{print $5}'" + ] + code, out = cli.exec_command(';'.join(commands)) + if code != 0: + return json_response(error=f'Exception: {out}') + response = {'disk': [], 'public_ip_address': [], 'private_ip_address': []} + for index, line in enumerate(out.strip().split('\n')): + if index == 0: + response['cpu'] = int(line) + elif index == 1: + response['memory'] = round(int(line) / 1000, 1) + elif index == 2: + for ip in line.split(): + if ipaddress.ip_address(ip).is_global: + response['public_ip_address'].append(ip) + else: + response['private_ip_address'].append(ip) + elif index == 3: + response['os_name'] = line + else: + response['disk'].append(round(int(line) / 1024 / 1024 / 1024, 0)) + return json_response(response) + return json_response(error=error) + + def post(self, request): + form, error = JsonParser( + Argument('host_id', type=int, help='参数错误'), + Argument('instance_id', required=False), + Argument('os_name', help='请输入操作系统'), + Argument('cpu', type=int, help='请输入CPU核心数'), + Argument('memory', type=float, help='请输入内存大小'), + Argument('disk', type=list, filter=lambda x: len(x), help='请添加磁盘'), + Argument('private_ip_address', type=list, filter=lambda x: len(x), help='请添加内网IP'), + Argument('public_ip_address', type=list, required=False), + Argument('instance_charge_type', default='Other'), + Argument('internet_charge_type', default='Other'), + Argument('created_time', required=False), + Argument('expired_time', required=False) + ).parse(request.body) + if error is None: + host = Host.objects.filter(pk=form.host_id).first() + form.disk = json.dumps(form.disk) + form.public_ip_address = json.dumps(form.public_ip_address) if form.public_ip_address else '[]' + form.private_ip_address = json.dumps(form.private_ip_address) + form.updated_at = human_datetime() + form.os_type = check_os_type(form.os_name) + if hasattr(host, 'hostextend'): + extend = host.hostextend + extend.update_by_dict(form) + else: + extend = HostExtend.objects.create(host=host, **form) + return json_response(extend.to_view()) + return json_response(error=error) diff --git a/spug_api/apps/host/models.py b/spug_api/apps/host/models.py index 440cf37..824e6e2 100644 --- a/spug_api/apps/host/models.py +++ b/spug_api/apps/host/models.py @@ -46,7 +46,7 @@ class Host(models.Model, ModelMixin): class HostExtend(models.Model, ModelMixin): INSTANCE_CHARGE_TYPES = ( ('PrePaid', '包年包月'), - ('PostPaid', '按量付费'), + ('PostPaid', '按量计费'), ('Other', '其他') ) INTERNET_CHARGE_TYPES = ( @@ -55,8 +55,8 @@ class HostExtend(models.Model, ModelMixin): ('Other', '其他') ) host = models.OneToOneField(Host, on_delete=models.CASCADE) - instance_id = models.CharField(max_length=64) - zone_id = models.CharField(max_length=30) + instance_id = models.CharField(max_length=64, null=True) + zone_id = models.CharField(max_length=30, null=True) cpu = models.IntegerField() memory = models.FloatField() disk = models.CharField(max_length=255, default='[]') @@ -66,7 +66,7 @@ class HostExtend(models.Model, ModelMixin): public_ip_address = models.CharField(max_length=255) instance_charge_type = models.CharField(max_length=20, choices=INSTANCE_CHARGE_TYPES) internet_charge_type = models.CharField(max_length=20, choices=INTERNET_CHARGE_TYPES) - created_time = models.CharField(max_length=20) + created_time = models.CharField(max_length=20, null=True) expired_time = models.CharField(max_length=20, null=True) updated_at = models.CharField(max_length=20, default=human_datetime) diff --git a/spug_api/apps/host/urls.py b/spug_api/apps/host/urls.py index 5aecd4f..d2c4a11 100644 --- a/spug_api/apps/host/urls.py +++ b/spug_api/apps/host/urls.py @@ -5,10 +5,12 @@ from django.urls import path from apps.host.views import * from apps.host.group import GroupView +from apps.host.extend import ExtendView from apps.host.add import get_regions, cloud_import urlpatterns = [ path('', HostView.as_view()), + path('extend/', ExtendView.as_view()), path('group/', GroupView.as_view()), path('import/', post_import), path('import/cloud/', cloud_import), diff --git a/spug_api/apps/host/views.py b/spug_api/apps/host/views.py index b3f3fe4..2565f4e 100644 --- a/spug_api/apps/host/views.py +++ b/spug_api/apps/host/views.py @@ -51,11 +51,14 @@ class HostView(View): if other and (not form.id or other.id != form.id): return json_response(error=f'已存在的主机名称【{form.name}】') if form.id: - Host.objects.filter(pk=form.id).update(**form) + Host.objects.filter(pk=form.id).update(is_verified=True, **form) host = Host.objects.get(pk=form.id) else: - host = Host.objects.create(created_by=request.user, **form) + host = Host.objects.create(created_by=request.user, is_verified=True, **form) host.groups.set(group_ids) + response = host.to_view() + response['group_ids'] = group_ids + return json_response(response) return json_response(error=error) def patch(self, request): @@ -91,7 +94,6 @@ class HostView(View): if detection: return json_response(error=f'监控中心的任务【{detection.name}】关联了该主机,请解除关联后再尝试删除该主机') Host.objects.filter(pk=form.id).delete() - print('pk: ', form.id) return json_response(error=error) @@ -143,13 +145,7 @@ def post_import(request): def valid_ssh(hostname, port, username, password=None, pkey=None, with_expect=True): - try: - private_key = AppSetting.get('private_key') - public_key = AppSetting.get('public_key') - except KeyError: - private_key, public_key = SSH.generate_key() - AppSetting.set('private_key', private_key, 'ssh private key') - AppSetting.set('public_key', public_key, 'ssh public key') + private_key, public_key = AppSetting.get_ssh_key() if password: _cli = SSH(hostname, port, username, password=str(password)) _cli.add_public_key(public_key) diff --git a/spug_api/apps/setting/utils.py b/spug_api/apps/setting/utils.py index d33b48e..9ecac7e 100644 --- a/spug_api/apps/setting/utils.py +++ b/spug_api/apps/setting/utils.py @@ -3,6 +3,7 @@ # Released under the AGPL-3.0 License. from functools import lru_cache from apps.setting.models import Setting, KEYS_DEFAULT +from libs.ssh import SSH import json @@ -29,3 +30,13 @@ class AppSetting: Setting.objects.update_or_create(key=key, defaults={'value': value, 'desc': desc}) else: raise KeyError('invalid key') + + @classmethod + def get_ssh_key(cls): + public_key = cls.get_default('public_key') + private_key = cls.get_default('private_key') + if not private_key or not public_key: + private_key, public_key = SSH.generate_key() + cls.set('private_key', private_key) + cls.set('public_key', public_key) + return private_key, public_key diff --git a/spug_api/libs/mixins.py b/spug_api/libs/mixins.py index 0b76b8a..8e418ce 100644 --- a/spug_api/libs/mixins.py +++ b/spug_api/libs/mixins.py @@ -18,6 +18,11 @@ class ModelMixin(object): else: return {f.attname: getattr(self, f.attname) for f in self._meta.fields} + def update_by_dict(self, data): + for key, value in data.items(): + setattr(self, key, value) + self.save() + # 使用该混入类,需要request.user对象实现has_perms方法 class PermissionMixin(object): diff --git a/spug_web/src/pages/host/Detail.js b/spug_web/src/pages/host/Detail.js index cad09e7..4f38bba 100644 --- a/spug_web/src/pages/host/Detail.js +++ b/spug_web/src/pages/host/Detail.js @@ -1,19 +1,117 @@ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { observer } from 'mobx-react'; -import { Drawer, Descriptions, List } from 'antd'; +import { Drawer, Descriptions, List, Button, Input, Select, DatePicker, Tag, message } from 'antd'; +import { EditOutlined, SaveOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons'; +import { http } from 'libs'; import store from './store'; +import lds from 'lodash'; +import moment from 'moment'; +import styles from './index.module.less'; export default observer(function () { - const host = store.record; - const group_ids = host.group_ids || []; + const [edit, setEdit] = useState(false); + const [host, setHost] = useState(store.record); + const diskInput = useRef(); + const sipInput = useRef(); + const gipInput = useRef(); + const [tag, setTag] = useState(); + const [inputVisible, setInputVisible] = useState(null); + const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(false); + + useEffect(() => { + if (store.detailVisible) { + setHost(lds.cloneDeep(store.record)) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store.detailVisible]) + + useEffect(() => { + if (inputVisible === 'disk') { + diskInput.current.focus() + } else if (inputVisible === 'sip') { + sipInput.current.focus() + } else if (inputVisible === 'gip') { + gipInput.current.focus() + } + }, [inputVisible]) + + function handleSubmit() { + setLoading(true) + if (host.created_time) host.created_time = moment(host.created_time).format('YYYY-MM-DD HH:mm:ss') + if (host.expired_time) host.expired_time = moment(host.expired_time).format('YYYY-MM-DD HH:mm:ss') + http.post('/api/host/extend/', {host_id: host.id, ...host}) + .then(res => { + Object.assign(host, res); + setEdit(false); + setHost(lds.cloneDeep(host)); + store.fetchRecords() + }) + .finally(() => setLoading(false)) + } + + function handleFetch() { + setFetching(true); + http.get('/api/host/extend/', {params: {host_id: host.id}}) + .then(res => { + Object.assign(host, res); + setHost(lds.cloneDeep(host)); + message.success('同步成功') + }) + .finally(() => setFetching(false)) + } + + function handleChange(e, key) { + host[key] = e && e.target ? e.target.value : e; + setHost({...host}) + } + + function handleClose() { + store.detailVisible = false; + setEdit(false) + } + + function handleTagConfirm(key) { + if (tag) { + if (key === 'disk') { + const value = Number(tag); + if (lds.isNaN(value)) return message.error('请输入数字'); + host.disk ? host.disk.push(value) : host.disk = [value] + } else if (key === 'sip') { + host.private_ip_address ? host.private_ip_address.push(tag) : host.private_ip_address = [tag] + } else if (key === 'gip') { + host.public_ip_address ? host.public_ip_address.push(tag) : host.public_ip_address = [tag] + } + setHost(lds.cloneDeep(host)) + } + setTag(undefined); + setInputVisible(false) + } + + function handleTagRemove(key, index) { + if (key === 'disk') { + host.disk.splice(index, 1) + } else if (key === 'sip') { + host.private_ip_address.splice(index, 1) + } else if (key === 'gip') { + host.public_ip_address.splice(index, 1) + } + setHost(lds.cloneDeep(host)) + } + return ( store.detailVisible = false} + onClose={handleClose} visible={store.detailVisible}> - 基本信息} column={1}> + 基本信息} + column={1}> {host.name} {host.username}@{host.hostname} {host.port} @@ -21,34 +119,148 @@ export default observer(function () { {host.desc} - {group_ids.map(g_id => ( + {lds.get(host, 'group_ids', []).map(g_id => ( {store.groups[g_id]} ))} - {host.id ? ( - 扩展信息}> - {host.instance_id} - {host.os_name} - {host.cpu}核 - {host.memory}GB - {host.disk.map(x => `${x}GB`).join(', ')} - {host.private_ip_address.join(', ')} - {host.public_ip_address.join(', ')} - {host.instance_charge_type_alias} - {host.internet_charge_type_alisa} - {host.created_time} - {host.expired_time || 'N/A'} - {host.updated_at} - - ) : null} - + } onClick={handleFetch}>同步, + + ]) : ( + + )} + title={扩展信息}> + + {edit ? ( + handleChange(e, 'instance_id')} placeholder="选填"/> + ) : host.instance_id} + + + {edit ? ( + handleChange(e, 'os_name')} + placeholder="例如:Ubuntu Server 16.04.1 LTS"/> + ) : host.os_name} + + + {edit ? ( + handleChange(e, 'cpu')} + placeholder="数字"/> + ) : host.cpu ? `${host.cpu}核` : null} + + + {edit ? ( + handleChange(e, 'memory')} + placeholder="数字"/> + ) : host.memory ? `${host.memory}GB` : null} + + + {lds.get(host, 'disk', []).map((item, index) => ( + handleTagRemove('disk', index)}>{item}GB + ))} + {edit && (inputVisible === 'disk' ? ( + setTag(e.target.value)} + onBlur={() => handleTagConfirm('disk')} + onPressEnter={() => handleTagConfirm('disk')} + /> + ) : ( + setInputVisible('disk')}> 新建 + ))} + + + {lds.get(host, 'private_ip_address', []).map((item, index) => ( + handleTagRemove('sip', index)}>{item} + ))} + {edit && (inputVisible === 'sip' ? ( + setTag(e.target.value)} + onBlur={() => handleTagConfirm('sip')} + onPressEnter={() => handleTagConfirm('sip')} + /> + ) : ( + setInputVisible('sip')}> 新建 + ))} + + + {lds.get(host, 'public_ip_address', []).map((item, index) => ( + handleTagRemove('gip', index)}>{item} + ))} + {edit && (inputVisible === 'gip' ? ( + setTag(e.target.value)} + onBlur={() => handleTagConfirm('gip')} + onPressEnter={() => handleTagConfirm('gip')} + /> + ) : ( + setInputVisible('gip')}> 新建 + ))} + + + {edit ? ( + + ) : host.instance_charge_type_alias} + + + {edit ? ( + + ) : host.internet_charge_type_alisa} + + + {edit ? ( + handleChange(v, 'created_time')}/> + ) : host.created_time} + + + {edit ? ( + handleChange(v, 'expired_time')}/> + ) : host.expired_time} + + {host.updated_at} + ) }) \ No newline at end of file diff --git a/spug_web/src/pages/host/Form.js b/spug_web/src/pages/host/Form.js index eeb9f1b..f3b0e6e 100644 --- a/spug_web/src/pages/host/Form.js +++ b/spug_web/src/pages/host/Form.js @@ -46,7 +46,8 @@ export default observer(function () { } else { message.success('操作成功'); store.formVisible = false; - store.fetchRecords() + store.fetchRecords(); + if (!store.record.id) handleNext(res) } }, () => setLoading(false)) } @@ -57,12 +58,24 @@ export default observer(function () { return http.post('/api/host/', formData).then(res => { message.success('验证成功'); store.formVisible = false; - store.fetchRecords() + store.fetchRecords(); + if (!store.record.id) handleNext(res) }) } message.error('请输入授权密码') } + function handleNext(res) { + Modal.confirm({ + title: '提示信息', + content: '是否继续完善主机的扩展信息?', + onOk: () => { + store.record = res; + store.detailVisible = true + } + }) + } + const ConfirmForm = (props) => (
@@ -94,14 +107,14 @@ export default observer(function () { return ( store.formVisible = false} confirmLoading={loading} onOk={handleSubmit}> - +