From 698370526320054fcd8fb4339b18cd0b38213bfe Mon Sep 17 00:00:00 2001 From: shiran Date: Mon, 14 Sep 2026 10:27:49 +0800 Subject: [PATCH] feat: enhance AWS instance runtime management --- src/api/admin/awsService.js | 8 ++ src/views/aws/AwsInstanceDetail.vue | 116 +++++++++++++----- src/views/product/UserGoodsDetail.vue | 2 +- .../product/components/AwsUserGoodsDetail.vue | 114 +++++------------ 4 files changed, 120 insertions(+), 120 deletions(-) diff --git a/src/api/admin/awsService.js b/src/api/admin/awsService.js index 24c8411..e28cf57 100644 --- a/src/api/admin/awsService.js +++ b/src/api/admin/awsService.js @@ -38,6 +38,10 @@ export const getAdminAwsRuntimeDetails = instanceId => http2.get(`${runtimeBase} 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 getAdminAwsSshKeys = instanceId => http2.get(`${runtimeBase}/ssh_keys`, { params: { instance_id: instanceId } }) +export const addAdminAwsSshKey = (instanceId, data, key) => http2.post(`${runtimeBase}/ssh_keys/add`, { instance_id: instanceId, ...data }, withKey(key)) +export const rotateAdminAwsSshKey = (instanceId, data, key) => http2.post(`${runtimeBase}/ssh_keys/rotate`, { instance_id: instanceId, ...data }, withKey(key)) +export const revealAdminAwsSshPrivateKey = (instanceId, keyId) => http2.post(`${runtimeBase}/ssh_keys/reveal`, { instance_id: instanceId, key_id: keyId }) 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)) @@ -61,5 +65,9 @@ export const resyncAdminAwsOperation = operationId => http2.post(`${runtimeBase} 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 getAdminAwsRebuildConfiguration = (goodId, region) => http2.post('/api/v1/tool/good/remote/configuration', { + good_id: goodId, + remote_args: JSON.stringify({ region }) +}) 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/AwsInstanceDetail.vue b/src/views/aws/AwsInstanceDetail.vue index f8b1406..29da311 100644 --- a/src/views/aws/AwsInstanceDetail.vue +++ b/src/views/aws/AwsInstanceDetail.vue @@ -15,10 +15,17 @@
管理员控制
- 管理员暂停 - 解除暂停 + 开机 + 关机 + 重启 + 管理员暂停 + 解除暂停
+ +
暂停原因:{{ adminPauseInfo.stopReasonDetail || '未提供暂停原因' }}
+
暂停时间:{{ time(adminPauseInfo.stoppedAt) }}
+
@@ -37,8 +44,39 @@ -
实时详情与访问控制
刷新详情主动刷新{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'读取登录凭据'}}重置密码读取监控重装系统流量配置
- +
实时资源详情
刷新详情主动刷新重装系统流量配置
+ + +
+ + +
登录凭据与访问密钥
{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'重新读取登录信息'}}重置密码刷新密钥添加密钥重置默认密钥
+ + +
+
+
+

{{user.username||'未知用户'}}

{{(user.methods||[]).length}} 种登录方式
+
+
{{credentialType(method)}}
{{method.credential?.available===false?'不可用':'可用'}}
+ +
主机地址{{method.host||'-'}}
协议{{String(method.protocol||'ssh').toUpperCase()}}
端口{{method.port||'-'}}
用户名{{user.username||'-'}}
+
登录密码
{{revealed[`${ui}-${mi}-p`]?method.credential.password:mask(method.credential.password)}}
{{revealed[`${ui}-${mi}-p`]?'隐藏':'展示'}}复制重置密码
+
SSH 私钥
{{method.credential?.key_name||'EC2 Key Pair'}}
下载复制安全轮换
+
+
+
+ +
+
SSH 密钥绑定在线生成的私钥只在敏感信息弹窗中展示,请及时下载并妥善保存。
+ + + +
+ + +
资源指标与网络趋势刷新监控
+
@@ -63,25 +101,15 @@ - - -
-
-

{{user.username||'未知用户'}}

