Files
lan-manager/web/src/views/MachineDetail.vue
shirainbown 8d6f831d22 feat: 全面开放访客浏览模式,访客可查看拓扑图/日志/详情页
- 路由权限放宽:拓扑图、操作日志对访客开放
- 访客模式提示 UI:横幅 + 侧边栏提示
- Logo 使用 logo.svg 替代 Element Plus 图标
- 访客可点击进入机器详情页
- IP 地址信息对访客开放
2026-06-19 00:46:31 +08:00

1023 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page" v-if="machine">
<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">
<el-icon class="host-icon" :size="22"><Platform /></el-icon>
{{ machine.hostname }}
<el-tag :type="machine.is_online ? 'success' : 'danger'" size="small" effect="light" round class="status-tag">
<el-icon :size="10"><component :is="machine.is_online ? CircleCheck : CircleClose" /></el-icon>
{{ machine.is_online ? '在线' : '离线' }}
</el-tag>
</div>
<div class="host-subtitle">
<span><el-icon :size="12"><Link /></el-icon> {{ machine.ip }}</span>
<span><el-icon :size="12"><Cpu /></el-icon> {{ 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-tag v-if="machine.pve_host_id && machine.pve_vmid" size="small" :type="vmStatusInfo._error ? 'info' : vmStatusInfo.status === 'running' ? 'success' : 'danger'" effect="light" class="vm-status-tag" round>
<el-icon :size="10"><component :is="vmStatusInfo.status === 'running' ? VideoPlay : VideoPause" /></el-icon>
{{ vmStatusInfo._error ? 'VM检测失败' : vmStatusInfo.status === 'running' ? 'VM运行中' : 'VM已停止' }}
</el-tag>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="success" :icon="VideoPlay" :loading="vmLoading" @click="startVM" :disabled="vmStatusInfo.status === 'running' || vmStatusInfo._error" size="small">启动</el-button>
<el-button v-if="machine.pve_host_id && machine.pve_vmid" type="warning" :icon="VideoPause" :loading="vmLoading" @click="stopVM" :disabled="vmStatusInfo.status === 'stopped' || vmStatusInfo._error" size="small">关闭</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">
<el-icon><Document /></el-icon> 基本信息
</div>
<div class="info-list">
<div class="info-item"><span class="info-label"><el-icon><Link /></el-icon> IP</span><span class="info-value">{{ machine.ip }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label"><el-icon><Connection /></el-icon> MAC</span><span class="info-value">{{ machine.mac || '-' }}</span></div>
<div class="info-item"><span class="info-label"><el-icon><Cpu /></el-icon> 系统</span><span class="info-value">{{ machine.os_type }} {{ machine.os_version || '' }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label"><el-icon><Lock /></el-icon> SSH 端口</span><span class="info-value">{{ machine.ssh_port || 22 }}</span></div>
<div class="info-item" v-if="isAdmin"><span class="info-label"><el-icon><EditPen /></el-icon> 备注</span><span class="info-value text-muted">{{ machine.notes || '-' }}</span></div>
</div>
</div>
<!-- PVE VM Status -->
<div class="card" v-if="machine.pve_host_id && machine.pve_vmid && vmStatusInfo.status">
<div class="card-title">
<el-icon><Grid /></el-icon> PVE 虚拟机状态
</div>
<div class="vm-status-grid">
<div class="vm-stat-item">
<span class="vm-stat-label">运行状态</span>
<span class="vm-stat-value" :class="vmStatusInfo.status">
<el-icon :size="14"><component :is="vmStatusInfo.status === 'running' ? VideoPlay : VideoPause" /></el-icon>
{{ vmStatusInfo.status === 'running' ? '运行中' : '已停止' }}
</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.cpu !== undefined">
<span class="vm-stat-label">CPU 使用率</span>
<span class="vm-stat-value">{{ vmStatusInfo.cpu?.toFixed ? vmStatusInfo.cpu.toFixed(1) : vmStatusInfo.cpu }}%</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.memory_used !== undefined && vmStatusInfo.memory_total">
<span class="vm-stat-label">内存使用</span>
<span class="vm-stat-value">{{ formatBytes(vmStatusInfo.memory_used) }} / {{ formatBytes(vmStatusInfo.memory_total) }}</span>
</div>
<div class="vm-stat-item" v-if="vmStatusInfo.uptime">
<span class="vm-stat-label">运行时长</span>
<span class="vm-stat-value">{{ formatUptime(vmStatusInfo.uptime) }}</span>
</div>
</div>
</div>
<!-- System Status -->
<div class="card" v-if="machine.cpu_info || machine.memory_info || machine.disk_info">
<div class="card-title">
<el-icon><Odometer /></el-icon> 系统状态
</div>
<div class="status-pills">
<div v-if="machine.cpu_info" class="status-pill">
<div class="sp-icon cpu"><el-icon :size="16"><Cpu /></el-icon></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"><el-icon :size="16"><Memory /></el-icon></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" :class="{ 'disk-multi': parseDisks(machine.disk_info).length > 1 }">
<template v-if="parseDisks(machine.disk_info).length <= 1">
<div class="sp-icon disk"><el-icon :size="16"><Histogram /></el-icon></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"><el-icon :size="16"><Histogram /></el-icon></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">
<el-icon><SetUp /></el-icon> 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>
<el-icon :size="10"><Clock /></el-icon> 上次同步 {{ 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"><el-icon><Monitor /></el-icon> 主机名</span><span class="rv">{{ sshResult.hostname }}</span></div>
<div class="result-item"><span class="rl"><el-icon><InfoFilled /></el-icon> 系统版本</span><span class="rv">{{ sshResult.os_version }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Cpu /></el-icon> CPU</span><span class="rv">{{ sshResult.cpu }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Memory /></el-icon> 内存</span><span class="rv">{{ sshResult.memory }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Histogram /></el-icon> 磁盘</span><span class="rv">{{ sshResult.disk }}</span></div>
<div class="result-item"><span class="rl"><el-icon><Timer /></el-icon> 运行时间</span><span class="rv">{{ sshResult.uptime }}</span></div>
<div class="result-item full"><span class="rl"><el-icon><Connection /></el-icon> 监听端口</span><span class="rv">{{ sshResult.listen_ports?.join(', ') }}</span></div>
</div>
</div>
</div>
<!-- Services -->
<div class="card">
<div class="card-title">
<el-icon><Service /></el-icon> 服务列表
<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">
<el-icon :size="14" color="var(--accent)"><Connection /></el-icon>
{{ 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 -->
<div class="card" v-if="isAdmin">
<div class="card-title">
<el-icon><Warning /></el-icon> 离线统计
</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">
<el-icon><List /></el-icon> 最近离线记录
</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-icon><Link /></el-icon> 关联关系
<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 color="var(--accent)"><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">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="密码">
<el-input v-model="sshForm.password" type="password" show-password>
<template #prefix><el-icon><Key /></el-icon></template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="sshDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="sshLoading" :icon="Connection" @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-icon><Platform /></el-icon> Linux</el-option>
<el-option label="Windows" value="Windows"><el-icon><Monitor /></el-icon> Windows</el-option>
<el-option label="macOS" value="macOS"><el-icon><Apple /></el-icon> macOS</el-option>
<el-option label="Other" value="Other"><el-icon><QuestionFilled /></el-icon> Other</el-option>
</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"><el-icon><Grid /></el-icon> 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" :icon="Check" @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" :icon="Check" @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" :icon="Check" @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,
Document, Odometer, Grid, Platform, Link, Connection, Cpu, Collection, Histogram,
SetUp, Clock, Warning, List, Check, User, Key, CircleCheck, CircleClose,
Monitor, Apple, QuestionFilled, InfoFilled, EditPen, Service
} 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 isLoggedIn = getAuth().is_logged_in
const machineId = route.params.id
const machine = ref(null)
const services = ref([])
const relationships = ref([])
const vmLoading = ref(false)
const vmStatusInfo = ref({})
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 loadVMStatus()
}
await checkAuth()
const interval = uiRefreshInterval || 10000
if (interval > 0) {
timer = setInterval(async () => {
await loadMachine()
await loadServices()
if (isAdmin) {
await loadRelationships()
await loadVMStatus()
}
}, 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
}
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
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 loadVMStatus() {
if (!machine.value || !machine.value.pve_host_id || !machine.value.pve_vmid) return
try {
const res = await fetchVMStatus(machineId)
vmStatusInfo.value = res.data
} catch (e) {
vmStatusInfo.value = { _error: true }
}
}
async function startVM() {
try {
await ElMessageBox.confirm('确定要启动虚拟机吗?', '确认启动', { type: 'info' })
vmLoading.value = true
await apiStartVM(machineId)
ElMessage.success('虚拟机已启动')
await loadVMStatus()
loadMachine()
} catch (e) { if (e !== 'cancel') {} }
vmLoading.value = false
}
async function stopVM() {
try {
await ElMessageBox.confirm('确定要关闭虚拟机吗?', '确认关闭', { type: 'warning' })
vmLoading.value = true
await apiStopVM(machineId)
ElMessage.success('虚拟机已关闭')
await loadVMStatus()
loadMachine()
} catch (e) { if (e !== 'cancel') {} }
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
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 'var(--danger)'
if (v >= 70) return 'var(--warning)'
return 'var(--success)'
}
function getDiskBarColor(v) {
if (v >= 90) return 'var(--danger)'
if (v >= 80) return 'var(--warning)'
return 'var(--accent)'
}
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
const pct = extractPercent(str)
const det = str ? str.replace(/\s*\(\d+(?:\.\d+)?%\)\s*/g, '').trim().split(',')[0].trim() : ''
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)
if (h < 24) return `${h}${m}`
const d = Math.floor(h / 24)
const hr = h % 24
return `${d}${hr}${m}`
}
function formatBytes(bytes) {
if (!bytes && bytes !== 0) return '-'
const b = Number(bytes)
if (b < 1024) return `${b}B`
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)}KB`
if (b < 1024 * 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)}MB`
return `${(b / 1024 / 1024 / 1024).toFixed(1)}GB`
}
function formatUptime(seconds) {
if (!seconds && seconds !== 0) return '-'
const s = Number(seconds)
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
if (d > 0) return `${d}${h}${m}`
if (h > 0) return `${h}${m}`
return `${m}`
}
</script>
<style scoped>
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.host-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 20px;
font-weight: 700;
}
.host-icon { color: var(--accent); }
.status-tag {
font-size: 11px;
height: 22px;
padding: 0 8px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 4px;
}
.vm-status-tag {
font-size: 12px;
height: 24px;
margin-right: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.host-subtitle {
margin-top: 4px;
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.host-subtitle span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.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(--card-hover);
border-radius: 8px;
font-size: 13px;
transition: background .2s ease;
}
.info-label {
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
}
.info-value { font-weight: 500; color: var(--text-primary); }
.text-muted { color: var(--text-muted); }
.vm-status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.vm-stat-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
background: var(--card-hover);
border-radius: 8px;
}
.vm-stat-label { font-size: 11px; color: var(--text-muted); }
.vm-stat-value { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.vm-stat-value.running { color: var(--success); }
.vm-stat-value.stopped { color: var(--danger); }
.status-pills {
display: flex;
flex-direction: column;
gap: 10px;
}
.status-pill {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--card-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, #58a6ff, #79b8ff); }
.sp-icon.mem { background: linear-gradient(135deg, #3fb950, #56d364); }
.sp-icon.disk { background: linear-gradient(135deg, #d29922, #e3b341); }
.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); border-radius: 3px; overflow: hidden; }
.sp-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width .3s ease; }
.sp-num { font-size: 14px; font-weight: 700; color: var(--text-primary); 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); 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-primary); }
.uptime-line {
margin-top: 14px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
padding: 8px 10px;
background: var(--card-hover);
border-radius: 8px;
transition: background .2s ease;
}
.sync-actions { margin-bottom: 10px; }
.ssh-actions { margin-bottom: 12px; }
.ssh-result {
background: var(--card-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); display: inline-flex; align-items: center; gap: 4px; }
.rv { font-size: 13px; color: var(--text-primary); 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(--card-hover);
border-radius: 10px;
flex-wrap: wrap;
transition: background .2s ease;
}
.service-row:hover { background: var(--border); }
.service-main {
display: flex;
align-items: center;
gap: 10px;
}
.service-name {
font-weight: 600;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.service-port {
font-size: 12px;
color: var(--text-muted);
background: var(--card-bg);
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(--accent);
}
.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(--card-hover);
border-radius: 10px;
flex-wrap: wrap;
transition: background .2s ease;
}
.rel-row:hover { background: var(--border); }
.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(--card-bg);
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(--card-hover);
border-radius: 10px;
padding: 12px;
text-align: center;
border: 1px solid var(--border);
transition: background .2s ease;
}
.stat-box:hover { background: var(--border); }
.stat-num { font-size: 20px; font-weight: 700; color: var(--text-primary); }
.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(--card-hover);
border-radius: 10px;
padding: 12px;
border: 1px solid var(--border);
}
.log-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 10px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.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-primary); font-weight: 500; }
.log-dur.pending { color: var(--warning); }
</style>