Files
TaskPool/web/src/views/interconnect/components/MasterList.vue
T
2026-06-25 21:33:50 +08:00

604 lines
31 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.
<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>