feat: add AWS admin suspension and fix recycle restore network parameter
Build and Deploy Vue3 / build (push) Failing after 30s
Build and Deploy Vue3 / deploy (push) Skipped

This commit is contained in:
shiran
2026-09-07 16:07:21 +08:00
parent 543bbb0b69
commit 3d8f76cac6
3 changed files with 47 additions and 1 deletions
+5
View File
@@ -25,6 +25,11 @@ export const deleteAwsGoods = (id) => http2.delete(`${base}/goods/delete`, { par
export const getAwsInstanceDetail = (identifier) => http2.get(`${base}/instances/detail`, { export const getAwsInstanceDetail = (identifier) => http2.get(`${base}/instances/detail`, {
params: typeof identifier === 'object' ? identifier : { instance_id: identifier } params: typeof identifier === 'object' ? identifier : { instance_id: identifier }
}) })
export const actionAdminAwsInstance = (instanceId, action, reason) => http2.post(`${base}/instances/action`, {
instance_id: instanceId,
action,
...(action === 'suspend' ? { reason } : {})
})
export const retryAwsInstance = (instanceId, note = '') => http2.post(`${base}/instances/retry`, { instance_id: instanceId, note }) 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 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 resolveAwsInstance = (instanceId, resolution, note = '') => http2.post(`${base}/instances/resolve`, { instance_id: instanceId, resolution, note })
+41
View File
@@ -12,6 +12,23 @@
</el-descriptions> </el-descriptions>
</div> </div>
<div v-if="instance" class="toolbar">
<span>管理员控制</span>
<div>
<el-button type="warning" :disabled="lifecycleSubmitting" @click="openSuspend">管理员暂停</el-button>
<el-button type="success" plain :disabled="lifecycleSubmitting" @click="resumeInstance">解除暂停</el-button>
</div>
</div>
<el-dialog v-model="suspendDialog" title="管理员暂停实例" width="500px" :close-on-click-modal="false" :close-on-press-escape="!lifecycleSubmitting" :show-close="!lifecycleSubmitting">
<el-alert title="暂停后用户将无法操作此实例,只有管理员解除暂停后才能恢复操作。" type="warning" show-icon :closable="false" class="alert"/>
<el-form ref="suspendFormRef" :model="suspendForm" label-width="90px">
<el-form-item label="暂停原因" prop="reason" :rules="[{ required: true, whitespace: true, message: '请输入暂停原因', trigger: 'blur' }]">
<el-input v-model="suspendForm.reason" type="textarea" :rows="4" :disabled="lifecycleSubmitting" placeholder="请输入暂停原因"/>
</el-form-item>
</el-form>
<template #footer><el-button :disabled="lifecycleSubmitting" @click="suspendDialog=false">取消</el-button><el-button type="warning" :loading="lifecycleSubmitting" @click="submitSuspend">确认暂停</el-button></template>
</el-dialog>
<el-tabs v-if="instance" v-model="tab" class="tabs" @tab-change="tabChanged"> <el-tabs v-if="instance" v-model="tab" class="tabs" @tab-change="tabChanged">
<el-tab-pane label="履约审核" name="fulfillment"> <el-tab-pane label="履约审核" name="fulfillment">
<div class="toolbar"><span>履约记录与失败审核</span><div><el-button @click="fulfillmentAction('resync')">重新同步</el-button><el-button v-if="instance.provisionStatus==='failed'" type="warning" @click="fulfillmentAction('retry')">审核重试</el-button><el-button v-if="instance.provisionStatus==='failed'" type="success" @click="fulfillmentAction('resolved')">确认解决</el-button><el-button v-if="instance.provisionStatus==='failed'" type="danger" plain @click="fulfillmentAction('reject')">驳回</el-button></div></div> <div class="toolbar"><span>履约记录与失败审核</span><div><el-button @click="fulfillmentAction('resync')">重新同步</el-button><el-button v-if="instance.provisionStatus==='failed'" type="warning" @click="fulfillmentAction('retry')">审核重试</el-button><el-button v-if="instance.provisionStatus==='failed'" type="success" @click="fulfillmentAction('resolved')">确认解决</el-button><el-button v-if="instance.provisionStatus==='failed'" type="danger" plain @click="fulfillmentAction('reject')">驳回</el-button></div></div>
@@ -85,6 +102,8 @@ const protocols=['tcp','udp','icmp','icmpv6','-1'],volumeTypes=['gp3','gp2','io1
const rule=reactive({direction:'ingress',protocol:'tcp',from_port:22,to_port:22,cidr:'0.0.0.0/0',description:''}) 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 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 trafficForm=reactive({enabled:true,limit_gib:100})
const suspendDialog=ref(false),suspendFormRef=ref(null),lifecycleSubmitting=ref(false)
const suspendForm=reactive({reason:''})
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 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 instance=computed(()=>baseDetail.value?.instance||null)
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 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'}))]})
@@ -119,6 +138,28 @@ async function loadVolumes(){const v=await safe(()=>api.getAdminAwsVolumes(insta
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}} 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'){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()} 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 promptNote(title){try{return(await ElMessageBox.prompt('请输入操作备注(可留空)',title,{inputValue:''})).value}catch{return null}}
function openSuspend(){suspendForm.reason='';suspendFormRef.value?.clearValidate();suspendDialog.value=true}
async function submitSuspend(){
if(lifecycleSubmitting.value)return
if(!await suspendFormRef.value?.validate().catch(()=>false))return
await submitLifecycleAction('suspend',suspendForm.reason.trim())
}
async function resumeInstance(){
if(lifecycleSubmitting.value)return
try{await ElMessageBox.confirm(`确认解除实例 #${instanceId} 的管理员暂停?解除后用户可恢复操作。`,'解除暂停',{type:'warning',confirmButtonText:'确认解除',cancelButtonText:'取消'})}catch{return}
await submitLifecycleAction('resume')
}
async function submitLifecycleAction(action,reason){
if(lifecycleSubmitting.value)return
lifecycleSubmitting.value=true
try{
const result=envelope(await api.actionAdminAwsInstance(instanceId,action,reason))
if(result.code!==200)throw new Error(result.message||'实例操作失败')
suspendDialog.value=false
ElMessage.success(action==='suspend'?'管理员暂停已受理':'解除暂停已受理')
await Promise.all([loadBase(),loadRuntime()])
}catch(e){ElMessage.error(message(e,'实例操作失败'))}finally{lifecycleSubmitting.value=false}
}
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()}} 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} function stopPoll(){clearTimeout(pollTimer.value);pollTimer.value=null}
function pollOperation(id,onSuccess){stopPoll();pollStarted.value=Date.now();const tick=async()=>{if(Date.now()-pollStarted.value>=120000)return ElMessage.warning('操作仍在处理,请稍后手动刷新');try{const r=await api.getAdminAwsOperationDetail(id);const op=dataOf(r);if(['queued','running'].includes(op?.status)){pollTimer.value=setTimeout(tick,5000);return}if(op?.status==='succeeded'){ElMessage.success('操作完成');await onSuccess?.()}else ElMessage.error(op?.errorMessage||'操作失败')}catch{pollTimer.value=setTimeout(tick,5000)}};pollTimer.value=setTimeout(tick,5000)} function pollOperation(id,onSuccess){stopPoll();pollStarted.value=Date.now();const tick=async()=>{if(Date.now()-pollStarted.value>=120000)return ElMessage.warning('操作仍在处理,请稍后手动刷新');try{const r=await api.getAdminAwsOperationDetail(id);const op=dataOf(r);if(['queued','running'].includes(op?.status)){pollTimer.value=setTimeout(tick,5000);return}if(op?.status==='succeeded'){ElMessage.success('操作完成');await onSuccess?.()}else ElMessage.error(op?.errorMessage||'操作失败')}catch{pollTimer.value=setTimeout(tick,5000)}};pollTimer.value=setTimeout(tick,5000)}
@@ -304,7 +304,7 @@ const submitRestore = async () => {
selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id)) selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
} }
if (selectedBridgeNetwork.value) { if (selectedBridgeNetwork.value) {
fd.append('internet_network_id', selectedBridgeNetwork.value.id) fd.append('network_ids', selectedBridgeNetwork.value.id)
} }
const res = await restoreRecycleBin(fd) const res = await restoreRecycleBin(fd)
if (res?.data?.code === 200) { if (res?.data?.code === 200) {