Files
homelab/stacks/portal/index.html
T

814 lines
33 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HOMELAB // CONSOLE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<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>
<style>
/* =========================================================================
Pure Monochrome Precision Design System (Vercel Geist / Linear / Apple Pro)
ZERO cartoon logos, ZERO random blue/purple/green text splashes.
100% Carbon, Obsidian, Graphite, Hairline Zinc & Platinum White.
========================================================================= */
:root, [data-theme="dark"] {
--bg-page: #080809;
--bg-card: #0f0f11;
--bg-widget: #0b0b0d;
--bg-muted: #18181b;
--bg-hover: #141417;
--text-main: #f4f4f5;
--text-secondary: #d4d4d8;
--text-muted: #8e8e93;
--text-subtle: #52525b;
--border-color: #222226;
--border-hover: #3f3f46;
--primary: #f4f4f5;
--primary-fg: #09090b;
--bar-fill: #e4e4e7;
--dot-online: #e4e4e7;
--card-shadow: 0 4px 20px -4px rgba(0, 0, 0, 0.7);
}
[data-theme="light"] {
--bg-page: #f5f5f7;
--bg-card: #ffffff;
--bg-widget: #fafafa;
--bg-muted: #ececef;
--bg-hover: #ffffff;
--text-main: #111113;
--text-secondary: #27272a;
--text-muted: #6e6e73;
--text-subtle: #a1a1aa;
--border-color: #e4e4e7;
--border-hover: #a1a1aa;
--primary: #111113;
--primary-fg: #ffffff;
--bar-fill: #18181b;
--dot-online: #18181b;
--card-shadow: 0 2px 10px -2px rgba(0, 0, 0, 0.04);
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
background-color: var(--bg-page);
color: var(--text-main);
transition: background-color 0.15s ease, color 0.15s ease;
-webkit-font-smoothing: antialiased;
}
.font-mono-num {
font-family: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
font-variant-numeric: tabular-nums;
}
.hl-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
box-shadow: var(--card-shadow);
border-radius: 0.625rem;
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
}
.hl-card-interactive:hover {
border-color: var(--border-hover);
background-color: var(--bg-hover);
transform: translateY(-1px);
}
.hl-widget-footer {
background-color: var(--bg-widget);
border-top: 1px solid var(--border-color);
}
.hl-pill {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
}
.hl-muted {
background-color: var(--bg-muted);
}
.hl-border {
border-color: var(--border-color);
}
.hl-btn-primary {
background-color: var(--primary);
color: var(--primary-fg);
font-weight: 500;
border-radius: 0.375rem;
transition: opacity 0.15s ease;
}
.hl-btn-primary:hover {
opacity: 0.88;
}
.hl-btn-outline {
background-color: var(--bg-card);
color: var(--text-main);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.hl-btn-outline:hover {
background-color: var(--bg-muted);
border-color: var(--border-hover);
}
.hl-bar-fill {
background-color: var(--bar-fill);
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
[v-cloak] { display: none; }
</style>
</head>
<body class="min-h-screen">
<div id="app" v-cloak class="min-h-screen flex flex-col">
<!-- Monochrome Toast Notification -->
<div v-if="toast" class="fixed bottom-5 right-5 z-50 hl-card px-4 py-3 flex items-center gap-2.5 shadow-2xl">
<span class="w-1.5 h-1.5 rounded-full" style="background-color: var(--primary)"></span>
<span class="text-xs font-mono-num">{{ toast }}</span>
</div>
<!-- =======================================================================
1. Top Utility & System Resources Ribbon (Pure Monochrome Geist Style)
======================================================================= -->
<header class="border-b hl-border sticky top-0 z-30 backdrop-blur-md" style="background-color: color-mix(in srgb, var(--bg-page) 88%, transparent);">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between gap-3">
<!-- Left: Brand + Live System Micro-Gauges -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-2.5 pr-2">
<div class="w-6 h-6 rounded flex items-center justify-center hl-btn-primary">
<i data-lucide="terminal" class="w-3.5 h-3.5"></i>
</div>
<span class="font-mono-num font-semibold text-xs tracking-widest uppercase">HOMELAB // M6</span>
</div>
<!-- Compact Monochrome Resource Gauges -->
<div class="hidden xl:flex items-center gap-2 text-[11px] font-mono-num">
<div class="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style="color: var(--text-muted)">CPU</span>
<div class="w-10 h-1 rounded-full hl-muted overflow-hidden">
<div class="h-full hl-bar-fill" :style="{ width: Math.max(6, telemetry.mini.cpu_pct) + '%' }"></div>
</div>
<span>{{ telemetry.mini.cpu_pct }}%</span>
</div>
<div class="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style="color: var(--text-muted)">RAM</span>
<div class="w-10 h-1 rounded-full hl-muted overflow-hidden">
<div class="h-full hl-bar-fill" :style="{ width: Math.max(6, telemetry.mini.mem_pct) + '%' }"></div>
</div>
<span>{{ telemetry.mini.mem_used_gb }}G</span>
</div>
<div class="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style="color: var(--text-muted)">Z4 NAS</span>
<div class="w-10 h-1 rounded-full hl-muted overflow-hidden">
<div class="h-full hl-bar-fill" :style="{ width: Math.max(6, telemetry.nas.pct) + '%' }"></div>
</div>
<span>{{ telemetry.nas.free_tb }}T FREE</span>
</div>
<div class="hl-pill px-2.5 py-1 flex items-center gap-1.5">
<span style="color: var(--text-muted)">SG EXIT</span>
<span>{{ telemetry.network.sg_delay_ms > 0 ? telemetry.network.sg_delay_ms + 'ms' : 'LOCKED' }}</span>
</div>
</div>
</div>
<!-- Right: Cmd+K, Route Switcher, Dark/Light Toggle, Terminal Trigger -->
<div class="flex items-center gap-2">
<!-- Cmd+K Trigger -->
<button
@click="openCmdPalette"
class="hl-btn-outline px-2.5 py-1 text-xs flex items-center gap-3"
>
<span class="flex items-center gap-1.5" style="color: var(--text-muted)">
<i data-lucide="search" class="w-3.5 h-3.5"></i>
<span class="hidden sm:inline">Search...</span>
</span>
<kbd class="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted" style="color: var(--text-muted)">⌘K</kbd>
</button>
<!-- Route Mode Segmented Switcher -->
<div class="hidden md:flex items-center p-0.5 rounded-md hl-muted border hl-border text-[11px] font-mono-num">
<button
v-for="m in modes"
:key="m.id"
@click="routeMode = m.id"
class="px-2.5 py-1 rounded transition-all"
:style="routeMode === m.id ? 'background-color: var(--bg-card); color: var(--text-main); box-shadow: 0 1px 2px rgba(0,0,0,0.12);' : 'color: var(--text-muted);'"
>
{{ m.label }}
</button>
</div>
<!-- Pure Dark / Light Toggle -->
<button
@click="toggleTheme"
class="hl-btn-outline p-1.5 text-xs flex items-center justify-center"
:title="currentTheme === 'dark' ? '切换为浅色钛银模式' : '切换为深色黑曜石模式'"
>
<i v-if="currentTheme === 'dark'" data-lucide="sun" class="w-3.5 h-3.5"></i>
<i v-else data-lucide="moon" class="w-3.5 h-3.5"></i>
</button>
<!-- Embedded AGY Terminal Trigger -->
<button
@click="showAgyModal = true"
class="hl-btn-primary px-3 py-1.5 text-xs font-mono-num flex items-center gap-1.5"
>
<i data-lucide="terminal-square" class="w-3.5 h-3.5"></i>
<span>AGY CLI</span>
</button>
</div>
</div>
</header>
<!-- =======================================================================
2. Main Categorized 3-Column Rows (Homepage Layout + Monochrome Aesthetics)
======================================================================= -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-7 space-y-8">
<!-- Category Group Loop -->
<section v-for="group in serviceGroups" :key="group.title" class="space-y-3.5">
<!-- Group Header -->
<div class="flex items-center justify-between px-0.5 border-b hl-border pb-2">
<div class="flex items-center gap-2.5">
<h2 class="text-xs font-mono-num font-semibold uppercase tracking-widest">
{{ group.title }}
</h2>
<span class="text-[11px] font-mono-num" style="color: var(--text-subtle)">// {{ group.subtitle }}</span>
</div>
<span v-if="group.showRouteHint" class="text-[11px] font-mono-num hidden sm:inline" style="color: var(--text-muted)">
MODE: {{ activeModeLabel }}
</span>
</div>
<!-- 3-Column Symmetric Service Cards Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a
v-for="card in group.items"
:key="card.id"
:href="resolveUrl(card)"
target="_blank"
rel="noopener noreferrer"
class="hl-card hl-card-interactive flex flex-col justify-between overflow-hidden group cursor-pointer"
>
<!-- Card Top -->
<div class="p-5 space-y-3">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3 min-w-0">
<!-- Crisp Monochrome Vector Icon Box -->
<div class="w-9 h-9 rounded-lg hl-muted border hl-border flex items-center justify-center shrink-0">
<i :data-lucide="card.icon" class="w-4 h-4"></i>
</div>
<div class="min-w-0">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold tracking-tight truncate">{{ card.title }}</h3>
<span
class="w-1.5 h-1.5 rounded-full shrink-0"
:style="{ backgroundColor: card.online !== false ? 'var(--dot-online)' : '#71717a' }"
></span>
</div>
<p class="text-[11px] font-mono-num truncate mt-0.5" style="color: var(--text-muted)">
{{ card.subtitle }}
</p>
</div>
</div>
<!-- Action Pill or Route Tag -->
<button
v-if="card.actionType === 'drawer'"
@click.prevent="showAgyModal = true"
class="hl-btn-primary px-2.5 py-1 text-[11px] font-mono-num shrink-0"
>
页内终端
</button>
<button
v-else-if="card.actionType === 'backup'"
@click.prevent="triggerAction('backup_nas', '正在打包备份至极空间 Z4...')"
class="hl-btn-outline px-2.5 py-1 text-[11px] font-mono-num shrink-0"
>
立即备份
</button>
<span v-else class="text-[11px] font-mono-num px-2 py-0.5 rounded hl-muted shrink-0" style="color: var(--text-muted)">
{{ resolveBadge(card) }} ↗
</span>
</div>
<p class="text-xs leading-relaxed line-clamp-2" style="color: var(--text-muted)">
{{ card.desc }}
</p>
</div>
<!-- Card Footer: 3-Cell Inline Telemetry Widget (100% Monochrome Tabular Numbers) -->
<div class="hl-widget-footer grid grid-cols-3 divide-x hl-border text-center">
<div
v-for="(stat, idx) in card.stats"
:key="idx"
class="py-2.5 px-2 flex flex-col items-center justify-center"
>
<span class="text-[10px] font-mono-num uppercase tracking-wider truncate max-w-full" style="color: var(--text-subtle)">
{{ stat.label }}
</span>
<span class="text-xs font-semibold font-mono-num mt-0.5 truncate max-w-full">
{{ stat.value }}
</span>
</div>
</div>
</a>
</div>
</section>
<!-- =====================================================================
3. Bottom Infrastructure & Quick SSH Shelf
===================================================================== -->
<section class="space-y-3.5 pt-1">
<div class="flex items-center justify-between px-0.5 border-b hl-border pb-2">
<div class="flex items-center gap-2.5">
<h2 class="text-xs font-mono-num font-semibold uppercase tracking-widest">
SSH 终端快捷指令与运维操作
</h2>
<span class="text-[11px] font-mono-num" style="color: var(--text-subtle)">// CLICK TO COPY</span>
</div>
<div class="flex items-center gap-4 text-xs font-mono-num">
<button
@click="triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...')"
class="hover:underline"
style="color: var(--text-muted)"
>
[刷新 DDNS]
</button>
<button
@click="triggerAction('mount_nas', '正在巡检极空间 20TB SMB 挂载...')"
class="hover:underline"
style="color: var(--text-muted)"
>
[巡检 NAS 挂载]
</button>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<button
v-for="item in sshList"
:key="item.cmd"
@click="copyCmd(item.cmd, item.label)"
class="hl-card hl-card-interactive px-4 py-3 text-left flex items-center justify-between gap-2"
>
<div class="min-w-0">
<div class="text-xs font-medium" style="color: var(--text-muted)">{{ item.label }}</div>
<div class="text-xs font-mono-num font-semibold truncate mt-0.5">{{ item.cmd }}</div>
</div>
<i data-lucide="copy" class="w-3.5 h-3.5 shrink-0" style="color: var(--text-subtle)"></i>
</button>
</div>
</section>
</main>
<!-- =======================================================================
4. Global Cmd+K Command Palette
======================================================================= -->
<div
v-if="showCmdPalette"
@click.self="showCmdPalette = false"
class="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-start justify-center pt-20 px-4"
>
<div class="w-full max-w-xl hl-card overflow-hidden shadow-2xl">
<div class="px-4 py-3 border-b hl-border flex items-center gap-3">
<i data-lucide="search" class="w-4 h-4" style="color: var(--text-muted)"></i>
<input
ref="cmdInputRef"
v-model="cmdQuery"
type="text"
placeholder="输入服务名称、SSH 指令或操作..."
class="w-full bg-transparent text-sm outline-none"
@keydown.esc="showCmdPalette = false"
@keydown.enter="runFirstCommand"
/>
<kbd class="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted" style="color: var(--text-muted)">ESC</kbd>
</div>
<div class="max-h-80 overflow-y-auto p-2 text-xs">
<button
v-for="cmd in filteredCommands"
:key="cmd.id"
@click="executeCommand(cmd)"
class="w-full px-3 py-2.5 rounded-md text-left flex items-center justify-between transition-colors"
onmouseover="this.style.backgroundColor='var(--bg-muted)'"
onmouseout="this.style.backgroundColor='transparent'"
>
<div class="flex items-center gap-3">
<span class="font-medium">{{ cmd.title }}</span>
<span class="font-mono-num text-[11px]" style="color: var(--text-muted)">{{ cmd.hint }}</span>
</div>
<span class="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted" style="color: var(--text-muted)">
{{ cmd.type }}
</span>
</button>
</div>
</div>
</div>
<!-- =======================================================================
5. Embedded AGY Cloud Terminal Modal
======================================================================= -->
<div v-if="showAgyModal" class="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-3 sm:p-6">
<div class="w-full h-full max-w-6xl hl-card flex flex-col overflow-hidden shadow-2xl">
<div class="px-4 py-2.5 border-b hl-border flex items-center justify-between hl-muted">
<div class="flex items-center gap-2.5">
<i data-lucide="terminal" class="w-4 h-4"></i>
<span class="text-xs font-mono-num font-medium">AGY CLOUD WORKBENCH // tmux attach -t agy</span>
</div>
<div class="flex items-center gap-2">
<a :href="agyModalUrl" target="_blank" class="hl-btn-outline px-2.5 py-1 text-xs font-mono-num">
FULLSCREEN ↗
</a>
<button @click="showAgyModal = false" class="hl-btn-primary px-3 py-1 text-xs font-mono-num">
CLOSE
</button>
</div>
</div>
<iframe :src="agyModalUrl" class="w-full flex-1 bg-black border-0"></iframe>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted, nextTick } = Vue;
createApp({
setup() {
const storedTheme = localStorage.getItem('hl_mono_theme');
const currentTheme = ref(storedTheme === 'light' ? 'light' : 'dark');
const routeMode = ref(localStorage.getItem('hl_route_mode') || 'auto');
const showAgyModal = ref(false);
const showCmdPalette = ref(false);
const cmdQuery = ref('');
const cmdInputRef = ref(null);
const toast = ref('');
const actionLoading = ref(false);
const modes = [
{ id: 'auto', label: 'AUTO' },
{ id: 'edge443', label: '443 免端口' },
{ id: 'home10443', label: '10443 家宽' },
{ id: 'lan', label: 'LAN 内网' },
];
const applyTheme = (id) => {
currentTheme.value = id;
localStorage.setItem('hl_mono_theme', id);
document.documentElement.setAttribute('data-theme', id);
nextTick(() => {
if (window.lucide) window.lucide.createIcons();
});
};
const toggleTheme = () => {
applyTheme(currentTheme.value === 'dark' ? 'light' : 'dark');
};
const telemetry = ref({
mini: {
chip: 'Apple M6 (12核)',
ip: '192.168.31.164',
tailscale_ip: '100.108.205.40',
cpu_pct: 6.5,
load_1m: 0.85,
mem_used_gb: 11.8,
mem_total_gb: 24.0,
mem_pct: 49.2,
disk_used_gb: 59.9,
disk_total_gb: 460.4,
disk_pct: 13.0,
uptime: '运行中',
agy_tmux: true,
},
nas: {
ip: '192.168.31.185',
online: true,
mounted: true,
total_tb: 19.94,
used_tb: 3.36,
free_tb: 16.58,
pct: 16.8,
latest_backup: '11-06 23:17',
},
network: {
home_wan_ip: '124.90.142.21',
ai_policy: 'Ai平台 → 狮城节点',
sg_node: '233boy-sea',
sg_egress_ip: '104.43.105.243',
sg_delay_ms: 68,
},
services: {
agy: true,
vaultwarden: true,
nas: true,
clash: true,
router: true,
uptime: true,
}
});
const effectiveMode = computed(() => {
localStorage.setItem('hl_route_mode', routeMode.value);
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 'LAN 192.168.31.x';
if (m === 'home10443') return 'WAN 10443 DIRECT';
return 'EDGE 443 HTTPS';
});
const serviceGroups = computed(() => {
const t = telemetry.value;
return [
{
title: '01 / 核心工作台与私有云存储',
subtitle: 'WORKBENCH & STORAGE',
showRouteHint: true,
items: [
{
id: 'agy',
title: 'AGY 云端工作台',
subtitle: 'agy.961504.xyz · ttyd + tmux',
icon: 'terminal',
online: t.services.agy,
actionType: 'drawer',
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步登录态并锁定新加坡出口,关窗后台继续运行。',
edgeUrl: 'https://agy.961504.xyz',
homeUrl: 'https://agy.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:7681',
stats: [
{ label: 'M6 CPU', value: `${t.mini.cpu_pct}%` },
{ label: 'MEMORY', value: `${t.mini.mem_used_gb} / 24G` },
{ label: 'TMUX', value: t.mini.agy_tmux ? 'ACTIVE' : 'IDLE' },
]
},
{
id: 'zspace',
title: '极空间 Z4 私有云',
subtitle: '192.168.31.185 · 20TB RAID',
icon: 'hard-drive',
online: t.services.nas,
desc: '家庭 20TB 大容量存储中心、极影视蓝光影院与手机相册备份,已自动挂载至 Mac mini ~/nas。',
edgeUrl: 'https://z4.home.961504.xyz:10443',
homeUrl: 'https://z4.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.185:5055',
stats: [
{ label: 'FREE', value: `${t.nas.free_tb} TB` },
{ label: 'USED', value: `${t.nas.used_tb} TB (${t.nas.pct}%)` },
{ label: 'MOUNT', value: t.nas.mounted ? '~/nas OK' : 'RETRY' },
]
},
{
id: 'vaultwarden',
title: 'Vaultwarden 密码库',
subtitle: 'vw.961504.xyz · Bitwarden',
icon: 'key-round',
online: t.services.vaultwarden,
actionType: 'backup',
desc: '自托管全平台加密密码保险箱,支持手机与浏览器自动填充,每天凌晨 03:30 自动打包灾备至极空间。',
edgeUrl: 'https://vw.961504.xyz',
homeUrl: 'https://vw.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:8222',
stats: [
{ label: 'STATUS', value: 'HEALTHY' },
{ label: 'LAST BACKUP', value: t.nas.latest_backup },
{ label: 'MAC SSD', value: `${t.mini.disk_pct}%` },
]
},
]
},
{
title: '02 / 网络网关、AI 专线与外部哨兵',
subtitle: 'NETWORK & CLOUD EDGE',
showRouteHint: false,
items: [
{
id: 'clash',
title: 'Mihomo 路由分流面板',
subtitle: '192.168.31.1:9999 · ShellCrash',
icon: 'waypoints',
online: t.services.clash,
desc: '小米主路由器透明网关控制台,实时监控全屋设备流量、分流规则与狮城 AI 算力专线锁定状态。',
edgeUrl: 'https://clash.home.961504.xyz:10443/ui/',
homeUrl: 'https://clash.home.961504.xyz:10443/ui/',
lanUrl: 'http://192.168.31.1:9999/ui/',
stats: [
{ label: 'AI ROUTE', value: 'SG LOCKED' },
{ label: 'LATENCY', value: t.network.sg_delay_ms > 0 ? `${t.network.sg_delay_ms} ms` : 'DIRECT' },
{ label: 'EGRESS IP', value: t.network.sg_egress_ip },
]
},
{
id: 'router',
title: '小米主路由器后台',
subtitle: '192.168.31.1 · PPPoE & DNAT',
icon: 'router',
online: t.services.router,
desc: '管理家庭联通公网宽带拨号、DHCP 静态 IP 绑定、10443 与 41641 端口转发及全屋内网 DNS。',
edgeUrl: 'https://router.home.961504.xyz:10443',
homeUrl: 'https://router.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.1',
stats: [
{ label: 'WAN IPv4', value: t.network.home_wan_ip },
{ label: 'DDNS', value: '*.home.961504.xyz' },
{ label: 'GATEWAY', value: '192.168.31.1' },
]
},
{
id: 'uptime',
title: 'Uptime Kuma 外部哨兵',
subtitle: 'status.961504.xyz · SG Azure',
icon: 'activity',
online: t.services.uptime,
desc: '独立部署在新加坡 Azure 公有云上的 24 小时外部监控哨兵,通过 Tailscale P2P 专线实时探测家庭服务。',
edgeUrl: 'https://status.961504.xyz',
homeUrl: 'https://status.961504.xyz',
lanUrl: 'https://status.961504.xyz',
stats: [
{ label: 'AZURE IP', value: '104.43.105.243' },
{ label: 'TAILSCALE', value: '100.115.204.51' },
{ label: 'UPTIME', value: '100% ONLINE' },
]
},
]
}
];
});
const sshList = [
{ 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 resolveUrl = (card) => {
const m = effectiveMode.value;
if (m === 'lan') return card.lanUrl;
if (m === 'home10443') return card.homeUrl;
return card.edgeUrl;
};
const resolveBadge = (card) => {
const m = effectiveMode.value;
if (m === 'lan') return 'LAN';
if (m === 'home10443') return '10443';
return '443';
};
const agyModalUrl = computed(() => resolveUrl(serviceGroups.value[0].items[0]));
const showToast = (msg) => {
toast.value = msg;
setTimeout(() => {
if (toast.value === msg) toast.value = '';
}, 3000);
};
const copyCmd = async (cmd, label) => {
try {
await navigator.clipboard.writeText(cmd);
showToast(`COPIED [${label}]: ${cmd}`);
} catch {
showToast(cmd);
}
};
const fetchTelemetry = async () => {
try {
const res = await fetch('/api/telemetry');
if (res.ok) {
telemetry.value = await res.json();
}
} catch (e) {}
};
const triggerAction = async (action, msg) => {
actionLoading.value = true;
showToast(msg);
try {
const res = await fetch(`/api/action/${action}`, { method: 'POST' });
const data = await res.json();
showToast(data.message || 'DONE');
await fetchTelemetry();
} catch {
showToast('ERROR');
} finally {
actionLoading.value = false;
}
};
const allCommands = computed(() => [
{ id: 'open-agy-drawer', title: '在页内唤起 AGY 云端终端', hint: 'tmux attach -t agy', type: 'TERM', run: () => { showAgyModal.value = true; } },
{ id: 'open-agy', title: '新窗口打开 AGY 云端工作台', hint: 'https://agy.961504.xyz', type: 'OPEN', run: () => window.open(resolveUrl(serviceGroups.value[0].items[0]), '_blank') },
{ id: 'open-z4', title: '打开 极空间 Z4 私有云 (20TB)', hint: '192.168.31.185:5055', type: 'OPEN', run: () => window.open(resolveUrl(serviceGroups.value[0].items[1]), '_blank') },
{ id: 'open-vw', title: '打开 Vaultwarden 密码库', hint: 'https://vw.961504.xyz', type: 'OPEN', run: () => window.open(resolveUrl(serviceGroups.value[0].items[2]), '_blank') },
{ id: 'open-clash', title: '打开 Mihomo 路由分流面板', hint: '192.168.31.1:9999', type: 'OPEN', run: () => window.open(resolveUrl(serviceGroups.value[1].items[0]), '_blank') },
{ id: 'act-backup', title: '立即打包备份至极空间 Z4', hint: 'backup-to-nas.sh', type: 'EXEC', run: () => triggerAction('backup_nas', '正在打包备份至极空间 Z4...') },
{ id: 'act-ddns', title: '立即刷新 Cloudflare DDNS', hint: 'cloudflare-ddns.sh', type: 'EXEC', run: () => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...') },
{ id: 'ssh-mini', title: '复制 SSH 连接 Mac mini 指令', hint: 'ssh mini', type: 'COPY', run: () => copyCmd('ssh mini', 'Mac mini') },
{ id: 'ssh-agy', title: '复制 终端接力 AGY 会话指令', hint: 'ssh mini -t "tmux attach -t agy"', type: 'COPY', run: () => copyCmd('ssh mini -t "tmux attach -t agy"', 'AGY') },
{ id: 'theme-toggle', title: '切换 深色黑曜石 / 浅色钛银 主题', hint: 'Dark / Light', type: 'THEME', run: () => toggleTheme() },
]);
const filteredCommands = computed(() => {
const q = cmdQuery.value.trim().toLowerCase();
if (!q) return allCommands.value;
return allCommands.value.filter(c => c.title.toLowerCase().includes(q) || c.hint.toLowerCase().includes(q));
});
const openCmdPalette = () => {
cmdQuery.value = '';
showCmdPalette.value = true;
nextTick(() => {
if (cmdInputRef.value) cmdInputRef.value.focus();
});
};
const executeCommand = (cmd) => {
showCmdPalette.value = false;
cmd.run();
};
const runFirstCommand = () => {
if (filteredCommands.value.length > 0) {
executeCommand(filteredCommands.value[0]);
}
};
onMounted(() => {
applyTheme(currentTheme.value);
fetchTelemetry();
setInterval(fetchTelemetry, 3000);
window.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openCmdPalette();
}
});
});
return {
currentTheme,
toggleTheme,
modes,
routeMode,
activeModeLabel,
telemetry,
serviceGroups,
sshList,
showAgyModal,
agyModalUrl,
showCmdPalette,
cmdQuery,
cmdInputRef,
filteredCommands,
openCmdPalette,
executeCommand,
runFirstCommand,
toast,
actionLoading,
resolveUrl,
resolveBadge,
copyCmd,
triggerAction,
};
}
}).mount('#app');
</script>
</body>
</html>