fix: 修改内存的基础单位为kb
This commit is contained in:
@@ -225,6 +225,11 @@ export const reloadImageOnHost = (data) => {
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取宿主机镜像列表与状态(对比) */
|
||||
export const getImageCompareHost = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/image/compare_host', { params })
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 网络管理
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" size="small">
|
||||
<el-tag :type="taskStatusType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -22,12 +22,18 @@
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleRestore(row)">恢复</el-button>
|
||||
<el-button link type="info" size="small" @click="handleProgress(row)">进度</el-button>
|
||||
<el-button link type="info" size="small" @click="handleProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!list.length && !loading" description="暂无备份数据" />
|
||||
<div class="pagination-wrapper" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { pageSize = s; currentPage = 1; loadList() }"
|
||||
@current-change="p => { currentPage = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="createVisible" title="创建备份" width="480px" destroy-on-close>
|
||||
<el-form :model="createForm" label-width="100px">
|
||||
@@ -49,15 +55,26 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="progressVisible" title="备份任务进度" width="420px" destroy-on-close>
|
||||
<el-dialog v-model="progressVisible" title="备份任务进度" width="520px" destroy-on-close>
|
||||
<div v-loading="progressLoading">
|
||||
<el-descriptions :column="1" border size="small" v-if="progressData">
|
||||
<el-descriptions-item v-for="(val, key) in progressData" :key="key" :label="key">{{ val }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务ID">
|
||||
<span style="font-family: Consolas, monospace; font-size: 13px">{{ progressData.task_id || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="taskStatusType(progressData.status)" size="small">{{ taskStatusLabel(progressData.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<template v-if="parsedMeta">
|
||||
<el-descriptions-item v-for="(val, key) in parsedMeta" :key="key" :label="metaLabelMap[key] || 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="progressVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="handleProgress(progressRow)" :loading="progressLoading" v-if="progressData?.status === 'running' || progressData?.status === 'pending'">刷新进度</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -74,8 +91,11 @@ const serviceId = inject('serviceId')
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const statusLabel = (s) => ({ completed: '完成', pending: '等待', running: '运行中', failed: '失败' }[s] || s || '-')
|
||||
const statusLabel = (s) => ({ completed: '完成', ready: '完成', success: '成功', pending: '等待', running: '运行中', failed: '失败', error: '错误' }[s] || s || '-')
|
||||
const formatTs = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
@@ -85,12 +105,13 @@ const formatTs = (ts) => {
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getBackupList({ service_id: serviceId.value })
|
||||
const res = await getBackupList({ service_id: serviceId.value, page: currentPage.value, page_size: pageSize.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
list.value = d.backups || d.data || d.list || (Array.isArray(d) ? d : [])
|
||||
} else list.value = []
|
||||
} catch { list.value = [] } finally { loading.value = false }
|
||||
total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length
|
||||
} else { list.value = []; total.value = 0 }
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const vmOptions = ref([])
|
||||
@@ -166,15 +187,35 @@ const handleDelete = (row) => {
|
||||
const progressVisible = ref(false)
|
||||
const progressLoading = ref(false)
|
||||
const progressData = ref(null)
|
||||
const progressRow = ref(null)
|
||||
|
||||
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', success: 'success', failed: 'danger', error: 'danger', pending: 'info', cancelled: 'warning' }[s] || 'info')
|
||||
const taskStatusLabel = (s) => ({ running: '运行中', completed: '已完成', ready: '已完成', success: '成功', failed: '失败', error: '错误', pending: '等待中', cancelled: '已取消' }[s] || s || '-')
|
||||
const metaLabelMap = { vm_name: '虚拟机名称', backup_path: '备份路径', snapshot_path: '快照路径', path: '路径', progress: '进度', message: '信息', error: '错误信息' }
|
||||
|
||||
const parsedMeta = computed(() => {
|
||||
if (!progressData.value?.meta) return null
|
||||
const raw = progressData.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 handleProgress = async (row) => {
|
||||
progressRow.value = row
|
||||
progressData.value = null
|
||||
progressVisible.value = true
|
||||
progressLoading.value = true
|
||||
try {
|
||||
const res = await getBackupProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
|
||||
if (res?.data?.code === 200) progressData.value = res.data.data
|
||||
else ElMessage.warning('暂无进度信息')
|
||||
if (res?.data?.code === 200) {
|
||||
const d = res.data.data
|
||||
progressData.value = d?.data ?? d
|
||||
} else ElMessage.warning('暂无进度信息')
|
||||
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -184,4 +225,5 @@ onMounted(() => { loadList() })
|
||||
<style scoped>
|
||||
.backup-manage { padding: 0; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -72,27 +72,36 @@
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">认证Token</span>
|
||||
<span class="config-value">
|
||||
<span class="config-value secret-cell">
|
||||
<template v-if="detail.token">
|
||||
<code>{{ showToken ? detail.token : '••••••••••••' }}</code>
|
||||
<el-button link type="primary" size="small" @click="showToken = !showToken">{{ showToken ? '隐藏' : '显示' }}</el-button>
|
||||
<el-button v-if="showToken" link type="primary" size="small" @click="copyText(detail.token)">复制</el-button>
|
||||
</template>
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">SSH 密码</span>
|
||||
<span class="config-value">
|
||||
<span class="config-value secret-cell">
|
||||
<template v-if="detail.password">
|
||||
<code>{{ showPassword ? detail.password : '••••••••' }}</code>
|
||||
<el-button link type="primary" size="small" @click="showPassword = !showPassword">{{ showPassword ? '隐藏' : '显示' }}</el-button>
|
||||
<el-button v-if="showPassword" link type="primary" size="small" @click="copyText(detail.password)">复制</el-button>
|
||||
</template>
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">私钥路径</span>
|
||||
<span class="config-value mono-text">{{ detail.private_key_path || '-' }}</span>
|
||||
<span class="config-label">私钥</span>
|
||||
<span class="config-value secret-cell">
|
||||
<template v-if="detail.private_key">
|
||||
<code>{{ showPrivateKey ? detail.private_key.substring(0, 40) + '...' : '••••••••••••' }}</code>
|
||||
<el-button link type="primary" size="small" @click="showPrivateKey = !showPrivateKey">{{ showPrivateKey ? '隐藏' : '显示' }}</el-button>
|
||||
<el-button v-if="showPrivateKey" link type="primary" size="small" @click="copyText(detail.private_key)">复制</el-button>
|
||||
</template>
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
@@ -176,6 +185,12 @@
|
||||
<el-tab-pane label="虚拟机管理" name="vm">
|
||||
<VmManage v-if="hostTabLoaded['vm']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="快照管理" name="snapshot">
|
||||
<SnapshotManage v-if="hostTabLoaded['snapshot']" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="备份管理" name="backup">
|
||||
<BackupManage v-if="hostTabLoaded['backup']" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +205,7 @@
|
||||
<el-form-item label="SSH 端口"><el-input-number v-model="formData.port" :min="0" :max="65535" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="SSH 用户名"><el-input v-model="formData.user" /></el-form-item>
|
||||
<el-form-item label="SSH 密码"><el-input v-model="formData.password" show-password /></el-form-item>
|
||||
<el-form-item label="私钥路径"><el-input v-model="formData.private_key_path" /></el-form-item>
|
||||
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
|
||||
<el-divider content-position="left">资源限制</el-divider>
|
||||
<el-form-item label="最大CPU(核)"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
@@ -252,6 +267,8 @@ import ImageManage from '@/views/virtualization/ImageManage.vue'
|
||||
import NetworkManage from '@/views/virtualization/NetworkManage.vue'
|
||||
import VolumeManage from '@/views/virtualization/VolumeManage.vue'
|
||||
import VmManage from '@/views/virtualization/VmManage.vue'
|
||||
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
|
||||
import BackupManage from '@/views/virtualization/BackupManage.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
@@ -264,7 +281,7 @@ const serviceName = computed(() => route.query.service_name || '')
|
||||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const activeTab = ref('info')
|
||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false })
|
||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false })
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (!['info', 'monitor'].includes(tab) && !hostTabLoaded[tab]) hostTabLoaded[tab] = true
|
||||
@@ -283,13 +300,34 @@ const metricsLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const showToken = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const showPrivateKey = ref(false)
|
||||
const copyText = (text) => {
|
||||
if (!text) return
|
||||
if (navigator.clipboard?.writeText) {
|
||||
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'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch { ElMessage.error('复制失败') }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
const metricsData = ref(null)
|
||||
const editDialogVisible = ref(false)
|
||||
const showGroupSelector = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key_path: '',
|
||||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: ''
|
||||
})
|
||||
const formRules = {
|
||||
@@ -501,7 +539,7 @@ const handleEdit = () => {
|
||||
const d = detail.value
|
||||
Object.assign(formData, {
|
||||
name: d.name || '', base_url: d.base_url || '', ip: d.ip || '', token: d.token || '',
|
||||
port: d.port || 22, user: d.user || '', password: d.password || '', private_key_path: d.private_key_path || '',
|
||||
port: d.port || 22, user: d.user || '', password: d.password || '', private_key: d.private_key || '',
|
||||
max_cpu: d.max_cpu || 0, max_memory: d.max_memory || 0, max_disk: d.max_disk || 0,
|
||||
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
|
||||
host_group_id: d.host_group_id || 0, description: d.description || ''
|
||||
@@ -517,7 +555,7 @@ const handleSubmit = () => {
|
||||
const payload = { ...formData, service_id: serviceId.value, id: hostId.value }
|
||||
if (!payload.token) delete payload.token
|
||||
if (!payload.password) delete payload.password
|
||||
if (!payload.private_key_path) delete payload.private_key_path
|
||||
if (!payload.private_key) delete payload.private_key
|
||||
if (!payload.description) delete payload.description
|
||||
if (!payload.host_group_id) delete payload.host_group_id
|
||||
const res = await updateRemoteHost(payload)
|
||||
@@ -554,6 +592,7 @@ const initPage = () => {
|
||||
detail.value = null
|
||||
showToken.value = false
|
||||
showPassword.value = false
|
||||
showPrivateKey.value = false
|
||||
metricsData.value = null
|
||||
metricsHistory.times.length = 0
|
||||
metricsHistory.cpu.length = 0
|
||||
@@ -616,6 +655,8 @@ onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
|
||||
.config-cell:last-child { border-right: none; }
|
||||
.config-label { display: block; font-size: 12px; color: #86909c; margin-bottom: 4px; }
|
||||
.config-value { display: block; font-size: 14px; color: #1d2129; word-break: break-all; }
|
||||
.secret-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono-text { font-family: 'Consolas', 'Monaco', monospace; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ const loadHostOptions = async () => {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-select v-model="filterHostId" placeholder="选择宿主机" clearable style="width: 180px" @change="handleSearch">
|
||||
<!-- <el-select v-if="!injectedHostId?.value" v-model="filterHostId" placeholder="选择宿主机" clearable style="width: 180px" @change="handleSearch">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="h.name || h.ip" :value="h.id" />
|
||||
</el-select>
|
||||
</el-select> -->
|
||||
<el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 130px" @change="handleSearch">
|
||||
<el-option label="Linux" value="linux" />
|
||||
<el-option label="Windows" value="windows" />
|
||||
@@ -61,6 +61,17 @@
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="injectedHostId?.value" label="同步状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="injectedHostId?.value" label="宿主机状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.host_status" :type="statusType(row.host_status)" size="small">{{ statusLabel(row.host_status) }}</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="大小" width="90">
|
||||
<template #default="{ row }">{{ row.size ? formatSize(row.size) : '-' }}</template>
|
||||
@@ -69,7 +80,6 @@
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">详情</el-button>
|
||||
<el-button link type="success" @click="handleSyncToHost(row)">同步</el-button>
|
||||
<el-button link type="warning" @click="handleReloadMaster(row)">主控重下载</el-button>
|
||||
<el-button link type="warning" @click="handleReloadOnHost(row)">宿主机重下载</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
@@ -218,7 +228,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getImageList, getImageDetail, getImageHostStatus, createImage, updateImage, deleteImage,
|
||||
getImageList, getImageCompareHost, getImageDetail, getImageHostStatus, createImage, updateImage, deleteImage,
|
||||
reloadImage, syncImageToHost, reloadImageOnHost, getRemoteHostList
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
@@ -228,6 +238,7 @@ const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const injectedHostId = inject('hostId', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
@@ -275,6 +286,8 @@ const formRules = {
|
||||
|
||||
const statusType = (s) => ({ ready: 'success', success: 'success', downloading: 'warning', pending: 'info', error: 'danger', failed: 'danger', not_found: 'warning' }[s] || 'info')
|
||||
const statusLabel = (s) => ({ ready: '就绪', success: '已同步', downloading: '下载中', pending: '等待中', error: '错误', failed: '失败', not_found: '未同步' }[s] || s || '-')
|
||||
const syncStatusType = (s) => ({ synced: 'success', not_synced: 'warning', downloading: 'primary', error: 'danger', pending: 'info' }[s] || 'info')
|
||||
const syncStatusLabel = (s) => ({ synced: '已同步', not_synced: '未同步', downloading: '同步中', error: '同步错误', pending: '等待同步' }[s] || s || '-')
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
@@ -320,19 +333,33 @@ const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterHostId.value) params.host_id = filterHostId.value
|
||||
const res = await getImageList(params)
|
||||
let res
|
||||
if (injectedHostId?.value) {
|
||||
res = await getImageCompareHost({ service_id: serviceId.value, host_id: injectedHostId.value })
|
||||
} else {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
if (filterHostId.value) params.host_id = filterHostId.value
|
||||
res = await getImageList(params)
|
||||
}
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
const items = Array.isArray(inner) ? inner : (inner.images || inner.data || inner.list || [])
|
||||
imageList.value = items
|
||||
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? items.length
|
||||
if (injectedHostId?.value && Array.isArray(inner.data)) {
|
||||
imageList.value = inner.data.map(item => ({
|
||||
...(item.image || {}),
|
||||
sync_status: item.sync_status || '',
|
||||
host_status: item.host_status || ''
|
||||
}))
|
||||
total.value = inner.total ?? imageList.value.length
|
||||
} else {
|
||||
const items = Array.isArray(inner) ? inner : (inner.images || inner.data || inner.list || [])
|
||||
imageList.value = items
|
||||
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? items.length
|
||||
}
|
||||
} else {
|
||||
imageList.value = []
|
||||
total.value = 0
|
||||
@@ -449,9 +476,10 @@ const handleViewDetail = async (row) => {
|
||||
}
|
||||
|
||||
// 同步镜像到宿主机
|
||||
const handleSyncToHost = (row) => {
|
||||
const handleSyncToHost = async (row) => {
|
||||
syncTarget.value = row
|
||||
syncHostId.value = ''
|
||||
if (!hostOptions.value.length) await loadHostOptions()
|
||||
syncDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -493,9 +521,10 @@ const handleReloadMaster = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleReloadOnHost = (row) => {
|
||||
const handleReloadOnHost = async (row) => {
|
||||
reloadTarget.value = row
|
||||
reloadHostId.value = ''
|
||||
if (!hostOptions.value.length) await loadHostOptions()
|
||||
reloadDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -543,7 +572,7 @@ const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
onMounted(() => {
|
||||
if (serviceId.value) {
|
||||
loadList()
|
||||
loadHostOptions()
|
||||
if (!injectedHostId?.value) loadHostOptions()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -178,7 +178,9 @@ const tabLoaded = reactive({
|
||||
'volume': false,
|
||||
'vm': false,
|
||||
'security': false,
|
||||
'vnc': false
|
||||
'vnc': false,
|
||||
'snapshot': false,
|
||||
'backup': false
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<el-option label="网桥(Bridge)" value="bridge" />
|
||||
<el-option label="内网(NAT)" value="nat" />
|
||||
</el-select>
|
||||
<el-select v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
|
||||
<el-select v-if="!injectedHostId?.value" v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
@@ -151,8 +151,9 @@ const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const injectedHostId = inject('hostId', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const hostId = computed(() => parseInt(route.query.host_id) || 0)
|
||||
const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -181,7 +182,7 @@ const loadHostOptions = async () => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
<el-descriptions-item label="ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="白名单模式">
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<el-tag :type="detail.drop_all ? 'warning' : 'info'" size="small">{{ detail.drop_all ? '开启(拦截所有未放行流量)' : '关闭' }}</el-tag>
|
||||
<el-button size="small" :type="detail.drop_all ? 'info' : 'warning'" @click="handleEnableWhitelist" :disabled="detail.drop_all">开启白名单</el-button>
|
||||
<el-button size="small" :type="!detail.drop_all ? 'info' : 'success'" @click="handleDisableWhitelist" :disabled="!detail.drop_all">关闭白名单</el-button>
|
||||
<div style="display: flex; align-items: center; gap: 10px">
|
||||
<el-switch :model-value="detail.drop_all" active-text="开启" inactive-text="关闭" @change="val => val ? handleEnableWhitelist() : handleDisableWhitelist()" />
|
||||
<span v-if="detail.drop_all" style="color: #e6a23c; font-size: 12px">拦截所有未放行流量</span>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="锁定" v-if="detail.lock !== undefined">
|
||||
@@ -244,7 +243,7 @@ const loadHostOptions = async () => {
|
||||
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 = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
@@ -52,10 +52,23 @@
|
||||
<el-tag :type="row.direction === 'in' ? 'success' : 'warning'" size="small">{{ row.direction === 'in' ? '入站' : row.direction === 'out' ? '出站' : (row.direction || '-') }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link type="success" size="small" @click="handleSync(row)">同步</el-button>
|
||||
<el-button link type="warning" size="small" @click="handleApply(row)">应用</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => handleRowMore(row, cmd)" style="margin-left: 4px">
|
||||
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="bind">绑定虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="unbind">解绑虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="whitelist" divided>{{ row.drop_all ? '关闭白名单' : '开启白名单' }}</el-dropdown-item>
|
||||
<el-dropdown-item command="detail">查看详情</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided style="color: #f56c6c">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -220,7 +233,7 @@
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList,
|
||||
getSecurityGroupList, getSecurityGroupDetail, createSecurityGroup,
|
||||
@@ -261,7 +274,7 @@ const loadHostOptions = async () => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
@@ -500,6 +513,14 @@ const handleDeleteRule = (rule) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleRowMore = (row, command) => {
|
||||
if (command === 'bind') handleBind(row)
|
||||
else if (command === 'unbind') handleUnbind(row)
|
||||
else if (command === 'whitelist') handleToggleWhitelist(row)
|
||||
else if (command === 'detail') handleViewDetail(row)
|
||||
else if (command === 'delete') handleDelete(row)
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'" size="small">
|
||||
<el-tag :type="taskStatusType(row.status)" size="small">
|
||||
{{ statusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
@@ -22,12 +22,18 @@
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleRestore(row)">恢复</el-button>
|
||||
<el-button link type="info" size="small" @click="handleProgress(row)">进度</el-button>
|
||||
<el-button link type="info" size="small" @click="handleProgress(row)" v-if="row.status === 'running' || row.status === 'pending'">进度</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!list.length && !loading" description="暂无快照数据" />
|
||||
<div class="pagination-wrapper" v-if="total > pageSize">
|
||||
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { pageSize = s; currentPage = 1; loadList() }"
|
||||
@current-change="p => { currentPage = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="createVisible" title="创建快照" width="480px" destroy-on-close>
|
||||
<el-form :model="createForm" label-width="100px">
|
||||
@@ -49,15 +55,26 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="progressVisible" title="快照任务进度" width="420px" destroy-on-close>
|
||||
<el-dialog v-model="progressVisible" title="快照任务进度" width="520px" destroy-on-close>
|
||||
<div v-loading="progressLoading">
|
||||
<el-descriptions :column="1" border size="small" v-if="progressData">
|
||||
<el-descriptions-item v-for="(val, key) in progressData" :key="key" :label="key">{{ val }}</el-descriptions-item>
|
||||
<el-descriptions-item label="任务ID">
|
||||
<span style="font-family: Consolas, monospace; font-size: 13px">{{ progressData.task_id || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="taskStatusType(progressData.status)" size="small">{{ taskStatusLabel(progressData.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<template v-if="parsedMeta">
|
||||
<el-descriptions-item v-for="(val, key) in parsedMeta" :key="key" :label="metaLabelMap[key] || 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="progressVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="handleProgress(progressRow)" :loading="progressLoading" v-if="progressData?.status === 'running' || progressData?.status === 'pending'">刷新进度</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -74,8 +91,11 @@ const serviceId = inject('serviceId')
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const statusLabel = (s) => ({ completed: '完成', pending: '等待', running: '运行中', failed: '失败' }[s] || s || '-')
|
||||
const statusLabel = (s) => ({ completed: '完成', ready: '完成', success: '成功', pending: '等待', running: '运行中', failed: '失败', error: '错误' }[s] || s || '-')
|
||||
const formatTs = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
@@ -85,12 +105,13 @@ const formatTs = (ts) => {
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSnapshotList({ service_id: serviceId.value })
|
||||
const res = await getSnapshotList({ service_id: serviceId.value, page: currentPage.value, page_size: pageSize.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
list.value = d.snapshots || d.data || d.list || (Array.isArray(d) ? d : [])
|
||||
} else list.value = []
|
||||
} catch { list.value = [] } finally { loading.value = false }
|
||||
total.value = d.meta?.count ?? d.all_count ?? d.total ?? list.value.length
|
||||
} else { list.value = []; total.value = 0 }
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const vmOptions = ref([])
|
||||
@@ -166,15 +187,35 @@ const handleDelete = (row) => {
|
||||
const progressVisible = ref(false)
|
||||
const progressLoading = ref(false)
|
||||
const progressData = ref(null)
|
||||
const progressRow = ref(null)
|
||||
|
||||
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', success: 'success', failed: 'danger', error: 'danger', pending: 'info', cancelled: 'warning' }[s] || 'info')
|
||||
const taskStatusLabel = (s) => ({ running: '运行中', completed: '已完成', ready: '已完成', success: '成功', failed: '失败', error: '错误', pending: '等待中', cancelled: '已取消' }[s] || s || '-')
|
||||
const metaLabelMap = { vm_name: '虚拟机名称', backup_path: '备份路径', snapshot_path: '快照路径', path: '路径', progress: '进度', message: '信息', error: '错误信息' }
|
||||
|
||||
const parsedMeta = computed(() => {
|
||||
if (!progressData.value?.meta) return null
|
||||
const raw = progressData.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 handleProgress = async (row) => {
|
||||
progressRow.value = row
|
||||
progressData.value = null
|
||||
progressVisible.value = true
|
||||
progressLoading.value = true
|
||||
try {
|
||||
const res = await getSnapshotProgress({ service_id: serviceId.value, task_id: String(row.task_id || row.id) })
|
||||
if (res?.data?.code === 200) progressData.value = res.data.data
|
||||
else ElMessage.warning('暂无进度信息')
|
||||
if (res?.data?.code === 200) {
|
||||
const d = res.data.data
|
||||
progressData.value = d?.data ?? d
|
||||
} else ElMessage.warning('暂无进度信息')
|
||||
} catch { ElMessage.warning('获取进度失败') } finally { progressLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -184,4 +225,5 @@ onMounted(() => { loadList() })
|
||||
<style scoped>
|
||||
.snapshot-manage { padding: 0; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -96,9 +96,10 @@
|
||||
<div class="config-cell">
|
||||
<span class="config-label">安全组</span>
|
||||
<span class="config-value">
|
||||
<el-link type="primary" @click="handleBindSg">绑定</el-link>
|
||||
<el-divider direction="vertical" />
|
||||
<el-link type="info" @click="handleUnbindSg">解绑</el-link>
|
||||
<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>
|
||||
@@ -120,6 +121,20 @@
|
||||
</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>
|
||||
@@ -236,6 +251,72 @@
|
||||
</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; gap: 8px">
|
||||
<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 prop="description" label="描述" min-width="160" 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="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatTimestamp(row.created_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>
|
||||
</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; gap: 8px">
|
||||
<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 prop="description" label="描述" min-width="160" 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="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatTimestamp(row.created_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>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="监控" name="monitor">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
@@ -246,11 +327,17 @@
|
||||
</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 v-for="(val, key) in metricsData.internet_speed" :key="key" class="metric-num" style="font-size: 14px; margin-bottom: 2px">
|
||||
{{ key }}: {{ val }}
|
||||
<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>
|
||||
@@ -259,7 +346,10 @@
|
||||
<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>
|
||||
@@ -287,21 +377,121 @@
|
||||
|
||||
<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-item label="描述">
|
||||
<el-input v-model="snapshotForm.description" type="textarea" :rows="2" 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-item label="描述">
|
||||
<el-input v-model="backupForm.description" type="textarea" :rows="2" 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="560px" destroy-on-close>
|
||||
<el-dialog v-model="editDialogVisible" title="编辑虚拟机" width="640px" destroy-on-close>
|
||||
<el-form ref="editFormRef" :model="editForm" label-width="120px">
|
||||
<el-form-item label="下行带宽(Mbps)">
|
||||
<el-input-number v-model="editForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="editForm.name" placeholder="虚拟机名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽(Mbps)">
|
||||
<el-input-number v-model="editForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
<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-form-item label="SSH端口">
|
||||
<el-input-number v-model="editForm.ssh_port" :min="1" :max="65535" controls-position="right" style="width: 100%" />
|
||||
<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" />
|
||||
@@ -321,7 +511,7 @@
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="内存(KB)">
|
||||
<el-input-number v-model="refactorForm.memory" :min="0" :step="65536" controls-position="right" style="width: 100%" />
|
||||
<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">
|
||||
@@ -472,8 +662,8 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择网络">
|
||||
<el-select v-model="netAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="netOptionsLoading">
|
||||
<el-option v-for="n in availableNetworks" :key="n.id" :label="`${n.name} - ${n.address || ''} (ID: ${n.id})`" :value="n.id" />
|
||||
<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>
|
||||
@@ -500,7 +690,7 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择数据卷">
|
||||
<el-select v-model="volAddSelectedId" placeholder="请先选择宿主机" filterable style="width: 100%" :loading="volOptionsLoading">
|
||||
<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>
|
||||
@@ -569,7 +759,9 @@ import {
|
||||
bindSecurityGroup, unbindSecurityGroup, getSecurityGroupList,
|
||||
createNetwork, updateNetwork, deleteNetwork, getNetworkList,
|
||||
createVolume, resizeVolume, mountVolume, unmountVolume, transferVolume, deleteVolume, getVolumeList,
|
||||
getVmList
|
||||
getVmList,
|
||||
getSnapshotList, createSnapshot, restoreSnapshot, deleteSnapshot, getSnapshotProgress,
|
||||
getBackupList, createBackup, restoreBackup, deleteBackup, getBackupProgress
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import * as echarts from 'echarts'
|
||||
@@ -582,7 +774,7 @@ 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.id) || 0)
|
||||
const vmId = computed(() => parseInt(route.query.vm_id) || parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
@@ -592,6 +784,7 @@ const detail = ref(null)
|
||||
const vmNetworks = ref([])
|
||||
const vmVolumes = ref([])
|
||||
const vmImage = ref(null)
|
||||
const vmPortGroup = ref(null)
|
||||
const metricsData = ref(null)
|
||||
const hostOptions = ref([])
|
||||
const rebuildDialogVisible = ref(false)
|
||||
@@ -602,8 +795,24 @@ const activeTab = ref('info')
|
||||
const showPassword = ref(false)
|
||||
|
||||
const copyText = (text) => {
|
||||
if (!text) return
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制')).catch(() => ElMessage.error('复制失败'))
|
||||
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) => {
|
||||
@@ -622,7 +831,8 @@ const vmStatusLabel = (s) => ({ running: '运行中', ready: '就绪', creating:
|
||||
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 '-'; if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'; if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'; return kb + ' KB' }
|
||||
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')
|
||||
@@ -636,7 +846,7 @@ const loadHostOptions = async () => {
|
||||
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 = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
}
|
||||
} catch { /* */ }
|
||||
}
|
||||
@@ -652,10 +862,37 @@ const loadDetail = async () => {
|
||||
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
|
||||
@@ -697,7 +934,7 @@ const fetchVmMetrics = async () => {
|
||||
const pushHistory = (d) => {
|
||||
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
metricsHistory.times.push(now)
|
||||
metricsHistory.cpu.push(d.cpu_usage_percent /100 ?? 0)
|
||||
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]) {
|
||||
@@ -744,10 +981,10 @@ const renderCharts = () => {
|
||||
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: ${(params[0].value * 100).toFixed(1)}%` },
|
||||
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 * 100).toFixed(0) + '%' } },
|
||||
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)
|
||||
}
|
||||
@@ -762,7 +999,10 @@ const renderCharts = () => {
|
||||
netChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||||
let s = params[0]?.axisValue || ''
|
||||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${p.value}` })
|
||||
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 },
|
||||
@@ -865,12 +1105,41 @@ const handleExitRescue = () => {
|
||||
// ---- 编辑虚拟机 ----
|
||||
const editDialogVisible = ref(false)
|
||||
const editFormRef = ref(null)
|
||||
const editForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, root_password: '', ssh_port: 22, port_group_id: 0 })
|
||||
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
|
||||
Object.assign(editForm, { rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0, root_password: '', ssh_port: d.ssh_port || 22, port_group_id: null })
|
||||
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
|
||||
}
|
||||
@@ -881,9 +1150,15 @@ const submitEditVm = async () => {
|
||||
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)
|
||||
@@ -900,7 +1175,13 @@ const refactorForm = reactive({ memory: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidt
|
||||
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: null })
|
||||
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
|
||||
}
|
||||
@@ -1089,7 +1370,7 @@ const loadAvailableNetworks = async (hostId) => {
|
||||
if (!hostId) return
|
||||
netOptionsLoading.value = true
|
||||
try {
|
||||
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, page: 1, page_size: 200 })
|
||||
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 : [])
|
||||
@@ -1311,6 +1592,182 @@ const submitTransferVolume = async () => {
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '迁移失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 快照/备份管理 ----
|
||||
const snapshotList = ref([])
|
||||
const snapshotLoading = ref(false)
|
||||
const backupList = ref([])
|
||||
const backupLoading = ref(false)
|
||||
const snapshotCreateVisible = ref(false)
|
||||
const backupCreateVisible = ref(false)
|
||||
const snapshotForm = reactive({ name: '', description: '' })
|
||||
const backupForm = reactive({ name: '', description: '' })
|
||||
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 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
const all = d.snapshots || d.data || d.list || (Array.isArray(d) ? d : [])
|
||||
snapshotList.value = all.filter(s => s.vm_id === vmId.value || s.vm_id === String(vmId.value))
|
||||
} else snapshotList.value = []
|
||||
} catch { snapshotList.value = [] } finally { snapshotLoading.value = false }
|
||||
}
|
||||
|
||||
const loadBackups = async () => {
|
||||
backupLoading.value = true
|
||||
try {
|
||||
const res = await getBackupList({ service_id: serviceId.value })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
const all = d.backups || d.data || d.list || (Array.isArray(d) ? d : [])
|
||||
backupList.value = all.filter(b => b.vm_id === vmId.value || b.vm_id === String(vmId.value))
|
||||
} else backupList.value = []
|
||||
} catch { backupList.value = [] } finally { backupLoading.value = false }
|
||||
}
|
||||
|
||||
const handleCreateSnapshot = () => {
|
||||
Object.assign(snapshotForm, { name: '', description: '' })
|
||||
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)
|
||||
if (snapshotForm.description) fd.append('description', snapshotForm.description)
|
||||
const res = await createSnapshot(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('快照创建成功'); snapshotCreateVisible.value = false; loadSnapshots() }
|
||||
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() }
|
||||
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: '', description: '' })
|
||||
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)
|
||||
if (backupForm.description) fd.append('description', backupForm.description)
|
||||
const res = await createBackup(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success('备份创建成功'); backupCreateVisible.value = false; loadBackups() }
|
||||
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() }
|
||||
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 } })
|
||||
@@ -1333,7 +1790,11 @@ 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()
|
||||
if (tab === 'backup') loadBackups()
|
||||
})
|
||||
onActivated(() => {
|
||||
isPageActive = true
|
||||
@@ -1405,5 +1866,10 @@ onMounted(() => { isPageActive = true; initPage() })
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="vm-config">
|
||||
<el-tag size="small" type="info" v-if="row.vcpu">{{ row.vcpu }}核</el-tag>
|
||||
<el-tag size="small" type="info" v-if="row.memory">{{ formatMemory(row.memory) }}</el-tag>
|
||||
<el-tag size="small" type="info" v-if="row.system_size">{{ row.system_size }}MB盘</el-tag>
|
||||
<el-tag size="small" type="info" v-if="row.system_size">{{ row.system_size }}GB盘</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -58,10 +58,26 @@
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="user_id" label="用户" width="80" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleGoDetail(row)">详情</el-button>
|
||||
<el-button link type="success" size="small" @click="handlePower(row, 'start')" :disabled="row.status === 'running'">启动</el-button>
|
||||
<el-button link type="warning" size="small" @click="handlePower(row, 'stop')" :disabled="row.status === 'stopped' || row.status === 'stop'">关机</el-button>
|
||||
<el-dropdown trigger="click" @command="cmd => handleMoreAction(row, cmd)" style="margin-left: 4px">
|
||||
<el-button link type="info" size="small">更多<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="reboot">重启</el-dropdown-item>
|
||||
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
|
||||
<el-dropdown-item command="resume" v-if="row.status === 'paused'">恢复</el-dropdown-item>
|
||||
<el-dropdown-item command="rebuild" divided>重建</el-dropdown-item>
|
||||
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
||||
<el-dropdown-item command="exit_rescue">退出救援</el-dropdown-item>
|
||||
<el-dropdown-item command="detail" divided>查看详情</el-dropdown-item>
|
||||
<el-dropdown-item command="delete" divided style="color: #f56c6c">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -108,7 +124,7 @@
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = 0; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
@@ -119,20 +135,20 @@
|
||||
<el-select v-model="memoryUnit" class="resource-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="memoryDisplay" :min="1" controls-position="right" class="resource-input" />
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">* 系统盘</span>
|
||||
<el-select v-model="diskUnit" class="resource-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="diskDisplay" :min="1" controls-position="right" class="resource-input" />
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-row">
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">* CPU(核)</span>
|
||||
<el-input-number v-model="createForm.vcpu" :min="1" controls-position="right" class="resource-input" />
|
||||
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">下行带宽(Mbps)</span>
|
||||
@@ -155,10 +171,10 @@
|
||||
<el-input-number v-model="createForm.ip_num" :min="1" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
|
||||
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择网络IP" style="width: 100%">
|
||||
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''} (ID: ${n.id})`" :value="n.id" />
|
||||
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
|
||||
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" />
|
||||
</el-select>
|
||||
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络</div>
|
||||
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络(仅显示未使用的网络)</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -193,7 +209,7 @@
|
||||
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="CPU">{{ currentDetail.vcpu }} 核</el-descriptions-item>
|
||||
<el-descriptions-item label="内存">{{ formatMemory(currentDetail.memory) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统盘">{{ currentDetail.system_size }} MB</el-descriptions-item>
|
||||
<el-descriptions-item label="系统盘">{{ currentDetail.system_size }} GB</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="vmStatusType(currentDetail.status)" size="small">{{ vmStatusLabel(currentDetail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
@@ -304,8 +320,9 @@ const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const injectedHostId = inject('hostId', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const hostId = computed(() => parseInt(route.query.host_id) || 0)
|
||||
const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -329,35 +346,37 @@ const hostMode = ref('host')
|
||||
const ipMode = ref('num')
|
||||
const networkOptions = ref([])
|
||||
|
||||
// 内存单位: API传输单位为 KB
|
||||
// 内存单位: API传输单位为 bytes
|
||||
const memoryUnitOptions = [
|
||||
{ label: 'KB', factor: 1 },
|
||||
{ label: 'MB', factor: 1024 },
|
||||
{ label: 'GB', factor: 1048576 }
|
||||
]
|
||||
const memoryUnit = ref('KB')
|
||||
const memoryUnit = ref('GB')
|
||||
const getMemFactor = () => memoryUnitOptions.find(u => u.label === memoryUnit.value)?.factor || 1
|
||||
const memoryDisplay = computed({
|
||||
get: () => Math.round(createForm.memory / getMemFactor()),
|
||||
set: (v) => { createForm.memory = Math.round(v * getMemFactor()) }
|
||||
})
|
||||
|
||||
// 系统盘单位: API传输单位为 MB
|
||||
// 系统盘: API传输单位为 GB
|
||||
const diskUnitOptions = [
|
||||
{ label: 'MB', factor: 1 },
|
||||
{ label: 'GB', factor: 1024 }
|
||||
{ label: 'GB', factor: 1 },
|
||||
{ label: 'TB', factor: 1024 }
|
||||
]
|
||||
const diskUnit = ref('GB')
|
||||
const getDiskFactor = () => diskUnitOptions.find(u => u.label === diskUnit.value)?.factor || 1
|
||||
const diskDisplay = computed({
|
||||
get: () => Math.round(createForm.system_size / getDiskFactor()),
|
||||
get: () => {
|
||||
const f = getDiskFactor()
|
||||
return f === 1 ? createForm.system_size : +(createForm.system_size / f).toFixed(2)
|
||||
},
|
||||
set: (v) => { createForm.system_size = Math.round(v * getDiskFactor()) }
|
||||
})
|
||||
|
||||
const loadNetworkOptions = async (hostId) => {
|
||||
if (!hostId) return
|
||||
try {
|
||||
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, page: 1, page_size: 200 })
|
||||
const res = await getNetworkList({ service_id: serviceId.value, host_id: hostId, used: false, page: 1, page_size: 200 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
@@ -377,7 +396,7 @@ const loadHostOptions = async () => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
@@ -402,16 +421,16 @@ const rebuildImageName = ref('')
|
||||
const vmMetricsData = ref(null)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '', host_id: 0, image_id: 0, vcpu: 1, memory: 1048576,
|
||||
system_size: 10240, rx_bandwidth: 0, tx_bandwidth: 0,
|
||||
host_group_id: 0, user_id: 0, ip_num: 0, network_ids: [],
|
||||
name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0,
|
||||
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0,
|
||||
host_group_id: null, user_id: 0, ip_num: 0, network_ids: [],
|
||||
_imageName: '', _groupName: '', _userName: ''
|
||||
})
|
||||
|
||||
const createRules = {
|
||||
image_id: [{ required: true, message: '请选择镜像', trigger: 'blur', type: 'number', min: 1 }],
|
||||
vcpu: [{ required: true, message: '请输入CPU核数', trigger: 'blur' }],
|
||||
memory: [{ required: true, message: '请输入内存(KB)', trigger: 'blur' }],
|
||||
memory: [{ required: true, message: '请输入内存', trigger: 'blur' }],
|
||||
system_size: [{ required: true, message: '请输入系统盘大小', trigger: 'blur' }],
|
||||
user_id: [{ required: true, message: '请选择用户', trigger: 'change', type: 'number', min: 1 }]
|
||||
}
|
||||
@@ -430,6 +449,8 @@ const vmStatusLabel = (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'
|
||||
@@ -484,14 +505,16 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
Object.assign(createForm, {
|
||||
name: '', host_id: hostId.value || 0, image_id: 0,
|
||||
vcpu: 1, memory: 1048576, system_size: 10240,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, user_id: 0, ip_num: 0, network_ids: [],
|
||||
name: '', host_id: null, image_id: 0,
|
||||
vcpu: 0, memory: 0, system_size: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ip_num: 0, network_ids: [],
|
||||
_imageName: '', _groupName: '', _userName: ''
|
||||
})
|
||||
memoryUnit.value = 'GB'
|
||||
diskUnit.value = 'GB'
|
||||
hostMode.value = 'host'
|
||||
ipMode.value = 'num'
|
||||
if (createForm.host_id) loadNetworkOptions(createForm.host_id)
|
||||
networkOptions.value = []
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -552,9 +575,13 @@ const handlePower = (row, action) => {
|
||||
}
|
||||
|
||||
const handleMoreAction = (row, command) => {
|
||||
const powerActions = ['reboot', 'suspend', 'resume']
|
||||
if (powerActions.includes(command)) { handlePower(row, command); return }
|
||||
if (command === 'rebuild') handleRebuild(row)
|
||||
else if (command === 'rescue') handleRescue(row)
|
||||
else if (command === 'exit_rescue') handleExitRescue(row)
|
||||
else if (command === 'detail') handleViewDetail(row)
|
||||
else if (command === 'delete') handleDelete(row)
|
||||
}
|
||||
|
||||
const handleRebuild = (row) => {
|
||||
@@ -639,7 +666,7 @@ const fetchVmMetrics = async (vm) => {
|
||||
}
|
||||
|
||||
const handleGoDetail = (row) => {
|
||||
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } })
|
||||
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: row.id } })
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
|
||||
@@ -194,7 +194,7 @@ const loadHostOptions = async () => {
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
hostOptions.value = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
|
||||
<div class="header-info">
|
||||
<h3>数据卷管理</h3>
|
||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }} | 宿主机:{{ selectedHostName || '请选择' }}</span>
|
||||
<span class="sub-info" v-if="serviceName">主控服务:{{ serviceName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
@@ -25,9 +25,6 @@
|
||||
<el-option label="错误" value="error" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
<el-select v-model="hostIdInput" placeholder="选择宿主机" clearable filterable style="width: 220px" @change="handleSearch">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="volumeList" v-loading="loading" stripe>
|
||||
@@ -143,7 +140,7 @@
|
||||
<el-form-item label="当前宿主机">{{ getHostLabel(transferTarget?.host_id) }}</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-select v-model="transferHostId" placeholder="请选择目标宿主机" style="width: 100%" filterable>
|
||||
<el-option v-for="h in hostOptions.filter(x => x.id !== transferTarget?.host_id)" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" :disabled="h.id === transferTarget?.host_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -185,7 +182,7 @@
|
||||
<!-- 虚拟机选择器 (创建) -->
|
||||
<VmSelectorPopup v-model="showVmSelector" :service-id="serviceId" :host-id="createForm.host_id" :current-id="createForm.vm_id" @confirm="handleVmSelected" />
|
||||
<!-- 虚拟机选择器 (挂载) -->
|
||||
<VmSelectorPopup v-model="showMountVmSelector" :service-id="serviceId" :host-id="mountTarget?.host_id || hostIdInput" :current-id="mountVmId" @confirm="handleMountVmSelected" />
|
||||
<VmSelectorPopup v-model="showMountVmSelector" :service-id="serviceId" :host-id="mountTarget?.host_id || 0" :current-id="mountVmId" @confirm="handleMountVmSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -207,8 +204,9 @@ const router = useRouter()
|
||||
const embedded = inject('embedded', false)
|
||||
const injectedServiceId = inject('serviceId', null)
|
||||
const injectedServiceName = inject('serviceName', null)
|
||||
const injectedHostId = inject('hostId', null)
|
||||
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
|
||||
const hostId = computed(() => parseInt(route.query.host_id) || 0)
|
||||
const hostId = computed(() => injectedHostId?.value || parseInt(route.query.host_id) || 0)
|
||||
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -217,15 +215,9 @@ const detailLoading = ref(false)
|
||||
const volumeList = ref([])
|
||||
const total = ref(0)
|
||||
const filterStatus = ref('')
|
||||
const hostIdInput = ref(0)
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, count: 10 })
|
||||
|
||||
const selectedHostName = computed(() => {
|
||||
const h = hostOptions.value.find(x => x.id === hostIdInput.value)
|
||||
return h ? `${h.name} (${h.ip || h.id})` : (hostIdInput.value || '')
|
||||
})
|
||||
|
||||
const getHostLabel = (hid) => {
|
||||
const h = hostOptions.value.find(x => x.id === hid)
|
||||
return h ? `${h.name}` : (hid || '-')
|
||||
@@ -245,11 +237,13 @@ const formatTimestamp = (ts) => {
|
||||
|
||||
const loadHostOptions = async () => {
|
||||
try {
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 200 })
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
hostOptions.value = inner.hosts || inner.data || (Array.isArray(inner) ? inner : [])
|
||||
const items = Array.isArray(inner) ? inner : (inner.hosts || inner.list || inner.data || [])
|
||||
hostOptions.value = items
|
||||
if (!items.length) console.warn('[VolumeManage] host list empty, raw:', JSON.stringify(inner).slice(0, 500))
|
||||
}
|
||||
} catch (e) { console.error('加载宿主机列表失败:', e) }
|
||||
}
|
||||
@@ -298,11 +292,10 @@ const handleMountVmSelected = (vm) => { mountVmId.value = vm.id; mountVmName.val
|
||||
|
||||
const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
const hid = hostIdInput.value || hostId.value
|
||||
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.count }
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.count }
|
||||
if (hostId.value) params.host_id = hostId.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
const res = await getVolumeList(params)
|
||||
const body = res?.data
|
||||
@@ -318,7 +311,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
Object.assign(createForm, {
|
||||
name: '', size: 10, host_id: hostIdInput.value || hostId.value || 0,
|
||||
name: '', size: 10, host_id: hostId.value || 0,
|
||||
is_system: false, image_id: 0, vm_id: 0, target_device: '',
|
||||
_imageName: '', _vmName: ''
|
||||
})
|
||||
@@ -384,9 +377,10 @@ const handleUnmount = (row) => {
|
||||
}
|
||||
|
||||
// 迁移卷
|
||||
const handleTransfer = (row) => {
|
||||
const handleTransfer = async (row) => {
|
||||
transferTarget.value = row
|
||||
transferHostId.value = ''
|
||||
if (!hostOptions.value.length) await loadHostOptions()
|
||||
transferDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -438,15 +432,10 @@ const handleDelete = (row) => {
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
if (serviceId.value) {
|
||||
await loadHostOptions()
|
||||
if (hostId.value) {
|
||||
hostIdInput.value = hostId.value
|
||||
} else if (hostOptions.value.length > 0) {
|
||||
hostIdInput.value = hostOptions.value[0].id
|
||||
}
|
||||
if (hostIdInput.value) loadList()
|
||||
loadHostOptions()
|
||||
loadList()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user