feat(portal): replace Sun-Panel with custom Apple Dark Glass HomeLab OS Portal + live telemetry API

Browse Source
This commit is contained in:
Yin Kou committed 2026-11-06 23:16:18 +08:00
1 parent 5dafc94d5e
commit a072a62141
3 files changed
+1025 -1

No files matched your search

+1 -1
View File
@@ -22,7 +22,7 @@ ARCHIVE="$NAS_BACKUP_DIR/homelab_backup_${TIMESTAMP}.tar.gz"
tar -czf "$ARCHIVE" \ tar -czf "$ARCHIVE" \
-C /Users/ixvii0iv/homelab \ -C /Users/ixvii0iv/homelab \
stacks/vaultwarden/data \ stacks/vaultwarden/data \
stacks/sun-panel/conf \ stacks/portal \
configs \ configs \
scripts \ scripts \
README.md PLAN.md CHANGELOG.md 2>/dev/null || true README.md PLAN.md CHANGELOG.md 2>/dev/null || true
+696
View File
@@ -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>
+328
View File
@@ -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()