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 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
{{user.username||'未知用户'}}
{{(user.methods||[]).length}} 种登录方式
+
+ {{credentialType(method)}}
{{method.credential?.available===false?'不可用':'可用'}}
+
+
+ 登录密码
{{revealed[`${ui}-${mi}-p`]?method.credential.password:mask(method.credential.password)}}
{{revealed[`${ui}-${mi}-p`]?'隐藏':'展示'}}复制重置密码
+ SSH 私钥
{{method.credential?.key_name||'EC2 Key Pair'}}
下载复制安全轮换
+
+
+
+
+
+ SSH 密钥绑定在线生成的私钥只在敏感信息弹窗中展示,请及时下载并妥善保存。
+
+ {{row.status||'-'}}读取私钥轮换
+
+
+
+
+ 资源指标与网络趋势刷新监控
+
@@ -63,25 +101,15 @@
-
-
-
-
-
{{user.username||'未知用户'}}
{{(user.methods||[]).length}} 种登录方式
-
- {{credentialType(method)}}
{{method.credential?.available===false?'不可用':'可用'}}
-
-
- 登录密码
{{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||'-'}}关闭复制下载 .pem
+ 取消确认重装
{{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()})