diff --git a/spug_api/apps/host/add.py b/spug_api/apps/host/add.py new file mode 100644 index 0000000..0a80e22 --- /dev/null +++ b/spug_api/apps/host/add.py @@ -0,0 +1,59 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# Released under the AGPL-3.0 License. +from libs import json_response, JsonParser, Argument +from apps.host.models import Host, HostExtend, Group +from apps.host import utils +import json + + +def get_regions(request): + form, error = JsonParser( + Argument('type', filter=lambda x: x in ('ali', 'tencent'), help='参数错误'), + Argument('ak', help='请输入AccessKey ID'), + Argument('ac', help='请输入AccessKey Secret'), + ).parse(request.GET) + if error is None: + response = [] + if form.type == 'ali': + for item in utils.fetch_ali_regions(form.ak, form.ac): + response.append({'id': item['RegionId'], 'name': item['LocalName']}) + else: + for item in utils.fetch_tencent_regions(form.ak, form.ac): + response.append({'id': item['Region'], 'name': item['RegionName']}) + return json_response(response) + return json_response(error=error) + + +def cloud_import(request): + form, error = JsonParser( + Argument('type', filter=lambda x: x in ('ali', 'tencent'), help='参数错误'), + Argument('ak', help='请输入AccessKey ID'), + Argument('ac', help='请输入AccessKey Secret'), + Argument('region_id', help='请选择区域'), + Argument('group_id', type=int, help='请选择分组') + ).parse(request.body) + if error is None: + group = Group.objects.filter(pk=form.group_id).first() + if not group: + return json_response(error='未找到指定分组') + if form.type == 'ali': + instances = utils.fetch_ali_instances(form.ak, form.ac, form.region_id) + else: + instances = utils.fetch_tencent_instances(form.ak, form.ac, form.region_id) + + host_add_ids = [] + for item in instances: + instance_id = item['instance_id'] + item['public_ip_address'] = json.dumps(item['public_ip_address'] or []) + item['private_ip_address'] = json.dumps(item['private_ip_address'] or []) + if HostExtend.objects.filter(instance_id=instance_id).exists(): + HostExtend.objects.filter(instance_id=instance_id).update(**item) + else: + host = Host.objects.create(name=instance_id, created_by=request.user) + HostExtend.objects.create(host=host, **item) + host_add_ids.append(host.id) + if host_add_ids: + group.hosts.add(*host_add_ids) + return json_response(len(instances)) + return json_response(error=error) diff --git a/spug_api/apps/host/models.py b/spug_api/apps/host/models.py index 8276939..440cf37 100644 --- a/spug_api/apps/host/models.py +++ b/spug_api/apps/host/models.py @@ -6,15 +6,17 @@ from libs import ModelMixin, human_datetime from apps.account.models import User from apps.setting.utils import AppSetting from libs.ssh import SSH +import json class Host(models.Model, ModelMixin): name = models.CharField(max_length=50) hostname = models.CharField(max_length=50) - port = models.IntegerField() + port = models.IntegerField(null=True) username = models.CharField(max_length=50) pkey = models.TextField(null=True) desc = models.CharField(max_length=255, null=True) + is_verified = models.BooleanField(default=False) created_at = models.CharField(max_length=20, default=human_datetime) created_by = models.ForeignKey(User, models.PROTECT, related_name='+') @@ -28,6 +30,8 @@ class Host(models.Model, ModelMixin): def to_view(self): tmp = self.to_dict() + if hasattr(self, 'hostextend'): + tmp.update(self.hostextend.to_view()) tmp['group_ids'] = [] return tmp @@ -55,7 +59,7 @@ class HostExtend(models.Model, ModelMixin): zone_id = models.CharField(max_length=30) cpu = models.IntegerField() memory = models.FloatField() - disk = models.CharField(max_length=255) + disk = models.CharField(max_length=255, default='[]') os_name = models.CharField(max_length=50) os_type = models.CharField(max_length=20) private_ip_address = models.CharField(max_length=255) @@ -66,6 +70,15 @@ class HostExtend(models.Model, ModelMixin): expired_time = models.CharField(max_length=20, null=True) updated_at = models.CharField(max_length=20, default=human_datetime) + def to_view(self): + tmp = self.to_dict(excludes=('id',)) + tmp['disk'] = json.loads(self.disk) + tmp['private_ip_address'] = json.loads(self.private_ip_address) + tmp['public_ip_address'] = json.loads(self.public_ip_address) + tmp['instance_charge_type_alias'] = self.get_instance_charge_type_display() + tmp['internet_charge_type_alisa'] = self.get_internet_charge_type_display() + return tmp + class Meta: db_table = 'host_extend' diff --git a/spug_api/apps/host/urls.py b/spug_api/apps/host/urls.py index 71dd57a..5aecd4f 100644 --- a/spug_api/apps/host/urls.py +++ b/spug_api/apps/host/urls.py @@ -5,10 +5,13 @@ from django.urls import path from apps.host.views import * from apps.host.group import GroupView +from apps.host.add import get_regions, cloud_import urlpatterns = [ path('', HostView.as_view()), path('group/', GroupView.as_view()), path('import/', post_import), + path('import/cloud/', cloud_import), + path('import/region/', get_regions), path('parse/', post_parse), ] diff --git a/spug_api/apps/host/utils.py b/spug_api/apps/host/utils.py new file mode 100644 index 0000000..7d32af3 --- /dev/null +++ b/spug_api/apps/host/utils.py @@ -0,0 +1,174 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# Released under the AGPL-3.0 License. +from libs.helper import make_ali_request, make_tencent_request +from collections import defaultdict +from datetime import datetime + + +def check_os_type(os_name): + os_name = os_name.lower() + types = ('centos', 'coreos', 'debian', 'suse', 'ubuntu', 'windows', 'freebsd', 'tencent', 'alibaba') + for t in types: + if t in os_name: + return t + return 'unknown' + + +def check_instance_charge_type(value, supplier): + if supplier == 'ali': + if value in ('PrePaid', 'PostPaid'): + return value + else: + return 'Other' + if supplier == 'tencent': + if value == 'PREPAID': + return 'PrePaid' + if value == 'POSTPAID_BY_HOUR': + return 'PostPaid' + return 'Other' + + +def check_internet_charge_type(value, supplier): + if supplier == 'ali': + if value in ('PayByTraffic', 'PayByBandwidth'): + return value + else: + return 'Other' + if supplier == 'tencent': + if value == 'TRAFFIC_POSTPAID_BY_HOUR': + return 'PayByTraffic' + if value in ('BANDWIDTH_PREPAID', 'BANDWIDTH_POSTPAID_BY_HOUR'): + return 'PayByBandwidth' + return 'Other' + + +def parse_utc_date(value): + if not value: + return None + s_format = '%Y-%m-%dT%H:%M:%S%z' + if len(value) == 17: + s_format = '%Y-%m-%dT%H:%M%z' + date = datetime.strptime(value, s_format) + return date.astimezone().strftime('%Y-%m-%d %H:%M:%S') + + +def parse__date(value, supplier): + if not value: + return None + date = datetime.strptime(value, '%Y-%m-%dT%H:%M%z') + return date.astimezone().strftime('%Y-%m-%d %H:%M:%S') + + +def fetch_ali_regions(ak, ac): + params = dict(Action='DescribeRegions') + res = make_ali_request(ak, ac, 'http://ecs.aliyuncs.com', params) + if 'Regions' in res: + return res['Regions']['Region'] + else: + raise Exception(res) + + +def fetch_ali_disks(ak, ac, region_id, page_number=1): + data, page_size = defaultdict(list), 20 + params = dict( + Action='DescribeDisks', + RegionId=region_id, + PageNumber=page_number, + PageSize=page_size + ) + res = make_ali_request(ak, ac, 'http://ecs.aliyuncs.com', params) + if 'Disks' in res: + for item in res['Disks']['Disk']: + data[item['InstanceId']].append(item['Size']) + if len(res['Disks']['Disk']) == page_size: + page_number += 1 + new_data = fetch_ali_disks(ak, ac, 'http://ecs.aliyuncs.com', page_number) + data.update(new_data) + return data + else: + raise Exception(res) + + +def fetch_ali_instances(ak, ac, region_id, page_number=1): + data, page_size = {}, 20 + params = dict( + Action='DescribeInstances', + RegionId=region_id, + PageNumber=page_number, + PageSize=page_size + ) + res = make_ali_request(ak, ac, 'http://ecs.aliyuncs.com', params) + if 'Instances' not in res: + raise Exception(res) + for item in res['Instances']['Instance']: + network_interface = item['NetworkInterfaces']['NetworkInterface'] + data[item['InstanceId']] = dict( + instance_id=item['InstanceId'], + os_name=item['OSName'], + os_type=check_os_type(item['OSName']), + cpu=item['Cpu'], + memory=item['Memory'] / 1024, + created_time=parse_utc_date(item['CreationTime']), + expired_time=parse_utc_date(item['ExpiredTime']), + instance_charge_type=check_instance_charge_type(item['InstanceChargeType'], 'ali'), + internet_charge_type=check_internet_charge_type(item['InternetChargeType'], 'ali'), + public_ip_address=item['PublicIpAddress']['IpAddress'], + private_ip_address=list(map(lambda x: x['PrimaryIpAddress'], network_interface)), + zone_id=item['ZoneId'] + ) + if len(res['Instances']['Instance']) == page_size: + page_number += 1 + new_data = fetch_ali_instances(ak, ac, region_id, page_number) + data.update(new_data) + if page_number != 1: + return data + for instance_id, disk in fetch_ali_disks(ak, ac, region_id).items(): + if instance_id in data: + data[instance_id]['disk'] = disk + return list(data.values()) + + +def fetch_tencent_regions(ak, ac): + params = dict(Action='DescribeRegions') + res = make_tencent_request(ak, ac, 'cvm.tencentcloudapi.com', params) + if 'RegionSet' in res['Response']: + return res['Response']['RegionSet'] + else: + raise Exception(res) + + +def fetch_tencent_instances(ak, ac, region_id, page_number=1): + data, page_size = [], 20 + params = dict( + Action='DescribeInstances', + Region=region_id, + Offset=(page_number - 1) * page_size, + Limit=page_size + ) + res = make_tencent_request(ak, ac, 'cvm.tencentcloudapi.com', params) + if 'InstanceSet' not in res['Response']: + raise Exception(res) + for item in res['Response']['InstanceSet']: + data_disks = list(map(lambda x: x['DiskSize'], item['DataDisks'])) + internet_charge_type = item['InternetAccessible']['InternetChargeType'] + data.append(dict( + instance_id=item['InstanceId'], + os_name=item['OsName'], + os_type=check_os_type(item['OsName']), + cpu=item['CPU'], + memory=item['Memory'], + disk=[item['SystemDisk']['DiskSize']] + data_disks, + created_time=parse_utc_date(item['CreatedTime']), + expired_time=parse_utc_date(item['ExpiredTime']), + instance_charge_type=check_instance_charge_type(item['InstanceChargeType'], 'tencent'), + internet_charge_type=check_internet_charge_type(internet_charge_type, 'tencent'), + public_ip_address=item['PublicIpAddresses'], + private_ip_address=item['PrivateIpAddresses'], + zone_id=item['Placement']['Zone'] + )) + if len(res['Response']['InstanceSet']) == page_size: + page_number += 1 + new_data = fetch_tencent_instances(ak, ac, region_id, page_number) + data.extend(new_data) + return data diff --git a/spug_api/apps/host/views.py b/spug_api/apps/host/views.py index bc9977b..b3f3fe4 100644 --- a/spug_api/apps/host/views.py +++ b/spug_api/apps/host/views.py @@ -10,7 +10,6 @@ from apps.host.models import Host, Group from apps.app.models import Deploy from apps.schedule.models import Task from apps.monitor.models import Detection -from apps.account.models import Role from libs.ssh import SSH, AuthenticationException from paramiko.ssh_exception import BadAuthenticationType from libs import AttrDict @@ -25,7 +24,7 @@ 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 = {x.id: x.to_view() for x in Host.objects.all()} + hosts = {x.id: x.to_view() for x in Host.objects.select_related('hostextend').all()} for rel in Group.hosts.through.objects.all(): hosts[rel.host_id]['group_ids'].append(rel.group_id) return json_response(list(hosts.values())) @@ -91,10 +90,8 @@ class HostView(View): detection = Detection.objects.filter(type__in=('3', '4'), addr=form.id).first() if detection: return json_response(error=f'监控中心的任务【{detection.name}】关联了该主机,请解除关联后再尝试删除该主机') - role = Role.objects.filter(host_perms__regex=fr'[^0-9]{form.id}[^0-9]').first() - if role: - return json_response(error=f'角色【{role.name}】的主机权限关联了该主机,请解除关联后再尝试删除该主机') Host.objects.filter(pk=form.id).delete() + print('pk: ', form.id) return json_response(error=error) diff --git a/spug_api/libs/helper.py b/spug_api/libs/helper.py new file mode 100644 index 0000000..dbd60f1 --- /dev/null +++ b/spug_api/libs/helper.py @@ -0,0 +1,60 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# Released under the AGPL-3.0 License. +from urllib.parse import quote, urlencode +from datetime import datetime +from pytz import timezone +import requests +import hashlib +import base64 +import random +import time +import hmac +import uuid + + +def _special_url_encode(value) -> str: + if isinstance(value, (str, bytes)): + rst = quote(value) + else: + rst = urlencode(value) + return rst.replace('+', '%20').replace('*', '%2A').replace('%7E', '~') + + +def _make_ali_signature(key: str, params: dict) -> bytes: + sorted_str = _special_url_encode(dict(sorted(params.items()))) + sign_str = 'GET&%2F&' + _special_url_encode(sorted_str) + sign_digest = hmac.new(key.encode(), sign_str.encode(), hashlib.sha1).digest() + return base64.encodebytes(sign_digest).strip() + + +def _make_tencent_signature(endpoint: str, key: str, params: dict) -> bytes: + sorted_str = '&'.join(f'{k}={v}' for k, v in sorted(params.items())) + sign_str = f'POST{endpoint}/?{sorted_str}' + sign_digest = hmac.new(key.encode(), sign_str.encode(), hashlib.sha1).digest() + return base64.encodebytes(sign_digest).strip() + + +def make_ali_request(ak, ac, endpoint, params): + params.update( + AccessKeyId=ak, + Format='JSON', + SignatureMethod='HMAC-SHA1', + SignatureNonce=uuid.uuid4().hex, + SignatureVersion='1.0', + Timestamp=datetime.now(tz=timezone('UTC')).strftime('%Y-%m-%dT%H:%M:%SZ'), + Version='2014-05-26' + ) + params['Signature'] = _make_ali_signature(ac + '&', params) + return requests.get(endpoint, params).json() + + +def make_tencent_request(ak, ac, endpoint, params): + params.update( + Nonce=int(random.random() * 10000), + SecretId=ak, + Timestamp=int(time.time()), + Version='2017-03-12' + ) + params['Signature'] = _make_tencent_signature(endpoint, ac, params) + return requests.post(f'https://{endpoint}', data=params).json() diff --git a/spug_web/src/pages/host/CloudImport.js b/spug_web/src/pages/host/CloudImport.js new file mode 100644 index 0000000..f0eb7e0 --- /dev/null +++ b/spug_web/src/pages/host/CloudImport.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) OpenSpug Organization. https://github.com/openspug/spug + * Copyright (c) + * Released under the AGPL-3.0 License. + */ +import React, { useState } from 'react'; +import { observer } from 'mobx-react'; +import { Modal, Form, Input, Select, Button, Steps, Cascader, message } from 'antd'; +import http from 'libs/http'; +import store from './store'; +import styles from './index.module.less'; + +export default observer(function () { + const [loading, setLoading] = useState(false); + const [step, setStep] = useState(0); + const [ak, setAK] = useState(); + const [ac, setAC] = useState(); + const [regionId, setRegionId] = useState(); + const [groupId, setGroupId] = useState([]); + const [regions, setRegions] = useState([]); + + function handleSubmit() { + setLoading(true); + const formData = {ak, ac, type: store.cloudImport, region_id: regionId, group_id: groupId[groupId.length - 1]}; + http.post('/api/host/import/cloud/', formData, {timeout: 120000}) + .then(res => { + message.success(`已同步/导入 ${res} 台主机`); + store.cloudImport = null; + store.fetchRecords() + }, () => setLoading(false)) + } + + function fetchRegions() { + setLoading(true); + http.get('/api/host/import/region/', {params: {ak, ac, type: store.cloudImport}}) + .then(res => { + setRegions(res) + setStep(1) + }) + .finally(() => setLoading(false)) + } + + return ( + store.cloudImport = null}> + + + + +
+ + + + + + {step === 0 ? ( + + ) : ([ + , + + ])} + +
+
+ ); +}) diff --git a/spug_web/src/pages/host/Detail.js b/spug_web/src/pages/host/Detail.js index 86bc3e0..cad09e7 100644 --- a/spug_web/src/pages/host/Detail.js +++ b/spug_web/src/pages/host/Detail.js @@ -20,13 +20,35 @@ export default observer(function () { {host.pkey ? '是' : '否'} {host.desc} - + {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} + ) }) \ 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 886c01a..eeb9f1b 100644 --- a/spug_web/src/pages/host/Form.js +++ b/spug_web/src/pages/host/Form.js @@ -124,7 +124,7 @@ export default observer(function () { - + {fileList.length === 0 ? : null} diff --git a/spug_web/src/pages/host/Table.js b/spug_web/src/pages/host/Table.js index 3fae7f7..c601827 100644 --- a/spug_web/src/pages/host/Table.js +++ b/spug_web/src/pages/host/Table.js @@ -5,11 +5,12 @@ */ import React, { useEffect } from 'react'; import { observer } from 'mobx-react'; -import { Table, Modal, message } from 'antd'; -import { PlusOutlined, ImportOutlined } from '@ant-design/icons'; -import { Action, TableCard, AuthButton } from 'components'; +import { Table, Modal, Dropdown, Button, Menu, Avatar, Tooltip, Space, Tag, message } from 'antd'; +import { PlusOutlined, DownOutlined } from '@ant-design/icons'; +import { Action, TableCard, AuthButton, AuthFragment } from 'components'; import { http, hasPermission } from 'libs'; import store from './store'; +import icons from './icons'; function ComTable() { useEffect(() => { @@ -30,6 +31,24 @@ function ComTable() { }) } + function handleImport(menu) { + if (menu.key === 'excel') { + store.importVisible = true + } else { + store.cloudImport = menu.key + } + } + + function IpAddress(props) { + if (props.ip && props.ip.length > 0) { + return ( +
{props.ip[0]}({props.isPublic ? '公' : '私有'})
+ ) + } else { + return null + } + } + return ( } onClick={() => store.showForm()}>新建, - } - onClick={() => store.importVisible = true}>批量导入 + + + + + + Excel + + + + + + 阿里云 + + + + + + 腾讯云 + + + + )}> + + + ]} pagination={{ showSizeChanger: true, @@ -61,14 +101,30 @@ function ComTable() { title="主机名称" render={info => store.showDetail(info)}>{info.name}} sorter={(a, b) => a.name.localeCompare(b.name)}/> - a.name.localeCompare(b.name)}/> - - + ( +
+ + +
+ )}/> + ( + + + + + {info.cpu}核 {info.memory}GB + + )}/> + + v ? 已验证 : 未验证}/> {hasPermission('host.host.edit|host.host.del|host.host.console') && ( ( store.showForm(info)}>编辑 - handleDelete(info)}>删除 + handleDelete(info)}>删除 )}/> )} diff --git a/spug_web/src/pages/host/icons/alibaba.png b/spug_web/src/pages/host/icons/alibaba.png new file mode 100644 index 0000000..0481839 Binary files /dev/null and b/spug_web/src/pages/host/icons/alibaba.png differ diff --git a/spug_web/src/pages/host/icons/centos.png b/spug_web/src/pages/host/icons/centos.png new file mode 100644 index 0000000..5f17507 Binary files /dev/null and b/spug_web/src/pages/host/icons/centos.png differ diff --git a/spug_web/src/pages/host/icons/coreos.png b/spug_web/src/pages/host/icons/coreos.png new file mode 100644 index 0000000..56ade78 Binary files /dev/null and b/spug_web/src/pages/host/icons/coreos.png differ diff --git a/spug_web/src/pages/host/icons/debian.png b/spug_web/src/pages/host/icons/debian.png new file mode 100644 index 0000000..51614f0 Binary files /dev/null and b/spug_web/src/pages/host/icons/debian.png differ diff --git a/spug_web/src/pages/host/icons/excel.png b/spug_web/src/pages/host/icons/excel.png new file mode 100644 index 0000000..b7eaa6b Binary files /dev/null and b/spug_web/src/pages/host/icons/excel.png differ diff --git a/spug_web/src/pages/host/icons/freebsd.png b/spug_web/src/pages/host/icons/freebsd.png new file mode 100644 index 0000000..cee9dcd Binary files /dev/null and b/spug_web/src/pages/host/icons/freebsd.png differ diff --git a/spug_web/src/pages/host/icons/index.js b/spug_web/src/pages/host/icons/index.js new file mode 100644 index 0000000..b67c359 --- /dev/null +++ b/spug_web/src/pages/host/icons/index.js @@ -0,0 +1,23 @@ +import iconExcel from './excel.png'; +import iconCentos from './centos.png'; +import iconAlibaba from './alibaba.png'; +import iconCoreos from './coreos.png'; +import iconDebian from './debian.png'; +import iconFreebsd from './freebsd.png'; +import iconSuse from './suse.png'; +import iconTencent from './tencent.png'; +import iconUbuntu from './ubuntu.png'; +import iconWindows from './windows.png'; + +export default { + excel: iconExcel, + alibaba: iconAlibaba, + centos: iconCentos, + coreos: iconCoreos, + debian: iconDebian, + freebsd: iconFreebsd, + suse: iconSuse, + tencent: iconTencent, + ubuntu: iconUbuntu, + windows: iconWindows, +} \ No newline at end of file diff --git a/spug_web/src/pages/host/icons/suse.png b/spug_web/src/pages/host/icons/suse.png new file mode 100644 index 0000000..83f58f8 Binary files /dev/null and b/spug_web/src/pages/host/icons/suse.png differ diff --git a/spug_web/src/pages/host/icons/tencent.png b/spug_web/src/pages/host/icons/tencent.png new file mode 100644 index 0000000..0972c98 Binary files /dev/null and b/spug_web/src/pages/host/icons/tencent.png differ diff --git a/spug_web/src/pages/host/icons/ubuntu.png b/spug_web/src/pages/host/icons/ubuntu.png new file mode 100644 index 0000000..eb81c78 Binary files /dev/null and b/spug_web/src/pages/host/icons/ubuntu.png differ diff --git a/spug_web/src/pages/host/icons/windows.png b/spug_web/src/pages/host/icons/windows.png new file mode 100644 index 0000000..482a8b1 Binary files /dev/null and b/spug_web/src/pages/host/icons/windows.png differ diff --git a/spug_web/src/pages/host/index.js b/spug_web/src/pages/host/index.js index 65beb94..28141a9 100644 --- a/spug_web/src/pages/host/index.js +++ b/spug_web/src/pages/host/index.js @@ -12,6 +12,7 @@ import Group from './Group'; import ComTable from './Table'; import ComForm from './Form'; import ComImport from './Import'; +import CloudImport from './CloudImport'; import Detail from './Detail'; import Selector from './Selector'; import store from './store'; @@ -40,6 +41,7 @@ export default observer(function () { {store.formVisible && } {store.importVisible && } + {store.cloudImport && } {store.selectorVisible && store.selectorVisible = false} onOk={store.updateGroup}/>} diff --git a/spug_web/src/pages/host/index.module.less b/spug_web/src/pages/host/index.module.less new file mode 100644 index 0000000..b6a71ac --- /dev/null +++ b/spug_web/src/pages/host/index.module.less @@ -0,0 +1,4 @@ +.steps { + width: 350px; + margin: 0 auto 30px; +} \ 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 7608480..bbc4f7f 100644 --- a/spug_web/src/pages/host/store.js +++ b/spug_web/src/pages/host/store.js @@ -20,6 +20,7 @@ class Store { @observable isFetching = false; @observable formVisible = false; @observable importVisible = false; + @observable cloudImport = null; @observable detailVisible = false; @observable selectorVisible = false;