2001 lines
96 KiB
Vue
2001 lines
96 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'">关机</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">编辑虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="updateTraffic">修改带宽</el-dropdown-item>
|
||
<el-dropdown-item divided command="bindSg">绑定安全组</el-dropdown-item>
|
||
<el-dropdown-item command="unbindSg">解绑安全组</el-dropdown-item>
|
||
<el-dropdown-item divided command="rebuild">重建虚拟机</el-dropdown-item>
|
||
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
||
<el-dropdown-item command="exitRescue">退出救援</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) }}
|
||
</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">IP地址</span>
|
||
<span class="status-value">{{ detail.ips || '-' }}</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">创建时间</span>
|
||
<span class="status-value">{{ formatTimestamp(detail.created_at) }}</span>
|
||
</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">{{ detail.ips || '暂无' }}</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 class="config-cell">
|
||
<span class="config-label">安全组</span>
|
||
<span class="config-value">
|
||
<template v-if="vmPortGroup">
|
||
<el-tag size="small" type="success">{{ vmPortGroup.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">流量上限(GB)</span>
|
||
<span class="config-value">{{ detail.traffic_max ?? '-' }}</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>
|
||
<el-button size="small" type="primary" @click="handleAddNetwork">添加网络</el-button>
|
||
</div>
|
||
<el-table v-if="vmNetworks.length" :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="IP地址" min-width="140" show-overflow-tooltip />
|
||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||
<el-table-column prop="mac_address" label="MAC地址" min-width="150" show-overflow-tooltip />
|
||
<el-table-column prop="type" label="类型" width="80">
|
||
<template #default="{ row }"><el-tag size="small">{{ row.type || '-' }}</el-tag></template>
|
||
</el-table-column>
|
||
<el-table-column prop="bridge_name" label="网桥" width="80" />
|
||
<el-table-column label="操作" width="120" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleEditNetwork(row)">编辑</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDeleteNetwork(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-else 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>
|
||
<el-button size="small" type="primary" @click="handleAddVolume">添加数据卷</el-button>
|
||
</div>
|
||
<el-table v-if="vmVolumes.length" :data="pagedVolumes" size="small" stripe>
|
||
<el-table-column prop="id" label="ID" width="60" />
|
||
<el-table-column prop="name" label="名称" min-width="100" />
|
||
<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.status === 'ready' ? 'success' : 'info'" size="small">{{ row.status === 'ready' ? '就绪' : (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="260" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="handleResizeVolume(row)">调整大小</el-button>
|
||
<el-button link type="success" size="small" @click="handleMountVolume(row)" v-if="!row.is_mount">挂载</el-button>
|
||
<el-button link type="warning" size="small" @click="handleUnmountVolume(row)" v-if="row.is_mount">卸载</el-button>
|
||
<el-button link type="info" size="small" @click="handleTransferVolume(row)">迁移</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDeleteVolume(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-else 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>
|
||
<el-button size="small" type="primary" @click="handleBindSgFromTab">绑定安全组</el-button>
|
||
</div>
|
||
<el-table v-if="vmSecurityGroups.length" :data="pagedSecurityGroups" size="small" stripe>
|
||
<el-table-column prop="id" label="ID" width="80" />
|
||
<el-table-column prop="name" label="名称" min-width="120" />
|
||
<el-table-column prop="note" label="备注" min-width="160" show-overflow-tooltip />
|
||
<el-table-column label="锁定" width="80">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.lock ? 'danger' : 'info'" size="small">{{ row.lock ? '已锁定' : '未锁定' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="白名单" width="90">
|
||
<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.shared ? 'success' : 'info'" size="small">{{ row.shared ? '共享' : '私有' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="100" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="danger" size="small" @click="handleUnbindSgFromTab(row)">解绑</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<el-empty v-else description="暂无绑定的安全组" :image-size="60" />
|
||
<div class="pagination-wrapper" v-if="vmSecurityGroups.length > 0">
|
||
<el-pagination v-model:current-page="securityPage" v-model:page-size="securityPageSize"
|
||
:page-sizes="[10, 20, 50]" :total="vmSecurityGroups.length" layout="total, sizes, prev, pager, next" small
|
||
@size-change="s => { securityPageSize = s; securityPage = 1 }"
|
||
@current-change="p => { securityPage = p }" />
|
||
</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="monitor">
|
||
<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="pollingActive" type="success" size="small" effect="light">每3秒刷新</el-tag>
|
||
<el-button size="small" :icon="Refresh" @click="pollMetrics" :loading="metricsLoading">刷新指标</el-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="metricsData" class="metrics-summary">
|
||
<div class="metric-card">
|
||
<div class="metric-title">CPU 使用率</div>
|
||
<div class="metric-num">{{ (metricsData.cpu_usage_percent ?? 0).toFixed(1) }}%</div>
|
||
</div>
|
||
<div class="metric-card" v-if="metricsData.internet_speed && Object.keys(metricsData.internet_speed).length">
|
||
<div class="metric-title">网络速率</div>
|
||
<div class="net-speed-items">
|
||
<div v-for="(val, key) in metricsData.internet_speed" :key="key" class="net-speed-item">
|
||
<span class="net-speed-label">{{ key === 'rx_bytes' ? '↓ 接收' : key === 'tx_bytes' ? '↑ 发送' : key }}</span>
|
||
<span class="net-speed-value">{{ formatNetSpeed(val) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="charts-area">
|
||
<div class="chart-wrapper">
|
||
<h4 class="chart-label">CPU 使用率</h4>
|
||
<div ref="cpuChartRef" class="chart-container"></div>
|
||
</div>
|
||
<div class="chart-wrapper" v-if="metricsHistory.netKeys.length">
|
||
<h4 class="chart-label">网络速率</h4>
|
||
<div ref="netChartRef" class="chart-container"></div>
|
||
</div>
|
||
</div>
|
||
<el-empty v-if="!metricsData && !metricsLoading" description="暂无指标数据,切换到此标签页后自动开始采集" />
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
|
||
<!-- 重建弹窗 -->
|
||
<el-dialog v-model="rebuildDialogVisible" title="重建虚拟机" width="480px" destroy-on-close>
|
||
<el-alert title="重建会清除当前虚拟机数据并使用新镜像重新创建,请谨慎操作!" type="warning" :closable="false" style="margin-bottom: 16px" />
|
||
<el-form label-width="100px">
|
||
<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>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="rebuildDialogVisible = false">取消</el-button>
|
||
<el-button type="danger" :loading="actionLoading" @click="submitRebuild">确定重建</el-button>
|
||
</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>
|
||
<el-form :model="snapshotForm" label-width="100px">
|
||
<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>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="snapshotCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateSnapshot">创建</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 创建备份弹窗 -->
|
||
<el-dialog v-model="backupCreateVisible" title="创建备份" width="480px" destroy-on-close>
|
||
<el-form :model="backupForm" label-width="100px">
|
||
<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>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="backupCreateVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitCreateBackup">创建</el-button>
|
||
</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>
|
||
|
||
<!-- 编辑虚拟机弹窗 -->
|
||
<el-dialog v-model="editDialogVisible" title="编辑虚拟机" width="640px" destroy-on-close>
|
||
<el-form ref="editFormRef" :model="editForm" label-width="120px">
|
||
<el-form-item label="名称">
|
||
<el-input v-model="editForm.name" placeholder="虚拟机名称" />
|
||
</el-form-item>
|
||
<el-form-item label="内存">
|
||
<div style="display: flex; align-items: center; gap: 8px; width: 100%">
|
||
<el-input-number v-model="editMemoryDisplay" :min="1" :precision="editMemoryUnit === 'GB' ? 2 : 0" controls-position="right" style="width: 200px" />
|
||
<el-select v-model="editMemoryUnit" style="width: 80px">
|
||
<el-option label="MB" value="MB" />
|
||
<el-option label="GB" value="GB" />
|
||
</el-select>
|
||
<span style="color: #909399; font-size: 12px">({{ editForm.memory }} KB)</span>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="CPU(核)">
|
||
<el-input-number v-model="editForm.vcpu" :min="1" controls-position="right" style="width: 200px" />
|
||
</el-form-item>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="下行带宽(Mbps)">
|
||
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="上行带宽(Mbps)">
|
||
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="Root密码">
|
||
<el-input v-model="editForm.root_password" placeholder="留空则不修改" show-password />
|
||
</el-form-item>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="SSH端口">
|
||
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="流量上限(GB)">
|
||
<el-input-number v-model="editForm.traffic_max" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="快照配额">
|
||
<el-input-number v-model="editForm.snapshot_num" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="备份配额">
|
||
<el-input-number v-model="editForm.backup_num" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<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>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitEditVm">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 重构虚拟机弹窗 -->
|
||
<el-dialog v-model="refactorDialogVisible" title="重构虚拟机" width="600px" destroy-on-close>
|
||
<el-alert title="重构会修改虚拟机的底层配置参数,请谨慎操作!" type="warning" :closable="false" style="margin-bottom: 16px" />
|
||
<el-form ref="refactorFormRef" :model="refactorForm" label-width="120px">
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="内存(KB)">
|
||
<el-input-number v-model="refactorForm.memory" :min="0" :step="1024" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="CPU(核)">
|
||
<el-input-number v-model="refactorForm.vcpu" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="下行带宽">
|
||
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="上行带宽">
|
||
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="Root密码">
|
||
<el-input v-model="refactorForm.root_password" placeholder="不修改留空" show-password />
|
||
</el-form-item>
|
||
<el-row :gutter="16">
|
||
<el-col :span="12">
|
||
<el-form-item label="SSH端口">
|
||
<el-input-number v-model="refactorForm.ssh_port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="VNC端口">
|
||
<el-input-number v-model="refactorForm.vnc_port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
<el-form-item label="VNC密码">
|
||
<el-input v-model="refactorForm.vnc_password" placeholder="不填随机" show-password />
|
||
</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>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="refactorDialogVisible = false">取消</el-button>
|
||
<el-button type="warning" :loading="actionLoading" @click="submitRefactorVm">确定重构</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- VNC 连接弹窗 -->
|
||
<el-dialog v-model="vncDialogVisible" title="获取 VNC 连接" width="560px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<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>
|
||
</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>
|
||
<el-button @click="vncDialogVisible = false">关闭</el-button>
|
||
<el-button type="primary" :loading="vncLoading" @click="submitGetVnc">获取</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 绑定/解绑安全组弹窗 -->
|
||
<el-dialog v-model="sgDialogVisible" :title="sgDialogType === 'bind' ? '绑定安全组' : '解绑安全组'" width="480px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<el-form-item label="虚拟机">{{ detail?.name || '-' }}</el-form-item>
|
||
<el-form-item label="安全组">
|
||
<el-select v-model="sgSelectedId" placeholder="选择安全组" filterable 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>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="sgDialogVisible = false">取消</el-button>
|
||
<el-button :type="sgDialogType === 'bind' ? 'success' : 'warning'" :loading="actionLoading" @click="submitSgAction">
|
||
{{ sgDialogType === 'bind' ? '绑定' : '解绑' }}
|
||
</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 编辑网络弹窗 -->
|
||
<el-dialog v-model="netEditVisible" title="编辑网络" width="560px" destroy-on-close>
|
||
<el-form :model="netEditForm" label-width="120px">
|
||
<el-form-item label="名称">
|
||
<el-input v-model="netEditForm.name" />
|
||
</el-form-item>
|
||
<el-form-item label="IP地址(CIDR)">
|
||
<el-input v-model="netEditForm.address" placeholder="如 192.168.1.100/24" />
|
||
</el-form-item>
|
||
<el-form-item label="网关">
|
||
<el-input v-model="netEditForm.gateway" placeholder="如 192.168.1.1" />
|
||
</el-form-item>
|
||
<el-form-item label="DNS">
|
||
<el-input v-model="netEditForm.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
|
||
</el-form-item>
|
||
<el-form-item label="网络类型">
|
||
<el-select v-model="netEditForm.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="MAC地址">
|
||
<el-input v-model="netEditForm.mac_address" placeholder="不填随机" />
|
||
</el-form-item>
|
||
<el-form-item label="网桥名称">
|
||
<el-input v-model="netEditForm.bridge_name" placeholder="不填默认" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="netEditVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitEditNetwork">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 调整卷大小弹窗 -->
|
||
<el-dialog v-model="volResizeVisible" title="调整数据卷大小" width="440px" destroy-on-close>
|
||
<el-form :model="volResizeForm" label-width="100px">
|
||
<el-form-item label="卷名称">{{ volResizeForm._name || '-' }}</el-form-item>
|
||
<el-form-item label="当前大小">{{ volResizeForm._currentSize }} GB</el-form-item>
|
||
<el-form-item label="新大小(GB)">
|
||
<el-input-number v-model="volResizeForm.size" :min="volResizeForm._currentSize || 1" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="volResizeVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitResizeVolume">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 添加网络弹窗(从已有列表选择) -->
|
||
<el-dialog v-model="netAddVisible" title="添加网络到虚拟机" width="600px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<el-form-item label="选择宿主机">
|
||
<el-select v-model="netAddHostId" placeholder="选择宿主机以加载网络列表" filterable style="width: 100%" @change="loadAvailableNetworks">
|
||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="选择网络">
|
||
<el-select v-model="netAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="netOptionsLoading" :disabled="!netAddHostId">
|
||
<el-option v-for="n in availableNetworks" :key="n.id" :label="`${n.name} - ${n.address || ''}`" :value="n.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div v-if="netAddSelectedId" style="margin-top: 8px">
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="名称">{{ selectedNetworkInfo?.name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="IP地址">{{ selectedNetworkInfo?.address || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="网关">{{ selectedNetworkInfo?.gateway || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="类型">{{ selectedNetworkInfo?.type || '-' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="netAddVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitAddNetwork" :disabled="!netAddSelectedId">添加</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 挂载数据卷弹窗(从已有列表选择) -->
|
||
<el-dialog v-model="volAddVisible" title="挂载数据卷到虚拟机" width="600px" destroy-on-close>
|
||
<el-form label-width="100px">
|
||
<el-form-item label="选择宿主机">
|
||
<el-select v-model="volAddHostId" placeholder="选择宿主机以加载数据卷列表" filterable style="width: 100%" @change="loadAvailableVolumes">
|
||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="选择数据卷">
|
||
<el-select v-model="volAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="volOptionsLoading" :disabled="!volAddHostId">
|
||
<el-option v-for="v in availableVolumes" :key="v.id" :label="`${v.name} (${v.size || 0} GB, ID: ${v.id})`" :value="v.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-form>
|
||
<div v-if="volAddSelectedId" style="margin-top: 8px">
|
||
<el-descriptions :column="2" border size="small">
|
||
<el-descriptions-item label="名称">{{ selectedVolumeInfo?.name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="大小">{{ selectedVolumeInfo?.size ? selectedVolumeInfo.size + ' GB' : '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="状态">{{ selectedVolumeInfo?.status || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="路径">{{ selectedVolumeInfo?.path || '-' }}</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
<template #footer>
|
||
<el-button @click="volAddVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitAddVolume" :disabled="!volAddSelectedId">挂载</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 迁移卷弹窗 -->
|
||
<el-dialog v-model="volTransferVisible" title="迁移数据卷" width="480px" destroy-on-close>
|
||
<el-form :model="volTransferForm" label-width="120px">
|
||
<el-form-item label="卷名称">{{ volTransferForm._name || '-' }}</el-form-item>
|
||
<el-form-item label="目标虚拟机" required>
|
||
<el-select v-model="volTransferForm.vm_id" 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>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="volTransferVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitTransferVolume">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- 修改带宽弹窗 -->
|
||
<el-dialog v-model="trafficDialogVisible" title="修改虚拟机带宽" width="520px" destroy-on-close>
|
||
<el-form :model="trafficForm" label-width="140px">
|
||
<el-form-item label="下行带宽(Mbps)">
|
||
<el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item label="上行带宽(Mbps)">
|
||
<el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
<el-form-item label="每月最大流量(KB)">
|
||
<el-input-number v-model="trafficForm.traffic_max" :min="0" controls-position="right" style="width: 100%" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="trafficDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="actionLoading" @click="submitUpdateTraffic">确定</el-button>
|
||
</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 } from '@element-plus/icons-vue'
|
||
import {
|
||
getVmDetail, getVmStatus, getVmMetrics,
|
||
startVm, stopVm, rebootVm, suspendVm, resumeVm,
|
||
rebuildVm, refactorVm, updateVm, updateVmTraffic, rescueVm, exitRescueVm, deleteVm,
|
||
getRemoteHostList, getVmVnc, getVncNodeList,
|
||
bindSecurityGroup, unbindSecurityGroup, getSecurityGroupList,
|
||
createNetwork, updateNetwork, deleteNetwork, getNetworkList,
|
||
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, getVolumeList,
|
||
getVmList,
|
||
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress, getSnapshotCount, setSnapshotLimit,
|
||
getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress, getBackupCount, setBackupLimit
|
||
} from '@/api/admin/kvmService'
|
||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||
import * as echarts from 'echarts'
|
||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.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) || parseInt(route.query.id) || 0)
|
||
|
||
const loading = ref(false)
|
||
const actionLoading = ref(false)
|
||
const statusLoading = ref(false)
|
||
const metricsLoading = ref(false)
|
||
const detail = ref(null)
|
||
const vmNetworks = ref([])
|
||
const vmVolumes = ref([])
|
||
const vmImage = ref(null)
|
||
|
||
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 vmPortGroup = ref(null)
|
||
const metricsData = 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 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,
|
||
bindSg: handleBindSg, unbindSg: handleUnbindSg,
|
||
rebuild: handleRebuild, rescue: handleRescue, exitRescue: handleExitRescue
|
||
}
|
||
if (actionMap[cmd]) actionMap[cmd]()
|
||
}
|
||
|
||
const vmStatusType = (s) => ({ running: 'success', ready: 'success', creating: 'warning', pending: 'info', stopped: 'danger', stop: 'danger', error: 'danger', paused: 'warning', reboot: 'warning', poweroff: 'info', unknown: 'info' }[s] || 'info')
|
||
const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating: '创建中', pending: '等待中', stopped: '已停止', stop: '已停止', error: '错误', paused: '已暂停', reboot: '重启中', poweroff: '已关机', unknown: '未知' }[s] || 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: 100 })
|
||
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
|
||
} else ElMessage.error(extractApiError(res?.data, '加载失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '加载失败')) } finally { loading.value = false }
|
||
}
|
||
|
||
const loadVmVolumes = async () => {
|
||
if (!detail.value) return
|
||
const hid = detail.value.host_id
|
||
if (!hid) return
|
||
try {
|
||
const res = await getVolumeList({ service_id: serviceId.value, host_id: hid, vm_id: vmId.value, page: 1, count: 200 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
vmVolumes.value = inner.data || inner.volumes || (Array.isArray(inner) ? inner : [])
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
|
||
const loadVmNetworks = async () => {
|
||
if (!detail.value) return
|
||
const hid = detail.value.host_id
|
||
if (!hid) return
|
||
try {
|
||
const res = await getNetworkList({ service_id: serviceId.value, host_id: hid, page: 1, page_size: 200 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
vmNetworks.value = inner.data || inner.networks || (Array.isArray(inner) ? inner : [])
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
|
||
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 sd = res.data.data
|
||
detail.value = { ...detail.value, status: sd.status ?? sd }
|
||
ElMessage.success('状态已刷新: ' + vmStatusLabel(detail.value.status))
|
||
}
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取状态失败')) } finally { statusLoading.value = false }
|
||
}
|
||
|
||
// ---- ECharts 监控 ----
|
||
const cpuChartRef = ref(null)
|
||
const netChartRef = ref(null)
|
||
let cpuChart = null
|
||
let netChart = null
|
||
const MAX_HISTORY = 60
|
||
const metricsHistory = reactive({ times: [], cpu: [], netKeys: [], netSeries: {} })
|
||
const pollingActive = ref(false)
|
||
let pollTimer = null
|
||
let isPageActive = false
|
||
|
||
const fetchVmMetrics = async () => {
|
||
if (!detail.value || !isPageActive) return
|
||
metricsLoading.value = true
|
||
try {
|
||
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: detail.value.name })
|
||
if (res?.data?.code === 200) {
|
||
metricsData.value = res.data.data?.data ?? res.data.data
|
||
pushHistory(metricsData.value)
|
||
await nextTick()
|
||
renderCharts()
|
||
}
|
||
} catch { /* silent for polling */ } finally { metricsLoading.value = false }
|
||
}
|
||
|
||
const pushHistory = (d) => {
|
||
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||
metricsHistory.times.push(now)
|
||
metricsHistory.cpu.push(d.cpu_usage_percent ?? 0)
|
||
if (d.internet_speed && typeof d.internet_speed === 'object') {
|
||
for (const key of Object.keys(d.internet_speed)) {
|
||
if (!metricsHistory.netSeries[key]) {
|
||
metricsHistory.netKeys.push(key)
|
||
metricsHistory.netSeries[key] = []
|
||
const fillCount = metricsHistory.times.length - 1
|
||
for (let i = 0; i < fillCount; i++) metricsHistory.netSeries[key].push(0)
|
||
}
|
||
metricsHistory.netSeries[key].push(Number(d.internet_speed[key]) || 0)
|
||
}
|
||
for (const key of metricsHistory.netKeys) {
|
||
if (metricsHistory.netSeries[key].length < metricsHistory.times.length) {
|
||
metricsHistory.netSeries[key].push(0)
|
||
}
|
||
}
|
||
}
|
||
if (metricsHistory.times.length > MAX_HISTORY) {
|
||
metricsHistory.times.shift()
|
||
metricsHistory.cpu.shift()
|
||
for (const key of metricsHistory.netKeys) {
|
||
metricsHistory.netSeries[key]?.shift()
|
||
}
|
||
}
|
||
}
|
||
|
||
const makeLineOption = (seriesArr) => ({
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { top: 10, right: 16, bottom: 24, left: 50 },
|
||
xAxis: { type: 'category', data: metricsHistory.times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10 } },
|
||
series: seriesArr.map(s => ({
|
||
name: s.name, type: 'line', smooth: true, symbol: 'none',
|
||
areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: s.color },
|
||
itemStyle: { color: s.color }, data: s.data
|
||
}))
|
||
})
|
||
|
||
const COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
|
||
|
||
const renderCharts = () => {
|
||
const times = [...metricsHistory.times]
|
||
const cpuData = [...metricsHistory.cpu]
|
||
|
||
if (cpuChartRef.value) {
|
||
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
|
||
cpuChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (params) => `${params[0].axisValue}<br/>${params[0].marker} CPU: ${Number(params[0].value).toFixed(2)}%` },
|
||
grid: { top: 10, right: 16, bottom: 24, left: 50 },
|
||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => v.toFixed(1) + '%' } },
|
||
series: [{ name: 'CPU', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: cpuData }]
|
||
}, true)
|
||
}
|
||
if (netChartRef.value) {
|
||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||
const series = metricsHistory.netKeys.map((key, i) => ({
|
||
name: key, type: 'line', smooth: true, symbol: 'none',
|
||
areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: COLORS[i % COLORS.length] },
|
||
itemStyle: { color: COLORS[i % COLORS.length] }, data: [...(metricsHistory.netSeries[key] || [])]
|
||
}))
|
||
if (series.length === 0) series.push({ name: '网络', type: 'line', smooth: true, symbol: 'none', data: [], itemStyle: { color: '#409eff' } })
|
||
netChart.setOption({
|
||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||
let s = params[0]?.axisValue || ''
|
||
params.forEach(p => {
|
||
const label = p.seriesName === 'rx_bytes' ? '↓ 接收' : p.seriesName === 'tx_bytes' ? '↑ 发送' : p.seriesName
|
||
s += `<br/>${p.marker} ${label}: ${formatNetSpeed(p.value)}`
|
||
})
|
||
return s
|
||
}},
|
||
grid: { top: 10, right: 16, bottom: 24, left: 50 },
|
||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10 } },
|
||
series
|
||
}, true)
|
||
}
|
||
}
|
||
|
||
const pollMetrics = () => { fetchVmMetrics() }
|
||
|
||
const startPolling = () => {
|
||
if (!isPageActive || !serviceId.value || !vmId.value) return
|
||
stopPolling()
|
||
pollingActive.value = true
|
||
fetchVmMetrics()
|
||
pollTimer = setInterval(() => { fetchVmMetrics() }, 3000)
|
||
}
|
||
|
||
const stopPolling = () => {
|
||
pollingActive.value = false
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||
}
|
||
|
||
const disposeCharts = () => {
|
||
cpuChart?.dispose(); cpuChart = null
|
||
netChart?.dispose(); netChart = null
|
||
}
|
||
|
||
const clearHistory = () => {
|
||
metricsHistory.times.length = 0
|
||
metricsHistory.cpu.length = 0
|
||
metricsHistory.netKeys.length = 0
|
||
for (const k in metricsHistory.netSeries) delete metricsHistory.netSeries[k]
|
||
}
|
||
|
||
const handlePower = (action) => {
|
||
const labels = { start: '启动', stop: '停止', reboot: '重启', suspend: '暂停', resume: '恢复' }
|
||
ElMessageBox.confirm(`确定要${labels[action]}虚拟机「${detail.value?.name}」吗?`, `${labels[action]}确认`, {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', type: action === 'stop' ? 'warning' : 'info'
|
||
}).then(async () => {
|
||
try {
|
||
const apis = { start: startVm, stop: stopVm, reboot: rebootVm, suspend: suspendVm, resume: resumeVm }
|
||
let res
|
||
if (action === 'resume') {
|
||
const fd = new FormData(); fd.append('service_id', serviceId.value); fd.append('vm_id', vmId.value)
|
||
res = await resumeVm(fd)
|
||
} else {
|
||
res = await apis[action]({ service_id: serviceId.value, vm_id: vmId.value })
|
||
}
|
||
if (res?.data?.code === 200) { ElMessage.success(`${labels[action]}成功`); loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, `${labels[action]}失败`))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${labels[action]}失败`)) }
|
||
}).catch(() => {})
|
||
}
|
||
|
||
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 res = await rebuildVm({ service_id: serviceId.value, vm_id: vmId.value, image_id: rebuildImageId.value })
|
||
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(() => {})
|
||
}
|
||
|
||
// ---- 编辑虚拟机 ----
|
||
const editDialogVisible = ref(false)
|
||
const editFormRef = ref(null)
|
||
const editForm = reactive({
|
||
name: '', memory: 0, vcpu: 1,
|
||
rx_bandwidth: 0, tx_bandwidth: 0,
|
||
root_password: '', ssh_port: 22,
|
||
traffic_max: 0, snapshot_num: 0, backup_num: 0,
|
||
port_group_id: 0
|
||
})
|
||
const editMemoryUnit = ref('MB')
|
||
const editMemUnitFactor = () => editMemoryUnit.value === 'GB' ? 1048576 : 1024
|
||
const editMemoryDisplay = computed({
|
||
get: () => {
|
||
const f = editMemUnitFactor()
|
||
const v = editForm.memory / f
|
||
return f === 1048576 ? parseFloat(v.toFixed(2)) : Math.round(v)
|
||
},
|
||
set: (v) => { editForm.memory = Math.round(v * editMemUnitFactor()) }
|
||
})
|
||
|
||
const handleEditVm = async () => {
|
||
if (!detail.value) return
|
||
const d = detail.value
|
||
const mem = d.memory || 0
|
||
editMemoryUnit.value = mem >= 1048576 ? 'GB' : 'MB'
|
||
Object.assign(editForm, {
|
||
name: d.name || '',
|
||
memory: mem, vcpu: d.vcpu || 1,
|
||
rx_bandwidth: d.rx_bandwidth || 0,
|
||
tx_bandwidth: d.tx_bandwidth || 0,
|
||
root_password: d.root_password || '',
|
||
ssh_port: d.ssh_port || 22,
|
||
traffic_max: d.traffic_max || 0,
|
||
snapshot_num: d.snapshot_num || 0,
|
||
backup_num: d.backup_num || 0,
|
||
port_group_id: vmPortGroup.value?.id || null
|
||
})
|
||
if (!sgOptions.value.length) await loadSgOptions()
|
||
editDialogVisible.value = true
|
||
}
|
||
|
||
const submitEditVm = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
if (editForm.name) fd.append('name', editForm.name)
|
||
fd.append('memory', editForm.memory)
|
||
fd.append('vcpu', editForm.vcpu)
|
||
fd.append('rx_bandwidth', editForm.rx_bandwidth)
|
||
fd.append('tx_bandwidth', editForm.tx_bandwidth)
|
||
fd.append('ssh_port', editForm.ssh_port)
|
||
fd.append('traffic_max', editForm.traffic_max)
|
||
fd.append('snapshot_num', editForm.snapshot_num)
|
||
fd.append('backup_num', editForm.backup_num)
|
||
if (editForm.root_password) fd.append('root_password', editForm.root_password)
|
||
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: '', ssh_port: 0, vnc_port: 0, vnc_password: '', port_group_id: 0 })
|
||
|
||
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: '', ssh_port: d.ssh_port || 0,
|
||
vnc_port: 0, vnc_password: '',
|
||
port_group_id: vmPortGroup.value?.id || null
|
||
})
|
||
if (!sgOptions.value.length) await loadSgOptions()
|
||
refactorDialogVisible.value = true
|
||
}
|
||
|
||
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.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)
|
||
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, traffic_max: 0 })
|
||
|
||
const handleUpdateTraffic = () => {
|
||
if (!detail.value) return
|
||
Object.assign(trafficForm, {
|
||
rx_bandwidth: detail.value.rx_bandwidth || 0,
|
||
tx_bandwidth: detail.value.tx_bandwidth || 0,
|
||
traffic_max: detail.value.traffic_max || 0
|
||
})
|
||
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.traffic_max) fd.append('traffic_max', trafficForm.traffic_max)
|
||
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 }
|
||
}
|
||
|
||
// ---- 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: 100 })
|
||
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
|
||
if (!vncNodeOptions.value.length) await loadVncNodes()
|
||
vncDialogVisible.value = true
|
||
}
|
||
|
||
const submitGetVnc = async () => {
|
||
if (!vncNodeId.value) { ElMessage.warning('请选择VNC节点'); return }
|
||
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 vmSecurityGroups = ref([])
|
||
const sgListLoading = ref(false)
|
||
const securityPage = ref(1)
|
||
const securityPageSize = ref(10)
|
||
const pagedSecurityGroups = computed(() => {
|
||
const start = (securityPage.value - 1) * securityPageSize.value
|
||
return vmSecurityGroups.value.slice(start, start + securityPageSize.value)
|
||
})
|
||
|
||
const loadVmSecurityGroups = async () => {
|
||
sgListLoading.value = true
|
||
try {
|
||
const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 200, vm_id: vmId.value })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
vmSecurityGroups.value = inner.groups || inner.post_groups || inner.data || (Array.isArray(inner) ? inner : [])
|
||
}
|
||
} catch { /* */ } finally { sgListLoading.value = false }
|
||
}
|
||
|
||
// ---- 绑定/解绑安全组 ----
|
||
const sgDialogVisible = ref(false)
|
||
const sgDialogType = ref('bind')
|
||
const sgSelectedId = ref(null)
|
||
const sgOptions = ref([])
|
||
|
||
const loadSgOptions = async () => {
|
||
try {
|
||
const res = await getSecurityGroupList({ service_id: serviceId.value, page: 1, page_size: 200 })
|
||
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 { /* */ }
|
||
}
|
||
|
||
const handleBindSg = async () => {
|
||
sgDialogType.value = 'bind'; sgSelectedId.value = null
|
||
if (!sgOptions.value.length) await loadSgOptions()
|
||
sgDialogVisible.value = true
|
||
}
|
||
const handleUnbindSg = async () => {
|
||
sgDialogType.value = 'unbind'; sgSelectedId.value = null
|
||
if (!sgOptions.value.length) await loadSgOptions()
|
||
sgDialogVisible.value = true
|
||
}
|
||
|
||
const handleBindSgFromTab = async () => {
|
||
sgDialogType.value = 'bind'; sgSelectedId.value = null
|
||
if (!sgOptions.value.length) await loadSgOptions()
|
||
sgDialogVisible.value = true
|
||
}
|
||
|
||
const handleUnbindSgFromTab = async (row) => {
|
||
try {
|
||
await ElMessageBox.confirm(`确认解绑安全组「${row.name}」?`, '提示', { type: 'warning' })
|
||
} catch { return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('id', row.id)
|
||
fd.append('vm_id', vmId.value)
|
||
const res = await unbindSecurityGroup(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('解绑成功'); loadVmSecurityGroups() }
|
||
else ElMessage.error(extractApiError(res?.data, '解绑失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '解绑失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const submitSgAction = async () => {
|
||
if (!sgSelectedId.value) { ElMessage.warning('请选择安全组'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const api = sgDialogType.value === 'bind' ? bindSecurityGroup : unbindSecurityGroup
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('id', sgSelectedId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
const res = await api(fd)
|
||
if (res?.data?.code === 200) {
|
||
ElMessage.success(sgDialogType.value === 'bind' ? '绑定成功' : '解绑成功')
|
||
sgDialogVisible.value = false
|
||
loadVmSecurityGroups()
|
||
}
|
||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 添加网络(从已有列表选择) ----
|
||
const netAddVisible = ref(false)
|
||
const netAddHostId = ref(null)
|
||
const netAddSelectedId = ref(null)
|
||
const availableNetworks = ref([])
|
||
const netOptionsLoading = ref(false)
|
||
|
||
const selectedNetworkInfo = computed(() => availableNetworks.value.find(n => n.id === netAddSelectedId.value) || null)
|
||
|
||
const loadAvailableNetworks = async (hostId) => {
|
||
netAddSelectedId.value = null
|
||
availableNetworks.value = []
|
||
if (!hostId) return
|
||
netOptionsLoading.value = true
|
||
try {
|
||
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
availableNetworks.value = inner.networks || inner.data || (Array.isArray(inner) ? inner : [])
|
||
}
|
||
} catch { /* */ } finally { netOptionsLoading.value = false }
|
||
}
|
||
|
||
const handleAddNetwork = () => {
|
||
netAddHostId.value = null
|
||
netAddSelectedId.value = null
|
||
availableNetworks.value = []
|
||
netAddVisible.value = true
|
||
}
|
||
|
||
const submitAddNetwork = async () => {
|
||
if (!netAddSelectedId.value) { ElMessage.warning('请选择网络'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const net = selectedNetworkInfo.value
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('vm_id', vmId.value)
|
||
fd.append('network_id', netAddSelectedId.value)
|
||
if (net?.host_id) fd.append('host_id', net.host_id)
|
||
const res = await createNetwork(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('网络添加成功'); netAddVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '添加失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '添加失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 网络编辑/删除 ----
|
||
const netEditVisible = ref(false)
|
||
const netEditForm = reactive({ id: 0, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', host_id: 0 })
|
||
|
||
const handleEditNetwork = (row) => {
|
||
Object.assign(netEditForm, {
|
||
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 || '', host_id: row.host_id || 0
|
||
})
|
||
netEditVisible.value = true
|
||
}
|
||
|
||
const submitEditNetwork = async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('id', netEditForm.id)
|
||
fd.append('host_id', netEditForm.host_id)
|
||
if (netEditForm.name) fd.append('name', netEditForm.name)
|
||
if (netEditForm.address) fd.append('address', netEditForm.address)
|
||
if (netEditForm.gateway) fd.append('gateway', netEditForm.gateway)
|
||
if (netEditForm.nameservers) fd.append('nameservers', netEditForm.nameservers)
|
||
if (netEditForm.type) fd.append('type', netEditForm.type)
|
||
if (netEditForm.mac_address) fd.append('mac_address', netEditForm.mac_address)
|
||
if (netEditForm.bridge_name) fd.append('bridge_name', netEditForm.bridge_name)
|
||
const res = await updateNetwork(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('网络修改成功'); netEditVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
const handleDeleteNetwork = (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 volAddVisible = ref(false)
|
||
const volAddHostId = ref(null)
|
||
const volAddSelectedId = ref(null)
|
||
const availableVolumes = ref([])
|
||
const volOptionsLoading = ref(false)
|
||
|
||
const selectedVolumeInfo = computed(() => availableVolumes.value.find(v => v.id === volAddSelectedId.value) || null)
|
||
|
||
const loadAvailableVolumes = async (hostId) => {
|
||
volAddSelectedId.value = null
|
||
availableVolumes.value = []
|
||
if (!hostId) return
|
||
volOptionsLoading.value = true
|
||
try {
|
||
const res = await getVolumeList({ service_id: serviceId.value, host_id: hostId, page: 1, page_size: 200 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
const list = inner.volumes || inner.data || (Array.isArray(inner) ? inner : [])
|
||
availableVolumes.value = list.filter(v => !v.is_mount)
|
||
}
|
||
} catch { /* */ } finally { volOptionsLoading.value = false }
|
||
}
|
||
|
||
const handleAddVolume = () => {
|
||
volAddHostId.value = null
|
||
volAddSelectedId.value = null
|
||
availableVolumes.value = []
|
||
volAddVisible.value = true
|
||
}
|
||
|
||
const submitAddVolume = async () => {
|
||
if (!volAddSelectedId.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('volume_id', volAddSelectedId.value)
|
||
const res = await mountVolume(fd)
|
||
if (res?.data?.code === 200) { ElMessage.success('数据卷挂载成功'); volAddVisible.value = false; loadDetail() }
|
||
else ElMessage.error(extractApiError(res?.data, '挂载失败'))
|
||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '挂载失败')) } finally { actionLoading.value = false }
|
||
}
|
||
|
||
// ---- 磁盘卷操作 ----
|
||
const volResizeVisible = ref(false)
|
||
const volResizeForm = reactive({ volume_id: 0, size: 0, _name: '', _currentSize: 0 })
|
||
|
||
const handleResizeVolume = (row) => {
|
||
Object.assign(volResizeForm, { volume_id: row.id, size: row.size || 0, _name: row.name, _currentSize: row.size || 0 })
|
||
volResizeVisible.value = true
|
||
}
|
||
|
||
const submitResizeVolume = async () => {
|
||
if (volResizeForm.size <= 0) { ElMessage.warning('请输入有效大小'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('volume_id', volResizeForm.volume_id)
|
||
fd.append('size', volResizeForm.size)
|
||
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 handleUnmountVolume = (row) => {
|
||
ElMessageBox.confirm(`确定要卸载磁盘卷「${row.name}」(ID: ${row.id}) 吗?`, '卸载磁盘卷', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', 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 handleDeleteVolume = (row) => {
|
||
ElMessageBox.confirm(`确定要删除磁盘卷「${row.name}」(ID: ${row.id}) 吗?此操作不可恢复!`, '删除磁盘卷', {
|
||
confirmButtonText: '确定删除', cancelButtonText: '取消', 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 handleMountVolume = (row) => {
|
||
ElMessageBox.confirm(`确定要将磁盘卷「${row.name}」(ID: ${row.id}) 挂载到当前虚拟机吗?`, '挂载磁盘卷', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', 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 volTransferVisible = ref(false)
|
||
const volTransferForm = reactive({ volume_id: 0, vm_id: null, _name: '' })
|
||
const vmListOptions = ref([])
|
||
|
||
const loadVmListOptions = async () => {
|
||
try {
|
||
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 200 })
|
||
if (res?.data?.code === 200 && res?.data?.data) {
|
||
const inner = res.data.data
|
||
vmListOptions.value = (inner.vms || inner.data || (Array.isArray(inner) ? inner : [])).filter(v => v.id !== vmId.value)
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
|
||
const handleTransferVolume = async (row) => {
|
||
Object.assign(volTransferForm, { volume_id: row.id, vm_id: null, _name: row.name })
|
||
if (!vmListOptions.value.length) await loadVmListOptions()
|
||
volTransferVisible.value = true
|
||
}
|
||
|
||
const submitTransferVolume = async () => {
|
||
if (!volTransferForm.vm_id) { ElMessage.warning('请选择目标虚拟机'); return }
|
||
actionLoading.value = true
|
||
try {
|
||
const fd = new FormData()
|
||
fd.append('service_id', serviceId.value)
|
||
fd.append('volume_id', volTransferForm.volume_id)
|
||
fd.append('vm_id', volTransferForm.vm_id)
|
||
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 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 } })
|
||
}
|
||
|
||
let loadedVmId = null
|
||
const initPage = () => {
|
||
if (!vmId.value || loadedVmId === vmId.value) return
|
||
loadedVmId = vmId.value
|
||
metricsData.value = null
|
||
stopPolling()
|
||
disposeCharts()
|
||
clearHistory()
|
||
loadHostOptions()
|
||
loadDetail()
|
||
if (activeTab.value === 'monitor') startPolling()
|
||
}
|
||
|
||
watch(vmId, () => { if (isPageActive) initPage() })
|
||
watch(activeTab, (tab) => {
|
||
if (tab === 'monitor' && detail.value) startPolling()
|
||
else stopPolling()
|
||
if (tab === 'network') loadVmNetworks()
|
||
if (tab === 'volume') loadVmVolumes()
|
||
if (tab === 'security') loadVmSecurityGroups()
|
||
if (tab === 'snapshot') { loadSnapshots(); loadSnapshotQuota() }
|
||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||
})
|
||
onActivated(() => {
|
||
isPageActive = true
|
||
if (loadedVmId !== vmId.value) initPage()
|
||
else if (activeTab.value === 'monitor') startPolling()
|
||
})
|
||
onDeactivated(() => { isPageActive = false; stopPolling() })
|
||
onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
|
||
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; }
|
||
|
||
/* 监控指标 */
|
||
.metrics-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; }
|
||
.metric-card { flex: 1; min-width: 180px; background: #f7f8fa; border-radius: 8px; padding: 16px 20px; }
|
||
.metric-title { font-size: 13px; color: #86909c; margin-bottom: 6px; }
|
||
.metric-num { font-size: 28px; font-weight: 600; }
|
||
.charts-area { display: flex; flex-direction: column; gap: 20px; }
|
||
.chart-wrapper { background: #fafbfc; border: 1px solid #f0f0f0; border-radius: 8px; padding: 16px; }
|
||
.chart-label { margin: 0 0 8px; font-size: 14px; font-weight: 600; color: #4e5969; }
|
||
.chart-container { width: 100%; height: 220px; }
|
||
|
||
.net-speed-items { display: flex; gap: 20px; flex-wrap: wrap; }
|
||
.net-speed-item { display: flex; flex-direction: column; gap: 2px; }
|
||
.net-speed-label { font-size: 12px; color: #86909c; }
|
||
.net-speed-value { font-size: 20px; font-weight: 600; color: #1d2129; }
|
||
|
||
.vnc-result { margin-top: 12px; }
|
||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||
</style>
|