|
|
@@ -18,6 +18,10 @@
|
|
|
<el-button v-if="vm.state === 'paused'" type="success" @click="doAction('resume')">恢复</el-button>
|
|
|
<el-button v-if="vm.state === 'running'" type="danger" @click="doAction('force_stop')">强制关机</el-button>
|
|
|
<el-button v-if="vm.state === 'running'" @click="doAction('restart')">重启</el-button>
|
|
|
+ <el-button v-if="vm.vnc_port > 0" type="primary" @click="openConsole">控制台</el-button>
|
|
|
+ <el-button @click="showCloneDialog = true">克隆</el-button>
|
|
|
+ <el-button @click="showMigrateDialog = true">迁移</el-button>
|
|
|
+ <el-button @click="loadXml">XML配置</el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -130,6 +134,61 @@
|
|
|
</el-table>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- VNC 控制台 -->
|
|
|
+ <div v-if="showConsole" class="info-card" style="margin-top: 16px;">
|
|
|
+ <div class="section-header">
|
|
|
+ <h3>VNC 控制台</h3>
|
|
|
+ <div style="display: flex; gap: 8px;">
|
|
|
+ <el-button size="small" @click="toggleFullscreen">全屏</el-button>
|
|
|
+ <el-button size="small" type="danger" @click="showConsole = false">关闭</el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="vnc-container" id="vnc-container">
|
|
|
+ <div id="vnc-screen"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 克隆对话框 -->
|
|
|
+ <el-dialog v-model="showCloneDialog" title="克隆虚拟机" width="450px">
|
|
|
+ <el-form :model="cloneForm" label-width="100px">
|
|
|
+ <el-form-item label="新虚拟机名">
|
|
|
+ <el-input v-model="cloneForm.new_name" placeholder="输入新虚拟机名称" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="showCloneDialog = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="cloneVM" :loading="cloning">克隆</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 迁移对话框 -->
|
|
|
+ <el-dialog v-model="showMigrateDialog" title="迁移虚拟机" width="500px">
|
|
|
+ <el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
|
|
|
+ 虚拟机将被迁移到目标宿主机,请确保目标主机已配置 libvirt 并允许远程连接。
|
|
|
+ </el-alert>
|
|
|
+ <el-form :model="migrateForm" label-width="120px">
|
|
|
+ <el-form-item label="目标URI">
|
|
|
+ <el-input v-model="migrateForm.dest_uri" placeholder="qemu+tcp://192.168.1.2/system" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="迁移模式">
|
|
|
+ <el-switch v-model="migrateForm.live" active-text="在线迁移" inactive-text="离线迁移" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="showMigrateDialog = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="migrateVM" :loading="migrating">迁移</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- XML 编辑对话框 -->
|
|
|
+ <el-dialog v-model="showXmlDialog" title="XML 配置" width="700px">
|
|
|
+ <el-input v-model="xmlContent" type="textarea" :rows="20" style="font-family: monospace;" />
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="showXmlDialog = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="saveXml" :loading="savingXml">保存</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
<!-- 创建快照对话框 -->
|
|
|
<el-dialog v-model="showSnapDialog" title="创建快照" width="400px">
|
|
|
<el-form :model="snapForm" label-width="80px">
|
|
|
@@ -149,20 +208,33 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, onMounted, onBeforeUnmount } from 'vue'
|
|
|
-import { useRoute } from 'vue-router'
|
|
|
+import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
|
+import { useRoute, useRouter } from 'vue-router'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
|
|
import api from '../api'
|
|
|
|
|
|
const route = useRoute()
|
|
|
+const router = useRouter()
|
|
|
const vmName = route.params.name
|
|
|
+const hostId = () => route.query.host_id || 'local'
|
|
|
const loading = ref(true)
|
|
|
const vm = ref({})
|
|
|
const monitor = ref({})
|
|
|
const snapshots = ref([])
|
|
|
const snapLoading = ref(false)
|
|
|
const showSnapDialog = ref(false)
|
|
|
+const showConsole = ref(false)
|
|
|
+const showCloneDialog = ref(false)
|
|
|
+const showXmlDialog = ref(false)
|
|
|
+const showMigrateDialog = ref(false)
|
|
|
+const cloning = ref(false)
|
|
|
+const savingXml = ref(false)
|
|
|
+const migrating = ref(false)
|
|
|
+const xmlContent = ref('')
|
|
|
+const cloneForm = ref({ new_name: '' })
|
|
|
+const migrateForm = ref({ dest_uri: '', live: true })
|
|
|
+let rfb = null
|
|
|
const snapForm = ref({ name: '', description: '' })
|
|
|
let timer = null
|
|
|
|
|
|
@@ -197,21 +269,21 @@ function formatTime(ts) {
|
|
|
|
|
|
async function loadVM() {
|
|
|
try {
|
|
|
- vm.value = await api.get(`/vm/detail/${vmName}`)
|
|
|
+ vm.value = await api.get(`/vm/detail/${vmName}`, { params: { host_id: hostId() } })
|
|
|
} catch (e) {}
|
|
|
loading.value = false
|
|
|
}
|
|
|
|
|
|
async function loadMonitor() {
|
|
|
try {
|
|
|
- monitor.value = await api.get(`/monitor/vm/${vmName}`)
|
|
|
+ monitor.value = await api.get(`/monitor/vm/${vmName}`, { params: { host_id: hostId() } })
|
|
|
} catch (e) {}
|
|
|
}
|
|
|
|
|
|
async function loadSnapshots() {
|
|
|
snapLoading.value = true
|
|
|
try {
|
|
|
- const data = await api.get(`/snapshot/list/${vmName}`)
|
|
|
+ const data = await api.get(`/snapshot/list/${vmName}`, { params: { host_id: hostId() } })
|
|
|
snapshots.value = data.snapshots || []
|
|
|
} catch (e) {}
|
|
|
snapLoading.value = false
|
|
|
@@ -221,7 +293,7 @@ async function doAction(action) {
|
|
|
const labels = { start: '启动', stop: '关机', force_stop: '强制关机', pause: '暂停', resume: '恢复', restart: '重启' }
|
|
|
try {
|
|
|
await ElMessageBox.confirm(`确定要${labels[action]}吗?`, '确认', { type: 'info' })
|
|
|
- await api.post(`/vm/action/${vmName}`, { action })
|
|
|
+ await api.post(`/vm/action/${vmName}`, { action }, { params: { host_id: hostId() } })
|
|
|
ElMessage.success(`${labels[action]}操作已发送`)
|
|
|
setTimeout(() => { loadVM(); loadMonitor() }, 2000)
|
|
|
} catch (e) {
|
|
|
@@ -235,7 +307,7 @@ async function createSnap() {
|
|
|
return
|
|
|
}
|
|
|
try {
|
|
|
- await api.post(`/snapshot/create/${vmName}`, snapForm.value)
|
|
|
+ await api.post(`/snapshot/create/${vmName}`, snapForm.value, { params: { host_id: hostId() } })
|
|
|
ElMessage.success('快照创建成功')
|
|
|
showSnapDialog.value = false
|
|
|
snapForm.value = { name: '', description: '' }
|
|
|
@@ -248,7 +320,7 @@ async function createSnap() {
|
|
|
async function revertSnap(name) {
|
|
|
try {
|
|
|
await ElMessageBox.confirm(`确定恢复到快照 ${name} 吗?虚拟机将重启。`, '确认', { type: 'warning' })
|
|
|
- await api.post(`/snapshot/revert/${vmName}/${name}`)
|
|
|
+ await api.post(`/snapshot/revert/${vmName}/${name}`, null, { params: { host_id: hostId() } })
|
|
|
ElMessage.success('快照已恢复')
|
|
|
loadVM()
|
|
|
loadSnapshots()
|
|
|
@@ -260,7 +332,7 @@ async function revertSnap(name) {
|
|
|
async function deleteSnap(name) {
|
|
|
try {
|
|
|
await ElMessageBox.confirm(`确定删除快照 ${name} 吗?`, '确认', { type: 'error' })
|
|
|
- await api.delete(`/snapshot/delete/${vmName}/${name}`)
|
|
|
+ await api.delete(`/snapshot/delete/${vmName}/${name}`, { params: { host_id: hostId() } })
|
|
|
ElMessage.success('快照已删除')
|
|
|
loadSnapshots()
|
|
|
} catch (e) {
|
|
|
@@ -268,6 +340,85 @@ async function deleteSnap(name) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+async function cloneVM() {
|
|
|
+ if (!cloneForm.value.new_name) {
|
|
|
+ ElMessage.warning('请输入新虚拟机名称')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ cloning.value = true
|
|
|
+ try {
|
|
|
+ await api.post(`/vm/clone/${vmName}`, cloneForm.value, { params: { host_id: hostId() } })
|
|
|
+ ElMessage.success('克隆成功')
|
|
|
+ showCloneDialog.value = false
|
|
|
+ cloneForm.value = { new_name: '' }
|
|
|
+ } catch (e) {
|
|
|
+ ElMessage.error(e.response?.data?.detail || '克隆失败')
|
|
|
+ }
|
|
|
+ cloning.value = false
|
|
|
+}
|
|
|
+
|
|
|
+async function migrateVM() {
|
|
|
+ if (!migrateForm.value.dest_uri) {
|
|
|
+ ElMessage.warning('请输入目标 URI')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ migrating.value = true
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(
|
|
|
+ `确定将虚拟机迁移到 ${migrateForm.value.dest_uri} 吗?`,
|
|
|
+ '确认迁移',
|
|
|
+ { type: 'warning' }
|
|
|
+ )
|
|
|
+ await api.post(`/vm/migrate/${vmName}`, null, {
|
|
|
+ params: { dest_uri: migrateForm.value.dest_uri, live: migrateForm.value.live, host_id: hostId() }
|
|
|
+ })
|
|
|
+ ElMessage.success('迁移成功')
|
|
|
+ showMigrateDialog.value = false
|
|
|
+ migrateForm.value = { dest_uri: '', live: true }
|
|
|
+ } catch (e) {
|
|
|
+ if (e !== 'cancel') ElMessage.error(e.response?.data?.detail || '迁移失败')
|
|
|
+ }
|
|
|
+ migrating.value = false
|
|
|
+}
|
|
|
+
|
|
|
+async function loadXml() {
|
|
|
+ try {
|
|
|
+ const data = await api.get(`/vm/xml/${vmName}`, { params: { host_id: hostId() } })
|
|
|
+ xmlContent.value = data.xml || ''
|
|
|
+ showXmlDialog.value = true
|
|
|
+ } catch (e) {
|
|
|
+ ElMessage.error('获取 XML 失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function saveXml() {
|
|
|
+ savingXml.value = true
|
|
|
+ try {
|
|
|
+ await api.put(`/vm/xml/${vmName}`, { xml: xmlContent.value }, { params: { host_id: hostId() } })
|
|
|
+ ElMessage.success('XML 配置已更新')
|
|
|
+ showXmlDialog.value = false
|
|
|
+ loadVM()
|
|
|
+ } catch (e) {
|
|
|
+ ElMessage.error(e.response?.data?.detail || '保存失败')
|
|
|
+ }
|
|
|
+ savingXml.value = false
|
|
|
+}
|
|
|
+
|
|
|
+function openConsole() {
|
|
|
+ router.push({ path: `/console/${vmName}`, query: { host_id: hostId() } })
|
|
|
+}
|
|
|
+
|
|
|
+function toggleFullscreen() {
|
|
|
+ const container = document.getElementById('vnc-container')
|
|
|
+ if (container) {
|
|
|
+ if (!document.fullscreenElement) {
|
|
|
+ container.requestFullscreen()
|
|
|
+ } else {
|
|
|
+ document.exitFullscreen()
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
onMounted(() => {
|
|
|
loadVM()
|
|
|
loadMonitor()
|
|
|
@@ -277,6 +428,7 @@ onMounted(() => {
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
if (timer) clearInterval(timer)
|
|
|
+ if (rfb) { rfb.disconnect(); rfb = null }
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
@@ -388,4 +540,24 @@ onBeforeUnmount(() => {
|
|
|
margin-bottom: 0;
|
|
|
padding-bottom: 0;
|
|
|
}
|
|
|
+
|
|
|
+.vnc-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 500px;
|
|
|
+ background: #000;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.vnc-container:fullscreen {
|
|
|
+ height: 100vh;
|
|
|
+}
|
|
|
+
|
|
|
+#vnc-screen {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+}
|
|
|
</style>
|