Files
lan-manager/web/src/views/MachineList.vue

643 lines
21 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">
<!-- Toolbar -->
<div class="toolbar" v-if="isAdmin">
<div class="search-wrap">
<el-icon class="search-icon"><Search /></el-icon>
<el-input v-model="search" placeholder="搜索主机名" clearable @change="load" />
</div>
<el-select v-model="osFilter" placeholder="系统类型" clearable @change="load" class="os-select">
<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-button v-if="isAdmin" type="primary" :icon="Plus" @click="openEdit()">添加机器</el-button>
<el-button v-if="isAdmin" type="success" :icon="Download" @click="handleExport">导出</el-button>
<el-button v-if="isAdmin" type="warning" :icon="Upload" @click="handleImportClick">导入</el-button>
<input ref="importFileRef" type="file" accept=".json" style="display:none" @change="handleImportFile">
</div>
<!-- Cards Grid -->
<div class="cards-grid" v-if="machines.length">
<!-- 管理员卡片完整信息 + 可点击 -->
<div v-if="isAdmin" v-for="m in machines" :key="m.id" class="server-card"
:class="[{ 'offline-card': !m.is_online }]"
@click="goDetail(m.id)">
<div class="card-header">
<div class="title-row">
<div class="os-badge" :class="osClass(m.os_type)" :title="m.os_type">
<el-icon :size="12">
<component :is="osIcon(m.os_type)" />
</el-icon>
<span>{{ osShort(m.os_type) }}</span>
</div>
<span class="hostname">{{ m.hostname }}</span>
<el-tag :type="m.is_online ? 'success' : 'danger'" size="small" effect="light" round class="status-tag">
<el-icon :size="10"><component :is="m.is_online ? CircleCheck : CircleClose" /></el-icon>
{{ m.is_online ? '在线' : '离线' }}
</el-tag>
<el-tag v-if="m.service_count" size="small" effect="plain" class="svc-tag" round>
<el-icon :size="10"><Service /></el-icon> {{ m.service_count }}
</el-tag>
</div>
<div class="meta-row">
<span class="meta-ip">
<el-icon :size="10"><Link /></el-icon> {{ m.ip }}
</span>
<span class="meta-item">
<el-icon :size="10"><Cpu /></el-icon> {{ m.os_type }}
</span>
<span v-if="m.os_version" class="meta-item">{{ m.os_version }}</span>
<span v-if="m.uptime" class="meta-uptime">
<el-icon :size="10"><Timer /></el-icon> {{ m.uptime }}
</span>
<span v-if="m.pve_host_id && m.pve_vmid" class="meta-pve">
<el-tag size="small" :type="m.pve_vm_status === 'running' ? 'success' : 'danger'" effect="light" round class="vm-tag">
<el-icon :size="10"><component :is="m.pve_vm_status === 'running' ? VideoPlay : VideoPause" /></el-icon>
{{ m.pve_vm_status === 'running' ? 'VM运行中' : m.pve_vm_status === 'stopped' ? 'VM已停止' : 'VM检测中' }}
</el-tag>
</span>
</div>
</div>
<div v-if="m.cpu_info || m.memory_info || m.disk_info" class="stats-row">
<div v-if="m.cpu_info" class="stat-pill">
<div class="pill-icon cpu">
<el-icon :size="12"><Cpu /></el-icon>
</div>
<div class="pill-body">
<div class="pill-label">CPU</div>
<div class="pill-bar"><div class="pill-fill" :style="{ width: extractPercent(m.cpu_info) + '%' }"></div></div>
</div>
<div class="pill-value">{{ extractPercent(m.cpu_info) }}%</div>
</div>
<div v-if="m.memory_info" class="stat-pill">
<div class="pill-icon mem">
<el-icon :size="12"><Collection /></el-icon>
</div>
<div class="pill-body">
<div class="pill-label">RAM</div>
<div class="pill-bar"><div class="pill-fill" :style="{ width: extractPercent(m.memory_info) + '%', background: getMemBarColor(extractPercent(m.memory_info)) }"></div></div>
</div>
<div class="pill-value">{{ extractDetail(m.memory_info) }}</div>
</div>
<div v-if="getMainDisk(m.disk_info)" class="stat-pill">
<div class="pill-icon disk">
<el-icon :size="12"><Histogram /></el-icon>
</div>
<div class="pill-body">
<div class="pill-label">DISK</div>
<div class="pill-bar"><div class="pill-fill" :style="{ width: getMainDisk(m.disk_info).percent + '%', background: getDiskBarColor(getMainDisk(m.disk_info).percent) }"></div></div>
</div>
<div class="pill-value">{{ getMainDisk(m.disk_info).detail }}</div>
</div>
</div>
<div v-if="m.listen_ports" class="ports-row">
<el-tag v-for="p in m.listen_ports.split(',').slice(0,6)" :key="p" size="small" effect="plain" round class="port-tag">
<el-icon :size="9"><Connection /></el-icon> {{ p.trim() }}
</el-tag>
<span v-if="m.listen_ports.split(',').length > 6" class="more-ports">+{{ m.listen_ports.split(',').length - 6 }}</span>
</div>
<div v-if="m.ssh_synced_at" class="sync-time">
<el-icon :size="10"><Clock /></el-icon>
同步于 {{ formatTime(m.ssh_synced_at) }}
</div>
</div>
<!-- 未登录用户卡片仅主机名 + 状态 + OS不可点击 -->
<div v-if="!isAdmin" v-for="m in machines" :key="m.id" class="server-card guest-card"
:class="[{ 'offline-card': !m.is_online }]">
<div class="card-header">
<div class="title-row">
<div class="os-badge" :class="osClass(m.os_type)" :title="m.os_type">
<el-icon :size="12">
<component :is="osIcon(m.os_type)" />
</el-icon>
<span>{{ osShort(m.os_type) }}</span>
</div>
<span class="hostname">{{ m.hostname }}</span>
<el-tag :type="m.is_online ? 'success' : 'danger'" size="small" effect="light" round class="status-tag">
<el-icon :size="10"><component :is="m.is_online ? CircleCheck : CircleClose" /></el-icon>
{{ m.is_online ? '在线' : '离线' }}
</el-tag>
</div>
<div class="meta-row">
<span class="meta-item">
<el-icon :size="10"><Cpu /></el-icon> {{ m.os_type }}
</span>
</div>
</div>
</div>
</div>
<el-empty v-if="!machines.length" description="暂无机器" :image-size="80">
<template #description>
<div style="color: var(--text-muted); margin-top: 8px;">暂无机器数据</div>
</template>
</el-empty>
<!-- Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="editing.id ? '编辑机器' : '添加机器'" width="500px" class="modern-dialog" destroy-on-close>
<el-form :model="editing" label-width="100px">
<el-form-item label="主机名" required>
<el-input v-model="editing.hostname" placeholder="如 web-server-01">
<template #prefix><el-icon><Monitor /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="IP" required>
<el-input v-model="editing.ip" placeholder="192.168.1.100">
<template #prefix><el-icon><Link /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="MAC">
<el-input v-model="editing.mac" placeholder="AA:BB:CC:DD:EE:FF">
<template #prefix><el-icon><Connection /></el-icon></template>
</el-input>
</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" placeholder="Ubuntu 22.04">
<template #prefix><el-icon><InfoFilled /></el-icon></template>
</el-input>
</el-form-item>
<el-divider content-position="left">
<el-icon><Lock /></el-icon> SSH 配置
</el-divider>
<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" placeholder="root">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="SSH 密码">
<el-input v-model="editing.ssh_password" type="password" show-password placeholder="输入则更新密码,留空保持不变">
<template #prefix><el-icon><Key /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editing.notes" type="textarea" :rows="3" placeholder="记录用途、负责人等信息">
<template #prefix><el-icon><EditPen /></el-icon></template>
</el-input>
</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">
<template #prefix><el-icon><Grid /></el-icon></template>
</el-input>
</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>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import {
Plus, Search, Download, Upload, Check, Platform, Monitor, Apple,
QuestionFilled, CircleCheck, CircleClose, Service, Link, Cpu, Timer,
Collection, Histogram, Connection, Clock, VideoPlay, VideoPause, Lock, User, Key,
EditPen, Grid, InfoFilled, Warning
} from '@element-plus/icons-vue'
import { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData, fetchPVEHosts, fetchVMStatus } from '@/api'
import { getAuth } from '@/router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const isAdmin = getAuth().is_admin
const machines = ref([])
const pveHosts = ref([])
const search = ref('')
const osFilter = ref('')
const dialogVisible = ref(false)
const editing = ref({ hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' })
const importFileRef = ref(null)
let timer = null
onMounted(async () => {
await load()
await checkAuth()
if (isAdmin) {
try {
const res = await fetchPVEHosts()
pveHosts.value = res.data
} catch (e) {}
}
const interval = uiRefreshInterval || 10000
if (interval > 0) {
timer = setInterval(load, interval)
}
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
async function load() {
const res = await fetchMachines({ search: search.value, os_type: osFilter.value })
machines.value = res.data
if (isAdmin) {
const pveMachines = machines.value.filter(m => m.pve_host_id && m.pve_vmid)
await Promise.all(pveMachines.map(async (m) => {
try {
const statusRes = await fetchVMStatus(m.id)
m.pve_vm_status = statusRes.data.status
} catch (e) {
m.pve_vm_status = 'unknown'
}
}))
}
}
function goDetail(id) {
router.push(`/machines/${id}`)
}
function openEdit(item) {
editing.value = item
? { ...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 || '' }
: { hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '', pve_host_id: null, pve_vmid: '' }
dialogVisible.value = true
}
async function saveMachine() {
const data = { ...editing.value }
if (!data.hostname || !data.ip || !data.os_type) {
ElMessage.warning('请填写必填项')
return
}
if (data.id) {
await updateMachine(data.id, data)
} else {
await createMachine(data)
}
ElMessage.success('保存成功')
dialogVisible.value = false
load()
}
async function handleExport() {
try {
const res = await exportData()
const blob = new Blob([res.data])
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `lan-manager-backup-${new Date().toISOString().slice(0,10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (e) {
ElMessage.error('导出失败')
}
}
function handleImportClick() {
importFileRef.value?.click()
}
async function handleImportFile(e) {
const file = e.target.files?.[0]
if (!file) return
const formData = new FormData()
formData.append('file', file)
formData.append('mode', 'overwrite')
try {
await importData(formData)
ElMessage.success('导入成功,页面即将刷新')
setTimeout(() => location.reload(), 800)
} catch (err) {
ElMessage.error('导入失败')
}
e.target.value = ''
}
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 extractDetail(str) {
if (!str) return ''
let s = str.replace(/\s*\(\d+(?:\.\d+)?%\)\s*/g, '').trim()
if (s.includes(',')) s = s.split(',')[0].trim()
s = s.replace(/^\/\s+/, '').trim()
if (/^\d+\.?\d*\/\d+\.?\d*$/.test(s)) s += 'G'
return s
}
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 = extractDetail(str)
if (det) return [{ mount: '', detail: det, percent: pct }]
return []
}
function getMainDisk(str) {
const disks = parseDisks(str)
const root = disks.find(d => d.mount === '/')
return root || disks[0] || null
}
function osClass(os) {
const map = { Linux: 'os-linux', Windows: 'os-windows', macOS: 'os-macos' }
return map[os] || 'os-other'
}
function osIcon(os) {
const map = { Linux: 'Platform', Windows: 'Monitor', macOS: 'Apple' }
return map[os] || 'QuestionFilled'
}
function osShort(os) {
const map = { Linux: 'L', Windows: 'W', macOS: 'M' }
return map[os] || 'O'
}
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())}`
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-wrap {
position: relative;
flex: 1;
max-width: 360px;
}
.search-wrap :deep(.el-input__wrapper) {
padding-left: 34px;
}
.search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
font-size: 16px;
z-index: 1;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@media (max-width: 900px) {
.cards-grid { grid-template-columns: 1fr; }
}
.server-card {
background: var(--card-bg);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow-card);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
cursor: pointer;
transition: all .15s ease;
}
.server-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--border-strong);
}
.server-card.guest-card {
cursor: default;
}
.server-card.guest-card:hover {
transform: none;
box-shadow: var(--shadow-card);
border-color: var(--border);
}
.server-card.offline-card {
opacity: 0.7;
border-left-color: var(--danger);
}
.server-card.offline-card .hostname {
color: var(--text-muted);
}
.card-header {
display: flex;
flex-direction: column;
gap: 6px;
}
.title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.os-badge {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.os-badge.os-linux { background: #58a6ff; }
.os-badge.os-windows { background: #3fb950; }
.os-badge.os-macos { background: #a371f7; }
.os-badge.os-other { background: #8b949e; }
.hostname {
font-weight: 700;
font-size: 15px;
color: var(--text-primary);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
font-size: 11px;
height: 20px;
padding: 0 8px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 4px;
}
.svc-tag {
font-size: 11px;
height: 20px;
padding: 0 8px;
color: var(--text-secondary);
border-color: var(--border);
display: inline-flex;
align-items: center;
gap: 4px;
}
.meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 0;
font-size: 12px;
color: var(--text-secondary);
}
.meta-ip {
color: var(--text-primary);
font-weight: 500;
background: var(--card-hover);
border: 1px solid var(--border);
padding: 2px 6px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.meta-item + .meta-item::before,
.meta-uptime::before {
content: '·';
margin: 0 6px;
color: var(--text-muted);
}
.meta-uptime { color: var(--success); display: inline-flex; align-items: center; gap: 4px; }
.vm-tag { font-size: 11px; height: 20px; padding: 0 8px; display: inline-flex; align-items: center; gap: 4px; }
.stats-row {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.stat-pill {
background: var(--pill-bg);
border: 1px solid var(--pill-border);
border-radius: 10px;
padding: 9px 10px;
color: var(--pill-text);
display: flex;
align-items: center;
gap: 8px;
transition: all .2s ease;
}
.pill-icon {
width: 24px; height: 24px; border-radius: 5px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800;
background: rgba(255,255,255,0.10);
flex-shrink: 0;
color: #fff;
}
.pill-icon.cpu { background: rgba(88, 166, 255, 0.3); color: #58a6ff; }
.pill-icon.mem { background: rgba(63, 185, 80, 0.3); color: #3fb950; }
.pill-icon.disk { background: rgba(163, 113, 247, 0.3); color: #a371f7; }
.pill-body {
flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0;
}
.pill-label { font-size: 10px; color: var(--text-muted); font-weight: 600; }
.pill-bar { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; }
.pill-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width .3s ease; }
.pill-value { font-size: 11px; font-weight: 700; color: var(--text-primary); white-space: nowrap; }
.ports-row {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.port-tag {
font-size: 11px;
height: 20px;
padding: 0 7px;
color: var(--text-secondary);
border-color: var(--border);
display: inline-flex;
align-items: center;
gap: 4px;
}
.more-ports { font-size: 11px; color: var(--text-muted); line-height: 20px; }
.sync-time {
margin-top: 10px;
font-size: 11px;
color: var(--text-muted);
display: inline-flex;
align-items: center;
gap: 4px;
}
</style>