feat: enhance AWS instance runtime management
Build and Deploy Vue3 / build (push) Failing after 17s
Build and Deploy Vue3 / deploy (push) Skipped

This commit is contained in:
shiran
2026-09-14 10:27:49 +08:00
parent 3d8f76cac6
commit 6983705263
4 changed files with 120 additions and 120 deletions
+8
View File
@@ -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))
+82 -34
View File
@@ -15,10 +15,17 @@
<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>
<el-button type="success" :disabled="!canLifecycleAction('start')" :loading="lifecycleSubmitting && lifecycleAction==='start'" @click="confirmLifecycleAction('start')">开机</el-button>
<el-button type="danger" plain :disabled="!canLifecycleAction('stop')" :loading="lifecycleSubmitting && lifecycleAction==='stop'" @click="confirmLifecycleAction('stop')">关机</el-button>
<el-button :disabled="!canLifecycleAction('reboot')" :loading="lifecycleSubmitting && lifecycleAction==='reboot'" @click="confirmLifecycleAction('reboot')">重启</el-button>
<el-button v-if="!isAdminPaused" type="warning" :disabled="lifecycleSubmitting" @click="openSuspend">管理员暂停</el-button>
<el-button v-else type="success" plain :disabled="lifecycleSubmitting" @click="resumeInstance">解除暂停</el-button>
</div>
</div>
<el-alert v-if="instance && isAdminPaused" title="实例已被管理员暂停" type="warning" show-icon :closable="false" class="alert">
<div>暂停原因{{ adminPauseInfo.stopReasonDetail || '未提供暂停原因' }}</div>
<div v-if="adminPauseInfo.stoppedAt">暂停时间{{ time(adminPauseInfo.stoppedAt) }}</div>
</el-alert>
<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">
@@ -37,8 +44,39 @@
</el-tab-pane>
<el-tab-pane label="运行概览" name="runtime">
<div class="toolbar"><span>实时详情与访问控制</span><div><el-button @click="loadRuntime">刷新详情</el-button><el-button :loading="submitting" @click="refreshRuntime">主动刷新</el-button><el-button :disabled="loginRetryAfter>0" @click="readLogin">{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'读取登录凭据'}}</el-button><el-button type="warning" @click="confirmPasswordReset">重置密码</el-button><el-button @click="loadMonitoring">读取监控</el-button><el-button type="danger" plain @click="rebuildDialog=true">重装系统</el-button><el-button type="primary" plain @click="openTraffic">流量配置</el-button></div></div>
<AwsRuntimeVisualization :details="runtimeDetails" :monitoring="monitoring" />
<div class="toolbar"><span>实时资源详情</span><div><el-button @click="loadRuntime">刷新详情</el-button><el-button :loading="submitting" @click="refreshRuntime">主动刷新</el-button><el-button type="danger" plain @click="openRebuild">重装系统</el-button><el-button type="primary" plain @click="openTraffic">流量配置</el-button></div></div>
<AwsRuntimeVisualization :details="runtimeDetails" />
<slot name="runtime-overview-extra" />
</el-tab-pane>
<el-tab-pane label="登录与密钥" name="access">
<div class="toolbar"><span>登录凭据与访问密钥</span><div><el-button :loading="loginLoading" :disabled="loginLoading||loginRetryAfter>0" @click="readLogin">{{loginRetryAfter?`${loginRetryAfter}s 后重试`:'重新读取登录信息'}}</el-button><el-button type="warning" @click="confirmPasswordReset">重置密码</el-button><el-button @click="loadSshKeys">刷新密钥</el-button><el-button type="primary" @click="openKeyDialog('add')">添加密钥</el-button><el-button type="danger" plain @click="openKeyDialog('rotate')">重置默认密钥</el-button></div></div>
<el-alert v-if="loginError" :title="loginError" type="error" show-icon :closable="false" class="alert" />
<el-alert v-if="loginLoaded" title="登录信息仅在当前 Tab 临时展示,离开后自动清除;请勿通过聊天工具或公开渠道发送。" type="warning" show-icon :closable="false" class="alert" />
<div v-loading="loginLoading" class="access-credentials">
<div v-if="loginUsers.length" class="credential-grid">
<div v-for="(user,ui) in loginUsers" :key="user.username||ui" class="credential-card">
<div class="credential-user"><span class="user-avatar"><el-icon><User /></el-icon></span><div><h4>{{user.username||'未知用户'}}</h4><span>{{(user.methods||[]).length}} 种登录方式</span></div></div>
<section v-for="(method,mi) in user.methods||[]" :key="mi" class="credential-method" :class="{unavailable:method.credential?.available===false}">
<div class="method-head"><div><b>{{credentialType(method)}}</b></div><el-tag :type="method.credential?.available===false?'danger':'success'" effect="light">{{method.credential?.available===false?'不可用':'可用'}}</el-tag></div>
<el-alert v-if="method.credential?.error" :title="method.credential.error" type="error" show-icon :closable="false" />
<div class="credential-meta-grid"><div><span>主机地址</span><code :title="method.host">{{method.host||'-'}}</code></div><div><span>协议</span><code>{{String(method.protocol||'ssh').toUpperCase()}}</code></div><div><span>端口</span><code>{{method.port||'-'}}</code></div><div><span>用户名</span><code>{{user.username||'-'}}</code></div></div>
<div v-if="isPassword(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>登录密码</span></div><div class="secret-display"><code>{{revealed[`${ui}-${mi}-p`]?method.credential.password:mask(method.credential.password)}}</code></div><div class="secret-actions"><el-button @click="toggleSecret(`${ui}-${mi}-p`)">{{revealed[`${ui}-${mi}-p`]?'隐藏':'展示'}}</el-button><el-button type="primary" plain @click="copy(method.credential.password)">复制</el-button><el-button type="warning" plain @click="confirmPasswordReset">重置密码</el-button></div></div>
<div v-if="isPrivateKey(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>SSH 私钥</span><el-tooltip :content="`密钥指纹:${method.credential?.fingerprint||'暂无'}`" placement="top"><el-button class="fingerprint-button" circle link :icon="InfoFilled" aria-label="查看密钥指纹"/></el-tooltip></div><div class="secret-display"><code>{{method.credential?.key_name||'EC2 Key Pair'}}</code></div><div class="secret-actions"><el-button @click="downloadPrivateKey(method.credential.private_key,method.credential?.key_name)">下载</el-button><el-button type="primary" plain @click="copy(method.credential.private_key)">复制</el-button><el-button type="warning" plain @click="openKeyDialog('rotate',managedKeyFor(user,method))">安全轮换</el-button></div></div>
</section>
</div>
</div>
<el-empty v-else-if="loginLoaded&&!loginLoading" description="暂无可用登录信息" :image-size="72" />
</div>
<div class="toolbar sub"><span>SSH 密钥绑定</span><small>在线生成的私钥只在敏感信息弹窗中展示请及时下载并妥善保存</small></div>
<el-table :data="sshKeys" border stripe empty-text="暂无 SSH 密钥绑定">
<el-table-column prop="name" label="名称" min-width="150"/><el-table-column prop="username" label="系统用户" width="120"/><el-table-column prop="algorithm" label="算法" width="120"/><el-table-column prop="fingerprint" label="指纹" min-width="210" show-overflow-tooltip/><el-table-column label="状态" width="100"><template #default="{row}"><el-tag :type="row.status==='active'?'success':row.status==='failed'?'danger':'warning'">{{row.status||'-'}}</el-tag></template></el-table-column><el-table-column label="操作" width="180"><template #default="{row}"><el-button link type="primary" @click="revealSshKey(row)">读取私钥</el-button><el-button link type="warning" @click="openKeyDialog('rotate',row)">轮换</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="实例监控" name="monitoring">
<div class="toolbar"><span>资源指标与网络趋势</span><el-button @click="loadMonitoring">刷新监控</el-button></div>
<AwsRuntimeVisualization :monitoring="monitoring" />
</el-tab-pane>
<el-tab-pane label="网络安全" name="network">
@@ -63,25 +101,15 @@
</el-tab-pane>
</el-tabs>
<el-dialog v-model="loginDialog" title="AWS 登录凭据" width="820px" destroy-on-close @closed="clearCredentials">
<el-alert title="敏感信息仅在当前弹窗临时展示,关闭后立即清除" type="warning" show-icon :closable="false" class="credential-alert"/>
<div class="credential-grid">
<div v-for="(user,ui) in loginUsers" :key="ui" class="credential-card">
<div class="credential-user"><span class="user-avatar"><el-icon><User /></el-icon></span><div><h4>{{user.username||'未知用户'}}</h4><span>{{(user.methods||[]).length}} 种登录方式</span></div></div>
<section v-for="(method,mi) in user.methods||[]" :key="mi" class="credential-method" :class="{unavailable:method.credential?.available===false}">
<div class="method-head"><div><b>{{credentialType(method)}}</b></div><el-tag :type="method.credential?.available===false?'danger':'success'">{{method.credential?.available===false?'不可用':'可用'}}</el-tag></div>
<el-alert v-if="method.credential?.error" :title="method.credential.error" type="error" show-icon :closable="false"/>
<div class="credential-meta-grid"><div><span>主机地址</span><code :title="method.host">{{method.host||'-'}}</code></div><div><span>协议</span><code>{{String(method.protocol||'ssh').toUpperCase()}}</code></div><div><span>端口</span><code>{{method.port||'-'}}</code></div><div><span>用户名</span><code>{{user.username||'-'}}</code></div></div>
<div v-if="isPassword(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>登录密码</span></div><div class="secret-display"><code>{{revealed[`${ui}-${mi}-p`]?method.credential.password:mask(method.credential.password)}}</code></div><div class="secret-actions"><el-button @click="toggleSecret(`${ui}-${mi}-p`)">{{revealed[`${ui}-${mi}-p`]?'隐藏密码':'查看密码'}}</el-button><el-button type="primary" plain @click="copy(method.credential.password)">复制密码</el-button></div></div>
<div v-if="isPrivateKey(method)" class="secret-block credential-secret-card"><div class="secret-label"><span>SSH 私钥</span><el-tooltip :content="`密钥指纹:${method.credential?.fingerprint||'暂无'}`" placement="top"><el-button class="fingerprint-button" circle link :icon="InfoFilled" aria-label="查看密钥指纹"/></el-tooltip></div><div class="secret-display"><code>{{method.credential?.key_name||'EC2 Key Pair'}}</code></div><div class="secret-actions"><el-button @click="downloadPrivateKey(method.credential.private_key,method.credential?.key_name)">下载私钥</el-button><el-button type="primary" plain @click="copy(method.credential.private_key)">复制私钥</el-button></div></div>
</section>
</div>
</div>
<el-empty v-if="!loginUsers.length" description="暂无可用登录凭据"/>
</el-dialog>
<el-dialog v-model="ruleDialog" title="添加安全组规则" width="520px"><el-form :model="rule" label-width="90px"><el-form-item label="方向"><el-select v-model="rule.direction"><el-option label="入站" value="ingress"/><el-option label="出站" value="egress"/></el-select></el-form-item><el-form-item label="协议"><el-select v-model="rule.protocol"><el-option v-for="p in protocols" :key="p" :label="p" :value="p"/></el-select></el-form-item><el-form-item label="端口范围"><el-input-number v-model="rule.from_port" :min="-1" :max="65535"/> - <el-input-number v-model="rule.to_port" :min="-1" :max="65535"/></el-form-item><el-form-item label="CIDR"><el-input v-model.trim="rule.cidr"/></el-form-item><el-form-item label="说明"><el-input v-model="rule.description" maxlength="200"/></el-form-item></el-form><template #footer><el-button @click="ruleDialog=false">取消</el-button><el-button type="primary" @click="addRule">确认</el-button></template></el-dialog>
<el-dialog v-model="volumeDialog" :title="volumeTitles[volumeMode]" width="560px"><el-alert v-if="volumeMode==='root_expand'" title="系统盘只能增加容量,卷类型、性能、加密和删除策略保持不变" type="info" show-icon :closable="false" class="alert"/><el-form :model="volumeForm" label-width="130px"><el-form-item v-if="volumeMode!=='create'" label="卷 ID"><el-input v-model="volumeForm.volume_id" :disabled="volumeMode!=='attach'"/></el-form-item><el-form-item v-if="volumeMode==='create'" label="名称"><el-input v-model="volumeForm.name"/></el-form-item><el-form-item v-if="['create','attach'].includes(volumeMode)" label="设备名"><el-input v-model="volumeForm.device_name" placeholder="/dev/sdf"/></el-form-item><el-form-item v-if="['create','modify','root_expand'].includes(volumeMode)" label="目标容量 GiB"><el-input-number v-model="volumeForm.size_gib" :min="volumeMode==='root_expand'?rootCurrentSize+1:1" :max="16384"/></el-form-item><el-form-item v-if="['create','modify'].includes(volumeMode)" label="类型"><el-select v-model="volumeForm.type"><el-option v-for="v in volumeTypes" :key="v" :label="v" :value="v"/></el-select></el-form-item><el-form-item v-if="['create','modify'].includes(volumeMode)" label="IOPS"><el-input-number v-model="volumeForm.iops" :min="0"/></el-form-item><el-form-item v-if="['create','modify'].includes(volumeMode)" label="吞吐量"><el-input-number v-model="volumeForm.throughput" :min="0"/></el-form-item><el-form-item v-if="volumeMode==='create'" label="加密"><el-switch v-model="volumeForm.encrypted"/></el-form-item><el-form-item v-if="['create','attach'].includes(volumeMode)" label="实例终止时删除"><el-switch v-model="volumeForm.delete_on_termination"/></el-form-item></el-form><template #footer><el-button @click="volumeDialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitVolume">提交</el-button></template></el-dialog>
<el-dialog v-model="rebuildDialog" title="重装 AWS 实例" width="500px"><el-alert title="重装将覆盖系统盘,请确认镜像 ID 和数据备份" type="warning" show-icon :closable="false" class="alert"/><el-form label-width="90px"><el-form-item label="镜像 ID"><el-input v-model.trim="rebuildImageId" placeholder="ami-..."/></el-form-item></el-form><template #footer><el-button @click="rebuildDialog=false">取消</el-button><el-button type="danger" :loading="submitting" @click="submitRebuild">确认重装</el-button></template></el-dialog>
<el-dialog v-model="keyDialog" :title="keyMode==='rotate'?'安全轮换 SSH 密钥':'添加 SSH 密钥'" width="620px" :close-on-click-modal="false">
<el-alert :title="keyMode==='rotate'?'新密钥安装成功后才会替换旧密钥。':'公钥留空时由服务端在线生成 RSA 密钥。'" type="info" show-icon :closable="false" class="alert"/>
<el-form label-width="100px"><el-form-item label="系统用户" required><el-input v-model.trim="keyForm.username" placeholder="ubuntu" maxlength="32"/></el-form-item><el-form-item label="密钥名称"><el-input v-model.trim="keyForm.name" placeholder="仅支持字母、数字、点、下划线和连字符" maxlength="128"/></el-form-item><el-form-item v-if="keyMode==='rotate'" label="替换密钥"><el-select v-model="keyForm.replace_key_id" clearable filterable placeholder="留空则重置默认启动密钥" style="width:100%"><el-option v-for="item in sshKeys" :key="item.ssh_key_id" :label="`${item.name || item.ssh_key_id} · ${item.username}`" :value="item.ssh_key_id"/></el-select></el-form-item><el-form-item label="OpenSSH 公钥"><el-input v-model="keyForm.public_key" type="textarea" :rows="5" maxlength="16384" placeholder="留空在线生成,或粘贴 ssh-rsa / ssh-ed25519 公钥"/></el-form-item></el-form>
<template #footer><el-button @click="keyDialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitSshKey">提交</el-button></template>
</el-dialog>
<el-dialog v-model="privateKeyDialog" title="SSH 私钥" width="720px" destroy-on-close @closed="clearPrivateKey"><el-alert title="私钥仅在本弹窗临时展示,关闭后立即清除。" type="warning" show-icon :closable="false" class="alert"/><el-descriptions v-if="privateKey" :column="2" border><el-descriptions-item label="名称">{{privateKey.key_name||'-'}}</el-descriptions-item><el-descriptions-item label="系统用户">{{privateKey.username||'-'}}</el-descriptions-item><el-descriptions-item label="指纹" :span="2">{{privateKey.fingerprint||'-'}}</el-descriptions-item></el-descriptions><el-input v-if="privateKey" :model-value="privateKey.private_key" type="textarea" :rows="12" readonly class="private-key-text"/><template #footer><el-button @click="privateKeyDialog=false">关闭</el-button><el-button @click="copy(privateKey?.private_key)">复制</el-button><el-button type="primary" @click="downloadPrivateKey(privateKey?.private_key,privateKey?.key_name)">下载 .pem</el-button></template></el-dialog>
<el-dialog v-model="rebuildDialog" title="重装 AWS 实例" width="680px"><el-alert title="重装将覆盖系统盘,请确认数据已备份。" type="warning" show-icon :closable="false" class="alert"/><el-form label-width="90px"><el-form-item label="目标镜像" required><el-select v-model="rebuildImageId" filterable :loading="imageLoading" placeholder="请选择商品在当前区域允许的镜像" style="width:100%"><el-option v-for="option in imageOptions" :key="String(option.value)" :label="imageOptionLabel(option)" :value="String(option.value)" :disabled="option.disabled===true"/></el-select></el-form-item><el-empty v-if="!imageLoading&&!imageOptions.length" description="当前商品和区域暂无可用镜像" :image-size="64"/></el-form><template #footer><el-button @click="rebuildDialog=false">取消</el-button><el-button type="danger" :loading="submitting" :disabled="!rebuildImageId" @click="submitRebuild">确认重装</el-button></template></el-dialog>
<el-dialog v-model="trafficDialog" title="实例流量上限" width="520px"><el-form label-width="110px"><el-form-item label="当前状态"><el-tag :type="trafficLimit?.enabled?'success':'info'">{{trafficLimit?.enabled?'已启用':'未启用'}}</el-tag></el-form-item><el-form-item label="当前额度">{{trafficLimit?.limit_bytes?bytes(trafficLimit.limit_bytes):'-'}}</el-form-item><el-form-item label="启用"><el-switch v-model="trafficForm.enabled"/></el-form-item><el-form-item label="额度 GiB"><el-input-number v-model="trafficForm.limit_gib" :min="1" :precision="0"/></el-form-item></el-form><template #footer><el-button @click="trafficDialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitTraffic">提交变更</el-button></template></el-dialog>
<el-dialog v-model="operationDialog" title="运行期操作详情" width="680px"><div v-if="currentOperation" class="operation-detail"><div class="operation-hero"><span class="operation-icon"><el-icon><Operation /></el-icon></span><div><h3>{{operationTypeText(currentOperation.operationType)}}</h3><p>操作记录 #{{currentOperation.id}}</p></div><el-tag :type="operationTag(currentOperation.status)" effect="dark">{{operationText(currentOperation.status)}}</el-tag></div><el-progress :percentage="Number(currentOperation.progress||0)" :status="currentOperation.status==='failed'?'exception':currentOperation.status==='succeeded'?'success':''"/><el-descriptions :column="2" border><el-descriptions-item label="实例 ID">#{{currentOperation.instanceId||'-'}}</el-descriptions-item><el-descriptions-item label="用户商品"><el-link v-if="currentOperation.userGoodsId" type="primary" @click="$router.push(`/user-goods/detail/${currentOperation.userGoodsId}`)">#{{currentOperation.userGoodsId}}</el-link><span v-else>-</span></el-descriptions-item><el-descriptions-item label="关联订单"><el-link v-if="currentOperation.orderId" type="primary" @click="$router.push({path:'/order/list',query:{order_id:currentOperation.orderId}})">#{{currentOperation.orderId}}</el-link><span v-else>-</span></el-descriptions-item><el-descriptions-item label="目标续费价">{{currentOperation.targetRenewPrice==null?'-':`¥${cents(currentOperation.targetRenewPrice)}`}}</el-descriptions-item><el-descriptions-item label="下次重试">{{time(currentOperation.nextRetryAt)}}</el-descriptions-item><el-descriptions-item label="完成时间">{{time(currentOperation.finishedAt)}}</el-descriptions-item><el-descriptions-item label="重试次数">{{currentOperation.retryCount||0}}</el-descriptions-item><el-descriptions-item label="更新时间">{{time(currentOperation.UpdatedAt)}}</el-descriptions-item><el-descriptions-item v-if="currentOperation.errorMessage" label="错误信息" :span="2">{{currentOperation.errorMessage}}</el-descriptions-item></el-descriptions></div></el-dialog>
</div>
@@ -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()})
</script>
<style scoped>
+1 -1
View File
@@ -210,7 +210,7 @@
</el-card>
<!-- 类型适配栏目区域 -->
<AwsUserGoodsDetail v-else-if="isAwsGoods" ref="awsDetailRef" :goods-id="goodsId" />
<AwsUserGoodsDetail v-else-if="isAwsGoods" ref="awsDetailRef" :goods-id="goodsId" :product-good-id="Number(detail?.goodId || detail?.good_id || detail?.good?.id || 0)" />
<UserVmDetail v-else-if="currentTag === '云服务器'" ref="vmDetailRef" embedded :goods-id="goodsId" />
File diff suppressed because one or more lines are too long