Files
lan-manager/web/src/views/MachineDetail.vue
openclaw 1fcf9cbdef fix(pve): 修复Scan语法错误 + MachineDetail编辑弹窗添加PVE配置
- services/pve.go: 修复 &h.PasswordEnch.Username -> &h.NodeName
- MachineDetail.vue: 编辑弹窗添加PVE主机选择+VMID输入
- 删除macOS自动生成的冲突副本文件
- 重新构建前端dist

[金渐层/K2.6-code-preview🐾]
2026-04-20 13:25:51 +08:00

981 lines
33 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">
<!-- 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>