{{(user.methods||[]).length}} 种登录方式
-
-
{{credentialType(method)}}
{{method.credential?.available===false?'不可用':'可用'}}
- -
主机地址{{method.host||'-'}}
协议{{String(method.protocol||'ssh').toUpperCase()}}
端口{{method.port||'-'}}
用户名{{user.username||'-'}}
-
登录密码
{{revealed[`${ui}-${mi}-p`]?method.credential.password:mask(method.credential.password)}}
{{revealed[`${ui}-${mi}-p`]?'隐藏密码':'查看密码'}}复制密码
-
SSH 私钥
{{method.credential?.key_name||'EC2 Key Pair'}}
下载私钥复制私钥
-
-
-
- -
- - + + + + + + {{privateKey.key_name||'-'}}{{privateKey.username||'-'}}{{privateKey.fingerprint||'-'}} + {{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}}
@@ -94,18 +122,27 @@ import { ElMessage, ElMessageBox } from 'element-plus' 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 props=defineProps({embedded:{type:Boolean,default:false},instanceId:{type:Number,default:0},productGoodId:{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(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 loginUsers=ref([]),loginLoading=ref(false),loginLoaded=ref(false),loginError=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(''),imageOptions=ref([]),imageLoading=ref(false),trafficDialog=ref(false) +let loginLoadToken=0 +const sshKeys=ref([]),keyDialog=ref(false),keyMode=ref('add'),privateKeyDialog=ref(false),privateKey=ref(null) 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 suspendDialog=ref(false),suspendFormRef=ref(null),lifecycleSubmitting=ref(false) const suspendForm=reactive({reason:''}) +const lifecycleAction=ref('') +const keyForm=reactive({username:'',name:'',public_key:'',replace_key_id:''}) 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 adminPauseInfo=computed(()=>{const runtime=runtimeDetails.value||{},resource=runtime.resource||{},remote=resource.instance||runtime.instance||{},lifecycle=resource.lifecycle||runtime.lifecycle||{};const sources=[instance.value,baseDetail.value,runtime,resource,remote,lifecycle];const pick=(...keys)=>{for(const source of sources)for(const key of keys)if(source?.[key]!=null)return source[key];return''};return{stopReason:pick('stopReason','stop_reason'),stopReasonDetail:pick('stopReasonDetail','stop_reason_detail'),stoppedAt:pick('stoppedAt','stopped_at')}}) +const isAdminPaused=computed(()=>adminPauseInfo.value.stopReason==='admin_pause') +const remoteState=computed(()=>runtimeDetails.value?.instance?.state||runtimeDetails.value?.resource?.instance?.state||instance.value?.status||instance.value?.provisionStatus||'') +const pendingLifecycleAction=computed(()=>instance.value?.pendingAction||runtimeDetails.value?.pendingAction||'') +const rebuildGoodId=computed(()=>Number(props.productGoodId||baseDetail.value?.goodId||baseDetail.value?.good_id||baseDetail.value?.good?.id||0)) 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 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)) @@ -119,7 +156,7 @@ const downloadPrivateKey=(value,name)=>{if(!value){ElMessage.warning('暂无可 const statusTag=s=>s==='active'?'success':s==='failed'?'danger':['pending','submitting','provisioning','stopping','terminating'].includes(s)?'warning':'info' 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':'修改流量上限' + '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':'重置实例密码','ssh_key.add':'添加 SSH 密钥','ssh_key.rotate':'轮换 SSH 密钥','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}):'-' @@ -129,14 +166,14 @@ const portText=r=>r.protocol==='-1'?'全部':`${r.from_port??r.fromPort??'-'}-${ 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;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 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'&&!runtimeDetails.value)await loadRuntime()}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'){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!=='access')clearCredentials();if(name==='runtime'){if(!runtimeDetails.value)loadRuntime();if(!trafficLimit.value)loadTraffic()}if(name==='access')Promise.all([readLogin(),loadSshKeys()]);if(name==='monitoring'&&!monitoring.value)loadMonitoring();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}} function openSuspend(){suspendForm.reason='';suspendFormRef.value?.clearValidate();suspendDialog.value=true} async function submitSuspend(){ @@ -151,25 +188,34 @@ async function resumeInstance(){ } async function submitLifecycleAction(action,reason){ if(lifecycleSubmitting.value)return - lifecycleSubmitting.value=true + lifecycleSubmitting.value=true;lifecycleAction.value=action 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'?'管理员暂停已受理':'解除暂停已受理') + ElMessage.success(({suspend:'管理员暂停',resume:'解除暂停',start:'开机',stop:'关机',reboot:'重启'})[action]+'操作已受理') await Promise.all([loadBase(),loadRuntime()]) - }catch(e){ElMessage.error(message(e,'实例操作失败'))}finally{lifecycleSubmitting.value=false} + }catch(e){ElMessage.error(message(e,'实例操作失败'))}finally{lifecycleSubmitting.value=false;lifecycleAction.value=''} } +function canLifecycleAction(action){if(lifecycleSubmitting.value||pendingLifecycleAction.value||isAdminPaused.value)return false;const state=String(remoteState.value).toLowerCase();return action==='start'?['stopped'].includes(state):['active','running'].includes(state)} +async function confirmLifecycleAction(action){if(!canLifecycleAction(action))return;const label={start:'开机',stop:'关机',reboot:'重启'}[action];try{await ElMessageBox.confirm(`确认对实例 #${instanceId} 执行${label}操作?`,`${label}实例`,{type:action==='stop'?'warning':'info',confirmButtonText:'确认',cancelButtonText:'取消'})}catch{return}await submitLifecycleAction(action)} 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 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)} async function runAsync(call,onSuccess){if(submitting.value)return;submitting.value=true;try{const r=await call();const b=envelope(r);if(b.code!==200)throw new Error(b.message||'提交失败');const op=b.data;ElMessage.success('操作已接受');if(op?.operation_id)pollOperation(op.operation_id,onSuccess)}catch(e){ElMessage.error(message(e,'操作提交失败'))}finally{submitting.value=false}} function refreshRuntime(){runAsync(()=>api.refreshAdminAwsRuntimeDetails(instanceId),loadRuntime)} function startLoginRetry(seconds){clearInterval(retryTimer.value);loginRetryAfter.value=Number(seconds)||5;retryTimer.value=setInterval(()=>{loginRetryAfter.value--;if(loginRetryAfter.value<=0){clearInterval(retryTimer.value);retryTimer.value=null}},1000)} -async function readLogin(){if(loginRetryAfter.value)return;try{const r=await api.getAdminAwsLoginInfo(instanceId);const b=envelope(r);if(b.code!==200)throw new Error(b.message||'读取登录凭据失败');loginUsers.value=b.data?.users||[];loginDialog.value=true}catch(e){if(e?.response?.status===429)startLoginRetry(e.response.headers?.['retry-after']||5);ElMessage.error(message(e,'读取登录凭据失败'))}} -function clearCredentials(){loginUsers.value=[];Object.keys(revealed).forEach(k=>delete revealed[k])} +async function readLogin(){if(loginLoading.value||loginRetryAfter.value)return;const token=++loginLoadToken;loginLoading.value=true;loginError.value='';loginLoaded.value=false;Object.keys(revealed).forEach(k=>delete revealed[k]);try{const r=await api.getAdminAwsLoginInfo(instanceId);const b=envelope(r);if(b.code!==200)throw new Error(b.message||'读取登录信息失败');if(token!==loginLoadToken||tab.value!=='access')return;loginUsers.value=b.data?.users||[];loginLoaded.value=true}catch(e){if(token!==loginLoadToken)return;if(e?.response?.status===429)startLoginRetry(e.response.headers?.['retry-after']||5);loginError.value=message(e,'读取登录信息失败')}finally{if(token===loginLoadToken)loginLoading.value=false}} +function clearCredentials(){loginLoadToken++;loginUsers.value=[];loginLoaded.value=false;loginLoading.value=false;loginError.value='';Object.keys(revealed).forEach(k=>delete revealed[k])} +async function loadSshKeys(){const v=await safe(()=>api.getAdminAwsSshKeys(instanceId),'读取 SSH 密钥失败');if(v!==null)sshKeys.value=Array.isArray(v)?v:(v.items||v.list||[])} +function openKeyDialog(mode,row=null){keyMode.value=mode;Object.assign(keyForm,{username:row?.username||loginUsers.value?.[0]?.username||runtimeDetails.value?.image_details?.recommended_user||'ubuntu',name:'',public_key:'',replace_key_id:row?.ssh_key_id||''});keyDialog.value=true} +function managedKeyFor(user,method){const credential=method?.credential||{},keyId=credential.ssh_key_id||credential.key_id,fingerprint=credential.fingerprint,name=credential.key_name||credential.name;return sshKeys.value.find(key=>(keyId&&key.ssh_key_id===keyId)||(fingerprint&&key.fingerprint===fingerprint)||(user?.username&&key.username===user.username&&name&&key.name===name))||{username:user?.username||'',name:name||'默认启动密钥',ssh_key_id:keyId||''}} +function validateKeyForm(){if(!/^[a-z_][a-z0-9_-]{0,31}$/.test(keyForm.username))return'系统用户格式不正确';if(keyForm.name&&!/^[A-Za-z0-9._-]+$/.test(keyForm.name))return'密钥名称格式不正确';if(keyForm.public_key&&!/^(ssh-rsa|ssh-ed25519)\s+\S+/.test(keyForm.public_key.trim()))return'请输入有效的 RSA 或 ED25519 OpenSSH 公钥';return''} +async function submitSshKey(){const error=validateKeyForm();if(error)return ElMessage.warning(error);const data={username:keyForm.username,...(keyForm.name?{name:keyForm.name}:{}),...(keyForm.public_key.trim()?{public_key:keyForm.public_key.trim()}:{}),...(keyMode.value==='rotate'&&keyForm.replace_key_id?{replace_key_id:keyForm.replace_key_id}:{})};const call=keyMode.value==='rotate'?()=>api.rotateAdminAwsSshKey(instanceId,data):()=>api.addAdminAwsSshKey(instanceId,data);runAsync(call,async()=>{keyDialog.value=false;tab.value='access';await Promise.all([loadSshKeys(),loadOperations()])})} +async function revealSshKey(row){try{const b=envelope(await api.revealAdminAwsSshPrivateKey(instanceId,row.ssh_key_id));if(b.code!==200)throw new Error(b.message||'读取私钥失败');privateKey.value=b.data;privateKeyDialog.value=true}catch(e){ElMessage.error(message(e,'该密钥没有可读取的托管私钥'))}} +function clearPrivateKey(){privateKey.value=null} function toggleSecret(k){revealed[k]=!revealed[k]} async function copy(v){if(!v)return ElMessage.warning('暂无可复制内容');await navigator.clipboard.writeText(v);ElMessage.success('已复制')} -async function confirmPasswordReset(){try{await ElMessageBox.confirm('重置后原密码可能立即失效,确认继续?','重置实例密码',{type:'warning'})}catch{return}runAsync(()=>api.resetAdminAwsPassword(instanceId))} +async function confirmPasswordReset(){try{await ElMessageBox.confirm('重置后原密码可能立即失效,确认继续?','重置实例密码',{type:'warning'})}catch{return}runAsync(()=>api.resetAdminAwsPassword(instanceId),()=>tab.value==='access'&&readLogin())} function validRule(){if(!protocols.includes(rule.protocol)||!['ingress','egress'].includes(rule.direction))return'方向或协议无效';if(!Number.isInteger(rule.from_port)||!Number.isInteger(rule.to_port)||rule.from_port< -1||rule.to_port>65535||rule.from_port>rule.to_port)return'端口范围无效';if(!(/^(?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}$|^[0-9a-fA-F:]+\/\d{1,3}$/).test(rule.cidr))return'CIDR 无效';return''} function addRule(){const err=validRule();if(err)return ElMessage.warning(err);runAsync(()=>api.addAdminAwsSecurityGroupRule(instanceId,{...rule}),async()=>{ruleDialog.value=false;await loadNetwork()})} async function deleteRule(row){try{await ElMessageBox.confirm(`确认删除 ${row.protocol} ${portText(row)} ${row.cidr}?`,'删除安全组规则',{type:'warning'})}catch{return}const r={direction:row.direction||'ingress',protocol:row.protocol,from_port:row.from_port??row.fromPort,to_port:row.to_port??row.toPort,cidr:row.cidr,description:row.description||''};runAsync(()=>api.deleteAdminAwsSecurityGroupRule(instanceId,r),loadNetwork)} @@ -179,13 +225,15 @@ async function dangerEip(type){try{await ElMessageBox.confirm(type==='release'?' 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()})} +const imageOptionLabel=option=>option?.metadata?.displayName||option?.metadata?.display_name||option?.label||String(option?.value||'') +async function openRebuild(){rebuildDialog.value=true;rebuildImageId.value='';imageOptions.value=[];if(!rebuildGoodId.value){ElMessage.warning('当前用户商品缺少关联商品 ID,无法读取镜像列表');return}imageLoading.value=true;try{const v=await safe(()=>api.getAdminAwsRebuildConfiguration(rebuildGoodId.value,instance.value?.region||runtimeDetails.value?.instance?.region||''),'读取重装镜像失败');imageOptions.value=(v?.fields||[]).find(field=>field.key==='image_id')?.options||[]}finally{imageLoading.value=false}} +async function submitRebuild(){if(!rebuildImageId.value)return ElMessage.warning('请选择目标镜像');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}) -onMounted(loadBase);onBeforeUnmount(()=>{stopPoll();clearInterval(retryTimer.value);clearCredentials()}) +onMounted(loadBase);onBeforeUnmount(()=>{stopPoll();clearInterval(retryTimer.value);clearCredentials();clearPrivateKey()})