feat: add openconnect

This commit is contained in:
duorameng
2026-06-25 20:06:38 +08:00
parent fac1582fe0
commit 90f06de298
49 changed files with 3854 additions and 969 deletions
@@ -0,0 +1,603 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import StatusDot from '@/components/StatusDot.vue'
import { Plus, Edit2, Trash2, RefreshCw, ExternalLink, Eye, Copy } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner'
import * as interconnectApi from '@/api/interconnect'
import { copyToClipboard } from '@/utils/clipboard'
import { setActiveInterconnectNodeId } from '@/api'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
const props = defineProps<{
nodes: interconnectApi.InterconnectNode[]
loading: boolean
searchQuery: string
}>()
const emit = defineEmits<{
(e: 'refresh'): void
}>()
const copied = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
const selectedNodeDetail = ref<any>(null)
const selectedNodeName = ref('')
const nodeStatuses = ref<Record<string, any>>({})
const filteredNodes = computed(() => {
if (!props.searchQuery) return props.nodes
const lowerKeyword = props.searchQuery.toLowerCase()
return props.nodes.filter(node =>
node.name.toLowerCase().includes(lowerKeyword) ||
node.url.toLowerCase().includes(lowerKeyword) ||
(node.remark && node.remark.toLowerCase().includes(lowerKeyword))
)
})
const dialogOpen = ref(false)
const isEditing = ref(false)
const currentForm = ref<Partial<interconnectApi.InterconnectNode>>({
name: '',
url: '',
token: '',
remark: ''
})
const showDeleteConfirm = ref(false)
const deleteId = ref('')
function formatUptime(seconds: number | undefined): string {
if (!seconds) return '-'
const days = Math.floor(seconds / (24 * 3600))
const hours = Math.floor((seconds % (24 * 3600)) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const parts = []
if (days > 0) parts.push(`${days}`)
if (hours > 0) parts.push(`${hours}小时`)
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}分钟`)
return parts.join('')
}
function formatBytes(bytes: number | undefined): string {
if (bytes === undefined) return '-'
const gb = bytes / (1024 * 1024 * 1024)
if (gb >= 1) return `${gb.toFixed(2)} GB`
const mb = bytes / (1024 * 1024)
return `${mb.toFixed(2)} MB`
}
function getLoadColor(percent: number | undefined): string {
if (percent === undefined) return 'text-muted-foreground'
if (percent < 50) return 'text-green-500'
if (percent < 80) return 'text-yellow-500'
return 'text-destructive'
}
function generateRandomToken(length = 32) {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
let res = ""
for (let i = 0; i < length; ++i) {
res += charset[Math.floor(Math.random() * charset.length)]
}
return res
}
async function handleCopy(text: string) {
const success = await copyToClipboard(text)
if (success) {
copied.value = true
toast.success('已复制到剪贴板')
setTimeout(() => {
copied.value = false
}, 2000)
} else {
toast.error('复制失败,请手动复制')
}
}
async function fetchNodeStatus(id: string) {
try {
nodeStatuses.value[id] = { status: 'loading' }
const res = await interconnectApi.getNodeStatus(id)
nodeStatuses.value[id] = Object.assign({ status: 'online' }, res)
} catch (error: any) {
nodeStatuses.value[id] = { status: 'offline', error: error.message || '离线' }
}
}
watch(() => props.nodes, (newNodes) => {
newNodes.forEach(node => {
if (!nodeStatuses.value[node.id] || nodeStatuses.value[node.id].status === 'offline') {
fetchNodeStatus(node.id)
}
})
}, { immediate: true })
async function showDetail(node: interconnectApi.InterconnectNode) {
selectedNodeName.value = node.name
selectedNodeDetail.value = null
detailLoading.value = true
detailOpen.value = true
try {
const res = await interconnectApi.getNodeStatus(node.id)
selectedNodeDetail.value = res
} catch (error: any) {
toast.error('获取子节点详细信息失败')
} finally {
detailLoading.value = false
}
}
function handleTravel(nodeId: string) {
setActiveInterconnectNodeId(nodeId)
window.location.href = '/'
}
function openAddDialog() {
isEditing.value = false
currentForm.value = { name: '', url: '', token: generateRandomToken(), remark: '' }
dialogOpen.value = true
}
function openEditDialog(node: interconnectApi.InterconnectNode) {
isEditing.value = true
currentForm.value = { ...node }
dialogOpen.value = true
}
async function handleSave() {
if (!currentForm.value.name || (!isEditing.value && !currentForm.value.token)) {
toast.error('请填写必要信息')
return
}
try {
if (isEditing.value && currentForm.value.id) {
await interconnectApi.updateNode(currentForm.value.id, currentForm.value)
toast.success('更新成功')
} else {
await interconnectApi.createNode(currentForm.value)
toast.success('添加成功')
}
dialogOpen.value = false
emit('refresh')
} catch (error: any) {
toast.error(isEditing.value ? '更新失败' : '添加失败')
}
}
function confirmDelete(id: string) {
deleteId.value = id
showDeleteConfirm.value = true
}
async function handleDelete() {
if (!deleteId.value) return
showDeleteConfirm.value = false
try {
await interconnectApi.deleteNode(deleteId.value)
toast.success('删除成功')
emit('refresh')
} catch (error: any) {
toast.error('删除失败')
}
}
defineExpose({
openAddDialog
})
</script>
<template>
<div class="space-y-4">
<div class="rounded-lg border bg-card overflow-hidden">
<!-- ========== 1. 大屏布局 (Large >= 1280px) ========== -->
<div class="hidden xl:block">
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-56 shrink-0">节点名称</span>
<span class="flex-1 min-w-0">隧道地址 </span>
<span class="w-48 shrink-0">运行状态 & 负载</span>
<span class="w-48 shrink-0">备注</span>
<span class="w-40 shrink-0 text-center">操作</span>
</div>
<div class="divide-y text-sm">
<div v-if="loading" class="py-8 text-center text-muted-foreground">加载中...</div>
<div v-else-if="filteredNodes.length === 0" class="py-8 text-center text-muted-foreground">暂无连接的子节点</div>
<div v-for="(node, index) in filteredNodes" :key="`large-${node.id}`" class="flex items-center gap-2 px-4 py-2 hover:bg-muted/30 transition-colors">
<StatusDot
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
:title="nodeStatuses[node.id]?.status === 'loading' ? '检测中' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? '在线' : '离线')"
/>
<div class="w-12 shrink-0 text-muted-foreground tabular-nums text-[11px]">#{{ index + 1 }}</div>
<div class="w-56 shrink-0 flex items-center">
<span class="font-medium truncate" :title="node.name">{{ node.name }}</span>
</div>
<div class="flex-1 min-w-0 text-muted-foreground truncate text-xs bg-muted/40 px-2 py-1 rounded" style="font-family: Inter, sans-serif;" :title="node.url">
{{ node.url || '-' }}
</div>
<div class="w-48 shrink-0 flex flex-col justify-center cursor-pointer gap-1" @click="fetchNodeStatus(node.id)" title="点击刷新状态">
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
<div class="flex items-center gap-1.5">
<RefreshCw class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span class="text-xs text-muted-foreground">检测中...</span>
</div>
</template>
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
<div class="flex items-center gap-1.5" v-if="nodeStatuses[node.id]?.version">
<span class="text-xs text-muted-foreground font-normal">v{{ nodeStatuses[node.id].version }}</span>
</div>
<div class="flex items-center gap-2 text-[10px]" v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.mem_percent !== undefined && node.metrics?.cpu_percent !== 0">
<span :class="getLoadColor(node.metrics?.cpu_percent)">CPU: {{ node.metrics?.cpu_percent.toFixed(1) }}%</span>
<span :class="getLoadColor(node.metrics?.mem_percent)">Mem: {{ node.metrics?.mem_percent.toFixed(1) }}%</span>
</div>
</template>
<template v-else>
<div class="flex items-center gap-1.5">
<span class="text-xs text-destructive font-medium">离线</span>
</div>
</template>
</div>
<div class="w-48 shrink-0 truncate text-xs text-muted-foreground" :title="node.remark">
{{ node.remark || '-' }}
</div>
<div class="w-40 shrink-0 flex justify-center gap-1">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showDetail(node)" title="查看详情">
<Eye class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="handleTravel(node.id)" title="穿越到此子节点">
<ExternalLink class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEditDialog(node)" title="编辑">
<Edit2 class="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="confirmDelete(node.id)" title="删除">
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
</div>
<!-- ========== 2. 中屏布局 (Medium 640px - 1280px) ========== -->
<div class="hidden sm:block xl:hidden">
<div class="flex items-center gap-4 px-4 py-1.5 border-b bg-muted/20 text-xs text-muted-foreground font-medium">
<span class="w-12 shrink-0 pl-1">序号</span>
<span class="w-48 shrink-0">节点信息</span>
<span class="flex-1 min-w-0">隧道地址 </span>
<span class="w-32 shrink-0">状态与负载</span>
<span class="w-36 shrink-0 text-center">操作</span>
</div>
<div class="divide-y text-sm">
<div v-if="loading" class="py-8 text-center text-muted-foreground">加载中...</div>
<div v-else-if="filteredNodes.length === 0" class="py-8 text-center text-muted-foreground">暂无连接的子节点</div>
<div v-for="(node, index) in filteredNodes" :key="`medium-${node.id}`" class="flex items-center gap-2 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<StatusDot
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
/>
<div class="w-12 shrink-0 text-muted-foreground tabular-nums text-[10px]">#{{ index + 1 }}</div>
<div class="w-48 shrink-0 flex items-center overflow-hidden">
<div class="flex flex-col min-w-0">
<span class="font-medium truncate text-sm">{{ node.name }}</span>
<span v-if="node.remark" class="text-[10px] text-muted-foreground truncate">{{ node.remark }}</span>
</div>
</div>
<div class="flex-1 min-w-0 text-[11px] text-muted-foreground bg-muted/20 px-2 py-1 rounded truncate" style="font-family: Inter, sans-serif;" :title="node.url">
{{ node.url || '-' }}
</div>
<div class="w-32 shrink-0 flex flex-col justify-center cursor-pointer gap-0.5" @click="fetchNodeStatus(node.id)">
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
<RefreshCw class="h-4 w-4 animate-spin text-muted-foreground" />
</template>
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
<div class="flex items-center gap-1.5" v-if="nodeStatuses[node.id]?.version">
<span class="text-[10px] text-muted-foreground font-normal">v{{ nodeStatuses[node.id].version }}</span>
</div>
<div class="flex items-center gap-1 text-[9px]" v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.mem_percent !== undefined && node.metrics?.cpu_percent !== 0">
<span :class="getLoadColor(node.metrics?.cpu_percent)">C:{{ node.metrics?.cpu_percent.toFixed(0) }}%</span>
<span :class="getLoadColor(node.metrics?.mem_percent)">M:{{ node.metrics?.mem_percent.toFixed(0) }}%</span>
</div>
</template>
<template v-else>
<div class="flex items-center gap-1.5">
<span class="text-xs text-destructive font-medium">离线</span>
</div>
</template>
</div>
<div class="w-36 shrink-0 flex justify-center gap-0.5">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="showDetail(node)" title="查看详情"><Eye class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="handleTravel(node.id)" title="穿越到此子节点"><ExternalLink class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="openEditDialog(node)" title="编辑"><Edit2 class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="confirmDelete(node.id)" title="删除"><Trash2 class="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
</div>
<!-- ========== 3. 小屏布局 (Small < 640px) ========== -->
<div class="divide-y sm:hidden">
<div v-if="loading" class="text-sm text-muted-foreground text-center py-8">加载中...</div>
<div v-else-if="filteredNodes.length === 0" class="text-sm text-muted-foreground text-center py-8">暂无连接的子节点</div>
<div v-for="(node, index) in filteredNodes" :key="`small-${node.id}`" class="p-3 hover:bg-muted/50 transition-colors">
<div class="flex items-start justify-between mb-3 border-b border-border/40 pb-2">
<div class="flex items-center gap-2 flex-1 min-w-0 pr-2">
<StatusDot
:state="nodeStatuses[node.id]?.status === 'loading' ? 'pending' : (nodeStatuses[node.id]?.status === 'online' || node.status === 'online' ? 'online' : 'failed')"
class="mt-0.5"
/>
<span class="text-[10px] text-muted-foreground tabular-nums flex-shrink-0">#{{ index + 1 }}</span>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
<span class="font-bold text-sm truncate">{{ node.name }}</span>
</div>
</div>
</div>
<div class="space-y-1.5 text-xs text-muted-foreground mb-3 px-1">
<div class="flex items-start gap-3">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">隧道地址:</span>
<div class="flex-1 min-w-0 overflow-hidden text-foreground">
<div class="text-[11px] bg-muted/40 px-1 py-0.5 rounded break-all" style="font-family: Inter, sans-serif;">{{ node.url || '-' }}</div>
</div>
</div>
<div v-if="node.remark" class="flex items-start gap-3">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">备注:</span>
<span class="flex-1 text-[11px] truncate">{{ node.remark }}</span>
</div>
<div class="flex items-start gap-3 cursor-pointer" @click="fetchNodeStatus(node.id)">
<span class="w-10 shrink-0 font-medium mt-0.5 opacity-70">状态:</span>
<div class="flex-1 text-[11px] flex items-center gap-2 flex-wrap">
<template v-if="nodeStatuses[node.id]?.status === 'loading'">
<RefreshCw class="h-3 w-3 animate-spin" />检测中...
</template>
<template v-else-if="nodeStatuses[node.id]?.status === 'online' || node.status === 'online'">
<span v-if="nodeStatuses[node.id]?.version" class="text-muted-foreground">v{{ nodeStatuses[node.id].version }}</span>
<span v-if="node.metrics?.cpu_percent !== undefined && node.metrics?.cpu_percent !== 0" :class="getLoadColor(node.metrics?.cpu_percent)">CPU: {{ node.metrics?.cpu_percent.toFixed(1) }}%</span>
<span v-if="node.metrics?.mem_percent !== undefined && node.metrics?.mem_percent !== 0" :class="getLoadColor(node.metrics?.mem_percent)">Mem: {{ node.metrics?.mem_percent.toFixed(1) }}%</span>
</template>
<template v-else>
<span class="text-destructive font-medium">离线</span>
</template>
</div>
</div>
</div>
<div class="grid grid-cols-4 items-center pt-2 mt-2 border-t border-border/40 -mx-1">
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none" @click="showDetail(node)">
<Eye class="h-3.5 w-3.5" />详情
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="handleTravel(node.id)">
<ExternalLink class="h-3.5 w-3.5" />穿越
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="openEditDialog(node)">
<Edit2 class="h-3.5 w-3.5" />编辑
</Button>
<Button variant="ghost" class="h-9 px-0 text-xs gap-1 hover:bg-primary/5 rounded-none border-l border-border/10" @click="confirmDelete(node.id)">
<Trash2 class="h-3.5 w-3.5" />删除
</Button>
</div>
</div>
</div>
</div>
<!-- 添加/编辑弹窗 -->
<Dialog :open="dialogOpen" @update:open="dialogOpen = $event">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{{ isEditing ? '编辑子节点' : '生成子节点专属接入密钥' }}</DialogTitle>
<DialogDescription>
{{ isEditing ? '修改节点的备注名称信息。' : '保存后请将此密钥粘贴到子节点的配置界面中建立连接。' }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="name">节点名称 <span class="text-destructive">*</span></Label>
<Input id="name" v-model="currentForm.name" placeholder="例如:海外节点-洛杉矶" autocomplete="off" />
</div>
<div class="grid gap-2" v-if="!isEditing">
<Label>专属互联密钥 (Token) <span class="text-destructive">*</span></Label>
<div class="flex items-center gap-2">
<Input v-model="currentForm.token" readonly class="font-mono text-sm bg-muted/30 placeholder:font-sans" />
<Button variant="outline" size="icon" @click="currentForm.token = generateRandomToken()" title="重新生成">
<RefreshCw class="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" @click="handleCopy(currentForm.token || '')" :title="copied ? '已复制' : '复制'">
<Copy class="h-4 w-4" :class="{ 'text-green-500': copied }" />
</Button>
</div>
<p class="text-xs text-orange-500 mt-1">请务必复制上方密钥一旦关闭窗口将无法再次查看完整密钥</p>
</div>
<div class="grid gap-2">
<Label for="remark">备注</Label>
<Input id="remark" v-model="currentForm.remark" placeholder="选填,关于该节点的附加说明" autocomplete="off" />
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="dialogOpen = false">取消</Button>
<Button @click="handleSave">保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 删除确认弹窗 -->
<AlertDialog :open="showDeleteConfirm" @update:open="showDeleteConfirm = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确定删除该互联节点</AlertDialogTitle>
<AlertDialogDescription>
此操作不可恢复节点连接信息将被永久删除
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">确认删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- 详情弹窗 -->
<Dialog :open="detailOpen" @update:open="detailOpen = $event">
<DialogContent class="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<span>节点详情</span>
<span class="text-sm font-normal text-muted-foreground">({{ selectedNodeName }})</span>
</DialogTitle>
<DialogDescription>
查看子节点的系统环境硬件指标和任务调度器运行状态
</DialogDescription>
</DialogHeader>
<!-- 加载中 -->
<div v-if="detailLoading" class="py-12 flex flex-col items-center justify-center space-y-4 animate-in fade-in duration-300">
<div class="relative flex items-center justify-center">
<!-- 外圈渐变呼吸环 -->
<div class="absolute h-10 w-10 rounded-full border border-primary/25 animate-ping"></div>
<!-- 旋转环 -->
<div class="h-10 w-10 rounded-full border-2 border-primary/10 border-t-primary animate-spin"></div>
</div>
<span class="text-xs font-medium text-muted-foreground/80 tracking-wider animate-pulse">正在获取远程数据...</span>
</div>
<!-- 加载失败或无数据 -->
<div v-else-if="!selectedNodeDetail" class="py-12 flex flex-col items-center justify-center gap-2 text-destructive">
<p class="text-sm font-medium">获取数据失败</p>
<p class="text-xs text-muted-foreground">子节点可能处于离线状态或者网络连接超时</p>
</div>
<!-- 数据展示 -->
<div v-else class="space-y-6 py-2">
<!-- 1. 硬件状态 -->
<div class="space-y-3">
<h3 class="text-sm font-semibold border-b pb-1">系统资源负载</h3>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<!-- CPU -->
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">CPU 使用率</span>
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.cpu_percent)">{{ selectedNodeDetail.host?.cpu_percent?.toFixed(1) }}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.cpu_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.cpu_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.cpu_percent || 0}%` }"></div>
</div>
</div>
<!-- 内存 -->
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">内存使用率</span>
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.mem_percent)">{{ selectedNodeDetail.host?.mem_percent?.toFixed(1) }}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.mem_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.mem_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.mem_percent || 0}%` }"></div>
</div>
<div class="text-[10px] text-muted-foreground flex justify-between">
<span>{{ formatBytes(selectedNodeDetail.host?.mem_used) }}</span>
<span>{{ formatBytes(selectedNodeDetail.host?.mem_total) }}</span>
</div>
</div>
<!-- 磁盘 -->
<div class="rounded-lg border p-3 bg-muted/10 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-muted-foreground">磁盘使用率</span>
<span class="font-medium" :class="getLoadColor(selectedNodeDetail.host?.disk_percent)">{{ selectedNodeDetail.host?.disk_percent?.toFixed(1) }}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class="h-2 rounded-full transition-all duration-300" :class="selectedNodeDetail.host?.disk_percent >= 80 ? 'bg-destructive' : selectedNodeDetail.host?.disk_percent >= 50 ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${selectedNodeDetail.host?.disk_percent || 0}%` }"></div>
</div>
<div class="text-[10px] text-muted-foreground flex justify-between">
<span>{{ formatBytes(selectedNodeDetail.host?.disk_used) }}</span>
<span>{{ formatBytes(selectedNodeDetail.host?.disk_total) }}</span>
</div>
</div>
</div>
</div>
<!-- 2. 环境信息 -->
<div class="space-y-3">
<h3 class="text-sm font-semibold border-b pb-1">运行环境与系统</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">操作系统</span>
<span class="font-medium uppercase">{{ selectedNodeDetail.env?.os }} ({{ selectedNodeDetail.env?.arch }})</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">系统平台</span>
<span class="font-medium truncate max-w-[160px]" :title="selectedNodeDetail.host?.platform">{{ selectedNodeDetail.host?.platform || '-' }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">CPU 核心数</span>
<span class="font-medium">{{ selectedNodeDetail.env?.num_cpu }} </span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">Goroutine 数量</span>
<span class="font-medium">{{ selectedNodeDetail.env?.goroutines }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">Go 编译版本</span>
<span class="font-medium">{{ selectedNodeDetail.env?.go_version }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40">
<span class="text-muted-foreground">节点运行时间</span>
<span class="font-medium">{{ formatUptime(selectedNodeDetail.host?.uptime) }}</span>
</div>
</div>
</div>
<!-- 3. 物理隧道连接状态 -->
<div class="space-y-3" v-if="selectedNodeDetail.tunnel_connected">
<h3 class="text-sm font-semibold border-b pb-1">物理隧道连接状态</h3>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
<div class="flex justify-between py-1 border-b border-border/40 col-span-2">
<span class="text-muted-foreground">隧道内部通讯地址</span>
<span class="font-medium truncate max-w-[280px]" :title="selectedNodeDetail.tunnel_url">{{ selectedNodeDetail.tunnel_url }}</span>
</div>
<div class="flex justify-between py-1 border-b border-border/40 col-span-2" v-if="selectedNodeDetail.host?.tx_bytes !== undefined || selectedNodeDetail.host?.rx_bytes !== undefined">
<span class="text-muted-foreground">实时累加隧道流量</span>
<span class="font-medium">
<span class="text-green-500" title="发送 (TX)">{{ formatBytes(selectedNodeDetail.host?.tx_bytes) }}</span>
<span class="mx-1 text-muted-foreground">/</span>
<span class="text-blue-500" title="接收 (RX)">{{ formatBytes(selectedNodeDetail.host?.rx_bytes) }}</span>
</span>
</div>
</div>
</div>
<!-- 4. 调度器与任务统计 -->
<div class="space-y-3" v-if="selectedNodeDetail.scheduler">
<h3 class="text-sm font-semibold border-b pb-1">任务调度统计</h3>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">计划中任务</div>
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.scheduled ?? 0 }}</div>
</div>
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">正在运行任务</div>
<div class="text-lg font-bold tabular-nums mt-0.5 text-green-500">{{ selectedNodeDetail.scheduler.running ?? 0 }}</div>
</div>
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">队列任务积压</div>
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.queue_size ?? 0 }}</div>
</div>
<div class="rounded border p-2 bg-muted/5">
<div class="text-[10px] text-muted-foreground">并发工作协程</div>
<div class="text-lg font-bold tabular-nums mt-0.5">{{ selectedNodeDetail.scheduler.worker_count ?? 0 }}</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</template>