From d0171659bd17f3953e7e3dac7fff11b7bb1bba70 Mon Sep 17 00:00:00 2001 From: shiran Date: Sat, 29 Aug 2026 17:26:27 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20AWS=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=B8=8E=E4=BA=91=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/admin/awsService.js | 41 +++- src/api/admin/userVm.js | 10 + .../admin/ProductGoodsTreeSelector.vue | 99 +++++++++ src/config/menus.js | 3 +- src/router/index.js | 6 - src/views/aws/AwsGoods.vue | 93 ++++++-- src/views/aws/AwsInstanceDetail.vue | 132 ++++++++++++ src/views/aws/AwsInstances.vue | 45 ---- src/views/aws/AwsRuntimeVisualization.vue | 41 ++++ src/views/product/UserGoodsDetail.vue | 12 ++ .../product/components/AwsUserGoodsDetail.vue | 202 ++++++++++++++++++ src/views/user-vm/UserVmDetail.vue | 4 + .../user-vm/components/VmOperationRecords.vue | 70 ++++++ 默认模块.openapi.json | 2 +- 14 files changed, 688 insertions(+), 72 deletions(-) create mode 100644 src/components/admin/ProductGoodsTreeSelector.vue create mode 100644 src/views/aws/AwsInstanceDetail.vue delete mode 100644 src/views/aws/AwsInstances.vue create mode 100644 src/views/aws/AwsRuntimeVisualization.vue create mode 100644 src/views/product/components/AwsUserGoodsDetail.vue create mode 100644 src/views/user-vm/components/VmOperationRecords.vue diff --git a/src/api/admin/awsService.js b/src/api/admin/awsService.js index bd40ad3..d6f2d28 100644 --- a/src/api/admin/awsService.js +++ b/src/api/admin/awsService.js @@ -1,6 +1,13 @@ import { http2 } from '@/utils/request.js' const base = '/api/v1/admin/server/aws_service' +const runtimeBase = `${base}/instances/runtime` + +export const createAwsIdempotencyKey = (prefix = 'aws') => { + const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}` + return `${prefix}-${random}`.slice(0, 128) +} +const withKey = (key) => ({ headers: { 'Idempotency-Key': key || createAwsIdempotencyKey() } }) export const getAwsServiceList = (params) => http2.get(`${base}/list`, { params }) export const getAwsServiceDetail = (id) => http2.get(`${base}/detail`, { params: { id } }) @@ -15,5 +22,35 @@ export const createAwsGoods = (data) => http2.post(`${base}/goods/create`, data) export const updateAwsGoods = (data) => http2.post(`${base}/goods/update`, data) export const deleteAwsGoods = (id) => http2.delete(`${base}/goods/delete`, { params: { id } }) -export const getAwsInstanceList = (params) => http2.get(`${base}/instances/list`, { params }) -export const retryAwsInstance = (instanceId) => http2.post(`${base}/instances/retry`, null, { params: { instance_id: instanceId } }) +export const getAwsInstanceDetail = (identifier) => http2.get(`${base}/instances/detail`, { + params: typeof identifier === 'object' ? identifier : { instance_id: identifier } +}) +export const retryAwsInstance = (instanceId, note = '') => http2.post(`${base}/instances/retry`, { instance_id: instanceId, note }) +export const resyncAwsInstance = (instanceId, note = '') => http2.post(`${base}/instances/resync`, { instance_id: instanceId, note }) +export const resolveAwsInstance = (instanceId, resolution, note = '') => http2.post(`${base}/instances/resolve`, { instance_id: instanceId, resolution, note }) + +export const getAdminAwsRuntimeDetails = instanceId => http2.get(`${runtimeBase}/details`, { params: { instance_id: instanceId } }) +export const refreshAdminAwsRuntimeDetails = (instanceId, key) => http2.post(`${runtimeBase}/details/refresh`, { instance_id: instanceId }, withKey(key)) +export const getAdminAwsLoginInfo = instanceId => http2.post(`${runtimeBase}/login_info`, { instance_id: instanceId }) +export const resetAdminAwsPassword = (instanceId, key) => http2.post(`${runtimeBase}/password/reset`, { instance_id: instanceId }, withKey(key)) +export const getAdminAwsMonitoring = instanceId => http2.get(`${runtimeBase}/monitoring`, { params: { instance_id: instanceId } }) +export const getAdminAwsSecurityGroup = instanceId => http2.get(`${runtimeBase}/security_group`, { params: { instance_id: instanceId } }) +export const addAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2.post(`${runtimeBase}/security_group/rules/add`, { instance_id: instanceId, rule }, withKey(key)) +export const deleteAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2.post(`${runtimeBase}/security_group/rules/delete`, { instance_id: instanceId, rule }, withKey(key)) +export const getAdminAwsVolumes = instanceId => http2.get(`${runtimeBase}/volumes`, { params: { instance_id: instanceId } }) +export const createAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/create`, data, withKey(key)) +export const modifyAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/modify`, data, withKey(key)) +export const attachAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/attach`, data, withKey(key)) +export const detachAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/detach`, data, withKey(key)) +export const deleteAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/delete`, data, withKey(key)) +export const getAdminAwsEip = instanceId => http2.get(`${runtimeBase}/eip`, { params: { instance_id: instanceId } }) +export const replaceAdminAwsEip = (data, key) => http2.post(`${runtimeBase}/eip/replace`, data, withKey(key)) +export const disassociateAdminAwsEip = (instanceId, key) => http2.post(`${runtimeBase}/eip/disassociate`, { instance_id: instanceId }, withKey(key)) +export const releaseAdminAwsEip = (instanceId, key) => http2.post(`${runtimeBase}/eip/release`, { instance_id: instanceId }, withKey(key)) +export const updateAdminAwsEipReverseDns = (data, key) => http2.post(`${runtimeBase}/eip/reverse_dns`, data, withKey(key)) +export const getAdminAwsOperations = (instanceId, params = {}) => http2.get(`${runtimeBase}/operations`, { params: { instance_id: instanceId, ...params } }) +export const getAdminAwsOperationDetail = operationId => http2.get(`${runtimeBase}/operations/detail`, { params: { operation_id: operationId } }) +export const retryAdminAwsOperation = (operationId, note = '', key) => http2.post(`${runtimeBase}/operations/retry`, { operation_id: operationId, note }, withKey(key)) +export const resyncAdminAwsOperation = operationId => http2.post(`${runtimeBase}/operations/resync`, null, { params: { operation_id: operationId } }) +export const resolveAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/resolve`, { operation_id: operationId, note }) +export const rejectAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/reject`, { operation_id: operationId, note }) diff --git a/src/api/admin/userVm.js b/src/api/admin/userVm.js index 5527691..0cc3d8c 100644 --- a/src/api/admin/userVm.js +++ b/src/api/admin/userVm.js @@ -114,6 +114,16 @@ export const renewUserGoods = (data) => http2.post(`${GOODS_BASE}/renew`, fd(dat export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params }) export const getUserVmTrafficHourly = (params) => http2.get(`${BASE}/traffic_hourly`, { params }) +// ========== 虚拟机专用操作日志 ========== +const OPERATION_RECORD_BASE = '/api/v1/admin/host_service/point/vm/operation_record' +export const getVmOperationRecordList = (params) => http2.get(`${OPERATION_RECORD_BASE}/list`, { params }) +export const getVmOperationRecordDetail = (id) => http2.get(`${OPERATION_RECORD_BASE}/detail`, { params: { id } }) +export const refreshVmOperationRecord = (id) => http2.post( + `${OPERATION_RECORD_BASE}/refresh`, + fd({ id }), + { headers: { 'Content-Type': 'multipart/form-data' } } +) + // ========== 流量策略 ========== // 测试未通过(接口新增,待联调) export const getUserVmTrafficPolicy = (params) => http2.get(`${BASE}/traffic_policy`, { params }) diff --git a/src/components/admin/ProductGoodsTreeSelector.vue b/src/components/admin/ProductGoodsTreeSelector.vue new file mode 100644 index 0000000..f98affa --- /dev/null +++ b/src/components/admin/ProductGoodsTreeSelector.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/config/menus.js b/src/config/menus.js index e2eff65..17fa551 100644 --- a/src/config/menus.js +++ b/src/config/menus.js @@ -186,8 +186,7 @@ export const menus = [ icon: 'Cloudy', children: [ { path: '/aws/services', title: '服务配置' }, - { path: '/aws/goods', title: '商品绑定' }, - { path: '/aws/instances', title: '履约实例' } + { path: '/aws/goods', title: '商品绑定' } ] }, { diff --git a/src/router/index.js b/src/router/index.js index 27c992c..7d2a034 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -689,12 +689,6 @@ const routes = [ name: 'AwsGoods', component: () => import('../views/aws/AwsGoods.vue'), meta: { title: '商品绑定' } - }, - { - path: 'instances', - name: 'AwsInstances', - component: () => import('../views/aws/AwsInstances.vue'), - meta: { title: '履约实例' } } ] }, diff --git a/src/views/aws/AwsGoods.vue b/src/views/aws/AwsGoods.vue index 01d099c..3f04634 100644 --- a/src/views/aws/AwsGoods.vue +++ b/src/views/aws/AwsGoods.vue @@ -10,52 +10,113 @@ +
- - - - - - + + + + + + + +
+ + 选择 +
+
+
+ +
+ + + + + + + + + 绑定 EIP加密系统盘安装 Agent启用绑定 + 登录凭据密码重置监控安全组数据卷读取
+ diff --git a/src/views/aws/AwsInstanceDetail.vue b/src/views/aws/AwsInstanceDetail.vue new file mode 100644 index 0000000..c71ce1a --- /dev/null +++ b/src/views/aws/AwsInstanceDetail.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/views/aws/AwsInstances.vue b/src/views/aws/AwsInstances.vue deleted file mode 100644 index 0329e6f..0000000 --- a/src/views/aws/AwsInstances.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - diff --git a/src/views/aws/AwsRuntimeVisualization.vue b/src/views/aws/AwsRuntimeVisualization.vue new file mode 100644 index 0000000..0e75dca --- /dev/null +++ b/src/views/aws/AwsRuntimeVisualization.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/src/views/product/UserGoodsDetail.vue b/src/views/product/UserGoodsDetail.vue index 8aad036..c22f3da 100644 --- a/src/views/product/UserGoodsDetail.vue +++ b/src/views/product/UserGoodsDetail.vue @@ -210,6 +210,8 @@ + + @@ -336,6 +338,7 @@ import { extractApiError } from '@/utils/kvmErrorUtil' import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue' import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue' import UserVmDetail from '@/views/user-vm/UserVmDetail.vue' +import AwsUserGoodsDetail from './components/AwsUserGoodsDetail.vue' import dayjs from 'dayjs' const route = useRoute() @@ -345,6 +348,7 @@ const goodsId = computed(() => parseInt(route.params.id) || 0) const loading = ref(false) const refreshing = ref(false) const vmDetailRef = ref(null) +const awsDetailRef = ref(null) const submitLoading = ref(false) const detail = ref(null) const detailExpanded = ref(false) @@ -387,6 +391,10 @@ const expireStatusClass = computed(() => { const goBack = () => router.push('/user-goods/list') const currentTag = computed(() => (detail.value?.tag || detail.value?.good?.tag || '').toLowerCase()) +const isAwsGoods = computed(() => { + const tag = currentTag.value.replace(/\s+/g, '') + return tag === 'aws' || tag.includes('aws云服务器') || tag.includes('aws服务器') +}) // 是否已删除(后端返回 deleteAt 字段,有值即已删除) const isDeleted = computed(() => !!(detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at)) @@ -458,6 +466,10 @@ const refreshDetail = async () => { await nextTick() await vmDetailRef.value?.refresh?.() } + if (isAwsGoods.value && !isDeleted.value) { + await nextTick() + await awsDetailRef.value?.refresh?.() + } } finally { refreshing.value = false } diff --git a/src/views/product/components/AwsUserGoodsDetail.vue b/src/views/product/components/AwsUserGoodsDetail.vue new file mode 100644 index 0000000..e6a1e98 --- /dev/null +++ b/src/views/product/components/AwsUserGoodsDetail.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/views/user-vm/UserVmDetail.vue b/src/views/user-vm/UserVmDetail.vue index 89fdceb..c683908 100644 --- a/src/views/user-vm/UserVmDetail.vue +++ b/src/views/user-vm/UserVmDetail.vue @@ -564,6 +564,9 @@ + + + @@ -1215,6 +1218,7 @@ import UserSelector from '@/components/UserSelector/index.vue' import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupSelector.vue' import UserVmVolumeSelector from '@/components/admin/UserVmVolumeSelector.vue' import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue' +import VmOperationRecords from './components/VmOperationRecords.vue' import dayjs from 'dayjs' import * as echarts from 'echarts' diff --git a/src/views/user-vm/components/VmOperationRecords.vue b/src/views/user-vm/components/VmOperationRecords.vue new file mode 100644 index 0000000..a2819ad --- /dev/null +++ b/src/views/user-vm/components/VmOperationRecords.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/默认模块.openapi.json b/默认模块.openapi.json index 10d90e9..bc9b325 100644 --- a/默认模块.openapi.json +++ b/默认模块.openapi.json @@ -24029,7 +24029,7 @@ }, "/api/v1/admin/good/user_vm/migrate": { "post": { - "summary": "迁移虚拟机(更换宿主机)", + "summary": "迁移虚拟机(更换宿主机,不保留数据)", "deprecated": false, "description": "", "tags": [ -- 2.54.0 From 543bbb0b6994408076b2ff5da6ec7f4d939b569f Mon Sep 17 00:00:00 2001 From: shiran Date: Sat, 5 Sep 2026 17:23:48 +0800 Subject: [PATCH 2/5] feat: enhance AWS and product management --- src/api/admin/awsService.js | 4 + src/views/aws/AwsGoods.vue | 87 ++-- src/views/aws/AwsInstanceDetail.vue | 67 ++- src/views/aws/AwsRuntimeVisualization.vue | 13 +- .../aws/components/AwsGoodsPolicyForm.vue | 82 ++++ src/views/aws/components/AwsVolumeEditor.vue | 20 + src/views/product/ProductGroup.vue | 230 +++++----- .../product/components/AwsUserGoodsDetail.css | 417 ++++++++++++++++++ .../product/components/AwsUserGoodsDetail.vue | 338 +++++++------- src/views/user/UserDetail.vue | 162 ++++++- 10 files changed, 1085 insertions(+), 335 deletions(-) create mode 100644 src/views/aws/components/AwsGoodsPolicyForm.vue create mode 100644 src/views/aws/components/AwsVolumeEditor.vue create mode 100644 src/views/product/components/AwsUserGoodsDetail.css diff --git a/src/api/admin/awsService.js b/src/api/admin/awsService.js index d6f2d28..4e697b6 100644 --- a/src/api/admin/awsService.js +++ b/src/api/admin/awsService.js @@ -40,6 +40,7 @@ export const deleteAdminAwsSecurityGroupRule = (instanceId, rule, key) => http2. export const getAdminAwsVolumes = instanceId => http2.get(`${runtimeBase}/volumes`, { params: { instance_id: instanceId } }) export const createAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/create`, data, withKey(key)) export const modifyAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/modify`, data, withKey(key)) +export const expandAdminAwsRootVolume = (instanceId, sizeGib, key) => http2.post(`${runtimeBase}/root_volume/expand`, { instance_id: instanceId, size_gib: sizeGib }, withKey(key)) export const attachAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/attach`, data, withKey(key)) export const detachAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/detach`, data, withKey(key)) export const deleteAdminAwsVolume = (data, key) => http2.post(`${runtimeBase}/volumes/delete`, data, withKey(key)) @@ -54,3 +55,6 @@ export const retryAdminAwsOperation = (operationId, note = '', key) => http2.pos export const resyncAdminAwsOperation = operationId => http2.post(`${runtimeBase}/operations/resync`, null, { params: { operation_id: operationId } }) export const resolveAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/resolve`, { operation_id: operationId, note }) export const rejectAdminAwsOperation = (operationId, note = '') => http2.post(`${runtimeBase}/operations/reject`, { operation_id: operationId, note }) +export const rebuildAdminAwsInstance = (instanceId, imageId, key) => http2.post(`${runtimeBase}/rebuild`, { instance_id: instanceId, image_id: imageId }, withKey(key)) +export const getAdminAwsTrafficLimit = instanceId => http2.get(`${runtimeBase}/traffic_limit`, { params: { instance_id: instanceId } }) +export const updateAdminAwsTrafficLimit = (instanceId, changes, key) => http2.post(`${runtimeBase}/traffic_limit/update`, { instance_id: instanceId, ...changes }, withKey(key)) diff --git a/src/views/aws/AwsGoods.vue b/src/views/aws/AwsGoods.vue index 3f04634..a1df370 100644 --- a/src/views/aws/AwsGoods.vue +++ b/src/views/aws/AwsGoods.vue @@ -15,47 +15,30 @@
- + - - - - - - -
- - 选择 -
-
-
- -
- - - - - - - - - - - 绑定 EIP加密系统盘安装 Agent启用绑定 - 登录凭据密码重置监控安全组数据卷读取 - - + + +
+ +
选择
+ +
+ + + 绑定 EIP加密系统盘安装 Agent启用绑定 +
+
+ +
+
+ +
登录凭据密码重置监控安全组数据卷读取SSH 密钥管理
新增 EIP更换 EIPPTR 反向解析数据盘变更系统盘扩容系统重装流量提升
启用每次更换附加费元 / 次计入用户 EIP 更换实时报价
+
+
+
+
@@ -70,15 +53,18 @@ import { ElMessage } from 'element-plus' import { Plus } from '@element-plus/icons-vue' import { createAwsGoods, deleteAwsGoods, getAwsGoodsDetail, getAwsGoodsList, getAwsServiceList, testAwsService, updateAwsGoods } from '@/api/admin/awsService.js' import ProductGoodsTreeSelector from '@/components/admin/ProductGoodsTreeSelector.vue' -const route=useRoute(), loading=ref(false), submitting=ref(false), regionsLoading=ref(false), rows=ref([]), services=ref([]), regionOptions=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref(), goodsSelectorVisible=ref(false) +import AwsGoodsPolicyForm from './components/AwsGoodsPolicyForm.vue' +const route=useRoute(), loading=ref(false), submitting=ref(false), regionsLoading=ref(false), rows=ref([]), services=ref([]), regionOptions=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref(), defaultsFormRef=ref(), constraintsFormRef=ref(), activeFormTab=ref('basic'), goodsSelectorVisible=ref(false) let regionRequestId=0 const query=reactive({page:1,count:20,service_id:route.query.service_id?Number(route.query.service_id):undefined}) -const empty=()=>({id:null,good_id:null,goods_target_type:'',goods_target_id:null,goods_target_name:'',service_id:null,allowed_regions:[],allowed_image_categories:[],allowed_images:[],allowed_type_categories:[],allowed_types:[],defaults_text:'{}',constraints_text:'{}',force_associate_eip:false,force_encrypted_root_volume:true,force_install_agent:true,allow_user_login_info:true,allow_user_password_reset:true,allow_user_monitoring:true,allow_user_security_group:true,allow_user_volume_read:true,grace_period_hours:168,exchange_rate:1,enabled:true}) +const normalizeVolume=(source={})=>({device_name:source.device_name||'',size_gib:Number(source.size_gib||20),type:source.type||'gp3',iops:Number(source.iops||0),throughput:Number(source.throughput||0),encrypted:source.encrypted!==false,kms_key_id:source.kms_key_id||'',delete_on_termination:source.delete_on_termination===true}) +const normalizeDefaults=(source={})=>({...source,placement_id:source.placement_id||'',image_id:source.image_id||'',instance_type:source.instance_type||'',name:source.name||'',security_group_mode:source.security_group_mode||'',iam_instance_profile_arn:source.iam_instance_profile_arn||'',user_data:source.user_data||'',private_ip_address:source.private_ip_address||'',associate_eip:source.associate_eip===true,monitoring:source.monitoring===true,ebs_optimized:source.ebs_optimized===true,shutdown_behavior:source.shutdown_behavior||'',root_volume:source.root_volume?normalizeVolume(source.root_volume):null,data_volumes:Array.isArray(source.data_volumes)?source.data_volumes.map(normalizeVolume):[],_tag_rows:Object.entries(source.tags||{}).map(([key,value])=>({key,value:String(value)})),install_agent:source.install_agent===true,traffic_limit_gib:Number(source.traffic_limit_gib||0)}) +const normalizeConstraints=(source={})=>({...source,root_volume_min_gib:Number(source.root_volume_min_gib||0),root_volume_max_gib:Number(source.root_volume_max_gib||0),allowed_volume_types:Array.isArray(source.allowed_volume_types)?[...source.allowed_volume_types]:[],associate_eip_locked:source.associate_eip_locked===true}) +const empty=()=>({id:null,good_id:null,goods_target_type:'',goods_target_id:null,goods_target_name:'',service_id:null,allowed_regions:[],allowed_image_categories:[],allowed_images:[],allowed_type_categories:[],allowed_types:[],defaults:normalizeDefaults(),constraints:normalizeConstraints(),force_associate_eip:false,force_encrypted_root_volume:true,force_install_agent:true,allow_user_login_info:true,allow_user_password_reset:true,allow_user_monitoring:true,allow_user_security_group:true,allow_user_volume_read:true,allow_user_ssh_key_manage:false,allow_user_eip_add:false,allow_user_eip_replace:false,allow_user_eip_reverse_dns:false,eip_replace_fee_enabled:false,eip_replace_fee_yuan:0,allow_user_volume_mutation:false,allow_user_root_volume_expand:false,allow_user_rebuild:false,allow_user_traffic_limit:false,grace_period_hours:168,exchange_rate:1,enabled:true}) const form=reactive(empty()) const selectedGoodsTargetLabel=computed(()=>form.goods_target_id?`${form.goods_target_type==='group'?'商品组':'商品'}:${form.goods_target_name||''} (#${form.goods_target_id})`:'') const regionPlaceholder=computed(()=>!form.service_id?'请先选择 AWS 服务':regionsLoading.value?'正在读取可用区域':regionOptions.value.length?'请选择允许销售的区域':'暂无可用区域') -const jsonRule=(_,value,cb)=>{try{const v=JSON.parse(value||'{}');if(!v||Array.isArray(v)||typeof v!=='object')throw new Error();cb()}catch{cb(new Error('请输入有效的 JSON 对象'))}} -const rules={good_id:[{required:true,message:'请输入商品 ID'}],goods_target_id:[{required:true,message:'请选择商品或商品组'}],service_id:[{required:true,message:'请选择服务'}],allowed_regions:[{type:'array',required:true,min:1,message:'至少填写一个区域'}],exchange_rate:[{required:true,message:'请输入大于 0 的汇率'}],defaults_text:[{validator:jsonRule}],constraints_text:[{validator:jsonRule}]} +const rules={good_id:[{required:true,message:'请输入商品 ID'}],goods_target_id:[{required:true,message:'请选择商品或商品组'}],service_id:[{required:true,message:'请选择服务'}],allowed_regions:[{type:'array',required:true,min:1,message:'至少填写一个区域'}],exchange_rate:[{required:true,message:'请输入大于 0 的汇率'}]} const body=r=>r?.data||{} async function loadServices(){const b=body(await getAwsServiceList({page:1,count:100}));if(b.code===200)services.value=b.data?.data||b.data?.list||[]} async function load(){loading.value=true;try{const params={...query};if(!params.service_id)delete params.service_id;const b=body(await getAwsGoodsList(params));if(b.code===200){rows.value=b.data?.data||b.data?.list||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}} @@ -108,15 +94,18 @@ async function loadAvailableRegions(serviceId){ } } function changeService(serviceId){form.allowed_regions=[];formRef.value?.clearValidate('allowed_regions');loadAvailableRegions(serviceId)} -function openCreate(){editing.value=false;Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true;loadAvailableRegions(form.service_id)} -async function openEdit(row){editing.value=true;let data=row;try{const b=body(await getAwsGoodsDetail(row.id));if(b.code===200)data=b.data}catch{}Object.assign(form,{id:data.id,good_id:data.goodId,service_id:data.serviceId,allowed_regions:[...(data.allowedRegions||[])],allowed_image_categories:[...(data.allowedImageCategories||[])],allowed_images:[...(data.allowedImages||[])],allowed_type_categories:[...(data.allowedTypeCategories||[])],allowed_types:[...(data.allowedTypes||[])],defaults_text:JSON.stringify(data.defaults||{},null,2),constraints_text:JSON.stringify(data.constraints||{},null,2),force_associate_eip:!!data.forceAssociateEip,force_encrypted_root_volume:!!data.forceEncryptedRootVolume,force_install_agent:!!data.forceInstallAgent,allow_user_login_info:data.allowUserLoginInfo===true,allow_user_password_reset:data.allowUserPasswordReset===true,allow_user_monitoring:data.allowUserMonitoring===true,allow_user_security_group:data.allowUserSecurityGroup===true,allow_user_volume_read:data.allowUserVolumeRead===true,grace_period_hours:data.gracePeriodHours??168,exchange_rate:data.exchangeRate??1,enabled:!!data.enabled});dialog.value=true;loadAvailableRegions(form.service_id)} +function openCreate(){editing.value=false;activeFormTab.value='basic';Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true;loadAvailableRegions(form.service_id)} +async function openEdit(row){editing.value=true;activeFormTab.value='basic';let data=row;try{const b=body(await getAwsGoodsDetail(row.id));if(b.code===200)data=b.data}catch{}Object.assign(form,{id:data.id,good_id:data.goodId,service_id:data.serviceId,allowed_regions:[...(data.allowedRegions||[])],allowed_image_categories:[...(data.allowedImageCategories||[])],allowed_images:[...(data.allowedImages||[])],allowed_type_categories:[...(data.allowedTypeCategories||[])],allowed_types:[...(data.allowedTypes||[])],defaults:normalizeDefaults(data.defaults||{}),constraints:normalizeConstraints(data.constraints||{}),force_associate_eip:!!data.forceAssociateEip,force_encrypted_root_volume:!!data.forceEncryptedRootVolume,force_install_agent:!!data.forceInstallAgent,allow_user_login_info:data.allowUserLoginInfo===true,allow_user_password_reset:data.allowUserPasswordReset===true,allow_user_monitoring:data.allowUserMonitoring===true,allow_user_security_group:data.allowUserSecurityGroup===true,allow_user_volume_read:data.allowUserVolumeRead===true,allow_user_ssh_key_manage:data.allowUserSshKeyManage===true,allow_user_eip_add:data.allowUserEipAdd===true,allow_user_eip_replace:data.allowUserEipReplace===true,allow_user_eip_reverse_dns:data.allowUserEipReverseDns===true,eip_replace_fee_enabled:data.eipReplaceFeeEnabled===true,eip_replace_fee_yuan:Number(data.eipReplaceFee||0)/100,allow_user_volume_mutation:data.allowUserVolumeMutation===true,allow_user_root_volume_expand:data.allowUserRootVolumeExpand===true,allow_user_rebuild:data.allowUserRebuild===true,allow_user_traffic_limit:data.allowUserTrafficLimit===true,grace_period_hours:data.gracePeriodHours??168,exchange_rate:data.exchangeRate??1,enabled:!!data.enabled});dialog.value=true;loadAvailableRegions(form.service_id)} function selectGoodsTarget(target){form.goods_target_type=target.type;form.goods_target_id=target.id;form.goods_target_name=target.name||'';formRef.value?.clearValidate('goods_target_id')} -function payload(){return{service_id:form.service_id,allowed_regions:form.allowed_regions,allowed_image_categories:form.allowed_image_categories,allowed_images:form.allowed_images,allowed_type_categories:form.allowed_type_categories,allowed_types:form.allowed_types,defaults:JSON.parse(form.defaults_text||'{}'),constraints:JSON.parse(form.constraints_text||'{}'),force_associate_eip:form.force_associate_eip,force_encrypted_root_volume:form.force_encrypted_root_volume,force_install_agent:form.force_install_agent,allow_user_login_info:form.allow_user_login_info,allow_user_password_reset:form.allow_user_password_reset,allow_user_monitoring:form.allow_user_monitoring,allow_user_security_group:form.allow_user_security_group,allow_user_volume_read:form.allow_user_volume_read,grace_period_hours:form.grace_period_hours,exchange_rate:form.exchange_rate,enabled:form.enabled}} -function enabledCapabilities(row){return[[row.allowUserLoginInfo,'凭据'],[row.allowUserPasswordReset,'重置密码'],[row.allowUserMonitoring,'监控'],[row.allowUserSecurityGroup,'安全组'],[row.allowUserVolumeRead,'数据卷']].filter(v=>v[0]===true).map(v=>v[1])} -async function submit(){await formRef.value?.validate();submitting.value=true;try{const data=payload();if(editing.value)data.id=form.id;else if(form.goods_target_type==='group')data.good_group_id=form.goods_target_id;else data.good_id=form.goods_target_id;const b=body(await(editing.value?updateAwsGoods(data):createAwsGoods(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}} +function serializeVolume(source){const volume={device_name:String(source.device_name||'').trim(),size_gib:Number(source.size_gib),type:source.type,encrypted:source.encrypted===true,delete_on_termination:source.delete_on_termination===true};if(['gp3','io1','io2'].includes(volume.type)&&Number(source.iops)>0)volume.iops=Number(source.iops);if(volume.type==='gp3'&&Number(source.throughput)>0)volume.throughput=Number(source.throughput);if(volume.encrypted&&source.kms_key_id)volume.kms_key_id=String(source.kms_key_id).trim();if(!volume.device_name)delete volume.device_name;return volume} +function serializeDefaults(source){const defaults={...source},stringFields=['placement_id','image_id','instance_type','name','security_group_mode','iam_instance_profile_arn','user_data','private_ip_address','shutdown_behavior'];for(const key of stringFields){const value=String(defaults[key]||'').trim();if(value)defaults[key]=value;else delete defaults[key]}defaults.root_volume=source.root_volume?serializeVolume(source.root_volume):undefined;defaults.data_volumes=(source.data_volumes||[]).map(serializeVolume);defaults.tags=Object.fromEntries((source._tag_rows||[]).filter(v=>v.key).map(v=>[v.key,String(v.value||'')]));delete defaults._tag_rows;if(!defaults.root_volume)delete defaults.root_volume;if(!defaults.data_volumes.length)delete defaults.data_volumes;if(!Object.keys(defaults.tags).length)delete defaults.tags;if(Number(defaults.traffic_limit_gib)>0)defaults.traffic_limit_gib=Number(defaults.traffic_limit_gib);else delete defaults.traffic_limit_gib;return defaults} +function serializeConstraints(source){return{...source,root_volume_min_gib:Number(source.root_volume_min_gib||0),root_volume_max_gib:Number(source.root_volume_max_gib||0),allowed_volume_types:[...(source.allowed_volume_types||[])],associate_eip_locked:source.associate_eip_locked===true}} +function payload(){return{service_id:form.service_id,allowed_regions:form.allowed_regions,allowed_image_categories:form.allowed_image_categories,allowed_images:form.allowed_images,allowed_type_categories:form.allowed_type_categories,allowed_types:form.allowed_types,defaults:serializeDefaults(form.defaults),constraints:serializeConstraints(form.constraints),force_associate_eip:form.force_associate_eip,force_encrypted_root_volume:form.force_encrypted_root_volume,force_install_agent:form.force_install_agent,allow_user_login_info:form.allow_user_login_info,allow_user_password_reset:form.allow_user_password_reset,allow_user_monitoring:form.allow_user_monitoring,allow_user_security_group:form.allow_user_security_group,allow_user_volume_read:form.allow_user_volume_read,allow_user_ssh_key_manage:form.allow_user_ssh_key_manage,allow_user_eip_add:form.allow_user_eip_add,allow_user_eip_replace:form.allow_user_eip_replace,allow_user_eip_reverse_dns:form.allow_user_eip_reverse_dns,eip_replace_fee_enabled:form.allow_user_eip_replace&&form.eip_replace_fee_enabled,eip_replace_fee:Math.round(Number(form.eip_replace_fee_yuan||0)*100),allow_user_volume_mutation:form.allow_user_volume_mutation,allow_user_root_volume_expand:form.allow_user_root_volume_expand,allow_user_rebuild:form.allow_user_rebuild,allow_user_traffic_limit:form.allow_user_traffic_limit,grace_period_hours:form.grace_period_hours,exchange_rate:form.exchange_rate,enabled:form.enabled}} +function enabledCapabilities(row){return[[row.allowUserLoginInfo,'凭据'],[row.allowUserPasswordReset,'重置密码'],[row.allowUserMonitoring,'监控'],[row.allowUserSecurityGroup,'安全组'],[row.allowUserVolumeRead,'数据卷'],[row.allowUserSshKeyManage,'SSH 密钥'],[row.allowUserEipAdd,'加 EIP'],[row.allowUserEipReplace,'换 EIP'],[row.allowUserEipReverseDns,'PTR'],[row.allowUserVolumeMutation,'磁盘变更'],[row.allowUserRootVolumeExpand,'系统盘扩容'],[row.allowUserRebuild,'重装'],[row.allowUserTrafficLimit,'流量提升'],[row.allowUserEipReplace&&row.eipReplaceFeeEnabled,`换 IP 费 ¥${(Number(row.eipReplaceFee||0)/100).toFixed(2)}`]].filter(v=>v[0]===true).map(v=>v[1])} +async function submit(){try{await formRef.value?.validate()}catch{activeFormTab.value='basic';return}const defaultsError=defaultsFormRef.value?.validate?.();if(defaultsError){activeFormTab.value='defaults';return ElMessage.warning(defaultsError)}const constraintsError=constraintsFormRef.value?.validate?.();if(constraintsError){activeFormTab.value='constraints';return ElMessage.warning(constraintsError)}submitting.value=true;try{const data=payload();if(editing.value)data.id=form.id;else if(form.goods_target_type==='group')data.good_group_id=form.goods_target_id;else data.good_id=form.goods_target_id;const b=body(await(editing.value?updateAwsGoods(data):createAwsGoods(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}} async function remove(row){const b=body(await deleteAwsGoods(row.id));if(b.code===200){ElMessage.success('删除成功');load()}else ElMessage.error(b.message||'删除失败')} onMounted(()=>{loadServices();load()}) diff --git a/src/views/aws/AwsInstanceDetail.vue b/src/views/aws/AwsInstanceDetail.vue index c71ce1a..c1c5f6d 100644 --- a/src/views/aws/AwsInstanceDetail.vue +++ b/src/views/aws/AwsInstanceDetail.vue @@ -2,7 +2,7 @@
← 返回

AWS 实例运维 #{{ instanceId }}

履约审核、远端资源和运行期操作统一管理

刷新
-
+
#{{instance.userGoodsId}} #{{instance.orderId}} @@ -16,29 +16,32 @@
履约记录与失败审核
重新同步审核重试确认解决驳回
{{instance.reviewStatus||'-'}}{{statusText(instance.previousProvisionStatus)}}{{instance.remoteTaskId||'-'}}{{instance.retryCount||0}}{{instance.lastErrorCode||'-'}}{{instance.failureStage||'-'}}{{instance.lastError||'-'}}{{instance.reviewNote||'-'}} -

创建请求

{{pretty(baseDetail?.request)}}

资源快照

{{snapshotText}}
+

开通配置

{{item.label}}{{item.value}}
-
实时详情与访问控制
刷新详情主动刷新{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'读取登录凭据'}}重置密码读取监控
+
实时详情与访问控制
刷新详情主动刷新{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'读取登录凭据'}}重置密码读取监控重装系统流量配置
-

实时详情

{{pretty(runtimeDetails)}}

监控聚合

{{pretty(monitoring)}}
安全组
刷新添加规则
- -
EIP
确保/更换更新 PTR解绑释放
{{pretty(eip)}}
+
安全组名称{{security.group_name||security.name||'-'}}{{security.group_id||security.id||'-'}}
所属 VPC{{security.vpc_id||'-'}}
规则统计{{(security.ingress_rules||[]).length}} 条入站 · {{(security.egress_rules||[]).length}} 条出站
AWS 托管
+ + +
弹性公网 IP
确保/更换更新 PTR解绑释放
+
公网 / 私网地址{{eipCurrent.public_ip||'-'}}{{eipCurrent.private_ip||'-'}}
Allocation ID{{eipCurrent.allocation_id||'-'}}
网络接口{{eipCurrent.network_interface_id||'-'}}
PTR 记录{{eipCurrent.ptr_record||'未设置'}}
已关联
+
数据卷
刷新创建并挂载
- +
运行期异步操作刷新
- +
@@ -60,8 +63,10 @@ - - -
{{pretty(currentOperation)}}
+ + + {{trafficLimit?.enabled?'已启用':'未启用'}}{{trafficLimit?.limit_bytes?bytes(trafficLimit.limit_bytes):'-'}} +

{{operationTypeText(currentOperation.operationType)}}

操作记录 #{{currentOperation.id}}

{{operationText(currentOperation.status)}}
#{{currentOperation.instanceId||'-'}}#{{currentOperation.userGoodsId}}-#{{currentOperation.orderId}}-{{currentOperation.targetRenewPrice==null?'-':`¥${cents(currentOperation.targetRenewPrice)}`}}{{time(currentOperation.nextRetryAt)}}{{time(currentOperation.finishedAt)}}{{currentOperation.retryCount||0}}{{time(currentOperation.UpdatedAt)}}{{currentOperation.errorMessage}}
@@ -69,37 +74,50 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { useRoute } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { InfoFilled, User } from '@element-plus/icons-vue' +import { Connection, InfoFilled, Lock, Operation, User } from '@element-plus/icons-vue' import * as api from '@/api/admin/awsService.js' import AwsRuntimeVisualization from './AwsRuntimeVisualization.vue' const props=defineProps({embedded:{type:Boolean,default:false},instanceId:{type:Number,default:0}}) -const route=useRoute(),embedded=computed(()=>props.embedded),instanceId=props.instanceId||Number(route.params.instanceId),loading=ref(false),submitting=ref(false),pageError=ref(''),tab=ref('fulfillment') -const baseDetail=ref(null),runtimeDetails=ref(null),monitoring=ref(null),security=ref(null),eip=ref(null),volumes=ref([]),operations=ref([]),operationPage=ref(1),operationTotal=ref(0) -const loginDialog=ref(false),loginUsers=ref([]),loginRetryAfter=ref(0),retryTimer=ref(null),revealed=reactive({}),ruleDialog=ref(false),volumeDialog=ref(false),volumeMode=ref('create'),operationDialog=ref(false),currentOperation=ref(null),pollTimer=ref(null),pollStarted=ref(0) -const protocols=['tcp','udp','icmp','icmpv6','-1'],volumeTypes=['gp3','gp2','io1','io2','st1','sc1','standard'],volumeTitles={create:'创建并挂载数据卷',modify:'扩容或修改数据卷',attach:'挂载数据卷'} +const route=useRoute(),embedded=computed(()=>props.embedded),instanceId=props.instanceId||Number(route.params.instanceId),loading=ref(false),submitting=ref(false),pageError=ref(''),tab=ref(props.embedded?'runtime':'fulfillment') +const baseDetail=ref(null),runtimeDetails=ref(null),monitoring=ref(null),security=ref(null),eip=ref(null),volumes=ref([]),operations=ref([]),operationPage=ref(1),operationTotal=ref(0),trafficLimit=ref(null) +const loginDialog=ref(false),loginUsers=ref([]),loginRetryAfter=ref(0),retryTimer=ref(null),revealed=reactive({}),ruleDialog=ref(false),volumeDialog=ref(false),volumeMode=ref('create'),rootCurrentSize=ref(0),operationDialog=ref(false),currentOperation=ref(null),pollTimer=ref(null),pollStarted=ref(0),rebuildDialog=ref(false),rebuildImageId=ref(''),trafficDialog=ref(false) +const protocols=['tcp','udp','icmp','icmpv6','-1'],volumeTypes=['gp3','gp2','io1','io2','st1','sc1','standard'],volumeTitles={create:'创建并挂载数据卷',modify:'扩容或修改数据卷',attach:'挂载数据卷',root_expand:'扩容系统盘'} const rule=reactive({direction:'ingress',protocol:'tcp',from_port:22,to_port:22,cidr:'0.0.0.0/0',description:''}) const volumeForm=reactive({volume_id:'',name:'',device_name:'/dev/sdf',size_gib:20,type:'gp3',iops:0,throughput:0,encrypted:true,kms_key_id:'',delete_on_termination:false}) +const trafficForm=reactive({enabled:true,limit_gib:100}) const envelope=r=>r?.data||r||{},dataOf=r=>envelope(r)?.data,message=(e,f)=>e?.response?.data?.message||e?.response?.data?.msg||envelope(e)?.message||e?.message||f const instance=computed(()=>baseDetail.value?.instance||null) -const snapshotText=computed(()=>{const raw=instance.value?.resourceSnapshot;if(!raw)return'暂无资源快照';try{return JSON.stringify(typeof raw==='string'?JSON.parse(raw):raw,null,2)}catch{return String(raw)}}) const securityRules=computed(()=>{const d=security.value||{};if(Array.isArray(d.rules))return d.rules;return[...(d.ingress_rules||d.ingressRules||[]).map(v=>({...v,direction:v.direction||'ingress'})),...(d.egress_rules||d.egressRules||[]).map(v=>({...v,direction:v.direction||'egress'}))]}) -const pretty=v=>JSON.stringify(v??{},null,2),mask=v=>v?'••••••••':'-',statusText=s=>({pending:'等待中',submitting:'提交中',provisioning:'创建中',active:'运行中',failed:'失败',stopping:'停止中',stopped:'已停止',terminating:'终止中',terminated:'已终止'})[s]||s||'-' +const publicManagementRisk=computed(()=>securityRules.value.some(r=>r.direction==='ingress'&&r.protocol==='tcp'&&(r.from_port??r.fromPort)<=22&&(r.to_port??r.toPort)>=22&&['0.0.0.0/0','::/0'].includes(r.cidr))) +const eipCurrent=computed(()=>eip.value?.current||(!eip.value?.available&&eip.value?.public_ip?eip.value:null)) +const requestRows=computed(()=>{const r=baseDetail.value?.request||{},root=r.root_volume||{};return[{label:'实例规格',value:r.instance_type||'-'},{label:'镜像 ID',value:r.image_id||'-'},{label:'系统盘',value:root.size_gib?`${root.size_gib} GiB · ${String(root.type||'').toUpperCase()}`:'-'},{label:'磁盘加密',value:root.encrypted?'已启用':'未启用'},{label:'弹性公网 IP',value:r.associate_eip?'自动关联':'不关联'},{label:'详细监控',value:r.monitoring?'已启用':'未启用'},{label:'管理 Agent',value:r.install_agent?'自动安装':'不安装'},{label:'安全组模式',value:r.security_group_mode==='common_ports'?'常用端口':r.security_group_mode||'-'},{label:'流量额度',value:r.traffic_limit_gib?`${r.traffic_limit_gib} GiB`:'未限制'},{label:'关机行为',value:r.shutdown_behavior==='stop'?'停止实例':r.shutdown_behavior||'-'}]}) +const mask=v=>v?'••••••••':'-',statusText=s=>({pending:'等待中',submitting:'提交中',provisioning:'创建中',active:'运行中',failed:'失败',stopping:'停止中',stopped:'已停止',terminating:'终止中',terminated:'已终止'})[s]||s||'-' const isPrivateKey=method=>method?.credential?.kind==='private_key'||String(method?.type||'').includes('private_key')||Boolean(method?.credential?.private_key) const isPassword=method=>method?.credential?.kind==='password'||String(method?.type||'').includes('password')||Boolean(method?.credential?.password) const credentialType=method=>isPrivateKey(method)?'SSH 私钥':isPassword(method)?'SSH 密码':method?.type||'登录凭据' const sourceText=source=>({ec2_key_pair:'EC2 密钥对',agent_managed:'Agent 托管'})[source]||source||'未知来源' const downloadPrivateKey=(value,name)=>{if(!value){ElMessage.warning('暂无可下载的私钥');return}const safeName=String(name||'aws-private-key').replace(/[^\w.-]/g,'_');const url=URL.createObjectURL(new Blob([value],{type:'application/x-pem-file'}));const link=document.createElement('a');link.href=url;link.download=safeName.endsWith('.pem')?safeName:`${safeName}.pem`;link.click();URL.revokeObjectURL(url)} const statusTag=s=>s==='active'?'success':s==='failed'?'danger':['pending','submitting','provisioning','stopping','terminating'].includes(s)?'warning':'info' -const operationText=s=>({queued:'排队中',running:'执行中',succeeded:'成功',failed:'失败',rejected:'已驳回'})[s]||s||'-',operationTag=s=>s==='succeeded'?'success':s==='failed'?'danger':s==='rejected'?'info':'warning' +const operationText=s=>({awaiting_payment:'待支付',queued:'排队中',running:'执行中',succeeded:'成功',failed:'失败',rejected:'已驳回'})[s]||s||'-',operationTag=s=>s==='succeeded'?'success':s==='failed'?'danger':s==='rejected'?'info':'warning' +const operationTypeText=value=>({ + 'details.refresh':'刷新实例详情','security_group.rule.add':'添加安全组规则','security_group.rule.delete':'删除安全组规则','eip.replace':'更换弹性 IP','eip.disassociate':'解绑弹性 IP','eip.release':'释放弹性 IP','eip.reverse_dns':'更新 PTR 记录','volume.create':'创建数据卷','volume.modify':'修改数据卷','root_volume.expand':'扩容系统盘','volume.attach':'挂载数据卷','volume.detach':'卸载数据卷','volume.delete':'删除数据卷','password.reset':'重置实例密码','instance.rebuild':'重装系统','traffic_limit.update':'修改流量上限' +})[value]||value||'-' +const actorText=value=>({user:'用户',admin:'管理员',system:'系统'})[value]||value||'-' +const time=value=>value?new Date(value).toLocaleString('zh-CN',{hour12:false}):'-' +const cents=value=>(Number(value||0)/100).toFixed(2) +function bytes(value){const n=Number(value||0);if(!n)return'0 B';const units=['B','KB','MB','GB','TB'],i=Math.min(Math.floor(Math.log(n)/Math.log(1024)),4);return`${(n/1024**i).toFixed(i?2:0)} ${units[i]}`} const portText=r=>r.protocol==='-1'?'全部':`${r.from_port??r.fromPort??'-'}-${r.to_port??r.toPort??'-'}` +const directionText=value=>({ingress:'入站',egress:'出站'})[value]||value||'-' +const protocolText=value=>value==='-1'?'全部':String(value||'-').toUpperCase() async function safe(call,fallback){try{const r=await call();const b=envelope(r);if(b.code!==200)throw new Error(b.message||fallback);return dataOf(r)}catch(e){ElMessage.error(message(e,fallback));return null}} -async function loadBase(){loading.value=true;pageError.value='';try{const v=await safe(()=>api.getAwsInstanceDetail(instanceId),'加载履约详情失败');if(v)baseDetail.value=v;else pageError.value='AWS 履约实例不存在或加载失败'}finally{loading.value=false}} +async function loadBase(){loading.value=true;pageError.value='';try{const v=await safe(()=>api.getAwsInstanceDetail(instanceId),'加载履约详情失败');if(v){baseDetail.value=v;if(embedded.value&&tab.value==='runtime')await Promise.all([runtimeDetails.value?null:loadRuntime(),monitoring.value?null:loadMonitoring()])}else pageError.value='AWS 履约实例不存在或加载失败'}finally{loading.value=false}} async function loadRuntime(){const v=await safe(()=>api.getAdminAwsRuntimeDetails(instanceId),'读取实时详情失败');if(v!==null)runtimeDetails.value=v} async function loadMonitoring(){const v=await safe(()=>api.getAdminAwsMonitoring(instanceId),'读取监控失败');if(v!==null)monitoring.value=v} +async function loadTraffic(){const v=await safe(()=>api.getAdminAwsTrafficLimit(instanceId),'读取流量上限失败');if(v!==null)trafficLimit.value=v} async function loadNetwork(){const [s,p]=await Promise.all([safe(()=>api.getAdminAwsSecurityGroup(instanceId),'读取安全组失败'),safe(()=>api.getAdminAwsEip(instanceId),'读取 EIP 失败')]);if(s!==null)security.value=s;if(p!==null)eip.value=p} async function loadVolumes(){const v=await safe(()=>api.getAdminAwsVolumes(instanceId),'读取数据卷失败');if(v!==null)volumes.value=Array.isArray(v)?v:(v.list||v.volumes||[])} async function loadOperations(){const v=await safe(()=>api.getAdminAwsOperations(instanceId,{page:operationPage.value,count:20}),'读取操作记录失败');if(v){operations.value=v.list||[];operationTotal.value=v.all_count||0}} -function tabChanged(name){if(name==='runtime'&&!runtimeDetails.value)loadRuntime();if(name==='network'&&!security.value)loadNetwork();if(name==='storage'&&!volumes.value.length)loadVolumes();if(name==='operations')loadOperations()} +function tabChanged(name){if(name==='runtime'){if(!runtimeDetails.value)loadRuntime();if(!monitoring.value)loadMonitoring();if(!trafficLimit.value)loadTraffic()}if(name==='network'&&!security.value)loadNetwork();if(name==='storage'&&!volumes.value.length)loadVolumes();if(name==='operations')loadOperations()} async function promptNote(title){try{return(await ElMessageBox.prompt('请输入操作备注(可留空)',title,{inputValue:''})).value}catch{return null}} async function fulfillmentAction(type){const note=await promptNote({resync:'重新同步履约',retry:'审核重试',resolved:'确认解决',reject:'驳回履约'}[type]);if(note===null)return;let call;if(type==='resync')call=()=>api.resyncAwsInstance(instanceId,note);else if(type==='retry')call=()=>api.retryAwsInstance(instanceId,note);else call=()=>api.resolveAwsInstance(instanceId,type,note);const v=await safe(call,'履约操作失败');if(v!==null){ElMessage.success('操作成功');loadBase()}} function stopPoll(){clearTimeout(pollTimer.value);pollTimer.value=null} @@ -117,9 +135,12 @@ async function deleteRule(row){try{await ElMessageBox.confirm(`确认删除 ${ro async function ensureEip(){const allocation=await promptNote('确保或更换 EIP(可填写 Allocation ID)');if(allocation===null)return;runAsync(()=>api.replaceAdminAwsEip({instance_id:instanceId,allocation_id:allocation}),loadNetwork)} async function setPtr(){const domain=await promptNote('更新 EIP PTR(填写域名,留空清除)');if(domain===null)return;runAsync(()=>api.updateAdminAwsEipReverseDns({instance_id:instanceId,domain_name:domain}),loadNetwork)} async function dangerEip(type){try{await ElMessageBox.confirm(type==='release'?'释放 EIP 后可能无法恢复,确认继续?':'确认解绑当前 EIP?',type==='release'?'释放 EIP':'解绑 EIP',{type:'error'})}catch{return}runAsync(()=>type==='release'?api.releaseAdminAwsEip(instanceId):api.disassociateAdminAwsEip(instanceId),loadNetwork)} -function openVolume(mode,row={}){volumeMode.value=mode;Object.assign(volumeForm,{volume_id:row.volume_id||'',name:row.name||'',device_name:row.device_name||'/dev/sdf',size_gib:Number(row.size_gib)||20,type:row.volume_type||'gp3',iops:Number(row.iops)||0,throughput:Number(row.throughput)||0,encrypted:row.encrypted!==false,kms_key_id:row.kms_key_id||'',delete_on_termination:!!row.delete_on_termination});volumeDialog.value=true} -function submitVolume(){if(['create','modify'].includes(volumeMode.value)&&(volumeForm.size_gib<1||volumeForm.size_gib>16384))return ElMessage.warning('容量范围为 1-16384 GiB');if(['create','attach'].includes(volumeMode.value)&&!volumeForm.device_name)return ElMessage.warning('请输入设备名');const data={instance_id:instanceId,...volumeForm};const call=volumeMode.value==='create'?()=>api.createAdminAwsVolume(data):volumeMode.value==='modify'?()=>api.modifyAdminAwsVolume(data):()=>api.attachAdminAwsVolume(data);runAsync(call,async()=>{volumeDialog.value=false;await loadVolumes()})} +function openVolume(mode,row={}){volumeMode.value=mode;rootCurrentSize.value=mode==='root_expand'?Number(row.size_gib||0):0;Object.assign(volumeForm,{volume_id:row.volume_id||'',name:row.name||'',device_name:row.device_name||'/dev/sdf',size_gib:mode==='root_expand'?rootCurrentSize.value+1:Number(row.size_gib)||20,type:row.volume_type||'gp3',iops:Number(row.iops)||0,throughput:Number(row.throughput)||0,encrypted:row.encrypted!==false,kms_key_id:row.kms_key_id||'',delete_on_termination:!!row.delete_on_termination});volumeDialog.value=true} +function submitVolume(){if(['create','modify','root_expand'].includes(volumeMode.value)&&(volumeForm.size_gib<1||volumeForm.size_gib>16384))return ElMessage.warning('容量范围为 1-16384 GiB');if(volumeMode.value==='root_expand'&&Number(volumeForm.size_gib)<=rootCurrentSize.value)return ElMessage.warning('目标容量必须大于当前系统盘容量');if(['create','attach'].includes(volumeMode.value)&&!volumeForm.device_name)return ElMessage.warning('请输入设备名');const data={instance_id:instanceId,...volumeForm};const call=volumeMode.value==='create'?()=>api.createAdminAwsVolume(data):volumeMode.value==='modify'?()=>api.modifyAdminAwsVolume(data):volumeMode.value==='root_expand'?()=>api.expandAdminAwsRootVolume(instanceId,Number(volumeForm.size_gib)):()=>api.attachAdminAwsVolume(data);runAsync(call,async()=>{volumeDialog.value=false;await loadVolumes()})} async function volumeDanger(type,row){try{await ElMessageBox.confirm(type==='delete'?'删除数据卷不可恢复,确认继续?':'卸载可能导致实例内文件系统异常,确认继续?',type==='delete'?'删除数据卷':'卸载数据卷',{type:'error'})}catch{return}const call=type==='delete'?()=>api.deleteAdminAwsVolume({instance_id:instanceId,volume_id:row.volume_id,force:false}):()=>api.detachAdminAwsVolume({instance_id:instanceId,volume_id:row.volume_id,force:false});runAsync(call,loadVolumes)} +async function submitRebuild(){if(!rebuildImageId.value)return ElMessage.warning('请输入镜像 ID');try{await ElMessageBox.confirm('重装将覆盖系统盘,确认继续?','确认重装',{type:'error'})}catch{return}runAsync(()=>api.rebuildAdminAwsInstance(instanceId,rebuildImageId.value),async()=>{rebuildDialog.value=false;tab.value='operations';await loadOperations()})} +async function openTraffic(){await loadTraffic();const gib=Math.round(Number(trafficLimit.value?.limit_bytes||0)/1024**3);Object.assign(trafficForm,{enabled:trafficLimit.value?.enabled!==false,limit_gib:gib||100});trafficDialog.value=true} +function submitTraffic(){if(trafficForm.enabled&&Number(trafficForm.limit_gib)<=0)return ElMessage.warning('请输入有效流量额度');runAsync(()=>api.updateAdminAwsTrafficLimit(instanceId,{enabled:trafficForm.enabled,limit_gib:Number(trafficForm.limit_gib)}),async()=>{trafficDialog.value=false;await Promise.all([loadTraffic(),loadRuntime()])})} async function showOperation(id){const v=await safe(()=>api.getAdminAwsOperationDetail(id),'读取操作详情失败');if(v){currentOperation.value=v;operationDialog.value=true}} async function operationReview(type,row){const note=type==='resync'?'':await promptNote({retry:'重试运行期操作',resolve:'确认操作已解决',reject:'驳回失败操作'}[type]);if(note===null)return;if(type==='resync'){const v=await safe(()=>api.resyncAdminAwsOperation(row.id),'同步操作失败');if(v!==null){ElMessage.success('同步完成');loadOperations()}return}if(type==='retry')runAsync(()=>api.retryAdminAwsOperation(row.id,note),loadOperations);else{const v=await safe(()=>type==='resolve'?api.resolveAdminAwsOperation(row.id,note):api.rejectAdminAwsOperation(row.id,note),'审核操作失败');if(v!==null){ElMessage.success('审核完成');loadOperations()}}} defineExpose({refresh:loadBase}) @@ -129,4 +150,6 @@ onMounted(loadBase);onBeforeUnmount(()=>{stopPoll();clearInterval(retryTimer.val diff --git a/src/views/aws/AwsRuntimeVisualization.vue b/src/views/aws/AwsRuntimeVisualization.vue index 0e75dca..4791ca5 100644 --- a/src/views/aws/AwsRuntimeVisualization.vue +++ b/src/views/aws/AwsRuntimeVisualization.vue @@ -1,5 +1,6 @@