feat(portal): replace Sun-Panel with custom Apple Dark Glass HomeLab OS Portal + live telemetry API
This commit is contained in:
1 parent
5dafc94d5e
commit
a072a62141
3 files changed
+1025
-1
No files matched your search
@@ -22,7 +22,7 @@ ARCHIVE="$NAS_BACKUP_DIR/homelab_backup_${TIMESTAMP}.tar.gz"
|
||||
tar -czf "$ARCHIVE" \
|
||||
-C /Users/ixvii0iv/homelab \
|
||||
stacks/vaultwarden/data \
|
||||
stacks/sun-panel/conf \
|
||||
stacks/portal \
|
||||
configs \
|
||||
scripts \
|
||||
README.md PLAN.md CHANGELOG.md 2>/dev/null || true
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>HomeLab OS · M6 All-in-One 中控台</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['-apple-system', 'BlinkMacSystemFont', 'SF Pro Display', 'Inter', 'PingFang SC', 'sans-serif'],
|
||||
mono: ['SF Mono', 'JetBrains Mono', 'Menlo', 'monospace'],
|
||||
},
|
||||
colors: {
|
||||
border: 'rgba(255, 255, 255, 0.08)',
|
||||
surface: 'rgba(24, 24, 27, 0.65)',
|
||||
surfaceHover: 'rgba(39, 39, 42, 0.75)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
radial-gradient(at 15% 0%, rgba(56, 189, 248, 0.10) 0px, transparent 45%),
|
||||
radial-gradient(at 85% 5%, rgba(139, 92, 246, 0.10) 0px, transparent 45%),
|
||||
radial-gradient(at 50% 100%, rgba(16, 185, 129, 0.06) 0px, transparent 50%);
|
||||
background-attachment: fixed;
|
||||
color: #f4f4f5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.glass-card {
|
||||
background: rgba(24, 24, 27, 0.62);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 10px 30px -10px rgba(0, 0, 0, 0.5), inset 0 1px 0 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.glass-card-interactive {
|
||||
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.glass-card-interactive:hover {
|
||||
background: rgba(39, 39, 42, 0.72);
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.7), inset 0 1px 0 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.progress-bar {
|
||||
transition: width 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
[v-cloak] { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen selection:bg-sky-500/30 selection:text-sky-200">
|
||||
<div id="app" v-cloak class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 space-y-6">
|
||||
|
||||
<!-- Top Toast Notification -->
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="transform -translate-y-4 opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="transform translate-y-0 opacity-100"
|
||||
leave-to-class="transform -translate-y-4 opacity-0"
|
||||
>
|
||||
<div v-if="toast" class="fixed top-5 right-5 z-50 glass-card px-4 py-3 rounded-xl border border-emerald-500/30 flex items-center gap-3 shadow-2xl">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-ping"></span>
|
||||
<span class="text-sm font-medium text-zinc-100">{{ toast }}</span>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Header / Navbar -->
|
||||
<header class="glass-card rounded-2xl p-4 sm:px-6 flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3.5">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-sky-500 via-indigo-500 to-purple-600 flex items-center justify-center shadow-lg shadow-indigo-500/20 border border-white/20">
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<h1 class="text-lg sm:text-xl font-semibold tracking-tight text-white">HomeLab OS</h1>
|
||||
<span class="px-2 py-0.5 text-[11px] font-mono font-medium rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/25 flex items-center gap-1.5">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
4/4 节点在线
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-400 mt-0.5 font-mono">
|
||||
Apple M6 Mac mini (192.168.31.164) ⇄ 极空间 Z4 (20TB) ⇄ SG Azure (104.43.105.243)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Controls: Mode Switcher & Embedded AGY Drawer Trigger -->
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<div class="bg-zinc-950/80 p-1 rounded-xl border border-white/10 flex items-center text-xs">
|
||||
<button
|
||||
v-for="m in modes"
|
||||
:key="m.id"
|
||||
@click="routeMode = m.id"
|
||||
:class="[
|
||||
'px-2.5 py-1.5 rounded-lg font-medium transition-all flex items-center gap-1.5',
|
||||
routeMode === m.id
|
||||
? 'bg-zinc-800 text-white shadow-sm border border-white/10'
|
||||
: 'text-zinc-400 hover:text-zinc-200'
|
||||
]"
|
||||
>
|
||||
<span>{{ m.icon }}</span>
|
||||
<span>{{ m.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="showAgyDrawer = true"
|
||||
class="px-3.5 py-2 rounded-xl text-xs font-medium bg-gradient-to-r from-sky-500/20 to-indigo-500/20 hover:from-sky-500/30 hover:to-indigo-500/30 text-sky-300 border border-sky-500/30 transition-all flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-sky-400 animate-ping"></span>
|
||||
<span>页内唤起 AGY 终端</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Bento Grid: 3 Real-Time Telemetry Cards -->
|
||||
<section class="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
|
||||
<!-- 1. M6 Mac mini Telemetry -->
|
||||
<div class="glass-card rounded-2xl p-5 flex flex-col justify-between space-y-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-sky-400 font-semibold">Core Compute Server</span>
|
||||
<span class="px-2 py-0.5 text-[10px] font-mono rounded-md bg-zinc-800/90 text-zinc-300 border border-white/5">
|
||||
UP {{ telemetry.mini.uptime }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-white mt-1 flex items-center gap-2">
|
||||
<span>🖥️ Mac mini · {{ telemetry.mini.chip }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-zinc-400 bg-zinc-900/90 px-2.5 py-1 rounded-lg border border-white/5">
|
||||
{{ telemetry.mini.ip }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Resource Bars -->
|
||||
<div class="space-y-3 pt-1">
|
||||
<!-- CPU -->
|
||||
<div>
|
||||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-zinc-400">CPU 负载 (12核 M6 · Load {{ telemetry.mini.load_1m }})</span>
|
||||
<span class="font-mono text-zinc-200 font-medium">{{ telemetry.mini.cpu_pct }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
|
||||
<div class="h-full bg-gradient-to-r from-sky-500 to-indigo-500 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.mini.cpu_pct) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RAM -->
|
||||
<div>
|
||||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-zinc-400">统一内存 ({{ telemetry.mini.mem_used_gb }} / {{ telemetry.mini.mem_total_gb }} GB)</span>
|
||||
<span class="font-mono text-zinc-200 font-medium">{{ telemetry.mini.mem_pct }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
|
||||
<div class="h-full bg-gradient-to-r from-indigo-500 to-purple-500 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.mini.mem_pct) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSD -->
|
||||
<div>
|
||||
<div class="flex justify-between text-xs mb-1">
|
||||
<span class="text-zinc-400">系统闪存 ({{ telemetry.mini.disk_used_gb }} / {{ telemetry.mini.disk_total_gb }} GB)</span>
|
||||
<span class="font-mono text-zinc-200 font-medium">{{ telemetry.mini.disk_pct }}%</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
|
||||
<div class="h-full bg-gradient-to-r from-emerald-500 to-teal-400 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.mini.disk_pct) + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Pills -->
|
||||
<div class="pt-2 border-t border-white/5 flex flex-wrap items-center justify-between gap-2 text-[11px] font-mono text-zinc-400">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400"></span>
|
||||
<span>AGY tmux: {{ telemetry.mini.agy_tmux ? '常驻运行中' : '待机' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
OrbStack 容器: <span class="text-zinc-200 font-semibold">{{ telemetry.mini.containers.length }} 运行中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. ZSpace Z4 20TB Storage Telemetry -->
|
||||
<div class="glass-card rounded-2xl p-5 flex flex-col justify-between space-y-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-emerald-400 font-semibold">Storage Pool & Backup</span>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 text-[10px] font-mono rounded-md border',
|
||||
telemetry.nas.mounted
|
||||
? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/25'
|
||||
: 'bg-amber-500/15 text-amber-300 border-amber-500/25'
|
||||
]"
|
||||
>
|
||||
{{ telemetry.nas.mounted ? 'SMB 已挂载 ~/nas' : '未挂载' }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-white mt-1">
|
||||
💾 极空间 Z4 · 20TB 存储中心
|
||||
</h2>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-zinc-400 bg-zinc-900/90 px-2.5 py-1 rounded-lg border border-white/5">
|
||||
{{ telemetry.nas.ip }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Big Storage Meter -->
|
||||
<div class="bg-zinc-950/60 rounded-xl p-3.5 border border-white/5 space-y-2.5">
|
||||
<div class="flex items-baseline justify-between">
|
||||
<div>
|
||||
<span class="text-2xl font-bold tracking-tight text-white font-mono">{{ telemetry.nas.free_tb }} TB</span>
|
||||
<span class="text-xs text-zinc-400 ml-1.5">剩余可用</span>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-zinc-400">
|
||||
已用 {{ telemetry.nas.used_tb }} TB / 总计 {{ telemetry.nas.total_tb }} TB ({{ telemetry.nas.pct }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full h-2.5 bg-zinc-900 rounded-full overflow-hidden border border-white/5">
|
||||
<div class="h-full bg-gradient-to-r from-emerald-500 via-teal-400 to-cyan-400 rounded-full progress-bar" :style="{ width: Math.max(4, telemetry.nas.pct) + '%' }"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px] text-zinc-400 font-mono pt-0.5">
|
||||
<span>挂载点: ~/nas/{共享文件,个人空间}</span>
|
||||
<span>Time Machine: 手动按需</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Action Row -->
|
||||
<div class="pt-2 border-t border-white/5 flex items-center justify-between gap-2">
|
||||
<div class="text-[11px] text-zinc-400 truncate">
|
||||
最近自动备份: <span class="font-mono text-zinc-200">{{ telemetry.nas.latest_backup }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="triggerAction('backup_nas', '正在打包备份至极空间 Z4...')"
|
||||
:disabled="actionLoading"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-100 border border-white/10 transition-all shrink-0 flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<span>⚡ 立即备份</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. AI Routing & SG Cloud Edge Telemetry -->
|
||||
<div class="glass-card rounded-2xl p-5 flex flex-col justify-between space-y-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono uppercase tracking-wider text-purple-400 font-semibold">AI Egress & Cloud Edge</span>
|
||||
<span class="px-2 py-0.5 text-[10px] font-mono rounded-md bg-purple-500/15 text-purple-300 border border-purple-500/25">
|
||||
SG 出口死锁保护
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="text-base font-semibold text-white mt-1">
|
||||
🛡️ 小米主路由 ⇄ 新加坡 Azure
|
||||
</h2>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
|
||||
{{ telemetry.network.sg_delay_ms > 0 ? telemetry.network.sg_delay_ms + ' ms' : '专线直连' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Network Rows -->
|
||||
<div class="space-y-2 text-xs font-mono">
|
||||
<div class="flex items-center justify-between bg-zinc-950/60 px-3 py-2 rounded-xl border border-white/5">
|
||||
<span class="text-zinc-400">AGY / AI 分流策略</span>
|
||||
<span class="text-purple-300 font-medium">{{ telemetry.network.ai_policy }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-zinc-950/60 px-3 py-2 rounded-xl border border-white/5">
|
||||
<span class="text-zinc-400">AI 锁定公网出口 (SG)</span>
|
||||
<span class="text-zinc-100">{{ telemetry.network.sg_egress_ip }} ({{ telemetry.network.sg_node }})</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between bg-zinc-950/60 px-3 py-2 rounded-xl border border-white/5">
|
||||
<span class="text-zinc-400">家庭联通公网 IPv4</span>
|
||||
<span class="text-sky-300">{{ telemetry.network.home_wan_ip }} (*.home.961504.xyz)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="pt-2 border-t border-white/5 flex items-center justify-between text-[11px] text-zinc-400">
|
||||
<span class="font-mono">Tailscale P2P: 100.108.205.40 ⇄ 100.115.204.51</span>
|
||||
<button
|
||||
@click="triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...')"
|
||||
:disabled="actionLoading"
|
||||
class="px-2.5 py-1 rounded-lg text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-white/10 transition-all"
|
||||
>
|
||||
刷新 DDNS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Main Services Launchpad Grid -->
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wider text-zinc-400">全屋核心服务矩阵 (Service Launchpad)</h2>
|
||||
<span class="text-xs text-zinc-500 font-mono">
|
||||
当前点击跳转模式: {{ activeModeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-500 hidden sm:inline">所有受保护服务统一账号: <code class="text-zinc-300 font-mono">ixvii0iv</code></span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<a
|
||||
v-for="svc in services"
|
||||
:key="svc.id"
|
||||
:href="resolveUrl(svc)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="glass-card glass-card-interactive rounded-2xl p-5 flex flex-col justify-between group cursor-pointer relative overflow-hidden"
|
||||
>
|
||||
<!-- Subtle Top Gradient Glow -->
|
||||
<div :class="['absolute -right-10 -top-10 w-28 h-28 rounded-full blur-2xl opacity-20 group-hover:opacity-40 transition-opacity', svc.glow]"></div>
|
||||
|
||||
<div class="space-y-3 relative z-10">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div :class="['w-11 h-11 rounded-xl flex items-center justify-center text-xl border border-white/15 shadow-inner', svc.iconBg]">
|
||||
{{ svc.emoji }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-white group-hover:text-sky-300 transition-colors">
|
||||
{{ svc.title }}
|
||||
</h3>
|
||||
<span
|
||||
:class="[
|
||||
'w-2 h-2 rounded-full',
|
||||
telemetry.services[svc.healthKey] !== false ? 'bg-emerald-400 shadow-[0_0_8px_#34d399]' : 'bg-rose-500'
|
||||
]"
|
||||
></span>
|
||||
</div>
|
||||
<span class="text-[11px] font-mono text-zinc-400">{{ svc.badge }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-zinc-500 group-hover:text-zinc-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 17L17 7M17 7H7M17 7V17"/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-zinc-400 leading-relaxed">
|
||||
{{ svc.desc }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-3.5 mt-4 border-t border-white/5 flex items-center justify-between text-[11px] font-mono relative z-10">
|
||||
<span class="text-zinc-400 truncate max-w-[72%]">{{ resolveUrl(svc) }}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
v-if="svc.id === 'agy'"
|
||||
@click.prevent="showAgyDrawer = true"
|
||||
class="px-2 py-0.5 rounded bg-sky-500/20 text-sky-300 border border-sky-500/30 hover:bg-sky-500/30"
|
||||
>
|
||||
页内打开
|
||||
</button>
|
||||
<span v-else class="px-2 py-0.5 rounded bg-zinc-800/80 text-zinc-300 border border-white/5">
|
||||
{{ resolveTag(svc) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SSH & Terminal Quick Commands Bar -->
|
||||
<section class="glass-card rounded-2xl p-4 sm:px-6 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
<span class="text-zinc-200 font-semibold">⌨️ 终端快捷指令 (点击即复制):</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-for="cmd in sshCommands"
|
||||
:key="cmd.cmd"
|
||||
@click="copyText(cmd.cmd, cmd.label)"
|
||||
class="px-3 py-1.5 rounded-xl bg-zinc-950/80 hover:bg-zinc-800/90 border border-white/10 text-xs font-mono text-zinc-300 hover:text-white transition-all flex items-center gap-2"
|
||||
>
|
||||
<span class="text-zinc-500">{{ cmd.label }}:</span>
|
||||
<span class="text-sky-300">{{ cmd.cmd }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Slide-over AGY Cloud Terminal Drawer / Modal -->
|
||||
<div v-if="showAgyDrawer" class="fixed inset-0 z-50 bg-black/80 backdrop-blur-md flex items-center justify-center p-2 sm:p-6">
|
||||
<div class="w-full h-full max-w-6xl bg-zinc-950 rounded-2xl border border-white/15 shadow-2xl flex flex-col overflow-hidden">
|
||||
<div class="px-4 py-3 bg-zinc-900/90 border-b border-white/10 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button @click="showAgyDrawer = false" class="w-3 h-3 rounded-full bg-rose-500 hover:opacity-80"></button>
|
||||
<span class="w-3 h-3 rounded-full bg-amber-500"></span>
|
||||
<a :href="agyIframeUrl" target="_blank" class="w-3 h-3 rounded-full bg-emerald-500 hover:opacity-80"></a>
|
||||
</div>
|
||||
<span class="text-xs font-mono text-zinc-200 font-medium">
|
||||
🤖 AGY 云端工作台 (tmux attach -t agy · 关窗不断连)
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
:href="agyIframeUrl"
|
||||
target="_blank"
|
||||
class="px-2.5 py-1 rounded-lg text-xs font-mono bg-zinc-800 hover:bg-zinc-700 text-zinc-200 border border-white/10"
|
||||
>
|
||||
↗ 新标签页全屏打开
|
||||
</a>
|
||||
<button
|
||||
@click="showAgyDrawer = false"
|
||||
class="px-2.5 py-1 rounded-lg text-xs bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/30"
|
||||
>
|
||||
关闭窗口 (后台继续运行)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe :src="agyIframeUrl" class="w-full flex-1 bg-black border-0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const routeMode = ref('auto');
|
||||
const showAgyDrawer = ref(false);
|
||||
const toast = ref('');
|
||||
const actionLoading = ref(false);
|
||||
|
||||
const modes = [
|
||||
{ id: 'auto', icon: '⚡', label: '智能自动' },
|
||||
{ id: 'edge443', icon: '🌐', label: '443 免端口' },
|
||||
{ id: 'home10443', icon: '🏠', label: '10443 家宽' },
|
||||
{ id: 'lan', icon: '🔌', label: '局域网 LAN' },
|
||||
];
|
||||
|
||||
const telemetry = ref({
|
||||
mini: {
|
||||
chip: 'Apple M6 (12核)',
|
||||
ip: '192.168.31.164',
|
||||
cpu_pct: 5.2,
|
||||
load_1m: 0.65,
|
||||
mem_used_gb: 6.4,
|
||||
mem_total_gb: 24.0,
|
||||
mem_pct: 26.7,
|
||||
disk_used_gb: 28.5,
|
||||
disk_total_gb: 460.4,
|
||||
disk_pct: 6.2,
|
||||
uptime: '运行中',
|
||||
containers: [{ name: 'vaultwarden' }],
|
||||
agy_tmux: true,
|
||||
},
|
||||
nas: {
|
||||
name: '极空间 Z4',
|
||||
ip: '192.168.31.185',
|
||||
online: true,
|
||||
mounted: true,
|
||||
total_tb: 20.0,
|
||||
used_tb: 2.9,
|
||||
free_tb: 17.1,
|
||||
pct: 14.5,
|
||||
latest_backup: '已就绪',
|
||||
},
|
||||
network: {
|
||||
home_wan_ip: '124.90.142.21',
|
||||
ai_policy: 'Ai平台 → 狮城节点',
|
||||
sg_node: 'coco-sea1-reality-sony',
|
||||
sg_egress_ip: '104.43.105.243',
|
||||
sg_delay_ms: 48,
|
||||
},
|
||||
services: {
|
||||
agy: true,
|
||||
vaultwarden: true,
|
||||
nas: true,
|
||||
clash: true,
|
||||
router: true,
|
||||
uptime: true,
|
||||
}
|
||||
});
|
||||
|
||||
const services = [
|
||||
{
|
||||
id: 'agy',
|
||||
healthKey: 'agy',
|
||||
emoji: '🤖',
|
||||
title: 'AGY 云端工作台',
|
||||
badge: 'ttyd + tmux 24h 常驻终端',
|
||||
desc: 'Mac mini 上的 Antigravity AI 编程终端,已同步 Google 登录态并锁定新加坡固定出口,关掉网页任务继续在后台跑。',
|
||||
edgeUrl: 'https://agy.961504.xyz',
|
||||
homeUrl: 'https://agy.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:7681',
|
||||
iconBg: 'bg-sky-500/20 text-sky-300',
|
||||
glow: 'bg-sky-500',
|
||||
},
|
||||
{
|
||||
id: 'zspace',
|
||||
healthKey: 'nas',
|
||||
emoji: '💾',
|
||||
title: '极空间 Z4 私有云',
|
||||
badge: '20TB RAID 存储池 · 极影视',
|
||||
desc: '家庭 20TB 核心大容量存储、极影视蓝光家庭影院、手机相册备份中心,已自动 SMB 挂载至 Mac mini ~/nas。',
|
||||
edgeUrl: 'https://z4.home.961504.xyz:10443',
|
||||
homeUrl: 'https://z4.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.185:5055',
|
||||
iconBg: 'bg-emerald-500/20 text-emerald-300',
|
||||
glow: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
healthKey: 'vaultwarden',
|
||||
emoji: '🔐',
|
||||
title: 'Vaultwarden 密码保险箱',
|
||||
badge: 'Bitwarden 官方客户端兼容',
|
||||
desc: '自托管全平台加密密码库,支持 iOS / Android / Chrome 自动填充,每天凌晨 03:30 自动打包灾备至极空间 Z4。',
|
||||
edgeUrl: 'https://vw.961504.xyz',
|
||||
homeUrl: 'https://vw.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:8222',
|
||||
iconBg: 'bg-indigo-500/20 text-indigo-300',
|
||||
glow: 'bg-indigo-500',
|
||||
},
|
||||
{
|
||||
id: 'clash',
|
||||
healthKey: 'clash',
|
||||
emoji: '🚀',
|
||||
title: 'Mihomo 路由分流面板',
|
||||
badge: 'ShellCrash 透明网关控制台',
|
||||
desc: '小米主路由器实时流量监控、策略组切换与规则明细(Ai平台已死锁狮城节点 104.43.105.243)。',
|
||||
edgeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||
homeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||
lanUrl: 'http://192.168.31.1:9999/ui/',
|
||||
iconBg: 'bg-purple-500/20 text-purple-300',
|
||||
glow: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
id: 'router',
|
||||
healthKey: 'router',
|
||||
emoji: '📡',
|
||||
title: '小米主路由器后台',
|
||||
badge: '192.168.31.1 · PPPoE & DNAT',
|
||||
desc: '管理家庭联通宽带拨号、DHCP 静态 IP 绑定、10443 与 41641 端口映射及全屋 Wi-Fi 设备状态。',
|
||||
edgeUrl: 'https://router.home.961504.xyz:10443',
|
||||
homeUrl: 'https://router.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.1',
|
||||
iconBg: 'bg-amber-500/20 text-amber-300',
|
||||
glow: 'bg-amber-500',
|
||||
},
|
||||
{
|
||||
id: 'uptime',
|
||||
healthKey: 'uptime',
|
||||
emoji: '📈',
|
||||
title: 'Uptime Kuma 外部哨兵',
|
||||
badge: 'SG Azure 104.43.105.243',
|
||||
desc: '独立部署在新加坡公有云上的 24 小时外部监控哨兵,从家庭外部实时探测家里宽带与各项服务在线率。',
|
||||
edgeUrl: 'https://status.961504.xyz',
|
||||
homeUrl: 'https://status.961504.xyz',
|
||||
lanUrl: 'https://status.961504.xyz',
|
||||
iconBg: 'bg-teal-500/20 text-teal-300',
|
||||
glow: 'bg-teal-500',
|
||||
},
|
||||
];
|
||||
|
||||
const sshCommands = [
|
||||
{ label: '连 Mac mini', cmd: 'ssh mini' },
|
||||
{ label: '接管 AGY 会话', cmd: 'ssh mini -t "tmux attach -t agy"' },
|
||||
{ label: '连新加坡 Azure', cmd: 'ssh azure' },
|
||||
{ label: '连小米路由器', cmd: 'ssh root@192.168.31.1' },
|
||||
];
|
||||
|
||||
const effectiveMode = computed(() => {
|
||||
if (routeMode.value !== 'auto') return routeMode.value;
|
||||
const host = window.location.hostname;
|
||||
if (host.startsWith('192.168.') || host.startsWith('100.') || host === 'localhost') {
|
||||
return 'lan';
|
||||
}
|
||||
if (host.includes('.home.961504.xyz') || window.location.port === '10443') {
|
||||
return 'home10443';
|
||||
}
|
||||
return 'edge443';
|
||||
});
|
||||
|
||||
const activeModeLabel = computed(() => {
|
||||
const m = effectiveMode.value;
|
||||
if (m === 'lan') return '局域网超低延迟直连 (192.168.31.x)';
|
||||
if (m === 'home10443') return '家宽公网 HTTPS 直连 (*.home.961504.xyz:10443)';
|
||||
return '443 免端口 HTTPS 直达 (*.961504.xyz)';
|
||||
});
|
||||
|
||||
const resolveUrl = (svc) => {
|
||||
const m = effectiveMode.value;
|
||||
if (m === 'lan') return svc.lanUrl;
|
||||
if (m === 'home10443') return svc.homeUrl;
|
||||
return svc.edgeUrl;
|
||||
};
|
||||
|
||||
const resolveTag = (svc) => {
|
||||
const m = effectiveMode.value;
|
||||
if (m === 'lan') return 'LAN 极速';
|
||||
if (m === 'home10443') return '10443 直连';
|
||||
return '443 免端口';
|
||||
};
|
||||
|
||||
const agyIframeUrl = computed(() => {
|
||||
return resolveUrl(services[0]);
|
||||
});
|
||||
|
||||
const showToast = (msg) => {
|
||||
toast.value = msg;
|
||||
setTimeout(() => {
|
||||
if (toast.value === msg) toast.value = '';
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const copyText = async (text, label) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
showToast(`已复制「${label}」指令: ${text}`);
|
||||
} catch {
|
||||
showToast(`指令: ${text}`);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTelemetry = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/telemetry');
|
||||
if (res.ok) {
|
||||
telemetry.value = await res.json();
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const triggerAction = async (action, startMsg) => {
|
||||
actionLoading.value = true;
|
||||
showToast(startMsg);
|
||||
try {
|
||||
const res = await fetch(`/api/action/${action}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showToast(data.message || '操作成功');
|
||||
await fetchTelemetry();
|
||||
} catch (e) {
|
||||
showToast('执行失败,请稍后重试');
|
||||
} finally {
|
||||
actionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchTelemetry();
|
||||
setInterval(fetchTelemetry, 3000);
|
||||
});
|
||||
|
||||
return {
|
||||
modes,
|
||||
routeMode,
|
||||
effectiveMode,
|
||||
activeModeLabel,
|
||||
telemetry,
|
||||
services,
|
||||
sshCommands,
|
||||
showAgyDrawer,
|
||||
agyIframeUrl,
|
||||
toast,
|
||||
actionLoading,
|
||||
resolveUrl,
|
||||
resolveTag,
|
||||
copyText,
|
||||
triggerAction,
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+328
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==============================================================================
|
||||
# HomeLab All-in-One 专属中控台后端服务 (Port 3002)
|
||||
# 提供 Apple/Shadcn 风格单页应用与全屋设备实时遥测 API (/api/telemetry, /api/action)
|
||||
# ==============================================================================
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
PORT = int(os.environ.get("PORT", "3002"))
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
INDEX_FILE = BASE_DIR / "index.html"
|
||||
|
||||
_cache = {"data": None, "ts": 0.0}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def run_cmd(cmd: str, timeout: float = 2.0) -> str:
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
res = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True, timeout=timeout, env=env
|
||||
)
|
||||
return res.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def check_tcp(host: str, port: int, timeout: float = 0.8) -> tuple[bool, int]:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=timeout):
|
||||
ms = int((time.monotonic() - t0) * 1000)
|
||||
return True, max(1, ms)
|
||||
except Exception:
|
||||
return False, -1
|
||||
|
||||
|
||||
def get_mac_mini_stats() -> dict:
|
||||
# 1. CPU Load & Core count
|
||||
load_str = run_cmd("sysctl -n vm.loadavg")
|
||||
# Format: "{ 1.42 1.18 1.05 }"
|
||||
loads = [float(x) for x in re.findall(r"[0-9]+\.[0-9]+", load_str)] or [0.0, 0.0, 0.0]
|
||||
cores = int(run_cmd("sysctl -n hw.ncpu") or "12")
|
||||
cpu_pct = min(100, round((loads[0] / max(1, cores)) * 100, 1))
|
||||
|
||||
# 2. Memory (24GB total)
|
||||
total_bytes = int(run_cmd("sysctl -n hw.memsize") or str(24 * 1024**3))
|
||||
total_gb = round(total_bytes / (1024**3), 1)
|
||||
vm_out = run_cmd("vm_stat")
|
||||
page_size = 16384
|
||||
m_ps = re.search(r"page size of (\d+) bytes", vm_out)
|
||||
if m_ps:
|
||||
page_size = int(m_ps.group(1))
|
||||
pages = {}
|
||||
for line in vm_out.splitlines():
|
||||
if ":" in line:
|
||||
k, v = line.split(":", 1)
|
||||
v_clean = re.sub(r"[^0-9]", "", v)
|
||||
if v_clean:
|
||||
pages[k.strip()] = int(v_clean)
|
||||
used_pages = (
|
||||
pages.get("Pages active", 0)
|
||||
+ pages.get("Pages wired down", 0)
|
||||
+ pages.get("Pages occupied by compressor", 0)
|
||||
)
|
||||
used_gb = round((used_pages * page_size) / (1024**3), 1)
|
||||
mem_pct = min(100, round((used_gb / max(1.0, total_gb)) * 100, 1))
|
||||
|
||||
# 3. Disk (/System/Volumes/Data or /)
|
||||
df_out = run_cmd("df -k /System/Volumes/Data || df -k /")
|
||||
disk_total_gb, disk_used_gb, disk_pct = 512.0, 40.0, 8.0
|
||||
lines = df_out.splitlines()
|
||||
if len(lines) >= 2:
|
||||
parts = lines[-1].split()
|
||||
if len(parts) >= 5:
|
||||
total_k = int(parts[1])
|
||||
avail_k = int(parts[3])
|
||||
used_k = total_k - avail_k
|
||||
disk_total_gb = round(total_k / (1024**2), 1)
|
||||
disk_used_gb = round(used_k / (1024**2), 1)
|
||||
disk_pct = round((used_k / max(1, total_k)) * 100, 1)
|
||||
|
||||
# 4. Uptime
|
||||
boot_out = run_cmd("sysctl -n kern.boottime")
|
||||
m_sec = re.search(r"sec = (\d+)", boot_out)
|
||||
uptime_str = "运行中"
|
||||
if m_sec:
|
||||
up_sec = max(0, int(time.time()) - int(m_sec.group(1)))
|
||||
days = up_sec // 86400
|
||||
hours = (up_sec % 86400) // 3600
|
||||
mins = (up_sec % 3600) // 60
|
||||
uptime_str = f"{days}天 {hours}小时" if days > 0 else f"{hours}小时 {mins}分"
|
||||
|
||||
# 5. Docker Containers
|
||||
containers = []
|
||||
docker_out = run_cmd("docker ps --format '{{.Names}}|{{.Status}}|{{.Image}}'")
|
||||
for line in docker_out.splitlines():
|
||||
if "|" in line:
|
||||
name, status, img = line.split("|", 2)
|
||||
containers.append({"name": name, "status": status, "image": img})
|
||||
|
||||
# 6. AGY tmux session
|
||||
tmux_out = run_cmd("tmux list-sessions 2>/dev/null")
|
||||
agy_running = "agy:" in tmux_out
|
||||
|
||||
return {
|
||||
"chip": "Apple M6 (12核)",
|
||||
"ip": "192.168.31.164",
|
||||
"tailscale_ip": "100.108.205.40",
|
||||
"cpu_pct": cpu_pct,
|
||||
"load_1m": loads[0],
|
||||
"cores": cores,
|
||||
"mem_used_gb": used_gb,
|
||||
"mem_total_gb": total_gb,
|
||||
"mem_pct": mem_pct,
|
||||
"disk_used_gb": disk_used_gb,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"disk_pct": disk_pct,
|
||||
"uptime": uptime_str,
|
||||
"containers": containers,
|
||||
"agy_tmux": agy_running,
|
||||
}
|
||||
|
||||
|
||||
def get_nas_stats() -> dict:
|
||||
nas_path = Path("/Users/ixvii0iv/nas/个人空间")
|
||||
mounted = nas_path.exists() and os.path.ismount("/Users/ixvii0iv/nas/个人空间")
|
||||
total_tb, used_tb, free_tb, pct = 20.0, 2.9, 17.1, 15.0
|
||||
if mounted:
|
||||
df_out = run_cmd("df -k /Users/ixvii0iv/nas/个人空间")
|
||||
lines = df_out.splitlines()
|
||||
if len(lines) >= 2:
|
||||
parts = lines[-1].split()
|
||||
if len(parts) >= 5:
|
||||
total_k = int(parts[1])
|
||||
avail_k = int(parts[3])
|
||||
used_k = total_k - avail_k
|
||||
total_tb = round(total_k / (1024**3), 2)
|
||||
free_tb = round(avail_k / (1024**3), 2)
|
||||
used_tb = round(used_k / (1024**3), 2)
|
||||
pct = round((used_k / max(1, total_k)) * 100, 1)
|
||||
|
||||
latest_backup = "暂无记录"
|
||||
backup_count = 0
|
||||
if mounted:
|
||||
ls_out = run_cmd("ls -lh /Users/ixvii0iv/nas/个人空间/MacMini_Backups/homelab_backup_*.tar.gz 2>/dev/null")
|
||||
lines = [l for l in ls_out.splitlines() if "homelab_backup_" in l]
|
||||
backup_count = len(lines)
|
||||
if lines:
|
||||
last_file = lines[-1].split()[-1]
|
||||
fname = os.path.basename(last_file)
|
||||
m = re.search(r"homelab_backup_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})", fname)
|
||||
if m:
|
||||
latest_backup = f"{m.group(2)}-{m.group(3)} {m.group(4)}:{m.group(5)} (共{backup_count}份)"
|
||||
else:
|
||||
latest_backup = f"已备份 ({backup_count}份)"
|
||||
|
||||
return {
|
||||
"name": "极空间 Z4",
|
||||
"ip": "192.168.31.185",
|
||||
"online": mounted,
|
||||
"latency_ms": 1 if mounted else -1,
|
||||
"mounted": mounted,
|
||||
"total_tb": total_tb,
|
||||
"used_tb": used_tb,
|
||||
"free_tb": free_tb,
|
||||
"pct": pct,
|
||||
"latest_backup": latest_backup,
|
||||
"backup_count": backup_count,
|
||||
}
|
||||
|
||||
|
||||
def get_network_and_edge_stats() -> dict:
|
||||
# 1. Home Public IP from DDNS log
|
||||
home_ip = "124.90.142.21"
|
||||
ddns_log = Path("/Users/ixvii0iv/homelab/logs/cf-ddns.log")
|
||||
if ddns_log.exists():
|
||||
try:
|
||||
last_lines = ddns_log.read_text(errors="ignore").splitlines()[-10:]
|
||||
for line in reversed(last_lines):
|
||||
m = re.search(r"公网 IP:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", line)
|
||||
if m:
|
||||
home_ip = m.group(1)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Query Xiaomi Router Mihomo API (192.168.31.1:9999) for AI group & SG node delay
|
||||
ai_group = "狮城节点"
|
||||
sg_node = "coco-sea1-reality-sony"
|
||||
sg_delay_ms = -1
|
||||
router_online, router_ms = check_tcp("192.168.31.1", 9999)
|
||||
if router_online:
|
||||
try:
|
||||
with urllib.request.urlopen("http://192.168.31.1:9999/proxies", timeout=1.2) as r:
|
||||
pdata = json.loads(r.read().decode()).get("proxies", {})
|
||||
ai_group = pdata.get("Ai平台", {}).get("now", "狮城节点")
|
||||
sg_group = pdata.get("狮城节点", {})
|
||||
sg_node = sg_group.get("now", "coco-sea1-reality-sony")
|
||||
hist = pdata.get(sg_node, {}).get("history", [])
|
||||
if hist:
|
||||
sg_delay_ms = hist[-1].get("delay", -1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. Check Azure Cloud Edge (104.43.105.243:443)
|
||||
azure_online, azure_ms = check_tcp("104.43.105.243", 443, timeout=1.2)
|
||||
|
||||
return {
|
||||
"home_wan_ip": home_ip,
|
||||
"router_ip": "192.168.31.1",
|
||||
"router_online": router_online,
|
||||
"router_ms": router_ms,
|
||||
"ai_policy": f"Ai平台 → {ai_group}",
|
||||
"sg_node": sg_node,
|
||||
"sg_egress_ip": "104.43.105.243",
|
||||
"sg_delay_ms": sg_delay_ms,
|
||||
"azure_tailscale_ip": "100.115.204.51",
|
||||
"azure_online": azure_online,
|
||||
"azure_ms": azure_ms,
|
||||
}
|
||||
|
||||
|
||||
def collect_telemetry() -> dict:
|
||||
now = time.time()
|
||||
with _cache_lock:
|
||||
if _cache["data"] and (now - _cache["ts"] < 2.0):
|
||||
return _cache["data"]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
f_mini = pool.submit(get_mac_mini_stats)
|
||||
f_nas = pool.submit(get_nas_stats)
|
||||
f_net = pool.submit(get_network_and_edge_stats)
|
||||
f_agy = pool.submit(check_tcp, "127.0.0.1", 7681)
|
||||
f_vw = pool.submit(check_tcp, "127.0.0.1", 8222)
|
||||
|
||||
mini = f_mini.result()
|
||||
nas = f_nas.result()
|
||||
net = f_net.result()
|
||||
agy_ok, _ = f_agy.result()
|
||||
vw_ok, _ = f_vw.result()
|
||||
|
||||
data = {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"mini": mini,
|
||||
"nas": nas,
|
||||
"network": net,
|
||||
"services": {
|
||||
"agy": agy_ok,
|
||||
"vaultwarden": vw_ok,
|
||||
"nas": nas["online"],
|
||||
"clash": net["router_online"],
|
||||
"router": net["router_online"],
|
||||
"uptime": net["azure_online"],
|
||||
},
|
||||
}
|
||||
with _cache_lock:
|
||||
_cache["data"] = data
|
||||
_cache["ts"] = now
|
||||
return data
|
||||
|
||||
|
||||
class PortalHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def _send_json(self, obj: dict, status: int = 200):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/telemetry"):
|
||||
self._send_json(collect_telemetry())
|
||||
return
|
||||
|
||||
# Serve SPA index.html for root and any path
|
||||
if INDEX_FILE.exists():
|
||||
body = INDEX_FILE.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_error(404, "index.html not found")
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/api/action/backup_nas":
|
||||
out = run_cmd("/Users/ixvii0iv/homelab/scripts/backup-to-nas.sh", timeout=15.0)
|
||||
with _cache_lock:
|
||||
_cache["ts"] = 0.0
|
||||
self._send_json({"ok": True, "message": out or "备份已完成"})
|
||||
return
|
||||
if self.path == "/api/action/mount_nas":
|
||||
out = run_cmd("/Users/ixvii0iv/homelab/scripts/mount-zspace.sh", timeout=10.0)
|
||||
with _cache_lock:
|
||||
_cache["ts"] = 0.0
|
||||
self._send_json({"ok": True, "message": out or "极空间挂载巡检完成"})
|
||||
return
|
||||
if self.path == "/api/action/refresh_ddns":
|
||||
out = run_cmd("/Users/ixvii0iv/homelab/scripts/cloudflare-ddns.sh", timeout=10.0)
|
||||
with _cache_lock:
|
||||
_cache["ts"] = 0.0
|
||||
self._send_json({"ok": True, "message": out or "Cloudflare DDNS 已刷新"})
|
||||
return
|
||||
self._send_json({"ok": False, "message": "Unknown action"}, status=404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), PortalHandler)
|
||||
print(f"[HomeLab Portal] Listening on http://0.0.0.0:{PORT}")
|
||||
server.serve_forever()
|
||||
Reference in new issue
Block a user