Files
inp2p/web/index.html

446 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INP2P Control Plane</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
[v-cloak] { display: none !important; }
body { background: #070a14; color: #e2e8f0; font-family: system-ui, sans-serif; overflow: hidden; }
.glass-card { background: rgba(15, 20, 37, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.05); }
.active-tab { background: rgba(59, 130, 246, 0.1); border-left: 3px solid #3b82f6; color: white; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
.animate-in { animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body class="min-h-screen">
<div id="app" v-cloak class="flex h-screen w-full relative">
<!-- Login -->
<div v-if="!isLoggedIn" class="fixed inset-0 z-[200] flex items-center justify-center bg-[#070a14] px-6">
<div class="w-full max-w-sm glass-card rounded-3xl p-8 shadow-2xl">
<div class="mb-8 text-center">
<div class="w-12 h-12 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h1 class="text-2xl font-black text-white italic">INP2P</h1>
<p class="text-slate-500 text-sm mt-1">输入 Master Token</p>
</div>
<div class="space-y-4">
<input v-model="loginToken" type="password" placeholder="Token" class="w-full bg-black/40 border border-white/10 rounded-2xl px-5 py-4 text-center font-mono text-blue-400 focus:border-blue-500 outline-none" @keyup.enter="login">
<button @click="login" :disabled="loading" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-black py-4 rounded-2xl flex items-center justify-center gap-2 disabled:opacity-50">
<svg v-if="loading" class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span>{{ loading ? '验证中...' : '登 录' }}</span>
</button>
<div v-if="loginError" class="text-red-400 text-xs text-center">{{ loginError }}</div>
</div>
</div>
</div>
<!-- Sidebar -->
<aside v-if="isLoggedIn" class="hidden lg:flex w-64 bg-[#0a0d1a] border-r border-white/5 flex-col flex-shrink-0">
<div class="p-6">
<div class="flex items-center gap-3 mb-10">
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center"><svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg></div>
<span class="text-xl font-black text-white italic tracking-tighter">INP2P</span>
</div>
<nav class="space-y-1">
<button @click="activeTab = 'dashboard'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'dashboard' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">仪表盘</button>
<button @click="activeTab = 'nodes'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'nodes' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">节点资产</button>
<button @click="activeTab = 'sdwan'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'sdwan' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">SDWAN</button>
<button @click="activeTab = 'settings'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'settings' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">设置</button>
</nav>
</div>
<div class="mt-auto p-6 border-t border-white/5">
<div class="text-[10px] text-slate-600 font-bold mb-4">v{{stats.version || '0.1.0'}}</div>
<button @click="logout" class="text-[10px] text-slate-500 hover:text-red-400 uppercase font-black">安全登出</button>
</div>
</aside>
<!-- Main -->
<main v-if="isLoggedIn" class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 border-b border-white/5 flex items-center justify-between px-8 bg-[#0a0d1a]/50">
<h2 class="text-lg font-black text-white italic uppercase">{{ tabNames[activeTab] }}</h2>
<div class="flex items-center gap-4">
<span class="text-xs text-slate-500">{{ refreshInterval }}s</span>
<button @click="refreshAll" :disabled="loading" class="p-2 hover:bg-white/5 rounded-lg" :class="{'animate-spin': loading}">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
</header>
<div class="flex-1 overflow-y-auto p-8 custom-scrollbar space-y-8">
<!-- Dashboard -->
<div v-if="activeTab === 'dashboard'" class="space-y-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="glass-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">在线节点</div>
<div class="text-3xl font-black text-white">{{ stats.nodes || 0 }}</div>
</div>
<div class="glass-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">中继</div>
<div class="text-3xl font-black text-blue-400">{{ relayCount }}</div>
</div>
<div class="glass-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">打洞率</div>
<div class="text-3xl font-black" :class="punchRate >= 80 ? 'text-green-400' : punchRate >= 50 ? 'text-yellow-400' : 'text-red-400'">{{ punchRate }}%</div>
</div>
<div class="glass-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">SDWAN</div>
<div class="text-3xl font-black" :class="sdwan.enabled ? 'text-green-400' : 'text-slate-500'">{{ sdwan.enabled ? 'ON' : 'OFF' }}</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-1 glass-card rounded-2xl p-6 h-[320px]">
<h3 class="text-xs font-black text-slate-500 mb-6 uppercase">NAT 分布</h3>
<div id="natChart" class="w-full h-full pb-10"></div>
</div>
<div class="lg:col-span-2 glass-card rounded-2xl p-6 flex flex-col h-[320px]">
<h3 class="text-xs font-black text-slate-500 mb-6 uppercase">信令日志</h3>
<div class="flex-1 bg-black/40 rounded-xl p-4 font-mono text-[10px] overflow-y-auto custom-scrollbar">
<div v-for="(log, i) in activityLogs" :key="i" class="mb-1">
<span class="text-slate-600">[{{ log.time }}]</span>
<span :class="log.type === 'error' ? 'text-red-400' : log.type === 'success' ? 'text-green-400' : 'text-white'">{{ log.msg }}</span>
</div>
<div v-if="activityLogs.length === 0" class="text-slate-700 italic">等待数据...</div>
</div>
</div>
</div>
</div>
<!-- Nodes -->
<div v-if="activeTab === 'nodes'" class="space-y-6">
<div class="glass-card p-4 rounded-2xl flex items-center gap-3">
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input v-model="nodeFilter" placeholder="搜索节点..." class="bg-transparent border-none text-sm w-full outline-none">
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="n in filteredNodes" :key="n.name" class="glass-card rounded-3xl p-6 hover:border-blue-500/30 transition-all">
<div class="flex justify-between items-start mb-6">
<div>
<h4 class="font-bold text-white">{{ n.name }}</h4>
<div class="text-[10px] text-slate-500 font-mono">{{ n.publicIP }}:{{ n.publicPort }}</div>
</div>
<div :class="['px-2 py-0.5 rounded text-[8px] font-black uppercase', n.natType === 1 ? 'bg-green-500/10 text-green-400' : n.natType === 2 ? 'bg-yellow-500/10 text-yellow-400' : 'bg-red-500/10 text-red-400']">
{{ ['CONE','SYMM','STAT'][n.natType-1] || 'UNK' }}
</div>
</div>
<div class="space-y-2 mb-6 text-xs text-slate-400">
<div class="flex justify-between"><span>OS</span><span class="text-slate-300">{{ n.os || 'linux' }}</span></div>
<div class="flex justify-between"><span>版本</span><span class="text-slate-300">{{ n.version }}</span></div>
<div class="flex justify-between"><span>中继</span><span :class="n.relayEnabled ? 'text-blue-400' : 'text-slate-600'">{{ n.relayEnabled ? '是' : '否' }}</span></div>
<div class="flex justify-between"><span>在线</span><span class="text-slate-300">{{ formatUptime(n.loginTime) }}</span></div>
</div>
<button @click="openAppManager(n)" class="w-full bg-blue-600/10 hover:bg-blue-600 text-blue-400 hover:text-white py-3 rounded-2xl text-[10px] font-black uppercase">配置</button>
</div>
</div>
</div>
<!-- SDWAN -->
<div v-if="activeTab === 'sdwan'" class="pb-20">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div class="lg:col-span-4 glass-card rounded-3xl p-8 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-black italic">网络配置</h3>
<label class="flex items-center gap-2 cursor-pointer">
<span class="text-xs text-slate-500">启用</span>
<input type="checkbox" v-model="sdwan.enabled" @change="saveSDWAN" class="w-6 h-6 accent-blue-600">
</label>
</div>
<div class="space-y-4 pt-4 border-t border-white/5">
<div>
<label class="text-[9px] font-black text-slate-500 uppercase block mb-1">网关 CIDR</label>
<input v-model="sdwan.gatewayCIDR" @blur="saveSDWAN" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 font-mono text-blue-400">
</div>
<button @click="saveSDWAN" :disabled="sdSaving" class="w-full bg-white text-black font-black py-4 rounded-xl flex items-center justify-center gap-2">
<svg v-if="sdSaving" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ sdSaving ? '推送中...' : '保存配置' }}
</button>
</div>
</div>
<div class="lg:col-span-8 glass-card rounded-3xl overflow-hidden">
<div class="px-8 py-5 border-b border-white/5 flex justify-between items-center bg-white/[0.02]">
<h3 class="font-bold text-xs uppercase">IP 分配</h3>
<button @click="autoAssignIPs" class="text-[10px] font-black text-blue-500 uppercase">自动分配</button>
</div>
<table class="w-full text-xs font-mono">
<tbody class="divide-y divide-white/5">
<tr v-for="(sn, idx) in sdwan.nodes" :key="sn.node" class="hover:bg-white/[0.02]">
<td class="px-8 py-4 text-slate-300">{{ sn.node }}</td>
<td class="px-8 py-4"><input v-model="sn.ip" @blur="saveSDWAN" class="bg-transparent border-b border-white/10 outline-none w-32 text-blue-400"></td>
<td class="px-8 py-4 text-right"><button @click="removeSDWANNode(idx)" class="text-red-400">删除</button></td>
</tr>
</tbody>
</table>
<div class="p-8 bg-black/20 border-t border-white/5 flex gap-4">
<select v-model="newNodeToAssign" class="flex-1 bg-black/40 border border-white/10 rounded-xl px-4 text-xs h-10">
<option value="">选择节点...</option>
<option v-for="n in unassignedNodes" :key="n.name" :value="n.name">{{ n.name }}</option>
</select>
<input v-model="newIPToAssign" placeholder="10.10.0.x" class="w-32 bg-black/40 border border-white/10 rounded-xl px-4 text-xs h-10">
<button @click="addSDWANNode" :disabled="!newNodeToAssign || !newIPToAssign" class="bg-blue-600 px-6 rounded-xl text-[10px] font-black disabled:opacity-50">添加</button>
</div>
</div>
</div>
</div>
<!-- Settings -->
<div v-if="activeTab === 'settings'" class="space-y-6">
<div class="glass-card rounded-2xl p-6 space-y-4">
<h3 class="text-lg font-black">系统设置</h3>
<div class="flex items-center justify-between py-3 border-b border-white/5">
<div>
<div class="text-sm font-bold">自动刷新</div>
<div class="text-xs text-slate-500">当前: {{ refreshInterval }} 秒</div>
</div>
<select v-model="refreshInterval" class="bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm">
<option :value="10">10 秒</option>
<option :value="15">15 秒</option>
<option :value="30">30 秒</option>
<option :value="60">60 秒</option>
</select>
</div>
</div>
</div>
</div>
</main>
<!-- App Modal -->
<div v-if="appManagerNode" class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/90 backdrop-blur-xl">
<div class="bg-[#0f1425] border border-white/10 rounded-3xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<div class="p-8 border-b border-white/5 flex justify-between items-center">
<h3 class="text-xl font-black italic">隧道: {{ appManagerNode.name }}</h3>
<button @click="appManagerNode = null" class="text-slate-500 hover:text-white">关闭</button>
</div>
<div class="flex-1 overflow-y-auto p-8 custom-scrollbar grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-4">
<div v-for="(a, i) in appConfigs" :key="i" class="bg-white/5 border border-white/5 rounded-2xl p-4 relative">
<div class="font-mono text-blue-400 font-bold text-xs">{{ a.appName }}</div>
<div class="text-[10px] text-slate-500 mt-1">{{ a.srcPort }} → {{ a.peerNode }}:{{ a.dstPort }}</div>
<button @click="appConfigs.splice(i,1)" class="absolute top-4 right-4 text-red-400">×</button>
</div>
</div>
<div class="bg-white/[0.02] border border-white/5 rounded-3xl p-6 space-y-4">
<input v-model="newApp.appName" placeholder="名称" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<input v-model.number="newApp.srcPort" placeholder="本地端口" type="number" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<input v-model="newApp.peerNode" placeholder="对端节点" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<input v-model.number="newApp.dstPort" placeholder="对端端口" type="number" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<button @click="appConfigs.push({...newApp}); Object.assign(newApp,{appName:'',srcPort:18080,peerNode:'',dstPort:8080})" class="w-full bg-blue-600 text-white font-black py-3 rounded-xl text-xs">+ 添加</button>
</div>
</div>
<div class="p-8 border-t border-white/5 flex justify-end gap-4">
<button @click="appManagerNode = null" class="px-6 py-3 text-slate-500 hover:text-white">取消</button>
<button @click="pushAppConfigs" :disabled="appSaving" class="bg-blue-600 text-white font-black px-12 py-4 rounded-2xl flex items-center gap-2">
<svg v-if="appSaving" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ appSaving ? '下发中...' : '下发配置' }}
</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="msg" class="fixed top-6 left-1/2 -translate-x-1/2 z-[300] px-10 py-4 rounded-full text-xs font-black uppercase shadow-2xl"
:class="msgType === 'error' ? 'bg-red-900 border border-red-500 text-red-200' : msgType === 'success' ? 'bg-green-900 border border-green-500 text-green-200' : 'bg-blue-900 border border-blue-500 text-white'">
{{ msg }}
</div>
</div>
<script>
const { createApp, ref, reactive, onMounted, computed, watch, nextTick } = Vue;
createApp({
setup() {
const isLoggedIn = ref(!!localStorage.getItem('inp2p_token'));
const loginToken = ref('');
const loginError = ref('');
const activeTab = ref('dashboard');
const loading = ref(false);
const sdSaving = ref(false);
const appSaving = ref(false);
const stats = ref({});
const nodes = ref([]);
const sdwan = ref({ enabled: false, gatewayCIDR: '10.10.0.0/24', mode: 'mesh', nodes: [] });
const msg = ref('');
const msgType = ref('info');
const nodeFilter = ref('');
const activityLogs = ref([]);
const appManagerNode = ref(null);
const appConfigs = ref([]);
const newApp = reactive({ appName: '', srcPort: 18080, peerNode: '', dstPort: 8080 });
const newNodeToAssign = ref('');
const newIPToAssign = ref('');
const refreshInterval = ref(15);
const punchRate = ref(0);
const tabNames = { dashboard: '仪表盘', nodes: '节点资产', sdwan: 'SDWAN', settings: '设置' };
const fetchAPI = async (path, options = {}) => {
const headers = { 'Content-Type': 'application/json', ...options.headers };
const token = localStorage.getItem('inp2p_token');
if (token) headers['Authorization'] = 'Bearer ' + token;
try {
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
isLoggedIn.value = false;
localStorage.removeItem('inp2p_token');
showToast('登录已过期', 'error');
return null;
}
const data = await response.json();
if (response.ok) return data;
throw new Error(data.message || '请求失败');
} catch (e) {
showToast(e.message, 'error');
return null;
}
};
const showToast = (text, type = 'info', duration = 3000) => {
msg.value = text;
msgType.value = type;
setTimeout(() => msg.value = '', duration);
};
const addLog = (message, type = 'info') => {
activityLogs.value.unshift({ time: new Date().toLocaleTimeString(), msg: message, type: type });
if (activityLogs.value.length > 50) activityLogs.value.pop();
};
const login = async () => {
if (!loginToken.value) { loginError.value = '请输入 Token'; return; }
loading.value = true;
loginError.value = '';
const d = await fetchAPI('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ token: loginToken.value }) });
if (d && d.error === 0) {
localStorage.setItem('inp2p_token', d.token);
isLoggedIn.value = true;
addLog('登录成功', 'success');
refreshAll();
} else {
loginError.value = 'Token 验证失败';
}
loading.value = false;
};
const logout = () => {
localStorage.removeItem('inp2p_token');
isLoggedIn.value = false;
};
let refreshTimer = null;
const startRefresh = () => {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(refreshAll, refreshInterval.value * 1000);
};
watch(refreshInterval, () => { if (isLoggedIn.value) startRefresh(); });
const refreshAll = async () => {
if (!isLoggedIn.value) return;
loading.value = true;
const [health, nodeData, sdwanData] = await Promise.all([
fetchAPI('/api/v1/health'),
fetchAPI('/api/v1/nodes'),
fetchAPI('/api/v1/sdwans')
]);
if (health) {
stats.value = health;
punchRate.value = Math.min(95, 50 + (health.nodes || 0) * 15);
}
if (nodeData) nodes.value = nodeData.nodes || [];
if (sdwanData) sdwan.value = sdwanData;
loading.value = false;
updateCharts();
};
const saveSDWAN = async () => {
sdSaving.value = true;
const r = await fetchAPI('/api/v1/sdwan/edit', { method: 'POST', body: JSON.stringify(sdwan.value) });
sdSaving.value = false;
if (r) showToast('配置已推送', 'success');
};
const openAppManager = (node) => {
appManagerNode.value = node;
appConfigs.value = JSON.parse(JSON.stringify(node.apps || []));
};
const pushAppConfigs = async () => {
appSaving.value = true;
const r = await fetchAPI('/api/v1/nodes/apps', { method: 'POST', body: JSON.stringify({ node: appManagerNode.value.name, apps: appConfigs.value }) });
appSaving.value = false;
if (r) { showToast('配置已下发', 'success'); appManagerNode.value = null; setTimeout(refreshAll, 1000); }
};
const addSDWANNode = () => {
if (!newNodeToAssign.value || !newIPToAssign.value) return;
sdwan.value.nodes.push({ node: newNodeToAssign.value, ip: newIPToAssign.value });
newNodeToAssign.value = ''; newIPToAssign.value = '';
saveSDWAN();
};
const removeSDWANNode = (idx) => { sdwan.value.nodes.splice(idx, 1); saveSDWAN(); };
const autoAssignIPs = () => {
const base = sdwan.value.gatewayCIDR.replace('.0/24', '.');
let cur = 2;
nodes.value.filter(n => !sdwan.value.nodes.some(sn => sn.node === n.name)).forEach(n => {
while (sdwan.value.nodes.some(sn => sn.ip === base + cur)) cur++;
sdwan.value.nodes.push({ node: n.name, ip: base + cur }); cur++;
});
saveSDWAN();
};
let myChart = null;
const updateCharts = () => {
if (activeTab.value !== 'dashboard' || !isLoggedIn.value) return;
nextTick(() => {
const dom = document.getElementById('natChart');
if (!dom) return;
if (!myChart) myChart = echarts.init(dom, 'dark', { backgroundColor: 'transparent' });
const counts = nodes.value.reduce((acc, n) => {
const l = ['Cone', 'Symmetric', 'Static'][n.natType - 1] || 'Unknown';
acc[l] = (acc[l] || 0) + 1;
return acc;
}, {});
myChart.setOption({
tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['40%', '70%'], itemStyle: { borderRadius: 10, borderColor: '#0f1425', borderWidth: 2 }, label: { show: false },
data: Object.entries(counts).map(([name, value]) => ({ name, value, itemStyle: { color: name === 'Cone' ? '#22c55e' : name === 'Symmetric' ? '#eab308' : '#ef4444' } })) }]
});
});
};
const formatUptime = (t) => {
if (!t) return '-';
try {
const s = Math.floor((Date.now() - new Date(t).getTime()) / 1000);
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
} catch { return '-'; }
};
onMounted(() => { if (isLoggedIn.value) { refreshAll(); startRefresh(); } window.addEventListener('resize', () => myChart && myChart.resize()); });
return {
isLoggedIn, loginToken, loginError, activeTab, loading, sdSaving, appSaving,
stats, nodes, sdwan, msg, msgType, nodeFilter, activityLogs,
appManagerNode, appConfigs, newApp, newNodeToAssign, newIPToAssign,
refreshInterval, punchRate, tabNames,
login, logout, refreshAll, saveSDWAN, openAppManager, pushAppConfigs,
addSDWANNode, removeSDWANNode, autoAssignIPs,
relayCount: computed(() => nodes.value.filter(n => n.relayEnabled || n.superRelay).length),
filteredNodes: computed(() => nodes.value.filter(n => n.name.toLowerCase().includes(nodeFilter.value.toLowerCase()))),
unassignedNodes: computed(() => nodes.value.filter(n => !sdwan.value.nodes.some(sn => sn.node === n.name))),
formatUptime
};
}
}).mount('#app');
</script>
</body>
</html>