- 路由权限放宽:拓扑图、操作日志对访客开放 - 访客模式提示 UI:横幅 + 侧边栏提示 - Logo 使用 logo.svg 替代 Element Plus 图标 - 访客可点击进入机器详情页 - IP 地址信息对访客开放
1023 lines
38 KiB
Vue
1023 lines
38 KiB
Vue
<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>
|