- services/pve.go: 修复 &h.PasswordEnch.Username -> &h.NodeName
- MachineDetail.vue: 编辑弹窗添加PVE主机选择+VMID输入
- 删除macOS自动生成的冲突副本文件
- 重新构建前端dist
[金渐层/K2.6-code-preview🐾]
981 lines
33 KiB
Vue
981 lines
33 KiB
Vue
<template>
|
||
<div class="page" v-if="machine">
|
||
<!-- Header -->
|
||
<div class="detail-header">
|
||
<div class="header-left">
|
||
<el-button text circle @click="$router.back()">
|
||
<el-icon><ArrowLeft /></el-icon>
|
||
</el-button>
|
||
<div>
|
||
<div class="host-title">
|
||
{{ machine.hostname }}
|
||
<span class="status-badge" :class="machine.is_online ? 'online' : 'offline'">
|
||
{{ machine.is_online ? '在线' : '离线' }}
|
||
</span>
|
||
</div>
|
||
<div class="host-subtitle">
|
||
<span v-if="isAdmin">{{ machine.ip }}</span>
|
||
<span>{{ machine.os_type }}</span>
|
||
<span v-if="machine.os_version">{{ machine.os_version }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions" v-if="isAdmin">
|
||
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM">启动</el-button>
|
||
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM">关闭</el-button>
|
||
<el-button text :icon="Edit" @click="openEdit(machine)">编辑</el-button>
|
||
<el-button text type="danger" :icon="Delete" @click="delMachine">删除</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-grid">
|
||
<!-- Basic Info -->
|
||
<div class="card">
|
||
<div class="card-title">基本信息</div>
|
||
<div class="info-list">
|
||
<div class="info-item"><span class="info-label">IP</span><span class="info-value">{{ machine.ip }}</span></div>
|
||
<div class="info-item" v-if="isAdmin"><span class="info-label">MAC</span><span class="info-value">{{ machine.mac || '-' }}</span></div>
|
||
<div class="info-item"><span class="info-label">系统</span><span class="info-value">{{ machine.os_type }} {{ machine.os_version || '' }}</span></div>
|
||
<div class="info-item" v-if="isAdmin"><span class="info-label">SSH 端口</span><span class="info-value">{{ machine.ssh_port || 22 }}</span></div>
|
||
<div class="info-item" v-if="isAdmin"><span class="info-label">备注</span><span class="info-value text-muted">{{ machine.notes || '-' }}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- System Status -->
|
||
<div class="card" v-if="machine.cpu_info || machine.memory_info || machine.disk_info">
|
||
<div class="card-title">系统状态</div>
|
||
<div class="status-pills">
|
||
<div v-if="machine.cpu_info" class="status-pill">
|
||
<div class="sp-icon cpu">CPU</div>
|
||
<div class="sp-info">
|
||
<div class="sp-title">{{ machine.cpu_info }}</div>
|
||
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.cpu_info) + '%' }"></div></div>
|
||
</div>
|
||
<div class="sp-num">{{ extractPercent(machine.cpu_info) }}%</div>
|
||
</div>
|
||
<div v-if="machine.memory_info" class="status-pill">
|
||
<div class="sp-icon mem">MEM</div>
|
||
<div class="sp-info">
|
||
<div class="sp-title">{{ machine.memory_info }}</div>
|
||
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.memory_info) + '%', background: getMemBarColor(extractPercent(machine.memory_info)) }"></div></div>
|
||
</div>
|
||
<div class="sp-num">{{ extractPercent(machine.memory_info) }}%</div>
|
||
</div>
|
||
<div v-if="machine.disk_info" class="status-pill disk-multi">
|
||
<template v-if="parseDisks(machine.disk_info).length <= 1">
|
||
<div class="sp-icon disk">DISK</div>
|
||
<div class="sp-info">
|
||
<div class="sp-title">{{ machine.disk_info }}</div>
|
||
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.disk_info) + '%', background: getDiskBarColor(extractPercent(machine.disk_info)) }"></div></div>
|
||
</div>
|
||
<div class="sp-num">{{ extractPercent(machine.disk_info) }}%</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="sp-icon disk">DISK</div>
|
||
<div class="sp-info multi-disk-info">
|
||
<div v-for="(d, idx) in parseDisks(machine.disk_info)" :key="idx" class="detail-disk-item">
|
||
<span class="dd-mount">{{ d.mount }}</span>
|
||
<div class="dd-bar-wrap">
|
||
<div class="dd-bar"><div class="dd-fill" :style="{ width: d.percent + '%', background: getDiskBarColor(d.percent) }"></div></div>
|
||
</div>
|
||
<span class="dd-val">{{ d.detail }}</span>
|
||
<span class="dd-pct">{{ d.percent }}%</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div v-if="machine.uptime" class="uptime-line">
|
||
<el-icon><Timer /></el-icon>
|
||
<span>运行时间 {{ machine.uptime }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSH Section -->
|
||
<div class="card" v-if="isAdmin">
|
||
<div class="card-title">SSH 系统信息</div>
|
||
<div v-if="machine.cpu_info || machine.memory_info || machine.disk_info || machine.uptime" class="sync-actions">
|
||
<el-tag size="small" effect="plain" round>上次同步 {{ formatTime(machine.ssh_synced_at) }}</el-tag>
|
||
</div>
|
||
<div class="ssh-actions">
|
||
<el-button type="primary" :icon="Refresh" @click="openSSH">获取系统信息</el-button>
|
||
</div>
|
||
<div v-if="sshResult" class="ssh-result">
|
||
<div class="result-grid">
|
||
<div class="result-item"><span class="rl">主机名</span><span class="rv">{{ sshResult.hostname }}</span></div>
|
||
<div class="result-item"><span class="rl">系统版本</span><span class="rv">{{ sshResult.os_version }}</span></div>
|
||
<div class="result-item"><span class="rl">CPU</span><span class="rv">{{ sshResult.cpu }}</span></div>
|
||
<div class="result-item"><span class="rl">内存</span><span class="rv">{{ sshResult.memory }}</span></div>
|
||
<div class="result-item"><span class="rl">磁盘</span><span class="rv">{{ sshResult.disk }}</span></div>
|
||
<div class="result-item"><span class="rl">运行时间</span><span class="rv">{{ sshResult.uptime }}</span></div>
|
||
<div class="result-item full"><span class="rl">监听端口</span><span class="rv">{{ sshResult.listen_ports?.join(', ') }}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Services -->
|
||
<div class="card">
|
||
<div class="card-title">
|
||
服务列表
|
||
<el-button v-if="isAdmin" size="small" type="primary" :icon="Plus" @click="openServiceEdit()">添加服务</el-button>
|
||
</div>
|
||
<div v-if="services.length" class="service-list">
|
||
<div v-for="s in services" :key="s.id" class="service-row">
|
||
<div class="service-main">
|
||
<div class="service-name">{{ s.name }}</div>
|
||
<div class="service-port">{{ s.port }} / {{ s.protocol }}</div>
|
||
</div>
|
||
<div class="service-target" v-if="isAdmin && s.target_machine_id">
|
||
<el-icon><Right /></el-icon>
|
||
<span>{{ machineMap[s.target_machine_id] || s.target_machine_id }}</span>
|
||
<span v-if="s.target_notes" class="target-note">{{ s.target_notes }}</span>
|
||
</div>
|
||
<div class="service-note" v-if="isAdmin && s.notes">{{ s.notes }}</div>
|
||
<div class="service-actions" v-if="isAdmin">
|
||
<el-button text size="small" :icon="Edit" @click.stop="openServiceEdit(s)">编辑</el-button>
|
||
<el-button text size="small" type="danger" :icon="Delete" @click.stop="delService(s)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-empty v-else description="暂无服务" :image-size="80" />
|
||
</div>
|
||
|
||
<!-- Offline Stats & Logs -->
|
||
<div class="card" v-if="isAdmin">
|
||
<div class="card-title">离线统计</div>
|
||
<div class="offline-stats">
|
||
<div class="stat-box">
|
||
<div class="stat-num">{{ machine.offline_count || 0 }}</div>
|
||
<div class="stat-label">离线次数</div>
|
||
</div>
|
||
<div class="stat-box">
|
||
<div class="stat-num">{{ formatDuration(machine.total_offline_seconds) }}</div>
|
||
<div class="stat-label">累计离线时长</div>
|
||
</div>
|
||
<div class="stat-box" v-if="machine.last_offline_reason">
|
||
<div class="stat-reason">{{ machine.last_offline_reason }}</div>
|
||
<div class="stat-label">上次离线原因</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="offlineLogs.length" class="offline-logs">
|
||
<div class="log-title">最近离线记录</div>
|
||
<div v-for="log in offlineLogs.slice(0, 5)" :key="log.id" class="log-row">
|
||
<span class="log-time">{{ formatTime(log.started_at) }}</span>
|
||
<span class="log-reason">{{ log.reason || '未知原因' }}</span>
|
||
<span class="log-dur" v-if="log.duration_seconds !== null && log.duration_seconds !== undefined">持续 {{ formatDuration(log.duration_seconds) }}</span>
|
||
<span class="log-dur pending" v-else>未恢复</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Relationships -->
|
||
<div class="card" v-if="isAdmin">
|
||
<div class="card-title">
|
||
关联关系
|
||
<el-button size="small" type="primary" :icon="Plus" @click="openRelEdit()">添加关系</el-button>
|
||
</div>
|
||
<div v-if="relationships.length" class="rel-list">
|
||
<div v-for="r in relationships" :key="r.id" class="rel-row">
|
||
<div class="rel-arrow">
|
||
<span class="rel-host">{{ r.source_hostname }}</span>
|
||
<el-icon><Right /></el-icon>
|
||
<span class="rel-host">{{ r.target_hostname }}</span>
|
||
</div>
|
||
<div class="rel-meta">
|
||
<el-tag size="small" effect="plain" round>{{ typeText(r.relation_type) }}</el-tag>
|
||
<span v-if="r.source_port" class="rel-port">{{ r.source_port }} → {{ r.target_port || '-' }}</span>
|
||
<span v-if="r.notes" class="rel-note">{{ r.notes }}</span>
|
||
</div>
|
||
<div class="rel-actions">
|
||
<el-button text size="small" :icon="Edit" @click="openRelEdit(r)">编辑</el-button>
|
||
<el-button text size="small" type="danger" :icon="Delete" @click="delRel(r)">删除</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<el-empty v-else description="暂无关系" :image-size="80" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SSH Dialog -->
|
||
<el-dialog v-model="sshDialogVisible" title="SSH 获取系统信息" width="360px" class="modern-dialog">
|
||
<el-form :model="sshForm" label-width="80px">
|
||
<el-form-item label="用户名">
|
||
<el-input v-model="sshForm.username" />
|
||
</el-form-item>
|
||
<el-form-item label="密码">
|
||
<el-input v-model="sshForm.password" type="password" show-password />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="sshDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="sshLoading" @click="fetchSSH">连接</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- Machine Edit Dialog -->
|
||
<el-dialog v-model="dialogVisible" title="编辑机器" width="480px" class="modern-dialog">
|
||
<el-form :model="editing" label-width="90px">
|
||
<el-form-item label="主机名" required><el-input v-model="editing.hostname" /></el-form-item>
|
||
<el-form-item label="IP" required><el-input v-model="editing.ip" /></el-form-item>
|
||
<el-form-item label="MAC"><el-input v-model="editing.mac" /></el-form-item>
|
||
<el-form-item label="SSH端口"><el-input-number v-model="editing.ssh_port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||
<el-form-item label="SSH用户"><el-input v-model="editing.ssh_username" /></el-form-item>
|
||
<el-form-item label="SSH密码"><el-input v-model="editing.ssh_password" type="password" show-password placeholder="输入则更新密码,留空保持不变" /></el-form-item>
|
||
<el-form-item label="系统" required>
|
||
<el-select v-model="editing.os_type" style="width:100%">
|
||
<el-option label="Linux" value="Linux" />
|
||
<el-option label="Windows" value="Windows" />
|
||
<el-option label="macOS" value="macOS" />
|
||
<el-option label="Other" value="Other" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="系统版本"><el-input v-model="editing.os_version" /></el-form-item>
|
||
<el-form-item label="备注"><el-input v-model="editing.notes" type="textarea" :rows="2" /></el-form-item>
|
||
<el-divider content-position="left">PVE 配置(可选)</el-divider>
|
||
<el-form-item label="PVE 主机">
|
||
<el-select v-model="editing.pve_host_id" placeholder="选择 PVE 主机" clearable style="width:100%">
|
||
<el-option v-for="h in pveHosts" :key="h.id" :label="h.name" :value="h.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="虚拟机 ID">
|
||
<el-input v-model="editing.pve_vmid" placeholder="如 101" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveMachine">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- Service Edit Dialog -->
|
||
<el-dialog v-model="serviceDialogVisible" title="编辑服务" width="440px" class="modern-dialog">
|
||
<el-form :model="serviceEditing" label-width="90px">
|
||
<el-form-item label="名称" required><el-input v-model="serviceEditing.name" /></el-form-item>
|
||
<el-form-item label="端口" required><el-input-number v-model="serviceEditing.port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||
<el-form-item label="协议">
|
||
<el-select v-model="serviceEditing.protocol" style="width:100%">
|
||
<el-option label="TCP" value="TCP" />
|
||
<el-option label="UDP" value="UDP" />
|
||
<el-option label="HTTP" value="HTTP" />
|
||
<el-option label="HTTPS" value="HTTPS" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="指向机器">
|
||
<el-select v-model="serviceEditing.target_machine_id" clearable style="width:100%" placeholder="选择目标机器(拓扑连线用)">
|
||
<el-option v-for="m in allMachines.filter(x => x.id != machineId)" :key="m.id" :label="m.hostname" :value="m.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="指向备注">
|
||
<el-input v-model="serviceEditing.target_notes" placeholder="如:反向代理到目标" />
|
||
</el-form-item>
|
||
<el-form-item label="备注"><el-input v-model="serviceEditing.notes" type="textarea" :rows="2" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="serviceDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveService">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<!-- Relationship Edit Dialog -->
|
||
<el-dialog v-model="relDialogVisible" title="编辑关系" width="440px" class="modern-dialog">
|
||
<el-form :model="relEditing" label-width="100px">
|
||
<el-form-item label="源机器" required>
|
||
<el-select v-model="relEditing.source_machine_id" style="width:100%">
|
||
<el-option v-for="m in allMachines" :key="m.id" :label="m.hostname" :value="m.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="目标机器" required>
|
||
<el-select v-model="relEditing.target_machine_id" style="width:100%">
|
||
<el-option v-for="m in allMachines" :key="m.id" :label="m.hostname" :value="m.id" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="关系类型" required>
|
||
<el-select v-model="relEditing.relation_type" style="width:100%">
|
||
<el-option label="端口转发" value="port_forward" />
|
||
<el-option label="依赖" value="dependency" />
|
||
<el-option label="主从" value="primary_secondary" />
|
||
<el-option label="自定义" value="custom" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="源端口"><el-input-number v-model="relEditing.source_port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||
<el-form-item label="目标端口"><el-input-number v-model="relEditing.target_port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||
<el-form-item label="备注"><el-input v-model="relEditing.notes" type="textarea" :rows="2" /></el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="relDialogVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveRel">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ArrowLeft, Edit, Delete, Plus, Refresh, Right, Timer, VideoPlay, VideoPause } from '@element-plus/icons-vue'
|
||
import {
|
||
fetchMachine, updateMachine, deleteMachine,
|
||
fetchServices, createService, updateService, deleteService,
|
||
fetchRelationships, createRelationship, updateRelationship, deleteRelationship,
|
||
sshInfo, syncSSH, fetchMachines, fetchOfflineLogs, fetchPVEHosts,
|
||
checkAuth, uiRefreshInterval,
|
||
startVM as apiStartVM, stopVM as apiStopVM, fetchVMStatus,
|
||
} from '@/api'
|
||
import { getAuth } from '@/router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const isAdmin = getAuth().is_admin
|
||
const machineId = route.params.id
|
||
|
||
const machine = ref(null)
|
||
const services = ref([])
|
||
const relationships = ref([])
|
||
const vmLoading = ref(false)
|
||
const allMachines = ref([])
|
||
const offlineLogs = ref([])
|
||
const pveHosts = ref([])
|
||
|
||
const dialogVisible = ref(false)
|
||
const editing = ref({})
|
||
const sshDialogVisible = ref(false)
|
||
const sshForm = ref({ username: '', password: '' })
|
||
const sshResult = ref(null)
|
||
const sshLoading = ref(false)
|
||
|
||
const serviceDialogVisible = ref(false)
|
||
const serviceEditing = ref({})
|
||
|
||
const relDialogVisible = ref(false)
|
||
const relEditing = ref({})
|
||
|
||
const machineMap = computed(() => {
|
||
const map = {}
|
||
allMachines.value.forEach(m => map[m.id] = m.hostname)
|
||
return map
|
||
})
|
||
|
||
let timer = null
|
||
|
||
onMounted(async () => {
|
||
await loadMachine()
|
||
await loadServices()
|
||
if (isAdmin) {
|
||
await loadRelationships()
|
||
const res = await fetchMachines()
|
||
allMachines.value = res.data
|
||
try {
|
||
const pveRes = await fetchPVEHosts()
|
||
pveHosts.value = pveRes.data
|
||
} catch (e) {}
|
||
}
|
||
await checkAuth()
|
||
const interval = uiRefreshInterval || 10000
|
||
if (interval > 0) {
|
||
timer = setInterval(async () => {
|
||
await loadMachine()
|
||
await loadServices()
|
||
if (isAdmin) {
|
||
await loadRelationships()
|
||
}
|
||
}, interval)
|
||
}
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (timer) clearInterval(timer)
|
||
})
|
||
|
||
async function loadMachine() {
|
||
const res = await fetchMachine(machineId)
|
||
machine.value = res.data.machine
|
||
if (isAdmin) {
|
||
const logsRes = await fetchOfflineLogs(machineId)
|
||
offlineLogs.value = logsRes.data
|
||
}
|
||
// 如果已有同步过的 SSH 信息,直接展示上次结果
|
||
if (machine.value && (machine.value.cpu_info || machine.value.memory_info || machine.value.disk_info || machine.value.uptime)) {
|
||
sshResult.value = {
|
||
hostname: machine.value.hostname,
|
||
os_version: machine.value.os_version,
|
||
cpu: machine.value.cpu_info,
|
||
memory: machine.value.memory_info,
|
||
disk: machine.value.disk_info,
|
||
uptime: machine.value.uptime,
|
||
listen_ports: machine.value.listen_ports ? machine.value.listen_ports.split(',').map(s => s.trim()).filter(Boolean) : []
|
||
}
|
||
} else {
|
||
sshResult.value = null
|
||
}
|
||
}
|
||
|
||
async function loadServices() {
|
||
const res = await fetchServices(machineId)
|
||
services.value = res.data
|
||
}
|
||
|
||
async function loadRelationships() {
|
||
const res = await fetchRelationships()
|
||
const map = {}
|
||
allMachines.value.forEach(m => map[m.id] = m.hostname)
|
||
relationships.value = res.data
|
||
.filter(r => r.source_machine_id == machineId || r.target_machine_id == machineId)
|
||
.map(r => ({
|
||
...r,
|
||
source_hostname: map[r.source_machine_id] || r.source_machine_id,
|
||
target_hostname: map[r.target_machine_id] || r.target_machine_id
|
||
}))
|
||
}
|
||
|
||
function openEdit(item) {
|
||
editing.value = { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '', pve_host_id: item.pve_host_id || null, pve_vmid: item.pve_vmid || '' }
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function openSSH() {
|
||
// 如果已保存用户名,先尝试用数据库里的凭据自动获取
|
||
if (machine.value.ssh_username) {
|
||
sshLoading.value = true
|
||
try {
|
||
const res = await sshInfo(machineId, { username: machine.value.ssh_username, password: '' })
|
||
sshResult.value = res.data
|
||
// 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库
|
||
const syncData = { ...sshResult.value }
|
||
delete syncData.hostname
|
||
await syncSSH(machineId, syncData)
|
||
ElMessage.success('获取并同步成功')
|
||
await loadMachine()
|
||
sshLoading.value = false
|
||
return
|
||
} catch (e) {
|
||
sshLoading.value = false
|
||
// 自动获取失败,继续弹出对话框让用户手动输入
|
||
}
|
||
}
|
||
sshForm.value = {
|
||
username: machine.value.ssh_username || '',
|
||
password: ''
|
||
}
|
||
sshDialogVisible.value = true
|
||
}
|
||
|
||
async function saveMachine() {
|
||
await updateMachine(editing.value.id, editing.value)
|
||
ElMessage.success('保存成功')
|
||
dialogVisible.value = false
|
||
loadMachine()
|
||
}
|
||
|
||
async function startVM() {
|
||
vmLoading.value = true
|
||
try {
|
||
await apiStartVM(machineId)
|
||
ElMessage.success('虚拟机已启动')
|
||
loadMachine()
|
||
} catch (e) {}
|
||
vmLoading.value = false
|
||
}
|
||
|
||
async function stopVM() {
|
||
try {
|
||
await ElMessageBox.confirm('确定要关闭虚拟机吗?', '确认关闭', { type: 'warning' })
|
||
vmLoading.value = true
|
||
await apiStopVM(machineId)
|
||
ElMessage.success('虚拟机已关闭')
|
||
loadMachine()
|
||
} catch (e) {}
|
||
vmLoading.value = false
|
||
}
|
||
|
||
async function delMachine() {
|
||
try {
|
||
await ElMessageBox.confirm('确定删除该机器?', '提示', { type: 'warning' })
|
||
await deleteMachine(machineId)
|
||
ElMessage.success('删除成功')
|
||
router.back()
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function fetchSSH() {
|
||
sshLoading.value = true
|
||
try {
|
||
const res = await sshInfo(machineId, sshForm.value)
|
||
sshResult.value = res.data
|
||
sshDialogVisible.value = false
|
||
// 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库
|
||
const syncData = { ...sshResult.value }
|
||
delete syncData.hostname
|
||
await syncSSH(machineId, syncData)
|
||
ElMessage.success('获取并同步成功')
|
||
await loadMachine()
|
||
} catch (e) {}
|
||
sshLoading.value = false
|
||
}
|
||
|
||
function openServiceEdit(row) {
|
||
serviceEditing.value = row
|
||
? { ...row, target_machine_id: row.target_machine_id || null, target_notes: row.target_notes || '' }
|
||
: { name: '', port: 80, protocol: 'TCP', notes: '', target_machine_id: null, target_notes: '' }
|
||
serviceDialogVisible.value = true
|
||
}
|
||
|
||
async function saveService() {
|
||
const data = { ...serviceEditing.value }
|
||
if (data.id) {
|
||
await updateService(data.id, data)
|
||
} else {
|
||
await createService(machineId, data)
|
||
}
|
||
ElMessage.success('保存成功')
|
||
serviceDialogVisible.value = false
|
||
loadServices()
|
||
}
|
||
|
||
async function delService(row) {
|
||
try {
|
||
await ElMessageBox.confirm('确定删除该服务?', '提示', { type: 'warning' })
|
||
await deleteService(row.id)
|
||
ElMessage.success('删除成功')
|
||
loadServices()
|
||
} catch (e) {}
|
||
}
|
||
|
||
function openRelEdit(row) {
|
||
relEditing.value = row
|
||
? { ...row }
|
||
: { source_machine_id: Number(machineId), target_machine_id: null, relation_type: 'dependency', source_port: null, target_port: null, notes: '' }
|
||
relDialogVisible.value = true
|
||
}
|
||
|
||
async function saveRel() {
|
||
const data = { ...relEditing.value }
|
||
if (data.id) {
|
||
await updateRelationship(data.id, data)
|
||
} else {
|
||
await createRelationship(data)
|
||
}
|
||
ElMessage.success('保存成功')
|
||
relDialogVisible.value = false
|
||
loadRelationships()
|
||
}
|
||
|
||
async function delRel(row) {
|
||
try {
|
||
await ElMessageBox.confirm('确定删除该关系?', '提示', { type: 'warning' })
|
||
await deleteRelationship(row.id)
|
||
ElMessage.success('删除成功')
|
||
loadRelationships()
|
||
} catch (e) {}
|
||
}
|
||
|
||
function typeText(t) {
|
||
const map = { port_forward: '端口转发', dependency: '依赖', primary_secondary: '主从', custom: '自定义' }
|
||
return map[t] || t
|
||
}
|
||
|
||
function extractPercent(str) {
|
||
if (!str) return 0
|
||
const m = str.match(/\((\d+)%\)/) || str.match(/(\d+(?:\.\d+)?)%/)
|
||
if (m) {
|
||
const v = parseFloat(m[1])
|
||
return isNaN(v) ? 0 : Math.min(100, Math.max(0, Math.round(v)))
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function getMemBarColor(v) {
|
||
if (v >= 90) return '#f87171'
|
||
if (v >= 70) return '#fbbf24'
|
||
return '#34d399'
|
||
}
|
||
function getDiskBarColor(v) {
|
||
if (v >= 90) return '#f87171'
|
||
if (v >= 80) return '#fbbf24'
|
||
return '#60a5fa'
|
||
}
|
||
|
||
function parseDisks(str) {
|
||
if (!str) return []
|
||
const disks = []
|
||
const regex = /(\S+)\s+([\d\.]+[KMGT]?\/[\d\.]+[KMGT]?)\s+\((\d+)%\)/g
|
||
let m
|
||
while ((m = regex.exec(str)) !== null) {
|
||
disks.push({ mount: m[1], detail: m[2], percent: parseInt(m[3], 10) })
|
||
}
|
||
if (disks.length > 0) {
|
||
return disks
|
||
}
|
||
// fallback: single disk old format
|
||
const pct = extractPercent(str)
|
||
const det = extractDetail(str)
|
||
if (det) {
|
||
return [{ mount: '', detail: det, percent: pct }]
|
||
}
|
||
return []
|
||
}
|
||
|
||
function formatTime(t) {
|
||
if (!t) return '-'
|
||
const d = new Date(t)
|
||
if (isNaN(d.getTime())) return t
|
||
const pad = n => String(n).padStart(2, '0')
|
||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
if (!seconds && seconds !== 0) return '-'
|
||
const s = Number(seconds)
|
||
if (s < 60) return `${s}秒`
|
||
if (s < 3600) return `${Math.floor(s / 60)}分${s % 60}秒`
|
||
const h = Math.floor(s / 3600)
|
||
const m = Math.floor((s % 3600) / 60)
|
||
const sec = s % 60
|
||
if (h < 24) return `${h}时${m}分`
|
||
const d = Math.floor(h / 24)
|
||
const hr = h % 24
|
||
return `${d}天${hr}时${m}分`
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
gap: 12px;
|
||
}
|
||
.header-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.host-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
}
|
||
.status-badge {
|
||
font-size: 11px;
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
font-weight: 600;
|
||
}
|
||
.status-badge.online { background: rgba(34,197,94,0.12); color: #15803d; }
|
||
.status-badge.offline { background: rgba(239,68,68,0.10); color: #b91c1c; }
|
||
html.dark .status-badge.online { background: rgba(52,211,153,0.15); color: #34d399; }
|
||
html.dark .status-badge.offline { background: rgba(248,113,113,0.15); color: #f87171; }
|
||
.host-subtitle {
|
||
margin-top: 4px;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.host-subtitle span + span::before {
|
||
content: '·';
|
||
margin-right: 8px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.detail-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
.detail-grid > .card {
|
||
margin-bottom: 0;
|
||
}
|
||
@media (max-width: 900px) {
|
||
.detail-grid { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
.info-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.info-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 10px;
|
||
background: var(--surface-hover);
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
transition: background .2s ease;
|
||
}
|
||
.info-label { color: var(--text-secondary); }
|
||
.info-value { font-weight: 500; color: var(--text); }
|
||
.text-muted { color: var(--text-muted); }
|
||
|
||
.status-pills {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.status-pill {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
background: var(--surface-hover);
|
||
border-radius: 10px;
|
||
transition: background .2s ease;
|
||
}
|
||
.sp-icon {
|
||
width: 36px; height: 36px; border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 11px; font-weight: 800; color: #fff;
|
||
flex-shrink: 0;
|
||
}
|
||
.sp-icon.cpu { background: linear-gradient(135deg, #3b82f6, #60a5fa); }
|
||
.sp-icon.mem { background: linear-gradient(135deg, #22c55e, #4ade80); }
|
||
.sp-icon.disk { background: linear-gradient(135deg, #f59e0b, #fbbf24); }
|
||
.sp-info { flex: 1; min-width: 0; }
|
||
.sp-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||
.sp-bar { height: 5px; background: var(--border-strong); border-radius: 3px; overflow: hidden; }
|
||
.sp-fill { height: 100%; border-radius: 3px; background: #3b82f6; transition: width .3s ease; }
|
||
.sp-num { font-size: 14px; font-weight: 700; color: var(--text); min-width: 36px; text-align: right; }
|
||
|
||
.status-pill.disk-multi { flex-wrap: wrap; }
|
||
.multi-disk-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
max-height: 220px;
|
||
overflow-y: auto;
|
||
}
|
||
.detail-disk-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 12px;
|
||
}
|
||
.dd-mount {
|
||
min-width: 50px;
|
||
color: var(--text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
.dd-bar-wrap {
|
||
flex: 1;
|
||
min-width: 80px;
|
||
}
|
||
.dd-bar {
|
||
height: 5px;
|
||
background: var(--border-strong);
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
.dd-fill { height: 100%; border-radius: 3px; }
|
||
.dd-val { white-space: nowrap; color: var(--text-secondary); }
|
||
.dd-pct { min-width: 32px; text-align: right; font-weight: 700; color: var(--text); }
|
||
|
||
.uptime-line {
|
||
margin-top: 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
padding: 8px 10px;
|
||
background: var(--surface-hover);
|
||
border-radius: 8px;
|
||
transition: background .2s ease;
|
||
}
|
||
|
||
.sync-actions {
|
||
margin-bottom: 10px;
|
||
}
|
||
.ssh-actions {
|
||
margin-bottom: 12px;
|
||
}
|
||
.ssh-result {
|
||
background: var(--surface-hover);
|
||
border-radius: 10px;
|
||
padding: 12px;
|
||
transition: background .2s ease;
|
||
}
|
||
.result-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 10px;
|
||
}
|
||
.result-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
.result-item.full { grid-column: 1 / -1; }
|
||
.rl { font-size: 11px; color: var(--text-muted); }
|
||
.rv { font-size: 13px; color: var(--text); font-weight: 500; }
|
||
|
||
.service-list,
|
||
.rel-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.service-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
background: var(--surface-hover);
|
||
border-radius: 10px;
|
||
flex-wrap: wrap;
|
||
transition: background .2s ease;
|
||
}
|
||
.service-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.service-name {
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
}
|
||
.service-port {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
background: var(--surface);
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.service-target {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 12px;
|
||
color: var(--primary);
|
||
}
|
||
.target-note {
|
||
color: var(--text-muted);
|
||
}
|
||
.service-note {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
flex: 1 1 100%;
|
||
}
|
||
.service-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.rel-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
background: var(--surface-hover);
|
||
border-radius: 10px;
|
||
flex-wrap: wrap;
|
||
transition: background .2s ease;
|
||
}
|
||
.rel-arrow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 14px;
|
||
}
|
||
.rel-host {
|
||
font-weight: 600;
|
||
}
|
||
.rel-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
}
|
||
.rel-port {
|
||
background: var(--surface);
|
||
padding: 1px 6px;
|
||
border-radius: 4px;
|
||
border: 1px solid var(--border);
|
||
color: var(--text-secondary);
|
||
}
|
||
.rel-note {
|
||
color: var(--text-muted);
|
||
}
|
||
.rel-actions {
|
||
display: flex;
|
||
gap: 6px;
|
||
}
|
||
|
||
.offline-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.stat-box {
|
||
background: var(--surface-hover);
|
||
border-radius: 10px;
|
||
padding: 12px;
|
||
text-align: center;
|
||
}
|
||
.stat-num {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
}
|
||
.stat-reason {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--danger);
|
||
word-break: break-all;
|
||
}
|
||
.stat-label {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.offline-logs {
|
||
background: var(--surface-hover);
|
||
border-radius: 10px;
|
||
padding: 12px;
|
||
}
|
||
.log-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 10px;
|
||
}
|
||
.log-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
font-size: 12px;
|
||
}
|
||
.log-row:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.log-time {
|
||
color: var(--text-muted);
|
||
min-width: 140px;
|
||
}
|
||
.log-reason {
|
||
flex: 1;
|
||
color: var(--text-secondary);
|
||
}
|
||
.log-dur {
|
||
color: var(--text);
|
||
font-weight: 500;
|
||
}
|
||
.log-dur.pending {
|
||
color: var(--warning);
|
||
}
|
||
</style>
|