3489 lines
178 KiB
Vue
3489 lines
178 KiB
Vue
<template>
|
||
<div class="vm-detail-page">
|
||
<!-- 顶部面包屑 + 返回 -->
|
||
<div class="page-header">
|
||
<div class="header-left">
|
||
<el-button @click="goBack" link class="back-btn"><el-icon><ArrowLeft /></el-icon> 返回虚拟机列表</el-button>
|
||
</div>
|
||
<div class="header-right">
|
||
<el-button plain :icon="Refresh" @click="loadDetail" :loading="loading">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main-content" v-loading="loading">
|
||
<!-- 实例概览栏 -->
|
||
<div class="instance-overview" v-if="detail">
|
||
<div class="overview-left">
|
||
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
|
||
</div>
|
||
<div class="overview-actions">
|
||
<el-button type="primary" @click="handleGetVnc">远程连接</el-button>
|
||
<el-button type="danger" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop' || isMigrating">关机</el-button>
|
||
<el-dropdown trigger="click" @command="handleMoreCommand">
|
||
<el-button>更多 <el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="start" :disabled="detail.status === 'running'">启动</el-dropdown-item>
|
||
<el-dropdown-item command="reboot">重启</el-dropdown-item>
|
||
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
|
||
<el-dropdown-item command="resume" v-if="detail.status === 'paused'">恢复</el-dropdown-item>
|
||
<el-dropdown-item divided command="editVm" :disabled="isMigrating">修改虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="refactorVm" :disabled="isMigrating">重构虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="updateTraffic" :disabled="isMigrating">修改带宽</el-dropdown-item>
|
||
<el-dropdown-item divided command="rebuild" :disabled="isMigrating">重装虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
||
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
|
||
<el-dropdown-item divided command="migrateVm" :disabled="isMigrating">迁移虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="dataMigrateVm" :disabled="isMigrating">数据迁移</el-dropdown-item>
|
||
<el-dropdown-item command="dataMigrateProgress" v-if="isMigrating">查看迁移进度</el-dropdown-item>
|
||
<el-dropdown-item command="abortDataMigrate" v-if="isMigrating" style="color:#f56c6c">中断迁移</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态概览条 -->
|
||
<div class="status-bar" v-if="detail">
|
||
<div class="status-item">
|
||
<span class="status-label">实例状态</span>
|
||
<span class="status-value">
|
||
<span class="status-dot" :class="detail.status === 'running' ? 'dot-running' : 'dot-other'"></span>
|
||
{{ vmStatusLabel(detail.status) }}
|
||
<el-tag v-if="isMigrating" type="warning" size="small" effect="dark" style="margin-left:8px">迁移中</el-tag>
|
||
</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">IP地址</span>
|
||
<span class="status-value" v-if="publicIpList.length || privateIpList.length">
|
||
{{ (publicIpList[0] || privateIpList[0]) }}
|
||
<el-popover v-if="publicIpList.length + privateIpList.length > 1" trigger="hover" placement="bottom-start" :width="300">
|
||
<template #reference>
|
||
<el-tag size="small" type="info" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ publicIpList.length + privateIpList.length - 1 }}</el-tag>
|
||
</template>
|
||
<div class="ip-popover-list">
|
||
<div v-if="publicIpList.length" style="font-size:12px;color:#909399;margin-bottom:4px">公网IP</div>
|
||
<div v-for="(ip, idx) in publicIpList" :key="'pub'+idx" class="ip-popover-item">{{ ip }}</div>
|
||
<div v-if="privateIpList.length" style="font-size:12px;color:#909399;margin:8px 0 4px">内网IP</div>
|
||
<div v-for="(ip, idx) in privateIpList" :key="'pri'+idx" class="ip-popover-item">{{ ip }}</div>
|
||
</div>
|
||
</el-popover>
|
||
</span>
|
||
<span class="status-value" v-else>{{ detail.ips || '-' }}</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">创建时间</span>
|
||
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 数据迁移进度横幅 -->
|
||
<div class="migrate-banner" v-if="isMigrating">
|
||
<div class="migrate-banner-info">
|
||
<el-icon class="migrate-spin"><Loading /></el-icon>
|
||
<span class="migrate-banner-title">数据迁移进行中</span>
|
||
<template v-if="dataMigrateProgressData">
|
||
<span class="migrate-divider"></span>
|
||
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small" effect="dark">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
|
||
<span v-if="dataMigrateProgressData.progress != null" class="migrate-progress-text">{{ dataMigrateProgressData.progress }}%</span>
|
||
<span v-if="dataMigrateProgressData.speed" class="migrate-speed">{{ dataMigrateProgressData.speed }}</span>
|
||
<span v-if="dataMigrateProgressData.message" class="migrate-msg">{{ dataMigrateProgressData.message }}</span>
|
||
</template>
|
||
<span v-else class="migrate-msg">正在获取进度信息...</span>
|
||
</div>
|
||
<div class="migrate-banner-actions">
|
||
<el-button size="small" @click="loadDataMigrateProgress" :loading="dataMigrateProgressLoading">
|
||
<el-icon v-if="!dataMigrateProgressLoading"><Refresh /></el-icon>刷新进度
|
||
</el-button>
|
||
<el-button size="small" type="danger" plain @click="handleAbortMigrate" :loading="abortLoading">中断迁移</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 标签页 -->
|
||
<el-tabs v-model="activeTab" class="detail-tabs" v-if="detail">
|
||
<el-tab-pane label="实例详情" name="info">
|
||
<!-- 配置信息 -->
|
||
<div class="section-block">
|
||
<h3 class="section-title">配置信息</h3>
|
||
<div class="config-grid">
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">实例规格</span>
|
||
<span class="config-value spec-value">{{ detail.vcpu || '-' }}核vCPU | {{ formatMemory(detail.memory) }} | {{ detail.rx_bandwidth || 0 }} Mbps</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">操作系统</span>
|
||
<span class="config-value">{{ vmImage?.name || '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">镜像类型</span>
|
||
<span class="config-value">
|
||
<el-tag v-if="vmImage" :type="vmImage.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ vmImage.os_type || '-' }}</el-tag>
|
||
<span v-else>-</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">公网IP</span>
|
||
<span class="config-value ip-value" v-if="publicIpList.length">
|
||
{{ publicIpList[0] }}
|
||
<el-popover v-if="publicIpList.length > 1" trigger="hover" placement="bottom-start" :width="300">
|
||
<template #reference>
|
||
<el-tag size="small" type="primary" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ publicIpList.length - 1 }}</el-tag>
|
||
</template>
|
||
<div class="ip-popover-list">
|
||
<div v-for="(ip, idx) in publicIpList" :key="idx" class="ip-popover-item">{{ ip }}</div>
|
||
</div>
|
||
</el-popover>
|
||
</span>
|
||
<span class="config-value ip-value" v-else>暂无</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">内网IP</span>
|
||
<span class="config-value ip-value" v-if="privateIpList.length">
|
||
{{ privateIpList[0] }}
|
||
<el-popover v-if="privateIpList.length > 1" trigger="hover" placement="bottom-start" :width="300">
|
||
<template #reference>
|
||
<el-tag size="small" type="success" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ privateIpList.length - 1 }}</el-tag>
|
||
</template>
|
||
<div class="ip-popover-list">
|
||
<div v-for="(ip, idx) in privateIpList" :key="idx" class="ip-popover-item">{{ ip }}</div>
|
||
</div>
|
||
</el-popover>
|
||
</span>
|
||
<span class="config-value ip-value" v-else>暂无</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">下行/上行带宽</span>
|
||
<span class="config-value">{{ detail.rx_bandwidth || 0 }} / {{ detail.tx_bandwidth || 0 }} Mbps</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">安全组</span>
|
||
<span class="config-value">
|
||
<template v-if="vmPortGroup || vmOutPortGroup">
|
||
<el-tag v-if="vmPortGroup" size="small" type="success" style="margin-right: 4px">入站: {{ vmPortGroup.name }}</el-tag>
|
||
<el-tag v-if="vmOutPortGroup" size="small" type="warning">出站: {{ vmOutPortGroup.name }}</el-tag>
|
||
</template>
|
||
<span v-else style="color: #909399">未绑定</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">用户名</span>
|
||
<span class="config-value">root</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">远程端口</span>
|
||
<span class="config-value">{{ detail.ssh_port || 22 }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">密码</span>
|
||
<span class="config-value password-cell">
|
||
<code>{{ showPassword ? (detail.root_password || '-') : '••••••••' }}</code>
|
||
<el-button link type="primary" size="small" @click="showPassword = !showPassword">{{ showPassword ? '隐藏' : '显示' }}</el-button>
|
||
<el-button link type="primary" size="small" @click="copyText(detail.root_password)">复制</el-button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell">
|
||
<span class="config-label">流量上限</span>
|
||
<span class="config-value">{{ detail.traffic_max != null ? `${(detail.traffic_max / 1024).toFixed(2)} GB` : '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">快照配额</span>
|
||
<span class="config-value">{{ detail.snapshot_num ?? '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">备份配额</span>
|
||
<span class="config-value">{{ detail.backup_num ?? '-' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="config-row">
|
||
<div class="config-cell" style="flex: 2">
|
||
<span class="config-label">UUID</span>
|
||
<span class="config-value mono-text">{{ detail.uuid || '-' }}</span>
|
||
</div>
|
||
<div class="config-cell">
|
||
<span class="config-label">更新时间</span>
|
||
<span class="config-value">{{ formatTimestamp(detail.updated_at) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="网络管理" name="network">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">网络列表</h3>
|
||
<div style="display: flex; gap: 8px">
|
||
<el-button size="small" type="primary" @click="showNetBindBridgeSelector = true">绑定外网</el-button>
|
||
<el-button size="small" type="success" @click="showNetBindNatSelector = true">绑定内网</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="pagedNetworks" size="small" stripe>
|
||
<el-table-column prop="id" label="ID" width="60" />
|
||
<el-table-column prop="name" label="名称" min-width="100" />
|
||
<el-table-column prop="address" label="地址(CIDR)" min-width="140" show-overflow-tooltip />
|
||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
|
||
<el-table-column prop="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip />
|
||
<el-table-column label="类型" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="bridge_name" label="网桥" width="100" />
|
||
<el-table-column prop="target_device" label="目标设备" width="100" show-overflow-tooltip />
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleNetDetail(row)">详情</el-button>
|
||
<el-button link type="primary" size="small" @click="handleNetEdit(row)">编辑</el-button>
|
||
<el-button link type="danger" size="small" @click="handleNetDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!vmNetworks.length" description="暂无网络" :image-size="60" />
|
||
<div class="pagination-wrapper" v-if="vmNetworks.length > 0">
|
||
<el-pagination v-model:current-page="networkPage" v-model:page-size="networkPageSize"
|
||
:page-sizes="[10, 20, 50]" :total="vmNetworks.length" layout="total, sizes, prev, pager, next" small
|
||
@size-change="s => { networkPageSize = s; networkPage = 1 }"
|
||
@current-change="p => { networkPage = p }" />
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="数据卷管理" name="volume">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">数据卷列表</h3>
|
||
<div style="display: flex; gap: 8px">
|
||
<el-button size="small" type="primary" @click="showVolSelector = true">绑定数据卷</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="pagedVolumes" size="small" stripe>
|
||
<el-table-column prop="id" label="ID" width="60" />
|
||
<el-table-column prop="name" label="名称" min-width="120" />
|
||
<el-table-column label="大小" width="80">
|
||
<template #default="{ row }">{{ row.size ? row.size + ' GB' : '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="类型" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.is_system ? 'danger' : ''" size="small">{{ row.is_system ? '系统盘' : '数据盘' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<!-- <el-table-column label="挂载" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.is_mount ? 'success' : 'info'" size="small">{{ row.is_mount ? '已挂载' : '未挂载' }}</el-tag>
|
||
</template>
|
||
</el-table-column> -->
|
||
<el-table-column label="状态" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="volumeStatusType(row.status)" size="small">{{ volumeStatusLabel(row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="path" label="路径" min-width="160" show-overflow-tooltip>
|
||
<template #default="{ row }"><span class="mono-text">{{ row.path || '-' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="300" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleVolDetail(row)">详情</el-button>
|
||
<el-button link type="primary" size="small" @click="handleVolResize(row)">调整大小</el-button>
|
||
<!-- <el-button link type="success" size="small" @click="handleVolMount(row)" v-if="!row.is_mount">挂载</el-button> -->
|
||
<el-button link type="warning" size="small" @click="handleVolUnmount(row)" v-if="row.is_mount">卸载</el-button>
|
||
<el-button link type="info" size="small" @click="handleVolTransfer(row)">迁移</el-button>
|
||
<el-button link type="danger" size="small" @click="handleVolDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!vmVolumes.length" description="暂无磁盘卷" :image-size="60" />
|
||
<div class="pagination-wrapper" v-if="vmVolumes.length > 0">
|
||
<el-pagination v-model:current-page="volumePage" v-model:page-size="volumePageSize"
|
||
:page-sizes="[10, 20, 50]" :total="vmVolumes.length" layout="total, sizes, prev, pager, next" small
|
||
@size-change="s => { volumePageSize = s; volumePage = 1 }"
|
||
@current-change="p => { volumePage = p }" />
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="安全组管理" name="security">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">安全组管理</h3>
|
||
<div style="display: flex; gap: 8px">
|
||
<el-button size="small" type="primary" @click="handleSgCreate"><el-icon><Plus /></el-icon>创建安全组</el-button>
|
||
<el-button size="small" @click="handleSgBind">绑定安全组</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="async () => { await loadDetail(); loadSgLockInfo() }">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="pagedSecurityGroups" size="small" stripe>
|
||
<el-table-column prop="id" label="ID" width="70" />
|
||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||
<el-table-column label="锁定" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.lock ? 'danger' : 'success'" size="small">{{ row.lock ? '是' : '否' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="白名单" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.drop_all ? 'warning' : 'info'" size="small">{{ row.drop_all ? '开启' : '关闭' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="方向" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="280" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleSgGoDetail(row)">编辑</el-button>
|
||
<el-button link type="success" size="small" @click="handleSgSync(row)">同步</el-button>
|
||
<el-button link type="warning" size="small" @click="handleSgApply(row)">应用</el-button>
|
||
<el-dropdown trigger="click" @command="cmd => handleSgRowMore(row, cmd)" style="margin-left: 4px; margin-top: 4.5px">
|
||
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item command="unbindCurrent" style="color: #e6a23c">解绑当前虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="bindVm" divided>绑定虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="unbindVm">解绑虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="whitelist" divided>{{ row.drop_all ? '关闭白名单' : '开启白名单' }}</el-dropdown-item>
|
||
<el-dropdown-item command="viewDetail">查看详情</el-dropdown-item>
|
||
<el-dropdown-item command="delete" divided style="color: #f56c6c">删除</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!sgTableData.length" description="暂无绑定的安全组" :image-size="60" />
|
||
|
||
<div class="pagination-wrapper" v-if="sgTableData.length > 0">
|
||
<el-pagination
|
||
v-model:current-page="sgPage"
|
||
v-model:page-size="sgPageSize"
|
||
:page-size="[10,20,50]"
|
||
:total="sgTableData.length"
|
||
layout="total,sizes,prev,pager,next"
|
||
small
|
||
@size-change="s => {sgPageSize = s; sgPage = 1}"
|
||
@current-change="p => {sgPage = p}"
|
||
>
|
||
</el-pagination>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="快照" name="snapshot">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">快照管理</h3>
|
||
<div style="display: flex; align-items: center; gap: 8px">
|
||
<el-tag v-if="snapshotQuota" size="small" effect="plain">{{ snapshotQuota.count }} / {{ snapshotQuota.limit }}</el-tag>
|
||
<el-button size="small" @click="handleSetSnapshotLimit">设置上限</el-button>
|
||
<el-button size="small" type="primary" @click="handleCreateSnapshot">创建快照</el-button>
|
||
<el-button size="small" @click="loadSnapshots">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="snapshotList" v-loading="snapshotLoading" stripe size="small" style="width: 100%">
|
||
<el-table-column prop="id" label="ID" width="60" />
|
||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="任务ID" min-width="160">
|
||
<template #default="{ row }">
|
||
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" width="170">
|
||
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="更新时间" width="170">
|
||
<template #default="{ row }">{{ formatTimestamp(row.updated_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleRestoreSnapshot(row)">恢复</el-button>
|
||
<el-button link type="info" size="small" @click="handleSnapshotProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDeleteSnapshot(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!snapshotList.length && !snapshotLoading" description="暂无快照" :image-size="60" />
|
||
<div class="pagination-wrapper" v-if="snapshotTotal > 0">
|
||
<el-pagination v-model:current-page="snapshotPage" v-model:page-size="snapshotPageSize"
|
||
:page-sizes="[10, 20, 50]" :total="snapshotTotal" layout="total, sizes, prev, pager, next" small
|
||
@size-change="s => { snapshotPageSize = s; snapshotPage = 1; loadSnapshots() }"
|
||
@current-change="p => { snapshotPage = p; loadSnapshots() }" />
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="备份" name="backup">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">备份管理</h3>
|
||
<div style="display: flex; align-items: center; gap: 8px">
|
||
<el-tag v-if="backupQuota" size="small" effect="plain">{{ backupQuota.count }} / {{ backupQuota.limit }}</el-tag>
|
||
<el-button size="small" @click="handleSetBackupLimit">设置上限</el-button>
|
||
<el-button size="small" type="primary" @click="handleCreateBackup">创建备份</el-button>
|
||
<el-button size="small" @click="loadBackups">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="backupList" v-loading="backupLoading" stripe size="small" style="width: 100%">
|
||
<el-table-column prop="id" label="ID" width="60" />
|
||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="taskStatusType(row.status)" size="small">{{ snapshotStatusLabel(row.status) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="任务ID" min-width="160">
|
||
<template #default="{ row }">
|
||
<span style="font-family: Consolas, monospace; font-size: 12px">{{ row.task_id || '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="创建时间" width="170">
|
||
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="更新时间" width="170">
|
||
<template #default="{ row }">{{ formatTimestamp(row.updated_at) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="180" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleRestoreBackup(row)">恢复</el-button>
|
||
<el-button link type="info" size="small" @click="handleBackupProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDeleteBackup(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!backupList.length && !backupLoading" description="暂无备份" :image-size="60" />
|
||
<div class="pagination-wrapper" v-if="backupTotal > 0">
|
||
<el-pagination v-model:current-page="backupPage" v-model:page-size="backupPageSize"
|
||
:page-sizes="[10, 20, 50]" :total="backupTotal" layout="total, sizes, prev, pager, next" small
|
||
@size-change="s => { backupPageSize = s; backupPage = 1; loadBackups() }"
|
||
@current-change="p => { backupPage = p; loadBackups() }" />
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="组网管理" name="userNetworking">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">用户组网</h3>
|
||
<div style="display: flex; gap: 8px">
|
||
<el-button size="small" type="primary" @click="handleVnCreate"><el-icon><Plus /></el-icon>创建组网</el-button>
|
||
<el-button size="small" :icon="Refresh" @click="loadVmNetworkingList" :loading="vnLoading">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<el-table :data="vnList" v-loading="vnLoading" stripe size="small">
|
||
<el-table-column label="组网" min-width="130">
|
||
<template #default="{ row }">{{ row.networking?.name || '-' }} <span style="color:#909399">(ID: {{ row.networking?.id }})</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.network" type="success" size="small">已加入</el-tag>
|
||
<el-tag v-else type="info" size="small">未加入</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="网桥" width="130">
|
||
<template #default="{ row }"><span class="mono-text">{{ row.network?.bridge_name || row.networking?.bridge_name || '-' }}</span></template>
|
||
</el-table-column>
|
||
<el-table-column label="IP地址" min-width="150">
|
||
<template #default="{ row }">
|
||
<span v-if="row.network" class="mono-text">{{ row.network.address || '-' }}</span>
|
||
<span v-else style="color:#c0c4cc">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="网关" width="140">
|
||
<template #default="{ row }">
|
||
<span v-if="row.network" class="mono-text">{{ row.network.gateway || '-' }}</span>
|
||
<span v-else class="mono-text" style="color:#c0c4cc">{{ row.networking?.gateway || '-' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="MAC地址" min-width="160">
|
||
<template #default="{ row }">
|
||
<span v-if="row.network" class="mono-text">{{ row.network.mac_address || '-' }}</span>
|
||
<span v-else style="color:#c0c4cc">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="类型" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.network" :type="row.network.type === 'bridge' ? 'success' : 'warning'" size="small">{{ row.network.type || '-' }}</el-tag>
|
||
<span v-else style="color:#c0c4cc">-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="120" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button v-if="!row.network" link type="primary" size="small" @click="handleJoinNetworking(row)">加入</el-button>
|
||
<el-button v-else link type="danger" size="small" @click="handleLeaveNetworking(row)">移除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-if="!vnList.length && !vnLoading" description="没有匹配的可用组网" :image-size="60" />
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="监控" name="monitor">
|
||
<div class="section-block">
|
||
<div class="section-header">
|
||
<h3 class="section-title">监控指标</h3>
|
||
<div style="display: flex; align-items: center; gap: 8px">
|
||
<el-select v-model="historyTimeRange" size="small" style="width: 120px" @change="loadHistoricalMetrics">
|
||
<el-option v-for="option in historyTimeOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||
</el-select>
|
||
<el-button size="small" :icon="Refresh" @click="loadHistoricalMetrics" :loading="historicalMetricsLoading">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
<template v-if="latestMetrics">
|
||
<div class="metric-summary-row">
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">CPU 使用率</div>
|
||
<div class="metric-summary-value">{{ latestMetrics.cpu_usage?.toFixed(1) }}%</div>
|
||
<div class="metric-summary-sub"> </div>
|
||
</div>
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">内存</div>
|
||
<div class="metric-summary-value">{{ vmMemPercent(latestMetrics) }}%</div>
|
||
<div class="metric-summary-sub">{{ formatMemKB(latestMetrics.mem_used) }} / {{ formatMemKB(latestMetrics.mem_total) }}</div>
|
||
</div>
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">磁盘 I/O</div>
|
||
<div class="metric-summary-value">读 {{ formatBytesRaw(latestMetrics.disk_read) }}</div>
|
||
<div class="metric-summary-sub">写 {{ formatBytesRaw(latestMetrics.disk_write) }}</div>
|
||
</div>
|
||
<div class="metric-summary-card">
|
||
<div class="metric-summary-label">网络流量</div>
|
||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<template v-if="historicalMetricsData">
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> CPU 使用率</span></template>
|
||
<div ref="cpuChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 内存使用率</span></template>
|
||
<div ref="memChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16" style="margin-top: 16px">
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 磁盘 I/O</span></template>
|
||
<div ref="diskChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-card shadow="hover" class="metrics-card">
|
||
<template #header><span class="metrics-title"><el-icon><Refresh /></el-icon> 网络流量</span></template>
|
||
<div ref="netChartRef" class="chart-container"></div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
</template>
|
||
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- 电源操作确认弹窗 -->
|
||
<el-dialog v-model="powerDialogVisible" :title="`${powerLabels[powerAction] || ''}虚拟机`" width="400px" destroy-on-close>
|
||
<div style="display: flex; align-items: flex-start; gap: 12px; padding: 8px 0">
|
||
<el-icon :size="22" :style="{ color: powerAction === 'stop' ? '#F56C6C' : powerAction === 'reboot' ? '#E6A23C' : '#409EFF', flexShrink: 0, marginTop: '-1px' }">
|
||
<WarningFilled />
|
||
</el-icon>
|
||
<div>
|
||
<div style="font-size: 15px; font-weight: 500; color: #303133; margin-bottom: 12px">
|
||
确定要{{ powerLabels[powerAction] }}虚拟机「{{ detail?.name }}」吗?
|
||
</div>
|
||
<el-checkbox v-model="powerForce" style="margin-bottom: 4px">强制执行</el-checkbox>
|
||
<div style="font-size: 12px; color: #909399; padding-left: 24px">勾选后将强制{{ powerLabels[powerAction] }},可能导致数据丢失</div>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="powerDialogVisible = false">取消</el-button>
|
||
<el-button :type="powerAction === 'stop' ? 'danger' : 'primary'" @click="submitPower">确定{{ powerLabels[powerAction] }}</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 重装弹窗 -->
|
||
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">重装信息</div>
|
||
<el-alert title="重装会清除当前虚拟机数据并使用新镜像重新创建,请谨慎操作!" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
|
||
<el-form-item label="虚拟机">{{ detail?.name || '-' }}</el-form-item>
|
||
<el-form-item label="新镜像" required>
|
||
<div style="display: flex; gap: 8px; width: 100%">
|
||
<el-input :model-value="rebuildImageId ? `${rebuildImageName || ''} (ID: ${rebuildImageId})` : ''" readonly placeholder="请选择镜像" style="flex: 1" />
|
||
<el-button type="primary" @click="showImageSelector = true">选择</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="rebuildDialogVisible = false">取消</el-button>
|
||
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重装</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<ImageSelectorPopup v-model="showImageSelector" :service-id="serviceId" :current-id="rebuildImageId" @confirm="img => { rebuildImageId = img.id; rebuildImageName = img.name }" />
|
||
|
||
<!-- 创建快照弹窗 -->
|
||
<el-dialog v-model="snapshotCreateVisible" title="创建快照" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form :model="snapshotForm" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">快照信息</div>
|
||
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
|
||
<el-form-item label="快照名称" required>
|
||
<el-input v-model="snapshotForm.name" placeholder="请输入快照名称" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="snapshotCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建备份弹窗 -->
|
||
<el-dialog v-model="backupCreateVisible" title="创建备份" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form :model="backupForm" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">备份信息</div>
|
||
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
|
||
<el-form-item label="备份名称" required>
|
||
<el-input v-model="backupForm.name" placeholder="请输入备份名称" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="backupCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 快照/备份进度弹窗 -->
|
||
<el-dialog v-model="taskProgressVisible" :title="taskProgressTitle" width="520px" destroy-on-close>
|
||
<div v-loading="taskProgressLoading">
|
||
<el-descriptions :column="1" border size="small" v-if="taskProgressData">
|
||
<el-descriptions-item label="任务ID">
|
||
<span class="mono-text">{{ taskProgressData.task_id || '-' }}</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="状态">
|
||
<el-tag :type="taskStatusType(taskProgressData.status)" size="small">{{ snapshotStatusLabel(taskProgressData.status) }}</el-tag>
|
||
</el-descriptions-item>
|
||
<template v-if="taskProgressMeta">
|
||
<el-descriptions-item v-for="(val, key) in taskProgressMeta" :key="key" :label="taskMetaLabel(key)">
|
||
<span :style="key.includes('path') ? 'font-family: Consolas, monospace; font-size: 13px; word-break: break-all' : ''">{{ val }}</span>
|
||
</el-descriptions-item>
|
||
</template>
|
||
</el-descriptions>
|
||
<el-empty v-else description="暂无进度信息" />
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="taskProgressVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 编辑虚拟机弹窗(仅 update API 支持的参数) -->
|
||
<el-dialog v-model="editDialogVisible" title="修改虚拟机" width="640px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="editFormRef" :model="editForm" label-width="100px" v-loading="dialogOptionsLoading">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">带宽与端口</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="下行带宽">
|
||
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽">
|
||
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="额外数据卷">
|
||
<div style="display:flex;align-items:center;gap:6px">
|
||
<el-input-number v-model="editForm.data_volume_size" :min="0" controls-position="right" style="width:200px" />
|
||
<span class="tk-res-unit">GB</span>
|
||
<span style="font-size:12px;color:#909399;margin-left:8px">0 表示不创建</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="Root密码">
|
||
<el-input v-model="editForm.root_password" placeholder="留空则不修改" show-password />
|
||
</el-form-item>
|
||
<el-form-item label="SSH端口">
|
||
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 200px" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">网络配置</div>
|
||
<el-form-item label="网络">
|
||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%">
|
||
<el-tag v-for="net in editSelectedNetworks" :key="net.id" closable @close="removeEditNetwork(net.id)">
|
||
{{ net.name }} (ID:{{ net.id }})
|
||
</el-tag>
|
||
<el-button size="small" @click="showEditNetworkSelector = true">添加网络</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="内网">
|
||
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
|
||
<el-tag v-if="editSelectedInternalNetworks.length" closable @close="removeEditInternalNetwork(editSelectedInternalNetworks[0].id)">
|
||
{{ editSelectedInternalNetworks[0].name }} (ID:{{ editSelectedInternalNetworks[0].id }})
|
||
</el-tag>
|
||
<el-button size="small" @click="showEditInternalNetworkSelector = true">{{ editSelectedInternalNetworks.length ? '更换内网' : '选择内网' }}</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="安全组">
|
||
<el-select v-model="editForm.port_group_id" placeholder="选择安全组(可选)" filterable clearable style="width: 100%">
|
||
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitEditVm">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
<!-- 编辑用网络选择器(外网 bridge) -->
|
||
<NetworkSelectorPopup ref="editNetSelectorRef" v-model="showEditNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleEditNetworkConfirm" @create="() => handleNetCreate('edit')" />
|
||
<!-- 编辑用内网选择器(内网 nat) -->
|
||
<NetworkSelectorPopup ref="editInternalNetSelectorRef" v-model="showEditInternalNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleEditInternalNetworkConfirm" @create="() => handleNetCreate('editInternal')" />
|
||
|
||
<!-- 重构虚拟机弹窗 -->
|
||
<el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="700px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="refactorFormRef" :model="refactorForm" label-width="100px" v-loading="dialogOptionsLoading">
|
||
<el-alert title="重构会修改虚拟机的底层配置参数,请谨慎操作!" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">资源配置</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="CPU">
|
||
<el-input-number v-model="refactorForm.vcpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
|
||
</el-form-item>
|
||
<el-form-item label="内存">
|
||
<el-input-number v-model="refactorMemDisplay" :min="0" :step="refactorMemUnit === 1048576 ? 1 : (refactorMemUnit === 1024 ? 512 : 1024)" :precision="refactorMemUnit === 1048576 ? 2 : 0" controls-position="right" />
|
||
<el-select v-model="refactorMemUnit" class="tk-unit-select" @change="onRefactorMemUnitChange">
|
||
<el-option label="KB" :value="1" />
|
||
<el-option label="MB" :value="1024" />
|
||
<el-option label="GB" :value="1048576" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="下行带宽">
|
||
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽">
|
||
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">基本参数</div>
|
||
<el-form-item label="Root密码">
|
||
<el-input v-model="refactorForm.root_password" placeholder="不修改留空" show-password />
|
||
</el-form-item>
|
||
<el-form-item label="UUID">
|
||
<el-input v-model="refactorForm.uuid" placeholder="虚拟机UUID" />
|
||
</el-form-item>
|
||
<el-form-item label="Mate Data ID">
|
||
<el-input v-model="refactorForm.mate_data_id" placeholder="元数据ID" />
|
||
</el-form-item>
|
||
<el-form-item label="物理机名称">
|
||
<el-input v-model="refactorForm.physical_name" placeholder="不修改留空" />
|
||
</el-form-item>
|
||
<el-form-item label="配置路径">
|
||
<el-input v-model="refactorForm.config_path" placeholder="不修改留空" />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">端口与VNC</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="SSH端口">
|
||
<el-input-number v-model="refactorForm.ssh_port" :min="0" :max="65535" controls-position="right" />
|
||
</el-form-item>
|
||
<el-form-item label="VNC端口">
|
||
<el-input-number v-model="refactorForm.vnc_port" :min="0" :max="65535" controls-position="right" />
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="VNC密码">
|
||
<el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">网络配置</div>
|
||
<el-form-item label="网络">
|
||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px; width: 100%">
|
||
<el-tag v-for="net in refactorSelectedNetworks" :key="net.id" closable @close="removeRefactorNetwork(net.id)">
|
||
{{ net.name }} (ID:{{ net.id }})
|
||
</el-tag>
|
||
<el-button size="small" @click="showRefactorNetworkSelector = true">添加网络</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="内网">
|
||
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
|
||
<el-tag v-if="refactorSelectedInternalNetworks.length" closable @close="removeRefactorInternalNetwork(refactorSelectedInternalNetworks[0].id)">
|
||
{{ refactorSelectedInternalNetworks[0].name }} (ID:{{ refactorSelectedInternalNetworks[0].id }})
|
||
</el-tag>
|
||
<el-button size="small" @click="showRefactorInternalNetworkSelector = true">{{ refactorSelectedInternalNetworks.length ? '更换内网' : '选择内网' }}</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="安全组">
|
||
<el-select v-model="refactorForm.port_group_id" placeholder="选择安全组(可选)" filterable clearable style="width: 100%">
|
||
<el-option v-for="g in sgOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="refactorDialogVisible = false">取消</el-button>
|
||
<el-button type="warning" :loading="actionLoading" @click="submitRefactorVm">确定重构</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 重构用网络选择器(外网 bridge) -->
|
||
<NetworkSelectorPopup ref="refactorNetSelectorRef" v-model="showRefactorNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleRefactorNetworkConfirm" @create="() => handleNetCreate('refactor')" />
|
||
<!-- 重构用内网选择器(内网 nat) -->
|
||
<NetworkSelectorPopup ref="refactorInternalNetSelectorRef" v-model="showRefactorInternalNetworkSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleRefactorInternalNetworkConfirm" @create="() => handleNetCreate('refactorInternal')" />
|
||
|
||
<!-- VNC 连接弹窗 -->
|
||
<el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px" v-loading="dialogOptionsLoading">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">VNC 配置</div>
|
||
<el-form-item label="虚拟机">{{ detail?.name || '-' }} (ID: {{ vmId }})</el-form-item>
|
||
<el-form-item label="VNC节点">
|
||
<el-select v-model="vncNodeId" placeholder="选择VNC节点" filterable style="width: 100%">
|
||
<el-option v-for="n in vncNodeOptions" :key="n.id" :label="`${n.name} (${n.ip}:${n.port})`" :value="n.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<div v-if="vncResult" class="vnc-result">
|
||
<el-descriptions :column="1" border size="small">
|
||
<el-descriptions-item label="VNC地址">
|
||
<el-link type="primary" :href="vncResult.url" target="_blank">{{ vncResult.url }}</el-link>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="过期时间">{{ formatTimestamp(vncResult.expire_at) }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="vncDialogVisible = false">关闭</el-button>
|
||
<el-button type="primary" :loading="vncLoading" @click="submitGetVnc">获取</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
|
||
<!-- 修改带宽弹窗 -->
|
||
<el-dialog v-model="trafficDialogVisible" title="修改虚拟机带宽" width="640px" destroy-on-close class="tk-dialog">
|
||
<el-form :model="trafficForm" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">带宽与流量</div>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="下行带宽">
|
||
<el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽">
|
||
<el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="流量上限">
|
||
<div class="tk-inline-unit">
|
||
<el-input-number v-model="trafficForm._trafficGB" :min="0" :precision="2" controls-position="right" /><span class="tk-res-unit">GB</span>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="trafficDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitUpdateTraffic">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 迁移虚拟机弹窗 -->
|
||
<el-dialog v-model="migrateDialogVisible" title="迁移虚拟机(更换宿主机)" width="580px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px" v-loading="migrateOptionsLoading">
|
||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">将虚拟机迁移到其他宿主机或宿主机组,请谨慎操作!</el-alert>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">迁移配置</div>
|
||
<!-- <el-form-item label="当前宿主机">
|
||
<el-tag>{{ detail?.host_name || detail?.host_id || '-' }}</el-tag>
|
||
</el-form-item> -->
|
||
<el-form-item label="迁移方式">
|
||
<el-radio-group v-model="migrateMode">
|
||
<el-radio value="host">指定宿主机</el-radio>
|
||
<el-radio value="group">指定宿主机组</el-radio>
|
||
</el-radio-group>
|
||
</el-form-item>
|
||
<el-form-item label="目标宿主机" v-if="migrateMode === 'host'">
|
||
<el-select v-model="migrateForm.target_host_id" placeholder="选择目标宿主机" filterable style="width: 100%">
|
||
<el-option v-for="h in migrateHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === detail?.host_id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目标宿主机组" v-if="migrateMode === 'group'">
|
||
<el-select v-model="migrateForm.target_host_group_id" placeholder="选择目标宿主机组" filterable style="width: 100%">
|
||
<el-option v-for="g in migrateGroupOptions" :key="g.id" :label="`${g.name} (ID: ${g.id})`" :value="g.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="IPv4数量">
|
||
<el-input-number v-model="migrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||
</el-form-item>
|
||
<el-form-item label="IPv6数量">
|
||
<el-input-number v-model="migrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||
</el-form-item>
|
||
</div>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="migrateDialogVisible = false">取消</el-button>
|
||
<el-button type="warning" :loading="actionLoading" @click="submitMigrateVm">确定迁移</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 数据迁移弹窗 -->
|
||
<el-dialog v-model="dataMigrateVisible" title="发起虚拟机数据迁移" width="600px" destroy-on-close class="tk-dialog">
|
||
<el-form :model="dataMigrateForm" label-width="100px" v-loading="dataMigrateLoading">
|
||
<el-alert type="info" :closable="false" show-icon style="margin-bottom:16px">
|
||
源服务的虚拟机数据将通过 rsync 传输到目标服务的宿主机上,完成后在目标宿主机上校验并恢复虚拟机。
|
||
</el-alert>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">源信息</div>
|
||
<el-form-item label="主控服务">
|
||
<el-input :model-value="`${serviceName} (ID: ${serviceId})`" disabled />
|
||
</el-form-item>
|
||
<el-form-item label="虚拟机">
|
||
<el-input :model-value="`${detail?.name || ''} (ID: ${vmId})`" disabled />
|
||
</el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">目标信息</div>
|
||
<el-form-item label="主控服务" required>
|
||
<el-select v-model="dataMigrateForm.target_service_id" placeholder="选择目标主控服务" filterable style="width:100%"
|
||
@change="dataMigrateForm.target_host_id = null; loadDataMigrateHosts()">
|
||
<el-option v-for="s in dataMigrateServiceOptions" :key="s.id" :label="`${s.name} (ID:${s.id})`" :value="s.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="宿主机" required>
|
||
<el-select v-model="dataMigrateForm.target_host_id" placeholder="选择目标宿主机" filterable style="width:100%"
|
||
:disabled="!dataMigrateForm.target_service_id" :loading="dataMigrateHostsLoading"
|
||
@change="dataMigrateForm.network_ids = []; dataMigrateSelectedNetworks = []; loadDataMigrateNetworks()">
|
||
<el-option v-for="h in dataMigrateHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<div class="tk-resource-grid">
|
||
<el-form-item label="IPv4数量">
|
||
<el-input-number v-model="dataMigrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||
</el-form-item>
|
||
<el-form-item label="IPv6数量">
|
||
<el-input-number v-model="dataMigrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||
</el-form-item>
|
||
</div>
|
||
<el-form-item label="网络ID">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%">
|
||
<el-tag v-for="n in dataMigrateSelectedNetworks" :key="n.id" closable @close="removeDataMigrateNetwork(n.id)">
|
||
{{ n.name || n.id }} (ID:{{ n.id }})
|
||
</el-tag>
|
||
<el-button size="small" :disabled="!dataMigrateForm.target_host_id" @click="showDataMigrateNetworkSelector = true">选择网络</el-button>
|
||
</div>
|
||
<span class="tk-form-tip">在目标宿主机监听的网络ID列表(可选)</span>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="dataMigrateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitDataMigrate">发起迁移</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
<!-- 数据迁移用网络选择器 -->
|
||
<NetworkSelectorPopup ref="dataMigrateNetSelectorRef" v-model="showDataMigrateNetworkSelector"
|
||
:service-id="dataMigrateForm.target_service_id || 0"
|
||
:host-id="dataMigrateForm.target_host_id || 0"
|
||
filter-type="bridge" filter-used="false" :multiple="true"
|
||
@confirm="handleDataMigrateNetworkConfirm"
|
||
@create="() => handleNetCreate('dataMigrate')" />
|
||
|
||
<!-- 数据迁移进度弹窗 -->
|
||
<el-dialog v-model="dataMigrateProgressVisible" title="数据迁移进度" width="560px" destroy-on-close>
|
||
<div v-loading="dataMigrateProgressLoading">
|
||
<el-descriptions :column="2" border size="small" v-if="dataMigrateProgressData">
|
||
<el-descriptions-item label="阶段" :span="2">
|
||
<el-tag :type="migrateStageType(dataMigrateProgressData.stage)" size="small">{{ migrateStageLabel(dataMigrateProgressData.stage) }}</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="导出任务ID" :span="2" v-if="dataMigrateProgressData.export_task_id">
|
||
<span style="font-family:monospace;font-size:12px">{{ dataMigrateProgressData.export_task_id }}</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="导入任务ID" :span="2" v-if="dataMigrateProgressData.import_task_id">
|
||
<span style="font-family:monospace;font-size:12px">{{ dataMigrateProgressData.import_task_id }}</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="源主控服务">{{ dataMigrateProgressData.source_service_id ?? '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="目标主控服务">{{ dataMigrateProgressData.target_service_id ?? '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="源宿主机">{{ dataMigrateProgressData.source_host_id ?? '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="目标宿主机">{{ dataMigrateProgressData.target_host_id ?? '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="进度" :span="2" v-if="dataMigrateProgressData.progress != null">
|
||
<el-progress :percentage="dataMigrateProgressData.progress" :status="migrateProgressBarStatus(dataMigrateProgressData.stage)" style="width:100%" />
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="速度" v-if="dataMigrateProgressData.speed">{{ dataMigrateProgressData.speed }}</el-descriptions-item>
|
||
<el-descriptions-item label="信息" :span="dataMigrateProgressData.speed ? 1 : 2" v-if="dataMigrateProgressData.message">{{ dataMigrateProgressData.message }}</el-descriptions-item>
|
||
<el-descriptions-item label="详情" :span="2" v-if="dataMigrateProgressData.detail">{{ dataMigrateProgressData.detail }}</el-descriptions-item>
|
||
<el-descriptions-item label="错误" :span="2" v-if="dataMigrateProgressData.error">
|
||
<span style="color:#f56c6c">{{ dataMigrateProgressData.error }}</span>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<el-empty v-else-if="!dataMigrateProgressLoading" description="暂无进度信息" :image-size="60" />
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="dataMigrateProgressVisible = false">关闭</el-button>
|
||
<el-button :icon="Refresh" @click="loadDataMigrateProgress" :loading="dataMigrateProgressLoading">刷新进度</el-button>
|
||
<el-button type="danger" @click="handleAbortMigrate" :loading="abortLoading" v-if="isMigrating">中断迁移</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 绑定外网选择器(bridge) -->
|
||
<NetworkSelectorPopup ref="bindBridgeNetSelectorRef" v-model="showNetBindBridgeSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="bridge" filter-used="false" @confirm="handleNetBindBridgeConfirm" @create="() => handleNetCreate('bindBridge')" />
|
||
<!-- 绑定内网选择器(nat) -->
|
||
<NetworkSelectorPopup ref="bindNatNetSelectorRef" v-model="showNetBindNatSelector" :service-id="serviceId" :host-id="vmHostId" filter-type="nat" filter-used="false" @confirm="handleNetBindNatConfirm" @create="() => handleNetCreate('bindNat')" />
|
||
|
||
<!-- 创建/编辑网络弹窗 -->
|
||
<el-dialog v-model="netDialogVisible" :title="netDialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="netFormRef" :model="netForm" :rules="netFormRules" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">基本信息</div>
|
||
<el-form-item label="名称" prop="name"><el-input v-model="netForm.name" placeholder="网络名称" /></el-form-item>
|
||
<el-form-item label="网络类型" prop="type">
|
||
<el-select v-model="netForm.type" style="width: 100%">
|
||
<el-option label="网桥(Bridge/外网)" value="bridge" />
|
||
<el-option label="内网(NAT)" value="nat" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="IP" prop="address">
|
||
<div class="tk-inline-unit">
|
||
<el-input v-model="netForm.address" placeholder="例如 192.168.1.0/24" />
|
||
<span class="tk-res-unit">CIDR</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="网关地址" prop="gateway"><el-input v-model="netForm.gateway" placeholder="例如 192.168.1.1" /></el-form-item>
|
||
<el-form-item label="DNS 服务器"><el-input v-model="netForm.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" /></el-form-item>
|
||
</div>
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">高级配置</div>
|
||
<el-form-item label="MAC 地址"><el-input v-model="netForm.mac_address" placeholder="不填则随机" /></el-form-item>
|
||
<el-form-item label="虚拟网桥名"><el-input v-model="netForm.bridge_name" placeholder="不填使用默认" /></el-form-item>
|
||
<el-form-item label="逻辑网桥名"><el-input v-model="netForm.ls_bridge_name" placeholder="不填使用默认" /></el-form-item>
|
||
<el-form-item label="逻辑端口名"><el-input v-model="netForm.ls_name" placeholder="不填使用默认" /></el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="handleNetDialogCancel">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitNetForm">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 网络详情弹窗 -->
|
||
<el-dialog v-model="netDetailVisible" title="网络详情" width="600px" destroy-on-close>
|
||
<el-descriptions :column="2" border v-if="netDetailData">
|
||
<el-descriptions-item label="ID">{{ netDetailData.id }}</el-descriptions-item>
|
||
<el-descriptions-item label="名称">{{ netDetailData.name }}</el-descriptions-item>
|
||
<el-descriptions-item label="类型"><el-tag :type="netDetailData.type === 'bridge' ? 'success' : 'warning'" size="small">{{ netDetailData.type === 'bridge' ? '网桥' : 'NAT' }}</el-tag></el-descriptions-item>
|
||
<el-descriptions-item label="地址">{{ netDetailData.address }}</el-descriptions-item>
|
||
<el-descriptions-item label="网关">{{ netDetailData.gateway }}</el-descriptions-item>
|
||
<el-descriptions-item label="DNS">{{ netDetailData.nameservers || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="MAC 地址">{{ netDetailData.mac_address || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="虚拟网桥">{{ netDetailData.bridge_name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="逻辑网桥">{{ netDetailData.ls_bridge_name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="逻辑端口">{{ netDetailData.ls_name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="目标设备">{{ netDetailData.target_device || '-' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<template #footer><el-button @click="netDetailVisible = false">关闭</el-button></template>
|
||
</el-dialog>
|
||
|
||
<!-- 数据卷选择器 -->
|
||
<VolumeSelectorPopup v-model="showVolSelector" :service-id="serviceId" :host-id="vmHostId" @confirm="handleVolBindConfirm" @create="handleVolCreateFromSelector" />
|
||
|
||
<!-- 创建数据卷弹窗 -->
|
||
<el-dialog v-model="volCreateVisible" title="创建数据卷" width="560px" :close-on-click-modal="false" class="tk-dialog">
|
||
<el-form ref="volCreateFormRef" :model="volCreateForm" :rules="volCreateRules" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">卷信息</div>
|
||
<el-form-item label="名称" prop="name"><el-input v-model="volCreateForm.name" placeholder="数据卷名称" /></el-form-item>
|
||
<el-form-item label="大小" prop="size">
|
||
<div class="tk-inline-unit">
|
||
<el-input-number v-model="volCreateForm.size" :min="1" controls-position="right" /><span class="tk-res-unit">GB</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="宿主机">
|
||
<el-input :model-value="vmHostId ? `宿主机 #${vmHostId}` : '-'" disabled style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item label="系统卷"><el-switch v-model="volCreateForm.is_system" /></el-form-item>
|
||
<el-form-item v-if="volCreateForm.is_system" label="镜像">
|
||
<div class="bind-selector-row">
|
||
<el-input :model-value="volCreateForm.image_id ? `镜像 #${volCreateForm.image_id}${volCreateForm._imageName ? ' - ' + volCreateForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||
<el-button type="primary" @click="volImageSelectorFromCreate = true; volCreateVisible = false; showVolImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||
<el-button v-if="volCreateForm.image_id" @click="volCreateForm.image_id = 0; volCreateForm._imageName = ''" style="margin-left: 4px">清除</el-button>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="虚拟机">
|
||
<el-input :model-value="detail ? `${detail.name || ''} (ID: ${vmId})` : '未选择'" disabled style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item label="目标设备名"><el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" /></el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="volCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitVolCreate">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
<ImageSelectorPopup v-model="showVolImageSelector" :service-id="serviceId" :current-id="volCreateForm.image_id" @confirm="handleVolImageSelected" />
|
||
|
||
<!-- 调整卷大小弹窗 -->
|
||
<el-dialog v-model="volResizeVisible" title="调整数据卷大小" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">调整信息</div>
|
||
<el-form-item label="卷名称">{{ volResizeTarget?._name || '-' }}</el-form-item>
|
||
<el-form-item label="当前大小">{{ volResizeTarget?._currentSize || 0 }} GB</el-form-item>
|
||
<el-form-item label="新大小">
|
||
<div class="tk-inline-unit">
|
||
<el-input-number v-model="volNewSize" :min="volResizeTarget?._currentSize || 1" controls-position="right" /><span class="tk-res-unit">GB</span>
|
||
</div>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="volResizeVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitVolResize">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 迁移卷弹窗 -->
|
||
<el-dialog v-model="volTransferVisible" title="迁移数据卷" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px" v-loading="dialogOptionsLoading">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">迁移信息</div>
|
||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">将数据卷迁移到另一台虚拟机上</el-alert>
|
||
<el-form-item label="卷名称">{{ volTransferTarget?._name || '-' }}</el-form-item>
|
||
<el-form-item label="目标虚拟机" required>
|
||
<el-select v-model="volTransferVmId" placeholder="选择目标虚拟机" filterable style="width: 100%">
|
||
<el-option v-for="v in vmListOptions" :key="v.id" :label="`${v.name} (ID: ${v.id})`" :value="v.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="volTransferVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitVolTransfer">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 数据卷详情弹窗 -->
|
||
<el-dialog v-model="volDetailVisible" title="数据卷详情" width="600px" destroy-on-close>
|
||
<el-descriptions :column="2" border v-if="volDetailData">
|
||
<el-descriptions-item label="ID">{{ volDetailData.id }}</el-descriptions-item>
|
||
<el-descriptions-item label="名称">{{ volDetailData.name }}</el-descriptions-item>
|
||
<el-descriptions-item label="大小">{{ volDetailData.size }} GB</el-descriptions-item>
|
||
<el-descriptions-item label="类型"><el-tag :type="volDetailData.is_system ? 'danger' : 'info'" size="small">{{ volDetailData.is_system ? '系统盘' : '数据盘' }}</el-tag></el-descriptions-item>
|
||
<el-descriptions-item label="挂载状态"><el-tag :type="volDetailData.is_mount ? 'success' : 'info'" size="small">{{ volDetailData.is_mount ? '已挂载' : '未挂载' }}</el-tag></el-descriptions-item>
|
||
<el-descriptions-item label="状态"><el-tag :type="volDetailData.status === 'ready' ? 'success' : 'info'" size="small">{{ volDetailData.status === 'ready' ? '就绪' : (volDetailData.status || '-') }}</el-tag></el-descriptions-item>
|
||
<el-descriptions-item label="宿主机ID">{{ volDetailData.host_id || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="宿主机卷ID">{{ volDetailData.host_volume_id || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ volDetailData.path || '-' }}</span></el-descriptions-item>
|
||
<el-descriptions-item label="目标设备" v-if="volDetailData.target_device">{{ volDetailData.target_device }}</el-descriptions-item>
|
||
<el-descriptions-item label="创建时间">{{ volDetailData.created_at ? new Date(Number(volDetailData.created_at.seconds) * 1000).toLocaleString('zh-CN') : '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="更新时间">{{ volDetailData.updated_at ? new Date(Number(volDetailData.updated_at.seconds) * 1000).toLocaleString('zh-CN') : '-' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
<template #footer><el-button @click="volDetailVisible = false">关闭</el-button></template>
|
||
</el-dialog>
|
||
|
||
<!-- 绑定安全组到当前虚拟机 -->
|
||
<SecurityGroupSelectorPopup v-model="sgBindVisible" :service-id="serviceId" @confirm="handleSgBindConfirm" @create="handleSgCreate" />
|
||
|
||
<!-- 创建安全组弹窗 -->
|
||
<el-dialog v-model="sgCreateDialogVisible" title="创建安全组" width="520px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="sgCreateFormRef" :model="sgCreateForm" :rules="sgCreateRules" label-width="100px" v-loading="dialogOptionsLoading">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">安全组信息</div>
|
||
<el-form-item label="名称" prop="name">
|
||
<el-input v-model="sgCreateForm.name" placeholder="安全组名称" />
|
||
</el-form-item>
|
||
<el-form-item label="方向" prop="direction">
|
||
<el-select v-model="sgCreateForm.direction" placeholder="选择规则方向" style="width: 100%">
|
||
<el-option label="入站 (in)" value="in" />
|
||
<el-option label="出站 (out)" value="out" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="宿主机" prop="host_id">
|
||
<el-select v-model="sgCreateForm.host_id" placeholder="选择宿主机" filterable clearable style="width: 100%">
|
||
<el-option v-for="h in sgHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="锁定">
|
||
<el-switch v-model="sgCreateForm.lock" active-text="用户不可修改" />
|
||
</el-form-item>
|
||
<el-form-item label="白名单模式">
|
||
<el-switch v-model="sgCreateForm.drop_all" active-text="开启白名单" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="sgCreateDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="sgSubmitLoading" @click="submitSgCreate">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 同步安全组弹窗 -->
|
||
<el-dialog v-model="sgSyncDialogVisible" title="同步安全组到宿主机" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px" v-loading="dialogOptionsLoading">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">同步配置</div>
|
||
<el-form-item label="安全组">{{ sgSyncTarget?.name || '-' }}</el-form-item>
|
||
<el-form-item label="目标宿主机">
|
||
<el-select v-model="sgSyncHostId" placeholder="选择宿主机" filterable style="width: 100%">
|
||
<el-option v-for="h in sgHostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="sgSyncDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="sgSubmitLoading" @click="submitSgSync">同步</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 安全组绑定/解绑虚拟机弹窗 -->
|
||
<el-dialog v-model="sgVmBindDialogVisible" :title="sgVmBindType === 'bind' ? '绑定安全组到虚拟机' : '解绑安全组'" width="420px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<el-form-item label="安全组">{{ sgVmBindTarget?.name || '-' }}</el-form-item>
|
||
<el-form-item label="虚拟机">
|
||
<el-input :model-value="detail ? `${detail.name || ''} (ID: ${vmId})` : '-'" disabled style="width: 100%" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="sgVmBindDialogVisible = false">取消</el-button>
|
||
<el-button :type="sgVmBindType === 'bind' ? 'primary' : 'warning'" :loading="sgSubmitLoading" @click="submitSgVmBind">
|
||
{{ sgVmBindType === 'bind' ? '绑定' : '解绑' }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 安全组详情+规则弹窗 -->
|
||
<el-dialog v-model="sgDetailVisible" title="安全组详情 & 规则" width="800px" destroy-on-close>
|
||
<el-descriptions :column="2" border v-if="sgCurrentDetail" v-loading="sgDetailLoading" style="margin-bottom: 20px">
|
||
<el-descriptions-item label="ID" width="100px">{{ sgCurrentDetail.id }}</el-descriptions-item>
|
||
<el-descriptions-item label="名称" width="100px">{{ sgCurrentDetail.name }}</el-descriptions-item>
|
||
<el-descriptions-item label="锁定" width="100px">
|
||
<el-tag :type="sgCurrentDetail.lock ? 'danger' : 'success'" size="small">{{ sgCurrentDetail.lock ? '是' : '否' }}</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="白名单" width="100px">
|
||
<el-tag :type="sgCurrentDetail.drop_all ? 'warning' : 'info'" size="small">{{ sgCurrentDetail.drop_all ? '开启' : '关闭' }}</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="方向">
|
||
<el-tag :type="sgCurrentDetail.direction === 'in' ? 'success' : 'warning'" size="small">{{ sgCurrentDetail.direction === 'in' ? '入站' : sgCurrentDetail.direction === 'out' ? '出站' : (sgCurrentDetail.direction || '-') }}</el-tag>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
<div class="rules-section">
|
||
<div class="rules-header">
|
||
<h4>安全组规则</h4>
|
||
<el-button type="primary" size="small" @click="handleSgAddRule">新增规则</el-button>
|
||
</div>
|
||
<el-table :data="sgCurrentDetail?.rules || []" stripe size="small" style="width: 100%">
|
||
<el-table-column prop="id" label="ID" width="60" />
|
||
<el-table-column prop="protocol" label="协议" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag size="small">{{ (row.protocol || '-').toUpperCase() }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="action" label="动作" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.action === 'allow' ? 'success' : 'danger'" size="small">{{ row.action === 'allow' ? '允许' : '拒绝' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="port_range" label="端口范围" min-width="120" />
|
||
<el-table-column prop="ip_range" label="IP 范围" min-width="140" />
|
||
<el-table-column prop="priority" label="优先级" width="80" />
|
||
<el-table-column label="操作" width="130">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleSgEditRule(row)">编辑</el-button>
|
||
<el-button link type="danger" size="small" @click="handleSgDeleteRule(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
<template #footer><el-button @click="sgDetailVisible = false">关闭</el-button></template>
|
||
</el-dialog>
|
||
|
||
<!-- 新增/编辑安全组规则弹窗 -->
|
||
<el-dialog v-model="sgRuleDialogVisible" :title="sgRuleDialogType === 'add' ? '新增安全组规则' : '编辑安全组规则'" width="520px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="sgRuleFormRef" :model="sgRuleForm" :rules="sgRuleRules" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">规则配置</div>
|
||
<el-form-item label="协议" prop="protocol">
|
||
<el-select v-model="sgRuleForm.protocol" style="width: 100%">
|
||
<el-option label="TCP" value="tcp" />
|
||
<el-option label="UDP" value="udp" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="动作" prop="action">
|
||
<el-select v-model="sgRuleForm.action" style="width: 100%">
|
||
<el-option label="允许 (Allow)" value="allow" />
|
||
<el-option label="拒绝 (Deny)" value="deny" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="端口范围">
|
||
<el-input v-model="sgRuleForm.port_range" placeholder="如 80 或 80-90" />
|
||
</el-form-item>
|
||
<el-form-item label="IP 范围">
|
||
<el-input v-model="sgRuleForm.ip_range" placeholder="如 0.0.0.0/0 或 192.168.1.0/24" />
|
||
</el-form-item>
|
||
<el-form-item label="优先级">
|
||
<el-input-number v-model="sgRuleForm.priority" :min="0" :max="9999" style="width: 100%" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="sgRuleDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="sgSubmitLoading" @click="submitSgRule">确定</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 加入组网弹窗 -->
|
||
<el-dialog v-model="vnJoinVisible" title="加入组网" width="480px" destroy-on-close class="tk-dialog">
|
||
<el-form label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">组网信息</div>
|
||
<el-form-item label="组网">{{ vnJoinTarget?.name || '-' }} (ID: {{ vnJoinTarget?.id }})</el-form-item>
|
||
<el-form-item label="网桥"><span class="mono-text">{{ vnJoinTarget?.bridge_name || '-' }}</span></el-form-item>
|
||
<el-form-item label="网关"><span class="mono-text">{{ vnJoinTarget?.gateway || '-' }}</span></el-form-item>
|
||
<el-form-item label="指定IP">
|
||
<el-input v-model="vnJoinIp" placeholder="留空自动分配" />
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="vnJoinVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="vnSubmitLoading" @click="submitJoinNetworking">加入</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建组网弹窗 -->
|
||
<el-dialog v-model="vnCreateVisible" title="创建组网" width="500px" destroy-on-close class="tk-dialog">
|
||
<el-form ref="vnCreateFormRef" :model="vnCreateForm" :rules="vnCreateRules" label-width="100px">
|
||
<div class="tk-section">
|
||
<div class="tk-section-title">组网配置</div>
|
||
<el-form-item label="名称" prop="name">
|
||
<el-input v-model="vnCreateForm.name" placeholder="组网名称" />
|
||
</el-form-item>
|
||
<el-form-item label="网桥名称" prop="bridge_name">
|
||
<el-input v-model="vnCreateForm.bridge_name" placeholder="网桥名称" />
|
||
</el-form-item>
|
||
<el-form-item label="网关">
|
||
<el-input v-model="vnCreateForm.gateway" placeholder="可选,如 10.0.0.1/24" />
|
||
</el-form-item>
|
||
<el-form-item label="宿主机">
|
||
<span style="color: #606266">{{ vnCreateHostName || '加载中...' }} <span style="color: #909399">(ID: {{ vmHostId }})</span></span>
|
||
</el-form-item>
|
||
<el-form-item label="用户">
|
||
<span style="color: #606266">{{ vnCreateUserName || '加载中...' }} <span style="color: #909399">(ID: {{ detail?.user_id || '-' }})</span></span>
|
||
</el-form-item>
|
||
</div>
|
||
</el-form>
|
||
<template #footer>
|
||
<div class="tk-dialog-footer">
|
||
<el-button @click="vnCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="vnSubmitLoading" @click="submitVnCreate">创建</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, nextTick, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { ArrowLeft, Refresh, ArrowDown, Plus, Search, WarningFilled, Loading } from '@element-plus/icons-vue'
|
||
import {
|
||
getVmDetail, getVmStatus,
|
||
startVm, stopVm, rebootVm, suspendVm, resumeVm,
|
||
rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm,
|
||
getVmVnc, getVncNodeList,
|
||
getSecurityGroupList, getUserNetworkingList, createUserNetworking, assignUserNetworking, removeUserNetworkingNetwork,
|
||
getRemoteHostList, getSecurityGroupDetail, createSecurityGroup,
|
||
syncSecurityGroup, deleteSecurityGroup,
|
||
enableSecurityGroupWhitelist, disableSecurityGroupWhitelist,
|
||
applySecurityGroup, createSecurityGroupRule, updateSecurityGroupRule, deleteSecurityGroupRule,
|
||
createNetwork, updateNetwork, deleteNetwork,
|
||
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume,
|
||
getVmList, bindSecurityGroup, unbindSecurityGroup,
|
||
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit,
|
||
getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit,
|
||
migrateVm, getRemoteHostGroupList, getRemoteHostDetail,
|
||
dataMigrateVm, getDataMigrateProgress, abortDataMigrate,
|
||
getKvmServiceList, getMetricsHistory, getNetworkList
|
||
} from '@/api/admin/kvmService'
|
||
import { getUserInfo } from '@/api/admin/user'
|
||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
|
||
import * as echarts from 'echarts'
|
||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
|
||
import SecurityGroupSelectorPopup from '@/components/admin/SecurityGroupSelectorPopup.vue'
|
||
import VolumeSelectorPopup from '@/components/admin/VolumeSelectorPopup.vue'
|
||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const tagsViewStore = useTagsViewStore()
|
||
|
||
const serviceId = computed(() => parseInt(route.query.service_id) || 0)
|
||
const serviceName = computed(() => route.query.service_name || '')
|
||
const vmId = computed(() => parseInt(route.query.vm_id) || 0)
|
||
|
||
const loading = ref(false)
|
||
const actionLoading = ref(false)
|
||
const statusLoading = ref(false)
|
||
const dialogOptionsLoading = ref(false)
|
||
const detail = ref(null)
|
||
const vmHostId = ref(0)
|
||
const vmNetworks = ref([])
|
||
const vmVolumes = ref([])
|
||
const vmImage = ref(null)
|
||
|
||
const vmPortGroup = ref(null)
|
||
const vmOutPortGroup = ref(null)
|
||
const hostOptions = ref([])
|
||
const rebuildDialogVisible = ref(false)
|
||
const rebuildImageId = ref(0)
|
||
const rebuildImageName = ref('')
|
||
const showImageSelector = ref(false)
|
||
const activeTab = ref('info')
|
||
const showPassword = ref(false)
|
||
|
||
const extractIp = (addr) => addr ? addr.split('/')[0] : ''
|
||
const publicIpList = computed(() => vmNetworks.value.filter(n => n.type === 'bridge').map(n => extractIp(n.address)).filter(Boolean))
|
||
const privateIpList = computed(() => vmNetworks.value.filter(n => n.type === 'nat').map(n => extractIp(n.address)).filter(Boolean))
|
||
const publicIps = computed(() => publicIpList.value.join(', '))
|
||
const privateIps = computed(() => privateIpList.value.join(', '))
|
||
|
||
const networkPage = ref(1)
|
||
const networkPageSize = ref(10)
|
||
const pagedNetworks = computed(() => {
|
||
const start = (networkPage.value - 1) * networkPageSize.value
|
||
return vmNetworks.value.slice(start, start + networkPageSize.value)
|
||
})
|
||
const volumePage = ref(1)
|
||
const volumePageSize = ref(10)
|
||
const pagedVolumes = computed(() => {
|
||
const start = (volumePage.value - 1) * volumePageSize.value
|
||
return vmVolumes.value.slice(start, start + volumePageSize.value)
|
||
})
|
||
|
||
const copyText = (text) => {
|
||
if (!text) { ElMessage.warning('无内容可复制'); return }
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制到剪贴板')).catch(() => fallbackCopy(text))
|
||
} else {
|
||
fallbackCopy(text)
|
||
}
|
||
}
|
||
const fallbackCopy = (text) => {
|
||
const ta = document.createElement('textarea')
|
||
ta.value = text
|
||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'
|
||
document.body.appendChild(ta)
|
||
ta.select()
|
||
try {
|
||
document.execCommand('copy')
|
||
ElMessage.success('已复制到剪贴板')
|
||
} catch { ElMessage.error('复制失败,请手动复制') }
|
||
document.body.removeChild(ta)
|
||
}
|
||
|
||
const handleMoreCommand = (cmd) => {
|
||
const powerActions = ['start', 'stop', 'reboot', 'suspend', 'resume']
|
||
if (powerActions.includes(cmd)) { handlePower(cmd); return }
|
||
const actionMap = {
|
||
editVm: handleEditVm, refactorVm: handleRefactorVm, updateTraffic: handleUpdateTraffic,
|
||
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue,
|
||
migrateVm: handleMigrateVm
|
||
}
|
||
if (actionMap[cmd]) actionMap[cmd]()
|
||
if (cmd === 'dataMigrateVm') handleDataMigrateVm()
|
||
if (cmd === 'dataMigrateProgress') { dataMigrateProgressVisible.value = true; loadDataMigrateProgress() }
|
||
if (cmd === 'abortDataMigrate') handleAbortMigrate()
|
||
}
|
||
|
||
const vmStatusType = (s) => vmStatusTypeUtil(s)
|
||
const vmStatusLabel = (s) => vmStatusLabelUtil(s)
|
||
const imgStatusType = (s) => ({ ready: 'success', downloading: 'warning', pending: 'info', error: 'danger' }[s] || 'info')
|
||
const imgStatusLabel = (s) => ({ ready: '就绪', downloading: '下载中', pending: '等待中', error: '错误' }[s] || s || '-')
|
||
|
||
const formatMemory = (kb) => { if (!kb) return '-'; kb = Number(kb); if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB'; if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'; if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'; return kb + ' KB' }
|
||
const formatNetSpeed = (bytes) => { if (bytes == null) return '-'; const n = Number(bytes); if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB/s'; if (n >= 1048576) return (n / 1048576).toFixed(2) + ' MB/s'; if (n >= 1024) return (n / 1024).toFixed(2) + ' KB/s'; return n + ' B/s' }
|
||
const formatTimestamp = (ts) => {
|
||
if (!ts) return '-'
|
||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||
if (typeof ts === 'string' || typeof ts === 'number') { const d = new Date(ts); return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN') }
|
||
return '-'
|
||
}
|
||
const getHostLabel = (hid) => { const h = hostOptions.value.find(x => x.id === hid); return h ? h.name : (hid || '-') }
|
||
|
||
const loadHostOptions = async () => {
|
||
try {
|
||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
|
||
const loadDetail = async () => {
|
||
if (!vmId.value) return
|
||
loading.value = true
|
||
try {
|
||
const res = await getVmDetail({ service_id: serviceId.value, vm_id: vmId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
detail.value = d.data ?? d.vm ?? d
|
||
vmNetworks.value = d.networks || []
|
||
vmVolumes.value = d.volumes || []
|
||
vmImage.value = d.image || null
|
||
vmPortGroup.value = d.in_port_group || null
|
||
vmOutPortGroup.value = d.out_port_group || null
|
||
vmHostId.value = detail.value?.host_id || vmVolumes.value[0]?.host_id || vmNetworks.value[0]?.host_id || 0
|
||
handleDetailMigrateState()
|
||
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||
}
|
||
|
||
const handleDetailMigrateState = () => {
|
||
const vm = detail.value
|
||
if (!vm) return
|
||
if (vm.migrating && vm.migrate_task_id) {
|
||
dataMigrateTaskId.value = vm.migrate_task_id
|
||
if (!dataMigrationId.value) dataMigrationId.value = vm.migrate_task_id
|
||
if (!migratePollingTimer) {
|
||
loadDataMigrateProgress()
|
||
startMigratePolling()
|
||
}
|
||
startDetailAutoRefresh()
|
||
} else if (!vm.migrating) {
|
||
stopDetailAutoRefresh()
|
||
if (migratePollingTimer) {
|
||
stopMigratePolling()
|
||
dataMigrateProgressData.value = null
|
||
dataMigrationId.value = ''
|
||
dataMigrateTaskId.value = ''
|
||
}
|
||
}
|
||
}
|
||
|
||
const fetchVmStatus = async () => {
|
||
if (!detail.value) return
|
||
statusLoading.value = true
|
||
try {
|
||
const res = await getVmStatus({ service_id: serviceId.value, vm_id: vmId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const outer = res.data.data
|
||
const inner = outer.data ?? outer
|
||
const state = inner.state ?? inner.status ?? inner
|
||
const desc = inner.desc || ''
|
||
detail.value = { ...detail.value, status: state }
|
||
ElMessage.success(`状态已刷新: ${desc || vmStatusLabel(state)}`)
|
||
}
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) } finally { statusLoading.value = false }
|
||
}
|
||
|
||
// ---- ECharts 监控 ----
|
||
const cpuChartRef = ref(null)
|
||
const memChartRef = ref(null)
|
||
const netChartRef = ref(null)
|
||
const diskChartRef = ref(null)
|
||
let cpuChart = null
|
||
let memChart = null
|
||
let netChart = null
|
||
let diskChart = null
|
||
let isPageActive = false
|
||
|
||
const historicalMetricsData = ref(null)
|
||
const historicalMetricsLoading = ref(false)
|
||
const historyTimeRange = ref('1h')
|
||
const historyTimeOptions = [
|
||
{ label: '最近1分钟', value: '1m' },
|
||
{ label: '最近5分钟', value: '5m' },
|
||
{ label: '最近1小时', value: '1h' },
|
||
{ label: '最近1天', value: '1d' }
|
||
]
|
||
|
||
const latestMetrics = computed(() => {
|
||
const arr = historicalMetricsData.value
|
||
if (!Array.isArray(arr) || !arr.length) return null
|
||
return arr[arr.length - 1]
|
||
})
|
||
|
||
const formatBytesRaw = (val) => {
|
||
if (!val && val !== 0) return '-'; val = Number(val)
|
||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||
return val + ' B'
|
||
}
|
||
|
||
const formatNetLabel = (v) => {
|
||
if (!v) return '0 B/s'
|
||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
|
||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
|
||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
|
||
return v + ' B/s'
|
||
}
|
||
|
||
const formatMemKB = (kb) => {
|
||
if (!kb && kb !== 0) return '-'
|
||
kb = Number(kb)
|
||
if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB'
|
||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||
return kb + ' KB'
|
||
}
|
||
|
||
const vmMemPercent = (m) => {
|
||
if (!m || !m.mem_total) return '0.0'
|
||
return ((m.mem_used / m.mem_total) * 100).toFixed(1)
|
||
}
|
||
|
||
const loadHistoricalMetrics = async () => {
|
||
if (!serviceId.value || !detail.value?.name) return
|
||
historicalMetricsLoading.value = true
|
||
try {
|
||
const now = new Date()
|
||
let startTime = new Date()
|
||
switch (historyTimeRange.value) {
|
||
case '1m': startTime.setMinutes(now.getMinutes() - 1); break
|
||
case '5m': startTime.setMinutes(now.getMinutes() - 5); break
|
||
case '1h': startTime.setHours(now.getHours() - 1); break
|
||
case '1d': startTime.setDate(now.getDate() - 1); break
|
||
}
|
||
const params = {
|
||
service_id: serviceId.value,
|
||
host_id: vmHostId.value,
|
||
vm_name: detail.value.name,
|
||
start: startTime.toISOString(),
|
||
end_time: now.toISOString(),
|
||
interval: { '1m': '1m', '5m': '5m', '1h': '1h', '1d': '1d' }[historyTimeRange.value] || '5m'
|
||
}
|
||
const res = await getMetricsHistory(params)
|
||
const body = res?.data
|
||
if (body?.code === 200 && body?.data) {
|
||
historicalMetricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||
await nextTick()
|
||
renderHistoricalCharts()
|
||
} else {
|
||
ElMessage.error(extractApiError(body, '加载监控数据失败'))
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(extractApiError(e?.response?.data, '加载监控数据失败'))
|
||
} finally {
|
||
historicalMetricsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const renderHistoricalCharts = () => {
|
||
const metrics = historicalMetricsData.value
|
||
if (!Array.isArray(metrics) || !metrics.length) return
|
||
|
||
const range = historyTimeRange.value
|
||
const showDate = range === '1d'
|
||
const symbolType = showDate ? 'circle' : 'none'
|
||
const labelRotate = showDate ? 45 : 0
|
||
|
||
const times = metrics.map(m => {
|
||
const date = new Date(m.bucket)
|
||
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
||
})
|
||
|
||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
||
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0)
|
||
const diskReadData = metrics.map(m => m.disk_read ?? 0)
|
||
const diskWriteData = metrics.map(m => m.disk_write ?? 0)
|
||
const netRxData = metrics.map(m => m.net_rx ?? 0)
|
||
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
||
|
||
const baseGrid = { top: 10, right: 16, bottom: 24, left: 50 }
|
||
const makeXAxis = () => ({ type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10, rotate: labelRotate } })
|
||
const makeSeries = (name, data, color) => ({ name, type: 'line', smooth: true, symbol: symbolType, areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data })
|
||
|
||
if (cpuChartRef.value) {
|
||
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
|
||
cpuChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` },
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||
series: [makeSeries('CPU', cpuData, '#409eff')]
|
||
}, true)
|
||
}
|
||
|
||
if (memChartRef.value) {
|
||
if (!memChart) memChart = echarts.init(memChartRef.value)
|
||
memChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||
series: [makeSeries('内存', memData, '#67c23a')]
|
||
}, true)
|
||
}
|
||
|
||
if (diskChartRef.value) {
|
||
if (!diskChart) diskChart = echarts.init(diskChartRef.value)
|
||
diskChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||
let s = params[0].axisValue
|
||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}` })
|
||
return s
|
||
}},
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) } },
|
||
series: [makeSeries('读取', diskReadData, '#409eff'), makeSeries('写入', diskWriteData, '#e6a23c')]
|
||
}, true)
|
||
}
|
||
|
||
if (netChartRef.value) {
|
||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||
netChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||
let s = params[0].axisValue
|
||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
|
||
return s
|
||
}},
|
||
grid: baseGrid, xAxis: makeXAxis(),
|
||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatNetLabel(v) } },
|
||
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
||
}, true)
|
||
}
|
||
}
|
||
|
||
const disposeCharts = () => {
|
||
cpuChart?.dispose(); cpuChart = null
|
||
memChart?.dispose(); memChart = null
|
||
netChart?.dispose(); netChart = null
|
||
diskChart?.dispose(); diskChart = null
|
||
}
|
||
|
||
const powerDialogVisible = ref(false)
|
||
const powerAction = ref('')
|
||
const powerForce = ref(false)
|
||
const powerLabels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
|
||
|
||
const handlePower = (action) => {
|
||
powerAction.value = action
|
||
powerForce.value = false
|
||
powerDialogVisible.value = true
|
||
}
|
||
|
||
const submitPower = async () => {
|
||
const action = powerAction.value
|
||
const label = powerLabels[action]
|
||
powerDialogVisible.value = false
|
||
try {
|
||
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
if (powerForce.value) fd.append('force', true)
|
||
const res = await apis[action](fd)
|
||
if (res?.data?.code === 200) { ElMessage.success(`${label}成功`); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, `${label}失败`))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${label}失败`)) }
|
||
}
|
||
|
||
const handleRebuild = () => {
|
||
rebuildImageId.value = detail.value?.image_id || 0
|
||
rebuildImageName.value = ''
|
||
rebuildDialogVisible.value = true
|
||
}
|
||
|
||
const submitRebuild = async () => {
|
||
if (!rebuildImageId.value) { ElMessage.warning('请选择镜像'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('image_id', rebuildImageId.value)
|
||
const res = await rebuildVm(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('重装成功'); rebuildDialogVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '重装失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重装失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const handleRescue = () => {
|
||
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」进入救援模式吗?`, '救援模式', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||
}).then(async () => {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||
try {
|
||
const res = await rescueVm(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('已进入救援模式'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const handleExitRescue = () => {
|
||
ElMessageBox.confirm(`确定让虚拟机「${detail.value?.name}」退出救援模式吗?`, '退出救援', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
|
||
}).then(async () => {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||
try {
|
||
const res = await exitRescueVm(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('已退出救援模式'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ---- 编辑虚拟机(仅 update API 参数) ----
|
||
const editDialogVisible = ref(false)
|
||
const editFormRef = ref(null)
|
||
const editForm = reactive({
|
||
rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
|
||
root_password: '', ssh_port: 22,
|
||
port_group_id: ''
|
||
})
|
||
const editSelectedNetworks = ref([])
|
||
const showEditNetworkSelector = ref(false)
|
||
const editSelectedInternalNetworks = ref([])
|
||
const showEditInternalNetworkSelector = ref(false)
|
||
|
||
const handleEditNetworkConfirm = (network) => {
|
||
if (!editSelectedNetworks.value.some(n => n.id === network.id)) {
|
||
editSelectedNetworks.value = [...editSelectedNetworks.value, { id: network.id, name: network.name }]
|
||
}
|
||
}
|
||
const removeEditNetwork = (id) => {
|
||
editSelectedNetworks.value = editSelectedNetworks.value.filter(n => n.id !== id)
|
||
}
|
||
const handleEditInternalNetworkConfirm = (network) => {
|
||
editSelectedInternalNetworks.value = [{ id: network.id, name: network.name }]
|
||
}
|
||
const removeEditInternalNetwork = (id) => {
|
||
editSelectedInternalNetworks.value = editSelectedInternalNetworks.value.filter(n => n.id !== id)
|
||
}
|
||
|
||
const handleEditVm = async () => {
|
||
if (!detail.value) return
|
||
const d = detail.value
|
||
Object.assign(editForm, {
|
||
rx_bandwidth: d.rx_bandwidth || 0,
|
||
tx_bandwidth: d.tx_bandwidth || 0,
|
||
data_volume_size: d.data_volume_size || 0,
|
||
root_password: d.root_password || '',
|
||
ssh_port: d.ssh_port || 22,
|
||
port_group_id: vmPortGroup.value?.id || ''
|
||
})
|
||
const bridgeNets = vmNetworks.value.filter(n => n.type === 'bridge')
|
||
const natNets = vmNetworks.value.filter(n => n.type === 'nat')
|
||
editSelectedNetworks.value = bridgeNets.map(n => ({ id: n.id, name: n.name }))
|
||
editSelectedInternalNetworks.value = natNets.length ? [{ id: natNets[0].id, name: natNets[0].name }] : []
|
||
editDialogVisible.value = true
|
||
dialogOptionsLoading.value = true
|
||
try {
|
||
await Promise.all([
|
||
!sgOptions.value.length ? loadSgOptions() : Promise.resolve()
|
||
])
|
||
} finally { dialogOptionsLoading.value = false }
|
||
}
|
||
|
||
const submitEditVm = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('rx_bandwidth', editForm.rx_bandwidth)
|
||
fd.append('tx_bandwidth', editForm.tx_bandwidth)
|
||
if (editForm.data_volume_size > 0) fd.append('data_volume_size', editForm.data_volume_size)
|
||
if (editForm.root_password) fd.append('root_password', editForm.root_password)
|
||
fd.append('ssh_port', editForm.ssh_port)
|
||
editSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
|
||
if (editSelectedInternalNetworks.value.length) fd.append('internet_network_id', editSelectedInternalNetworks.value[0].id)
|
||
if (editForm.port_group_id) fd.append('port_group_id', editForm.port_group_id)
|
||
const res = await updateVm(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 重构虚拟机 ----
|
||
const refactorDialogVisible = ref(false)
|
||
const refactorFormRef = ref(null)
|
||
const refactorForm = reactive({
|
||
memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidth: 0,
|
||
root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '',
|
||
ssh_port: 0, vnc_port: 0, vnc_password: '',
|
||
port_group_id: ''
|
||
})
|
||
const refactorMemUnit = ref(1048576)
|
||
const refactorMemDisplay = ref(0)
|
||
const refactorSelectedNetworks = ref([])
|
||
const showRefactorNetworkSelector = ref(false)
|
||
const refactorSelectedInternalNetworks = ref([])
|
||
const showRefactorInternalNetworkSelector = ref(false)
|
||
|
||
const onRefactorMemUnitChange = () => {
|
||
refactorMemDisplay.value = refactorForm.memory ? Math.round(refactorForm.memory / refactorMemUnit.value * 100) / 100 : 0
|
||
}
|
||
watch(refactorMemDisplay, (v) => { refactorForm.memory = Math.round(v * refactorMemUnit.value) })
|
||
|
||
const handleRefactorNetworkConfirm = (network) => {
|
||
if (!refactorSelectedNetworks.value.some(n => n.id === network.id)) {
|
||
refactorSelectedNetworks.value = [...refactorSelectedNetworks.value, { id: network.id, name: network.name }]
|
||
}
|
||
}
|
||
const removeRefactorNetwork = (id) => {
|
||
refactorSelectedNetworks.value = refactorSelectedNetworks.value.filter(n => n.id !== id)
|
||
}
|
||
const handleRefactorInternalNetworkConfirm = (network) => {
|
||
refactorSelectedInternalNetworks.value = [{ id: network.id, name: network.name }]
|
||
}
|
||
const removeRefactorInternalNetwork = (id) => {
|
||
refactorSelectedInternalNetworks.value = refactorSelectedInternalNetworks.value.filter(n => n.id !== id)
|
||
}
|
||
|
||
const handleRefactorVm = async () => {
|
||
if (!detail.value) return
|
||
const d = detail.value
|
||
Object.assign(refactorForm, {
|
||
memory: d.memory || 0, vcpu: d.vcpu || 0,
|
||
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
|
||
root_password: d.root_password || '',
|
||
uuid: d.uuid || '', mate_data_id: d.mate_data_id || '',
|
||
physical_name: d.physical_name || '', config_path: d.config_path || '',
|
||
ssh_port: d.ssh_port || 0, vnc_port: 0, vnc_password: '',
|
||
port_group_id: vmPortGroup.value?.id || ''
|
||
})
|
||
const bridgeNets = vmNetworks.value.filter(n => n.type === 'bridge')
|
||
const natNets = vmNetworks.value.filter(n => n.type === 'nat')
|
||
refactorSelectedNetworks.value = bridgeNets.map(n => ({ id: n.id, name: n.name }))
|
||
refactorSelectedInternalNetworks.value = natNets.length ? [{ id: natNets[0].id, name: natNets[0].name }] : []
|
||
const mem = d.memory || 0
|
||
if (mem >= 1048576 && mem % 1048576 === 0) { refactorMemUnit.value = 1048576; refactorMemDisplay.value = mem / 1048576 }
|
||
else if (mem >= 1024 && mem % 1024 === 0) { refactorMemUnit.value = 1024; refactorMemDisplay.value = mem / 1024 }
|
||
else { refactorMemUnit.value = 1; refactorMemDisplay.value = mem }
|
||
refactorDialogVisible.value = true
|
||
dialogOptionsLoading.value = true
|
||
try {
|
||
await Promise.all([
|
||
!sgOptions.value.length ? loadSgOptions() : Promise.resolve()
|
||
])
|
||
} finally { dialogOptionsLoading.value = false }
|
||
}
|
||
|
||
const submitRefactorVm = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
if (refactorForm.memory) fd.append('memory', refactorForm.memory)
|
||
if (refactorForm.vcpu) fd.append('vcpu', refactorForm.vcpu)
|
||
fd.append('rx_bandwidth', refactorForm.rx_bandwidth)
|
||
fd.append('tx_bandwidth', refactorForm.tx_bandwidth)
|
||
if (refactorForm.root_password) fd.append('root_password', refactorForm.root_password)
|
||
if (refactorForm.uuid) fd.append('uuid', refactorForm.uuid)
|
||
if (refactorForm.mate_data_id) fd.append('mate_data_id', refactorForm.mate_data_id)
|
||
if (refactorForm.physical_name) fd.append('physical_name', refactorForm.physical_name)
|
||
if (refactorForm.config_path) fd.append('config_path', refactorForm.config_path)
|
||
if (refactorForm.ssh_port) fd.append('ssh_port', refactorForm.ssh_port)
|
||
if (refactorForm.vnc_port) fd.append('vnc_port', refactorForm.vnc_port)
|
||
if (refactorForm.vnc_password) fd.append('vnc_password', refactorForm.vnc_password)
|
||
refactorSelectedNetworks.value.forEach(n => fd.append('network_ids', n.id))
|
||
if (refactorSelectedInternalNetworks.value.length) fd.append('internet_network_id', refactorSelectedInternalNetworks.value[0].id)
|
||
if (refactorForm.port_group_id) fd.append('port_group_id', refactorForm.port_group_id)
|
||
const res = await refactorVm(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('重构成功'); refactorDialogVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '重构失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '重构失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 修改带宽 ----
|
||
const trafficDialogVisible = ref(false)
|
||
const trafficForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, _trafficGB: 0 })
|
||
|
||
const handleUpdateTraffic = () => {
|
||
if (!detail.value) return
|
||
Object.assign(trafficForm, {
|
||
rx_bandwidth: detail.value.rx_bandwidth || 0,
|
||
tx_bandwidth: detail.value.tx_bandwidth || 0,
|
||
_trafficGB: ((detail.value.traffic_max || 0) / 1024).toFixed(2) * 1
|
||
})
|
||
trafficDialogVisible.value = true
|
||
}
|
||
|
||
const submitUpdateTraffic = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('rx_bandwidth', trafficForm.rx_bandwidth)
|
||
fd.append('tx_bandwidth', trafficForm.tx_bandwidth)
|
||
if (trafficForm._trafficGB) fd.append('traffic_max', Math.round(trafficForm._trafficGB * 1024)) // GB → Mb
|
||
const res = await updateVmTraffic(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('带宽修改成功'); trafficDialogVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 迁移虚拟机 ----
|
||
const migrateDialogVisible = ref(false)
|
||
const migrateOptionsLoading = ref(false)
|
||
const migrateMode = ref('host')
|
||
const migrateHostOptions = ref([])
|
||
const migrateGroupOptions = ref([])
|
||
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
|
||
|
||
const handleMigrateVm = async () => {
|
||
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
|
||
migrateMode.value = 'host'
|
||
migrateDialogVisible.value = true
|
||
migrateOptionsLoading.value = true
|
||
try {
|
||
const [hostRes, groupRes] = await Promise.all([
|
||
getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 }),
|
||
getRemoteHostGroupList({ service_id: serviceId.value, page: 1, page_size: 10 })
|
||
])
|
||
if (hostRes?.data?.code === 200 && hostRes?.data?.data) {
|
||
const inner = hostRes.data.data
|
||
migrateHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || [])
|
||
}
|
||
if (groupRes?.data?.code === 200 && groupRes?.data?.data) {
|
||
const inner = groupRes.data.data
|
||
migrateGroupOptions.value = Array.isArray(inner) ? inner : (inner.host_groups || inner.data || [])
|
||
}
|
||
} catch { /* */ } finally { migrateOptionsLoading.value = false }
|
||
}
|
||
|
||
const submitMigrateVm = async () => {
|
||
if (migrateMode.value === 'host' && !migrateForm.target_host_id) { ElMessage.warning('请选择目标宿主机'); return }
|
||
if (migrateMode.value === 'group' && !migrateForm.target_host_group_id) { ElMessage.warning('请选择目标宿主机组'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
if (migrateMode.value === 'host') fd.append('target_host_id', migrateForm.target_host_id)
|
||
else fd.append('target_host_group_id', migrateForm.target_host_group_id)
|
||
if (migrateForm.ipv4_num) fd.append('ipv4_num', migrateForm.ipv4_num)
|
||
if (migrateForm.ipv6_num) fd.append('ipv6_num', migrateForm.ipv6_num)
|
||
const res = await migrateVm(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('迁移成功'); migrateDialogVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 数据迁移 ----
|
||
const dataMigrateVisible = ref(false)
|
||
const dataMigrateLoading = ref(false)
|
||
const dataMigrateHostsLoading = ref(false)
|
||
const dataMigrateServiceOptions = ref([])
|
||
const dataMigrateHostOptions = ref([])
|
||
const dataMigrateProgressVisible = ref(false)
|
||
const dataMigrateProgressLoading = ref(false)
|
||
const dataMigrateProgressData = ref(null)
|
||
const dataMigrationId = ref('')
|
||
const dataMigrateTaskId = ref('')
|
||
const dataMigrateForm = reactive({ target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
|
||
const dataMigrateNetworkOptions = ref([])
|
||
const showDataMigrateNetworkSelector = ref(false)
|
||
const dataMigrateNetSelectorRef = ref(null)
|
||
const editNetSelectorRef = ref(null)
|
||
const editInternalNetSelectorRef = ref(null)
|
||
const refactorNetSelectorRef = ref(null)
|
||
const refactorInternalNetSelectorRef = ref(null)
|
||
const bindBridgeNetSelectorRef = ref(null)
|
||
const bindNatNetSelectorRef = ref(null)
|
||
const dataMigrateSelectedNetworks = ref([])
|
||
|
||
const handleDataMigrateNetworkConfirm = (items) => {
|
||
const arr = Array.isArray(items) ? items : [items]
|
||
const existIds = new Set(dataMigrateSelectedNetworks.value.map(n => n.id))
|
||
arr.forEach(n => { if (!existIds.has(n.id)) dataMigrateSelectedNetworks.value.push(n) })
|
||
dataMigrateForm.network_ids = dataMigrateSelectedNetworks.value.map(n => n.id)
|
||
}
|
||
const removeDataMigrateNetwork = (id) => {
|
||
dataMigrateSelectedNetworks.value = dataMigrateSelectedNetworks.value.filter(n => n.id !== id)
|
||
dataMigrateForm.network_ids = dataMigrateSelectedNetworks.value.map(n => n.id)
|
||
}
|
||
|
||
const handleDataMigrateVm = async () => {
|
||
Object.assign(dataMigrateForm, { target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
|
||
dataMigrateSelectedNetworks.value = []
|
||
dataMigrateNetworkOptions.value = []
|
||
dataMigrateVisible.value = true
|
||
dataMigrateLoading.value = true
|
||
try {
|
||
const res = await getKvmServiceList({ page: 1, count: 10 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
const raw = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||
dataMigrateServiceOptions.value = raw.map(s => ({ id: s.id ?? s.Id, name: s.name ?? s.Name }))
|
||
}
|
||
} catch { /* */ } finally { dataMigrateLoading.value = false }
|
||
}
|
||
|
||
const loadDataMigrateHosts = async () => {
|
||
if (!dataMigrateForm.target_service_id) return
|
||
dataMigrateHostsLoading.value = true
|
||
dataMigrateNetworkOptions.value = []
|
||
dataMigrateForm.network_ids = []
|
||
dataMigrateSelectedNetworks.value = []
|
||
try {
|
||
const res = await getRemoteHostList({ service_id: dataMigrateForm.target_service_id, page: 1, page_size: 10 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
dataMigrateHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.data || [])
|
||
}
|
||
} catch { /* */ } finally { dataMigrateHostsLoading.value = false }
|
||
}
|
||
|
||
const loadDataMigrateNetworks = async () => {
|
||
if (!dataMigrateForm.target_service_id || !dataMigrateForm.target_host_id) return
|
||
try {
|
||
const res = await getNetworkList({ service_id: dataMigrateForm.target_service_id, host_id: dataMigrateForm.target_host_id })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
dataMigrateNetworkOptions.value = Array.isArray(inner) ? inner : (inner.networks || inner.data || [])
|
||
}
|
||
} catch { dataMigrateNetworkOptions.value = [] }
|
||
}
|
||
|
||
const submitDataMigrate = async () => {
|
||
if (!dataMigrateForm.target_service_id) { ElMessage.warning('请选择目标主控服务'); return }
|
||
if (!dataMigrateForm.target_host_id) { ElMessage.warning('请选择目标宿主机'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('source_service_id', serviceId.value)
|
||
fd.append('source_vm_id', vmId.value)
|
||
fd.append('target_service_id', dataMigrateForm.target_service_id)
|
||
fd.append('target_host_id', dataMigrateForm.target_host_id)
|
||
if (dataMigrateForm.ipv4_num) fd.append('ipv4_num', dataMigrateForm.ipv4_num)
|
||
if (dataMigrateForm.ipv6_num) fd.append('ipv6_num', dataMigrateForm.ipv6_num)
|
||
if (dataMigrateForm.network_ids?.length) {
|
||
dataMigrateForm.network_ids.forEach(id => fd.append('network_ids', id))
|
||
}
|
||
const res = await dataMigrateVm(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('数据迁移已发起')
|
||
const d = res.data.data
|
||
dataMigrationId.value = d?.migration_id || ''
|
||
dataMigrateTaskId.value = d?.export_task?.task_id || ''
|
||
dataMigrateVisible.value = false
|
||
dataMigrateProgressVisible.value = true
|
||
loadDataMigrateProgress()
|
||
startMigratePolling()
|
||
} else ElMessage.error(extractApiError(res?.data, '发起迁移失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '发起迁移失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const MIGRATE_DONE_STAGES = ['completed', 'done', 'failed', 'aborted', 'cancelled', 'error']
|
||
|
||
const loadDataMigrateProgress = async () => {
|
||
if (!dataMigrationId.value && !dataMigrateTaskId.value) return
|
||
const params = { service_id: serviceId.value, host_id: vmHostId.value }
|
||
if (dataMigrationId.value) params.migration_id = dataMigrationId.value
|
||
if (dataMigrateTaskId.value) params.task_id = dataMigrateTaskId.value
|
||
dataMigrateProgressLoading.value = true
|
||
try {
|
||
const res = await getDataMigrateProgress(params)
|
||
if (res?.data?.code === 200 && res.data.data) {
|
||
dataMigrateProgressData.value = res.data.data
|
||
const d = res.data.data
|
||
if (!dataMigrationId.value && d.migration_id) dataMigrationId.value = d.migration_id
|
||
if (!dataMigrateTaskId.value) {
|
||
dataMigrateTaskId.value = d.export_task_id || d.import_task_id || ''
|
||
}
|
||
const stage = d.stage
|
||
if (MIGRATE_DONE_STAGES.includes(stage)) {
|
||
stopMigratePolling()
|
||
if (stage === 'completed' || stage === 'done') ElMessage.success('数据迁移已完成')
|
||
else if (stage === 'failed' || stage === 'error') ElMessage.error(d.error || '数据迁移失败')
|
||
loadDetail()
|
||
}
|
||
} else {
|
||
dataMigrateProgressData.value = null
|
||
stopMigratePolling()
|
||
}
|
||
} catch {
|
||
dataMigrateProgressData.value = null
|
||
stopMigratePolling()
|
||
} finally { dataMigrateProgressLoading.value = false }
|
||
}
|
||
|
||
const isMigrating = computed(() => {
|
||
if (detail.value?.migrating) return true
|
||
const stage = dataMigrateProgressData.value?.stage
|
||
return stage && !MIGRATE_DONE_STAGES.includes(stage)
|
||
})
|
||
|
||
const migrateStageLabel = (stage) => ({
|
||
exporting: '导出中', importing: '导入中', transferring: '传输中',
|
||
verifying: '校验中', completed: '已完成', done: '已完成',
|
||
failed: '失败', error: '错误', aborted: '已中断', cancelled: '已取消',
|
||
pending: '等待中', preparing: '准备中'
|
||
}[stage] || stage || '-')
|
||
|
||
const migrateStageType = (stage) => ({
|
||
exporting: 'warning', importing: 'warning', transferring: '',
|
||
verifying: '', completed: 'success', done: 'success',
|
||
failed: 'danger', error: 'danger', aborted: 'info', cancelled: 'info',
|
||
pending: 'info', preparing: 'info'
|
||
}[stage] || 'info')
|
||
|
||
const migrateProgressBarStatus = (stage) => {
|
||
if (stage === 'completed' || stage === 'done') return 'success'
|
||
if (stage === 'failed' || stage === 'error') return 'exception'
|
||
return ''
|
||
}
|
||
|
||
let migratePollingTimer = null
|
||
const startMigratePolling = () => {
|
||
stopMigratePolling()
|
||
migratePollingTimer = setInterval(loadDataMigrateProgress, 3000)
|
||
}
|
||
const stopMigratePolling = () => {
|
||
if (migratePollingTimer) { clearInterval(migratePollingTimer); migratePollingTimer = null }
|
||
}
|
||
|
||
let detailAutoRefreshTimer = null
|
||
const startDetailAutoRefresh = () => {
|
||
if (detailAutoRefreshTimer) return
|
||
detailAutoRefreshTimer = setInterval(() => { loadDetail() }, 3000)
|
||
}
|
||
const stopDetailAutoRefresh = () => {
|
||
if (detailAutoRefreshTimer) { clearInterval(detailAutoRefreshTimer); detailAutoRefreshTimer = null }
|
||
}
|
||
|
||
const abortLoading = ref(false)
|
||
const handleAbortMigrate = () => {
|
||
ElMessageBox.confirm('确定要中断当前数据迁移吗?此操作不可恢复!', '中断迁移', {
|
||
confirmButtonText: '确定中断', cancelButtonText: '取消', type: 'warning', confirmButtonClass: 'el-button--danger'
|
||
}).then(async () => {
|
||
abortLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
const res = await abortDataMigrate(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('迁移已中断')
|
||
stopMigratePolling()
|
||
dataMigrateProgressData.value = null
|
||
dataMigrationId.value = ''
|
||
dataMigrateTaskId.value = ''
|
||
loadDetail()
|
||
} else ElMessage.error(extractApiError(res?.data, '中断迁移失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '中断迁移失败')) } finally { abortLoading.value = false }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ---- VNC 连接 ----
|
||
const vncDialogVisible = ref(false)
|
||
const vncNodeId = ref(null)
|
||
const vncNodeOptions = ref([])
|
||
const vncLoading = ref(false)
|
||
const vncResult = ref(null)
|
||
|
||
const loadVncNodes = async () => {
|
||
try {
|
||
const res = await getVncNodeList({ service_id: serviceId.value, page: 1, page_size: 10 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
vncNodeOptions.value = inner.items || inner.vnc_nodes || inner.nodes || inner.data || (Array.isArray(inner) ? inner : [])
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
|
||
const handleGetVnc = async () => {
|
||
vncNodeId.value = null
|
||
vncResult.value = null
|
||
vncDialogVisible.value = true
|
||
if (!vncNodeOptions.value.length) {
|
||
dialogOptionsLoading.value = true
|
||
try { await loadVncNodes() } finally { dialogOptionsLoading.value = false }
|
||
}
|
||
}
|
||
|
||
const submitGetVnc = async () => {
|
||
vncLoading.value = true
|
||
vncResult.value = null
|
||
try {
|
||
const res = await getVmVnc({ service_id: serviceId.value, id: vncNodeId.value, vm_id: vmId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
vncResult.value = res.data.data
|
||
} else ElMessage.error(extractApiError(res?.data, '获取VNC连接失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取VNC连接失败')) } finally { vncLoading.value = false }
|
||
}
|
||
|
||
// ---- 安全组选项(用于编辑弹窗) ----
|
||
const sgOptions = ref([])
|
||
|
||
const loadSgOptions = async () => {
|
||
try {
|
||
const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 10 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
sgOptions.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
|
||
// ---- 绑定网络(通过 updateVm 接口) ----
|
||
const showNetBindBridgeSelector = ref(false)
|
||
const showNetBindNatSelector = ref(false)
|
||
|
||
const submitNetBind = async (paramName, newId, existingIds) => {
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
const bridgeIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
|
||
const natNet = vmNetworks.value.find(n => n.type === 'nat')
|
||
if (paramName === 'network_ids') {
|
||
const allBridge = [...bridgeIds, newId]
|
||
allBridge.forEach(id => fd.append('network_ids', id))
|
||
if (natNet) fd.append('internet_network_id', natNet.id)
|
||
} else {
|
||
bridgeIds.forEach(id => fd.append('network_ids', id))
|
||
fd.append('internet_network_id', newId)
|
||
}
|
||
if (detail.value?.rx_bandwidth) fd.append('rx_bandwidth', detail.value.rx_bandwidth)
|
||
if (detail.value?.tx_bandwidth) fd.append('tx_bandwidth', detail.value.tx_bandwidth)
|
||
if (detail.value?.ssh_port) fd.append('ssh_port', detail.value.ssh_port)
|
||
if (vmPortGroup.value?.id) fd.append('port_group_id', vmPortGroup.value.id)
|
||
const res = await updateVm(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('绑定网络成功')
|
||
loadDetail()
|
||
} else {
|
||
ElMessage.error(extractApiError(res?.data, '绑定网络失败'))
|
||
}
|
||
} catch (e) {
|
||
ElMessage.error(extractApiError(e?.response?.data, '绑定网络失败'))
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
}
|
||
|
||
const handleNetBindBridgeConfirm = (selectedNetwork) => {
|
||
const existingIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
|
||
if (existingIds.includes(selectedNetwork.id)) {
|
||
ElMessage.warning('该网络已绑定')
|
||
return
|
||
}
|
||
submitNetBind('network_ids', selectedNetwork.id)
|
||
}
|
||
|
||
const handleNetBindNatConfirm = (selectedNetwork) => {
|
||
const existingNat = vmNetworks.value.find(n => n.type === 'nat')
|
||
if (existingNat?.id === selectedNetwork.id) {
|
||
ElMessage.warning('该内网已绑定')
|
||
return
|
||
}
|
||
submitNetBind('internet_network_id', selectedNetwork.id)
|
||
}
|
||
|
||
// ---- 网络操作(创建/编辑/删除/详情) ----
|
||
const netDialogVisible = ref(false)
|
||
const netDialogType = ref('add')
|
||
const netDialogSource = ref('')
|
||
const netFormRef = ref(null)
|
||
const netDetailVisible = ref(false)
|
||
const netDetailData = ref(null)
|
||
const netForm = reactive({ id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0 })
|
||
const netFormRules = {
|
||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||
address: [{ required: true, message: '请输入 CIDR 格式网段', trigger: 'blur' }],
|
||
gateway: [{ required: true, message: '请输入网关', trigger: 'blur' }],
|
||
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
|
||
}
|
||
|
||
const handleNetCreate = (source = '') => {
|
||
netDialogSource.value = source
|
||
netDialogType.value = 'add'
|
||
const hostId = source === 'dataMigrate' ? (dataMigrateForm.target_host_id || vmHostId.value) : vmHostId.value
|
||
Object.assign(netForm, { id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostId })
|
||
netDialogVisible.value = true
|
||
}
|
||
const handleNetDialogCancel = () => {
|
||
netDialogVisible.value = false
|
||
netDialogSource.value = ''
|
||
}
|
||
|
||
const handleNetEdit = (row) => {
|
||
netDialogType.value = 'edit'
|
||
Object.assign(netForm, { id: row.id, name: row.name || '', address: row.address || '', gateway: row.gateway || '', nameservers: row.nameservers || '', type: row.type || 'bridge', mac_address: row.mac_address || '', bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '', ls_name: row.ls_name || '', host_id: row.host_id || vmHostId.value })
|
||
netDialogVisible.value = true
|
||
}
|
||
const submitNetForm = () => {
|
||
netFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
const sid = netDialogSource.value === 'dataMigrate' && dataMigrateForm.target_service_id ? dataMigrateForm.target_service_id : serviceId.value
|
||
fd.append('service_id', sid)
|
||
fd.append('name', netForm.name)
|
||
fd.append('address', netForm.address)
|
||
fd.append('gateway', netForm.gateway)
|
||
fd.append('type', netForm.type)
|
||
fd.append('host_id', netForm.host_id)
|
||
if (netForm.nameservers) fd.append('nameservers', netForm.nameservers)
|
||
if (netForm.mac_address) fd.append('mac_address', netForm.mac_address)
|
||
if (netForm.bridge_name) fd.append('bridge_name', netForm.bridge_name)
|
||
if (netForm.ls_bridge_name) fd.append('ls_bridge_name', netForm.ls_bridge_name)
|
||
if (netForm.ls_name) fd.append('ls_name', netForm.ls_name)
|
||
let res
|
||
if (netDialogType.value === 'add') { res = await createNetwork(fd) }
|
||
else { fd.append('id', netForm.id); res = await updateNetwork(fd) }
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success(netDialogType.value === 'add' ? '创建成功' : '修改成功')
|
||
netDialogVisible.value = false
|
||
const src = netDialogSource.value
|
||
netDialogSource.value = ''
|
||
const refMap = {
|
||
dataMigrate: dataMigrateNetSelectorRef,
|
||
edit: editNetSelectorRef,
|
||
editInternal: editInternalNetSelectorRef,
|
||
refactor: refactorNetSelectorRef,
|
||
refactorInternal: refactorInternalNetSelectorRef,
|
||
bindBridge: bindBridgeNetSelectorRef,
|
||
bindNat: bindNatNetSelectorRef,
|
||
}
|
||
const selectorRef = refMap[src]
|
||
if (selectorRef) {
|
||
nextTick(() => selectorRef.value?.loadList())
|
||
}
|
||
if (src !== 'dataMigrate') loadDetail()
|
||
}
|
||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||
})
|
||
}
|
||
const handleNetDetail = (row) => { netDetailData.value = row; netDetailVisible.value = true }
|
||
const handleNetDelete = (row) => {
|
||
ElMessageBox.confirm(`确定要删除网络「${row.name}」(ID: ${row.id}) 吗?`, '删除网络', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const res = await deleteNetwork({ service_id: serviceId.value, network_id: row.id, host_id: row.host_id })
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// ---- 数据卷操作(绑定/创建/调整/挂载/卸载/迁移/删除/详情) ----
|
||
const showVolSelector = ref(false)
|
||
|
||
const handleVolBindConfirm = async (vol) => {
|
||
if (!vol?.id) return
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('volume_id', vol.id)
|
||
fd.append('vm_id', vmId.value)
|
||
const res = await mountVolume(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '绑定失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '绑定失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const handleVolCreateFromSelector = () => {
|
||
Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' })
|
||
volCreateVisible.value = true
|
||
}
|
||
|
||
const volCreateVisible = ref(false)
|
||
const volCreateFormRef = ref(null)
|
||
const volCreateForm = reactive({ name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' })
|
||
const volCreateRules = { name: [{ required: true, message: '请输入名称', trigger: 'blur' }], size: [{ required: true, message: '请输入大小', trigger: 'blur' }] }
|
||
const showVolImageSelector = ref(false)
|
||
const volImageSelectorFromCreate = ref(false)
|
||
const handleVolImageSelected = (img) => { volCreateForm.image_id = img.id; volCreateForm._imageName = img.name; volCreateVisible.value = true; volImageSelectorFromCreate.value = false }
|
||
watch(showVolImageSelector, (val) => { if (!val && volImageSelectorFromCreate.value) { volCreateVisible.value = true; volImageSelectorFromCreate.value = false } })
|
||
const volResizeVisible = ref(false)
|
||
const volResizeTarget = ref(null)
|
||
const volNewSize = ref(1)
|
||
const volTransferVisible = ref(false)
|
||
const volTransferTarget = ref(null)
|
||
const volTransferVmId = ref(null)
|
||
const vmListOptions = ref([])
|
||
const volDetailVisible = ref(false)
|
||
const volDetailData = ref(null)
|
||
|
||
const handleVolCreate = () => {
|
||
Object.assign(volCreateForm, { name: '', size: 10, is_system: false, target_device: '', image_id: 0, _imageName: '' })
|
||
volCreateVisible.value = true
|
||
}
|
||
const submitVolCreate = () => {
|
||
volCreateFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('name', volCreateForm.name)
|
||
fd.append('size', volCreateForm.size)
|
||
fd.append('is_system', volCreateForm.is_system)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('host_id', vmHostId.value)
|
||
if (volCreateForm.target_device) fd.append('target_device', volCreateForm.target_device)
|
||
if (volCreateForm.image_id) fd.append('image_id', volCreateForm.image_id)
|
||
const res = await createVolume(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('创建成功'); volCreateVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||
})
|
||
}
|
||
const handleVolResize = (row) => { volResizeTarget.value = { id: row.id, _name: row.name, _currentSize: row.size || 0 }; volNewSize.value = row.size || 1; volResizeVisible.value = true }
|
||
const submitVolResize = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', volResizeTarget.value.id); fd.append('size', volNewSize.value)
|
||
const res = await resizeVolume(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('调整成功'); volResizeVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '调整失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleVolMount = (row) => {
|
||
ElMessageBox.confirm(`确定要将「${row.name}」挂载到当前虚拟机吗?`, '挂载', { type: 'info' }).then(async () => {
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', row.id); fd.append('vm_id', vmId.value)
|
||
const res = await mountVolume(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('挂载成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '挂载失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
const handleVolUnmount = (row) => {
|
||
ElMessageBox.confirm(`确定要卸载「${row.name}」吗?`, '卸载', { type: 'warning' }).then(async () => {
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', row.id)
|
||
const res = await unmountVolume(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('卸载成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '卸载失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '卸载失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
const loadVmListOptions = async () => {
|
||
try {
|
||
const res = await getVmList({ service_id: serviceId.value, page: 1, count: 10 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
vmListOptions.value = inner.vms || inner.data || (Array.isArray(inner) ? inner : [])
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
const handleVolTransfer = async (row) => {
|
||
volTransferTarget.value = { id: row.id, _name: row.name }; volTransferVmId.value = vmId.value
|
||
volTransferVisible.value = true
|
||
if (!vmListOptions.value.length) {
|
||
dialogOptionsLoading.value = true
|
||
try { await loadVmListOptions() } finally { dialogOptionsLoading.value = false }
|
||
}
|
||
}
|
||
const submitVolTransfer = async () => {
|
||
if (!volTransferVmId.value) { ElMessage.warning('请选择目标虚拟机'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('volume_id', volTransferTarget.value.id); fd.append('vm_id', volTransferVmId.value)
|
||
const res = await transferVolume(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('迁移成功'); volTransferVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '迁移失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
|
||
}
|
||
const handleVolDelete = (row) => {
|
||
ElMessageBox.confirm(`确定要删除「${row.name}」吗?此操作不可恢复!`, '删除', { type: 'error' }).then(async () => {
|
||
try {
|
||
const res = await deleteVolume({ service_id: serviceId.value, volume_id: row.id })
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
const handleVolDetail = (row) => {
|
||
router.push({ path: '/virtualization/volume-detail', query: { service_id: serviceId.value, volume_id: row.id } })
|
||
}
|
||
|
||
// ---- 安全组完整管理 ----
|
||
const sgTableData = computed(() => {
|
||
const list = []
|
||
if (vmPortGroup.value) list.push(vmPortGroup.value)
|
||
if (vmOutPortGroup.value) list.push(vmOutPortGroup.value)
|
||
return list
|
||
})
|
||
//安全组分页
|
||
const sgPage = ref(1)
|
||
const sgPageSize = ref(10)
|
||
const pagedSecurityGroups = computed(() =>{
|
||
const start = (sgPage.value -1) * sgPageSize.value
|
||
return sgTableData.value.slice(start,start + sgPageSize.value)
|
||
})
|
||
const sgSubmitLoading = ref(false)
|
||
const sgDetailLoading = ref(false)
|
||
const sgHostOptions = ref([])
|
||
|
||
const loadSgHostOptions = async () => {
|
||
try {
|
||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
|
||
const body = res?.data
|
||
if (body?.code === 200 && body?.data) {
|
||
const inner = body.data
|
||
sgHostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||
}
|
||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||
}
|
||
|
||
// 绑定安全组到当前虚拟机(通过安全组选择器)
|
||
const sgBindVisible = ref(false)
|
||
const handleSgBind = () => { sgBindVisible.value = true }
|
||
const handleSgBindConfirm = async (sg) => {
|
||
if (!sg?.id) return
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sg.id); fd.append('vm_id', vmId.value)
|
||
const res = await bindSecurityGroup(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('绑定成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '绑定失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '绑定失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// 解绑指定安全组与当前虚拟机
|
||
const handleSgUnbindFromVm = async (sg) => {
|
||
if (!sg) return
|
||
try { await ElMessageBox.confirm(`确定要解绑安全组「${sg.name}」(${sg.direction === 'in' ? '入站' : '出站'})吗?`, '解绑安全组', { type: 'warning' }) } catch { return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sg.id); fd.append('vm_id', vmId.value)
|
||
const res = await unbindSecurityGroup(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '解绑失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// 创建安全组
|
||
const sgCreateDialogVisible = ref(false)
|
||
const sgCreateFormRef = ref(null)
|
||
const sgCreateForm = reactive({ name: '', host_id: null, direction: '', lock: false, drop_all: false })
|
||
const sgCreateRules = {
|
||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||
direction: [{ required: true, message: '请选择方向', trigger: 'change' }],
|
||
host_id: [{ required: true, message: '请选择宿主机', trigger: 'change' }]
|
||
}
|
||
const handleSgCreate = async () => {
|
||
Object.assign(sgCreateForm, { name: '', host_id: vmHostId.value || null, direction: '', lock: false, drop_all: false })
|
||
sgCreateDialogVisible.value = true
|
||
if (!sgHostOptions.value.length) {
|
||
dialogOptionsLoading.value = true
|
||
try { await loadSgHostOptions() } finally { dialogOptionsLoading.value = false }
|
||
}
|
||
}
|
||
const submitSgCreate = () => {
|
||
sgCreateFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
sgSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('name', sgCreateForm.name)
|
||
fd.append('host_id', sgCreateForm.host_id)
|
||
fd.append('direction', sgCreateForm.direction)
|
||
fd.append('lock', sgCreateForm.lock ? 'true' : 'false')
|
||
fd.append('drop_all', sgCreateForm.drop_all ? 'true' : 'false')
|
||
const res = await createSecurityGroup(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('创建成功')
|
||
sgCreateDialogVisible.value = false
|
||
loadDetail()
|
||
} else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { sgSubmitLoading.value = false }
|
||
})
|
||
}
|
||
|
||
// 同步安全组
|
||
const sgSyncDialogVisible = ref(false)
|
||
const sgSyncTarget = ref(null)
|
||
const sgSyncHostId = ref(null)
|
||
const handleSgSync = async (row) => {
|
||
sgSyncTarget.value = row
|
||
sgSyncHostId.value = row.host_id || vmHostId.value || null
|
||
sgSyncDialogVisible.value = true
|
||
if (!sgHostOptions.value.length) {
|
||
dialogOptionsLoading.value = true
|
||
try { await loadSgHostOptions() } finally { dialogOptionsLoading.value = false }
|
||
}
|
||
}
|
||
const submitSgSync = async () => {
|
||
if (!sgSyncHostId.value) { ElMessage.warning('请选择宿主机'); return }
|
||
sgSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sgSyncTarget.value.id); fd.append('host_id', sgSyncHostId.value)
|
||
const res = await syncSecurityGroup(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('同步成功')
|
||
sgSyncDialogVisible.value = false
|
||
loadDetail()
|
||
} else ElMessage.error(extractApiError(res?.data, '同步失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '同步失败')) } finally { sgSubmitLoading.value = false }
|
||
}
|
||
|
||
// 应用安全组
|
||
const handleSgApply = (row) => {
|
||
ElMessageBox.confirm(`确定要应用安全组「${row.name}」的规则到所有已绑定虚拟机吗?`, '应用安全组', {
|
||
confirmButtonText: '确定应用', cancelButtonText: '取消', type: 'info'
|
||
}).then(async () => {
|
||
try {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', row.id)
|
||
const res = await applySecurityGroup(fd)
|
||
if (res?.data?.code === 200) ElMessage.success('应用成功')
|
||
else ElMessage.error(extractApiError(res?.data, '应用失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '应用失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// 编辑(跳转安全组详情页)
|
||
const handleSgGoDetail = (row) => {
|
||
router.push({ path: '/virtualization/security-group-detail', query: { service_id: serviceId.value, service_name: serviceName.value, sg_id: row.id } })
|
||
}
|
||
|
||
// 白名单切换
|
||
const handleSgToggleWhitelist = (row) => {
|
||
const action = row.drop_all ? '关闭' : '开启'
|
||
ElMessageBox.confirm(`确定要${action}安全组「${row.name}」的白名单模式吗?`, `${action}白名单`, {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||
}).then(async () => {
|
||
try {
|
||
const api = row.drop_all ? disableSecurityGroupWhitelist : enableSecurityGroupWhitelist
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', row.id)
|
||
const res = await api(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, `${action}失败`))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// 删除安全组
|
||
const handleSgDelete = (row) => {
|
||
ElMessageBox.confirm(`确定要删除安全组「${row.name}」吗?`, '删除确认', {
|
||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||
}).then(async () => {
|
||
try {
|
||
const res = await deleteSecurityGroup({ service_id: serviceId.value, id: row.id })
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// 绑定/解绑虚拟机(操作栏更多菜单,针对其他虚拟机)
|
||
const sgVmBindDialogVisible = ref(false)
|
||
const sgVmBindType = ref('bind')
|
||
const sgVmBindTarget = ref(null)
|
||
const handleSgBindVm = (row) => {
|
||
sgVmBindType.value = 'bind'; sgVmBindTarget.value = row
|
||
sgVmBindDialogVisible.value = true
|
||
}
|
||
const handleSgUnbindVm = (row) => {
|
||
sgVmBindType.value = 'unbind'; sgVmBindTarget.value = row
|
||
sgVmBindDialogVisible.value = true
|
||
}
|
||
const submitSgVmBind = async () => {
|
||
sgSubmitLoading.value = true
|
||
try {
|
||
const api = sgVmBindType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('id', sgVmBindTarget.value.id); fd.append('vm_id', vmId.value)
|
||
const res = await api(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success(sgVmBindType.value === 'bind' ? '绑定成功' : '解绑成功')
|
||
sgVmBindDialogVisible.value = false
|
||
loadDetail()
|
||
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { sgSubmitLoading.value = false }
|
||
}
|
||
|
||
// 查看安全组详情(含规则)
|
||
const sgDetailVisible = ref(false)
|
||
const sgCurrentDetail = ref(null)
|
||
const handleSgViewDetail = async (row) => {
|
||
sgDetailVisible.value = true
|
||
sgDetailLoading.value = true
|
||
sgCurrentDetail.value = row
|
||
try {
|
||
const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: row.id })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
sgCurrentDetail.value = inner.group || inner.data || inner
|
||
}
|
||
} catch { /* fallback */ } finally { sgDetailLoading.value = false }
|
||
}
|
||
|
||
// 安全组规则操作
|
||
const sgRuleDialogVisible = ref(false)
|
||
const sgRuleDialogType = ref('add')
|
||
const sgRuleFormRef = ref(null)
|
||
const sgRuleForm = reactive({ id: undefined, group_id: 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: '' })
|
||
const sgRuleRules = {
|
||
protocol: [{ required: true, message: '请选择协议', trigger: 'change' }],
|
||
action: [{ required: true, message: '请选择动作', trigger: 'change' }]
|
||
}
|
||
const handleSgAddRule = () => {
|
||
sgRuleDialogType.value = 'add'
|
||
Object.assign(sgRuleForm, { id: undefined, group_id: sgCurrentDetail.value?.id || 0, protocol: 'tcp', action: 'allow', port_range: '', ip_range: '', priority: 0, port_group_id: '' })
|
||
sgRuleDialogVisible.value = true
|
||
}
|
||
const handleSgEditRule = (rule) => {
|
||
sgRuleDialogType.value = 'edit'
|
||
Object.assign(sgRuleForm, {
|
||
id: rule.id, group_id: sgCurrentDetail.value?.id || 0, port_group_id: sgCurrentDetail.value?.id || '',
|
||
protocol: rule.protocol || 'tcp', action: rule.action || 'allow',
|
||
port_range: rule.port_range || '', ip_range: rule.ip_range || '', priority: rule.priority || 0
|
||
})
|
||
sgRuleDialogVisible.value = true
|
||
}
|
||
const submitSgRule = () => {
|
||
sgRuleFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
sgSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('group_id', sgRuleForm.group_id)
|
||
if (sgRuleForm.id) fd.append('id', sgRuleForm.id)
|
||
if (sgRuleForm.port_group_id) fd.append('port_group_id', sgRuleForm.port_group_id)
|
||
fd.append('protocol', sgRuleForm.protocol)
|
||
fd.append('action', sgRuleForm.action)
|
||
if (sgRuleForm.port_range) fd.append('port_range', sgRuleForm.port_range)
|
||
if (sgRuleForm.ip_range) fd.append('ip_range', sgRuleForm.ip_range)
|
||
fd.append('priority', sgRuleForm.priority || 0)
|
||
const res = sgRuleDialogType.value === 'add'
|
||
? await createSecurityGroupRule(fd)
|
||
: await updateSecurityGroupRule(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success(sgRuleDialogType.value === 'add' ? '规则创建成功' : '规则修改成功')
|
||
sgRuleDialogVisible.value = false
|
||
handleSgViewDetail(sgCurrentDetail.value)
|
||
} else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { sgSubmitLoading.value = false }
|
||
})
|
||
}
|
||
const handleSgDeleteRule = (rule) => {
|
||
ElMessageBox.confirm('确定要删除该规则吗?', '删除确认', {
|
||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||
}).then(async () => {
|
||
try {
|
||
const res = await deleteSecurityGroupRule({ service_id: serviceId.value, id: rule.id })
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('删除成功')
|
||
handleSgViewDetail(sgCurrentDetail.value)
|
||
} else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
// 更多菜单
|
||
const handleSgRowMore = (row, command) => {
|
||
if (command === 'unbindCurrent') handleSgUnbindFromVm(row)
|
||
else if (command === 'bindVm') handleSgBindVm(row)
|
||
else if (command === 'unbindVm') handleSgUnbindVm(row)
|
||
else if (command === 'whitelist') handleSgToggleWhitelist(row)
|
||
else if (command === 'viewDetail') handleSgViewDetail(row)
|
||
else if (command === 'delete') handleSgDelete(row)
|
||
}
|
||
|
||
// ---- 快照/备份管理 ----
|
||
const snapshotList = ref([])
|
||
const snapshotLoading = ref(false)
|
||
const snapshotQuota = ref(null)
|
||
const snapshotPage = ref(1)
|
||
const snapshotPageSize = ref(10)
|
||
const snapshotTotal = ref(0)
|
||
const backupList = ref([])
|
||
const backupLoading = ref(false)
|
||
const backupQuota = ref(null)
|
||
const backupPage = ref(1)
|
||
const backupPageSize = ref(10)
|
||
const backupTotal = ref(0)
|
||
const snapshotCreateVisible = ref(false)
|
||
const backupCreateVisible = ref(false)
|
||
const snapshotForm = reactive({ name: '' })
|
||
const backupForm = reactive({ name: '' })
|
||
const taskProgressVisible = ref(false)
|
||
const taskProgressLoading = ref(false)
|
||
const taskProgressData = ref(null)
|
||
const taskProgressTitle = ref('')
|
||
|
||
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', success: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
|
||
const snapshotStatusLabel = (s) => ({ completed: '完成', ready: '完成', success: '成功', pending: '等待', running: '运行中', failed: '失败', error: '错误' }[s] || s || '-')
|
||
const taskMetaLabel = (key) => ({ vm_name: '虚拟机名称', backup_path: '备份路径', snapshot_path: '快照路径', path: '路径', progress: '进度', message: '信息', error: '错误信息' }[key] || key)
|
||
|
||
const taskProgressMeta = computed(() => {
|
||
if (!taskProgressData.value?.meta) return null
|
||
const raw = taskProgressData.value.meta
|
||
if (typeof raw === 'object') return raw
|
||
if (typeof raw === 'string') {
|
||
const trimmed = raw.trim()
|
||
if (!trimmed || trimmed === '""' || trimmed === '{}') return null
|
||
try { return JSON.parse(trimmed) } catch { return { 信息: raw } }
|
||
}
|
||
return null
|
||
})
|
||
|
||
const loadSnapshots = async () => {
|
||
snapshotLoading.value = true
|
||
try {
|
||
const res = await getSnapshotList({ service_id: serviceId.value, vm_id: vmId.value, page: snapshotPage.value, page_size: snapshotPageSize.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
snapshotList.value = d.data || d.list || (Array.isArray(d) ? d : [])
|
||
snapshotTotal.value = d.meta?.count ?? d.total ?? snapshotList.value.length
|
||
} else { snapshotList.value = []; snapshotTotal.value = 0 }
|
||
} catch { snapshotList.value = []; snapshotTotal.value = 0 } finally { snapshotLoading.value = false }
|
||
}
|
||
|
||
const loadSnapshotQuota = async () => {
|
||
try {
|
||
const res = await getSnapshotCount({ service_id: serviceId.value, vm_id: vmId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
snapshotQuota.value = { count: d.count ?? 0, limit: d.limit ?? 0 }
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const handleSetSnapshotLimit = () => {
|
||
ElMessageBox.prompt('请输入快照数量上限', '设置快照上限', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消',
|
||
inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数',
|
||
inputValue: String(snapshotQuota.value?.limit || 10)
|
||
}).then(async ({ value }) => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('limit', value)
|
||
const res = await setSnapshotLimit(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('快照上限设置成功'); loadSnapshotQuota() }
|
||
else ElMessage.error(extractApiError(res?.data, '设置失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const loadBackups = async () => {
|
||
backupLoading.value = true
|
||
try {
|
||
const res = await getBackupList({ service_id: serviceId.value, vm_id: vmId.value, page: backupPage.value, page_size: backupPageSize.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
backupList.value = d.data || d.list || (Array.isArray(d) ? d : [])
|
||
backupTotal.value = d.meta?.count ?? d.total ?? backupList.value.length
|
||
} else { backupList.value = []; backupTotal.value = 0 }
|
||
} catch { backupList.value = []; backupTotal.value = 0 } finally { backupLoading.value = false }
|
||
}
|
||
|
||
const loadBackupQuota = async () => {
|
||
try {
|
||
const res = await getBackupCount({ service_id: serviceId.value, vm_id: vmId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data
|
||
backupQuota.value = { count: d.count ?? 0, limit: d.limit ?? 0 }
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
const handleSetBackupLimit = () => {
|
||
ElMessageBox.prompt('请输入备份数量上限', '设置备份上限', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消',
|
||
inputPattern: /^[1-9]\d*$/, inputErrorMessage: '请输入正整数',
|
||
inputValue: String(backupQuota.value?.limit || 10)
|
||
}).then(async ({ value }) => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('limit', value)
|
||
const res = await setBackupLimit(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('备份上限设置成功'); loadBackupQuota() }
|
||
else ElMessage.error(extractApiError(res?.data, '设置失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '设置失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const handleCreateSnapshot = () => {
|
||
Object.assign(snapshotForm, { name: '' })
|
||
snapshotCreateVisible.value = true
|
||
}
|
||
const submitCreateSnapshot = async () => {
|
||
if (!snapshotForm.name) { ElMessage.warning('请输入快照名称'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('name', snapshotForm.name)
|
||
const res = await createSnapshot(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); snapshotCreateVisible.value = false; loadSnapshots(); loadSnapshotQuota() }
|
||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const handleRestoreSnapshot = (row) => {
|
||
ElMessageBox.confirm(`确定要恢复快照「${row.name}」吗?`, '恢复确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('snapshot_id', row.id)
|
||
fd.append('vm_id', vmId.value)
|
||
const res = await restoreSnapshot(fd)
|
||
if (res?.data?.code === 200) ElMessage.success('恢复操作已提交')
|
||
else ElMessage.error(extractApiError(res?.data, '恢复失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const handleDeleteSnapshot = (row) => {
|
||
ElMessageBox.confirm(`确定要删除快照「${row.name}」吗?`, '删除确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('snapshot_id', row.id)
|
||
fd.append('vm_id', row.vm_id)
|
||
const res = await deleteSnapshot(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadSnapshots(); loadSnapshotQuota() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const handleSnapshotProgress = async (row) => {
|
||
taskProgressTitle.value = '快照任务进度'
|
||
taskProgressData.value = null
|
||
taskProgressVisible.value = true
|
||
taskProgressLoading.value = true
|
||
try {
|
||
const res = await getSnapshotProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
|
||
if (res?.data?.code === 200) taskProgressData.value = res.data.data?.data ?? res.data.data
|
||
else ElMessage.warning('暂无进度信息')
|
||
} catch { ElMessage.warning('获取进度失败') } finally { taskProgressLoading.value = false }
|
||
}
|
||
|
||
const handleCreateBackup = () => {
|
||
Object.assign(backupForm, { name: '' })
|
||
backupCreateVisible.value = true
|
||
}
|
||
const submitCreateBackup = async () => {
|
||
if (!backupForm.name) { ElMessage.warning('请输入备份名称'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('name', backupForm.name)
|
||
const res = await createBackup(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); backupCreateVisible.value = false; loadBackups(); loadBackupQuota() }
|
||
else ElMessage.error(extractApiError(res?.data, '创建失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const handleRestoreBackup = (row) => {
|
||
ElMessageBox.confirm(`确定要恢复备份「${row.name}」吗?`, '恢复确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('backup_id', row.id)
|
||
fd.append('vm_id', vmId.value)
|
||
const res = await restoreBackup(fd)
|
||
if (res?.data?.code === 200) ElMessage.success('恢复操作已提交')
|
||
else ElMessage.error(extractApiError(res?.data, '恢复失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '恢复失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const handleDeleteBackup = (row) => {
|
||
ElMessageBox.confirm(`确定要删除备份「${row.name}」吗?`, '删除确认', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('backup_id', row.id)
|
||
fd.append('vm_id', row.vm_id)
|
||
const res = await deleteBackup(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadBackups(); loadBackupQuota() }
|
||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
const handleBackupProgress = async (row) => {
|
||
taskProgressTitle.value = '备份任务进度'
|
||
taskProgressData.value = null
|
||
taskProgressVisible.value = true
|
||
taskProgressLoading.value = true
|
||
try {
|
||
const res = await getBackupProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
|
||
if (res?.data?.code === 200) taskProgressData.value = res.data.data?.data ?? res.data.data
|
||
else ElMessage.warning('暂无进度信息')
|
||
} catch { ElMessage.warning('获取进度失败') } finally { taskProgressLoading.value = false }
|
||
}
|
||
|
||
const goBack = () => {
|
||
tagsViewStore.delVisitedView(route)
|
||
// router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||
router.back()
|
||
}
|
||
|
||
// ---- 组网管理 ----
|
||
const vnLoading = ref(false)
|
||
const vnSubmitLoading = ref(false)
|
||
const vnList = ref([])
|
||
const vnJoinVisible = ref(false)
|
||
const vnJoinTarget = ref(null)
|
||
const vnJoinIp = ref('')
|
||
|
||
const vmBridgeNameMap = computed(() => {
|
||
const map = {}
|
||
for (const n of vmNetworks.value) {
|
||
if (n.bridge_name) map[n.bridge_name] = n
|
||
}
|
||
return map
|
||
})
|
||
|
||
const loadVmNetworkingList = async () => {
|
||
if (!detail.value) return
|
||
vnLoading.value = true
|
||
vnList.value = []
|
||
try {
|
||
const userId = detail.value.user_id
|
||
const hostId = vmHostId.value
|
||
if (!userId || !hostId) { vnLoading.value = false; return }
|
||
const res = await getUserNetworkingList({
|
||
service_id: serviceId.value, page: 1, count: 10,
|
||
host_id: hostId, user_id: userId
|
||
})
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
const networkings = Array.isArray(inner) ? inner : (inner.data || [])
|
||
const results = []
|
||
for (const nw of networkings) {
|
||
const matchedVmNet = nw.bridge_name ? vmBridgeNameMap.value[nw.bridge_name] : null
|
||
results.push({ networking: nw, network: matchedVmNet || null })
|
||
}
|
||
vnList.value = results
|
||
}
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取组网信息失败')) }
|
||
finally { vnLoading.value = false }
|
||
}
|
||
|
||
// 创建组网
|
||
const vnCreateVisible = ref(false)
|
||
const vnCreateFormRef = ref(null)
|
||
const vnCreateForm = reactive({ name: '', bridge_name: '', gateway: '' })
|
||
const vnCreateHostName = ref('')
|
||
const vnCreateUserName = ref('')
|
||
const vnCreateRules = {
|
||
bridge_name: [{ required: true, message: '请输入网桥名称', trigger: 'blur' }]
|
||
}
|
||
|
||
const handleVnCreate = async () => {
|
||
Object.assign(vnCreateForm, { name: '', bridge_name: '', gateway: '' })
|
||
vnCreateHostName.value = ''
|
||
vnCreateUserName.value = ''
|
||
vnCreateVisible.value = true
|
||
const userId = detail.value?.user_id
|
||
const hostId = vmHostId.value
|
||
const requests = []
|
||
if (userId) {
|
||
requests.push(
|
||
getUserInfo({ user_id: userId }).then(res => {
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
vnCreateUserName.value = res.data.data.UserName || ''
|
||
}
|
||
}).catch(() => {})
|
||
)
|
||
}
|
||
if (hostId) {
|
||
requests.push(
|
||
getRemoteHostDetail({ service_id: serviceId.value, id: hostId }).then(res => {
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const h = res.data.data.host ?? res.data.data
|
||
vnCreateHostName.value = h.name || ''
|
||
}
|
||
}).catch(() => {})
|
||
)
|
||
}
|
||
await Promise.all(requests)
|
||
}
|
||
|
||
const submitVnCreate = () => {
|
||
vnCreateFormRef.value?.validate(async (valid) => {
|
||
if (!valid) return
|
||
vnSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('name', vnCreateForm.name)
|
||
fd.append('bridge_name', vnCreateForm.bridge_name)
|
||
fd.append('host_id', vmHostId.value)
|
||
fd.append('user_id', detail.value?.user_id || 0)
|
||
if (vnCreateForm.gateway) fd.append('gateway', vnCreateForm.gateway)
|
||
const res = await createUserNetworking(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('创建组网成功')
|
||
vnCreateVisible.value = false
|
||
loadVmNetworkingList()
|
||
} else ElMessage.error(extractApiError(res?.data, '创建组网失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '创建组网失败')) }
|
||
finally { vnSubmitLoading.value = false }
|
||
})
|
||
}
|
||
|
||
const handleJoinNetworking = (row) => {
|
||
vnJoinTarget.value = row.networking
|
||
vnJoinIp.value = ''
|
||
vnJoinVisible.value = true
|
||
}
|
||
|
||
const submitJoinNetworking = async () => {
|
||
if (!vnJoinTarget.value) return
|
||
vnSubmitLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('networking_id', vnJoinTarget.value.id)
|
||
fd.append('vm_id', vmId.value)
|
||
if (vnJoinIp.value.trim()) fd.append('ip', vnJoinIp.value.trim())
|
||
const res = await assignUserNetworking(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('加入组网成功')
|
||
vnJoinVisible.value = false
|
||
loadVmNetworkingList()
|
||
loadDetail()
|
||
} else ElMessage.error(extractApiError(res?.data, '加入组网失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加入组网失败')) }
|
||
finally { vnSubmitLoading.value = false }
|
||
}
|
||
|
||
const handleLeaveNetworking = (row) => {
|
||
const netId = row.network?.id
|
||
const networkingId = row.networking?.id
|
||
if (!netId || !networkingId) { ElMessage.warning('缺少网络信息'); return }
|
||
ElMessageBox.confirm(
|
||
`确定要将该虚拟机从组网「${row.networking?.name || networkingId}」中移除?`,
|
||
'移除确认', { type: 'warning' }
|
||
).then(async () => {
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('networking_id', networkingId)
|
||
fd.append('network_id', netId)
|
||
fd.append('vm_id', vmId.value)
|
||
const res = await removeUserNetworkingNetwork(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success('已移除')
|
||
loadVmNetworkingList()
|
||
loadDetail()
|
||
} else ElMessage.error(extractApiError(res?.data, '移除失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '移除失败')) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
let loadedVmId = null
|
||
const initPage = async () => {
|
||
if (!vmId.value || loadedVmId === vmId.value) return
|
||
loadedVmId = vmId.value
|
||
historicalMetricsData.value = null
|
||
dataMigrateProgressData.value = null
|
||
dataMigrationId.value = ''
|
||
dataMigrateTaskId.value = ''
|
||
stopMigratePolling()
|
||
disposeCharts()
|
||
loadHostOptions()
|
||
await loadDetail()
|
||
triggerTabLoad(activeTab.value)
|
||
}
|
||
|
||
const triggerTabLoad = (tab) => {
|
||
if (tab === 'monitor' && !historicalMetricsData.value) loadHistoricalMetrics()
|
||
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
|
||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||
if (tab === 'userNetworking') loadVmNetworkingList()
|
||
if (tab === 'security') loadSgLockInfo()
|
||
}
|
||
|
||
// 请求安全组详情补充 lock 字段
|
||
const loadSgLockInfo = async () => {
|
||
const groups = [vmPortGroup.value, vmOutPortGroup.value].filter(Boolean)
|
||
for (const sg of groups) {
|
||
try {
|
||
const res = await getSecurityGroupDetail({ service_id: serviceId.value, id: sg.id })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const d = res.data.data.group || res.data.data.data || res.data.data
|
||
if (vmPortGroup.value?.id === sg.id) vmPortGroup.value = { ...vmPortGroup.value, lock: d.lock ?? sg.lock }
|
||
if (vmOutPortGroup.value?.id === sg.id) vmOutPortGroup.value = { ...vmOutPortGroup.value, lock: d.lock ?? sg.lock }
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
}
|
||
|
||
watch(vmId, () => { if (isPageActive) initPage() })
|
||
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
|
||
onActivated(() => {
|
||
isPageActive = true
|
||
if (loadedVmId !== vmId.value) initPage()
|
||
})
|
||
onDeactivated(() => { isPageActive = false; stopMigratePolling() })
|
||
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
|
||
onMounted(() => { isPageActive = true; initPage() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.vm-detail-page { padding: 0; background: #f5f7fa; min-height: 100vh; }
|
||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #fff; border-bottom: 1px solid #e8e8e8; }
|
||
.header-left { display: flex; align-items: center; gap: 0; }
|
||
.back-btn { font-size: 14px; color: #606266; }
|
||
.back-btn:hover { color: #409eff; }
|
||
.header-right { display: flex; gap: 8px; }
|
||
.main-content { padding: 20px 24px; }
|
||
|
||
/* 实例概览 */
|
||
.instance-overview { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20px 24px; border-radius: 4px; margin-bottom: 0; border: 1px solid #e8e8e8; border-bottom: none; }
|
||
.instance-name { margin: 0; font-size: 18px; font-weight: 600; color: #1d2129; }
|
||
.instance-id { font-size: 14px; font-weight: 400; color: #86909c; margin-left: 8px; }
|
||
.overview-actions { display: flex; gap: 8px; }
|
||
|
||
/* 状态概览条 */
|
||
.status-bar { display: flex; gap: 0; background: #fff; padding: 16px 24px; border: 1px solid #e8e8e8; border-top: 1px solid #f0f0f0; border-radius: 0 0 4px 4px; margin-bottom: 16px; }
|
||
.status-item { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||
.status-item + .status-item { border-left: 1px solid #e8e8e8; padding-left: 24px; }
|
||
.status-label { font-size: 12px; color: #86909c; }
|
||
.status-value { font-size: 14px; color: #1d2129; display: flex; align-items: center; gap: 6px; }
|
||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||
.dot-running { background: #00b42a; }
|
||
.dot-other { background: #86909c; }
|
||
|
||
/* 标签页 */
|
||
.detail-tabs { background: #fff; border-radius: 4px; border: 1px solid #e8e8e8; padding: 0 24px; }
|
||
:deep(.detail-tabs > .el-tabs__header) { margin-bottom: 0; }
|
||
:deep(.detail-tabs > .el-tabs__content) { padding: 0 0 24px; }
|
||
|
||
/* 区块 */
|
||
.section-block { margin-top: 24px; }
|
||
.section-title { font-size: 15px; font-weight: 600; color: #1d2129; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #f0f0f0; }
|
||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||
.section-header .section-title { margin-bottom: 0; padding-bottom: 0; border-bottom: none; }
|
||
|
||
/* 配置信息网格 */
|
||
.config-grid { border: 1px solid #e8e8e8; border-radius: 4px; overflow: hidden; }
|
||
.config-row { display: flex; border-bottom: 1px solid #e8e8e8; }
|
||
.config-row:last-child { border-bottom: none; }
|
||
.config-cell { flex: 1; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; border-right: 1px solid #e8e8e8; }
|
||
.config-cell:last-child { border-right: none; }
|
||
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
|
||
.config-value { font-size: 14px; color: #1d2129; line-height: 1.4; word-break: break-all; }
|
||
.spec-value { font-size: 13px; color: #4e5969; }
|
||
.ip-value { color: #165dff; font-weight: 500; }
|
||
.password-cell { display: flex; align-items: center; gap: 8px; }
|
||
.password-cell code { font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; color: #1d2129; }
|
||
.mono-text { font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; }
|
||
|
||
.tk-form-tip { display: block; font-size: 12px; color: #909399; line-height: 1.5; margin-top: 4px; }
|
||
|
||
/* 迁移横幅 */
|
||
.migrate-banner { display: flex; justify-content: space-between; align-items: center; background: linear-gradient(135deg, #fffbf0 0%, #fff7e6 100%); border: 1px solid #faecd8; border-radius: 8px; padding: 14px 20px; margin-bottom: 16px; box-shadow: 0 1px 4px rgba(230,162,60,.1); }
|
||
.migrate-banner-info { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; min-width: 0; }
|
||
.migrate-banner-title { font-weight: 600; font-size: 14px; color: #e6a23c; white-space: nowrap; }
|
||
.migrate-progress-text { font-size: 16px; font-weight: 700; color: #e6a23c; letter-spacing: .5px; }
|
||
.migrate-divider { width: 1px; height: 16px; background: #e6a23c; opacity: .35; flex-shrink: 0; }
|
||
.migrate-speed { font-size: 14px; font-weight: 700; color: #409eff; letter-spacing: .3px; }
|
||
.migrate-msg { font-size: 13px; color: #8c8c8c; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.migrate-banner-actions { display: flex; gap: 8px; flex-shrink: 0; margin-left: 16px; }
|
||
.migrate-spin { animation: migrate-rotate 1s linear infinite; color: #e6a23c; font-size: 18px; }
|
||
@keyframes migrate-rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||
|
||
/* 监控指标 */
|
||
.metrics-card { margin-bottom: 0; }
|
||
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
|
||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||
.chart-container { width: 100%; height: 220px; }
|
||
|
||
.metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
||
.metric-summary-card { flex: 1; min-width: 0; background: #f7f8fa; border-radius: 6px; padding: 14px 16px; border: 1px solid #e8e8e8; display: flex; flex-direction: column; }
|
||
.metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; }
|
||
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
|
||
.vnc-result { margin-top: 12px; }
|
||
.rules-section { margin-top: 8px; }
|
||
.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||
.rules-header h4 { margin: 0; font-size: 15px; font-weight: 600; color: #303133; }
|
||
.ip-popover-list { max-height: 200px; overflow-y: auto; }
|
||
.ip-popover-item { padding: 4px 0; font-size: 13px; color: #303133; border-bottom: 1px dashed #ebeef5; word-break: break-all; }
|
||
.ip-popover-item:last-child { border-bottom: none; }
|
||
</style>
|