Files
inp2p/web/index.html

458 lines
24 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 Console</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
[v-cloak]{display:none!important}
body{background:#070a14;color:#e2e8f0;font-family:system-ui,sans-serif}
.glass{background:rgba(15,20,37,.7);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.05)}
.active-tab{background:rgba(59,130,246,.1);border-left:3px solid #3b82f6;color:white}
.sb::-webkit-scrollbar{width:4px}.sb::-webkit-scrollbar-thumb{background:#1e293b;border-radius:4px}
.fade-in{animation:fi .3s ease-out}@keyframes fi{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.btn-blue{background:#3b82f6;color:white;font-weight:700;padding:.75rem 1.5rem;border-radius:.75rem;font-size:.75rem;transition:all .15s}
.btn-blue:hover{background:#2563eb}.btn-blue:disabled{opacity:.5}
.btn-red{background:#dc2626;color:white;font-weight:700;padding:.5rem 1rem;border-radius:.75rem;font-size:.7rem;transition:all .15s}
.btn-red:hover{background:#b91c1c}
.btn-ghost{background:transparent;color:#94a3b8;font-weight:700;padding:.5rem 1rem;border-radius:.75rem;font-size:.7rem;border:1px solid rgba(255,255,255,.1);transition:all .15s}
.btn-ghost:hover{background:rgba(255,255,255,.05);color:white}
.ipt{width:100%;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:.75rem;padding:.75rem 1rem;font-size:.8rem;outline:none;color:#93c5fd;transition:border .15s}
.ipt:focus{border-color:#3b82f6}
.tag-cone{background:rgba(34,197,94,.1);color:#22c55e;border:1px solid rgba(34,197,94,.2)}
.tag-symm{background:rgba(234,179,8,.1);color:#eab308;border:1px solid rgba(234,179,8,.2)}
.tag-unk{background:rgba(239,68,68,.1);color:#ef4444;border:1px solid rgba(239,68,68,.2)}
</style>
</head>
<body>
<div id="app" v-cloak class="flex h-screen">
<!-- Login -->
<div v-if="!loggedIn" class="fixed inset-0 z-50 flex items-center justify-center bg-[#070a14]">
<div class="w-full max-w-sm glass rounded-3xl p-8">
<div class="text-center mb-8">
<div class="w-12 h-12 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4"></div>
<h1 class="text-2xl font-black text-white">INP2P</h1>
<p class="text-slate-500 text-sm mt-1">输入 Master Token</p>
</div>
<input v-model="loginToken" type="password" placeholder="Token" class="ipt text-center mb-4" @keyup.enter="login">
<button @click="login" :disabled="busy" class="btn-blue w-full py-4">{{ busy ? '验证中...' : '登 录' }}</button>
<div v-if="loginErr" class="text-red-400 text-xs text-center mt-3">{{ loginErr }}</div>
</div>
</div>
<!-- Sidebar -->
<aside v-if="loggedIn" class="w-56 bg-[#0a0d1a] border-r border-white/5 flex flex-col flex-shrink-0">
<div class="p-5">
<div class="flex items-center gap-2 mb-8">
<span class="text-lg font-black text-white italic">⚡ INP2P</span>
</div>
<nav class="space-y-1 text-sm">
<button v-for="t in tabs" :key="t.id" @click="tab=t.id"
:class="['w-full text-left px-4 py-2.5 rounded-r-xl transition-all', tab===t.id ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">
{{ t.label }}
</button>
</nav>
</div>
<div class="mt-auto p-5 border-t border-white/5 text-[10px] text-slate-600">
<div>v{{ st.version || '0.1.0' }} · {{ st.nodes || 0 }} 节点在线</div>
<button @click="loggedIn=false;localStorage.removeItem('t')" class="text-slate-500 hover:text-red-400 mt-2">登出</button>
</div>
</aside>
<!-- Main -->
<main v-if="loggedIn" class="flex-1 flex flex-col overflow-hidden">
<header class="h-14 border-b border-white/5 flex items-center justify-between px-6 bg-[#0a0d1a]/50 flex-shrink-0">
<h2 class="font-bold text-white">{{ tabs.find(t=>t.id===tab)?.label }}</h2>
<button @click="refresh" :disabled="busy" class="btn-ghost text-xs">{{ busy ? '刷新中...' : '刷新' }}</button>
</header>
<div class="flex-1 overflow-y-auto p-6 sb space-y-6">
<!-- ===== 仪表盘 ===== -->
<div v-if="tab==='dash'" class="fade-in space-y-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="glass rounded-2xl p-5 text-center">
<div class="text-slate-500 text-[10px] font-bold uppercase">节点</div>
<div class="text-3xl font-black text-white mt-1">{{ st.nodes||0 }}</div>
</div>
<div class="glass rounded-2xl p-5 text-center">
<div class="text-slate-500 text-[10px] font-bold uppercase">中继</div>
<div class="text-3xl font-black text-blue-400 mt-1">{{ st.relay||0 }}</div>
</div>
<div class="glass rounded-2xl p-5 text-center">
<div class="text-slate-500 text-[10px] font-bold uppercase">Cone / Symm</div>
<div class="text-xl font-black mt-1"><span class="text-green-400">{{ st.cone||0 }}</span> / <span class="text-yellow-400">{{ st.symmetric||0 }}</span></div>
</div>
<div class="glass rounded-2xl p-5 text-center">
<div class="text-slate-500 text-[10px] font-bold uppercase">SDWAN</div>
<div class="text-3xl font-black mt-1" :class="st.sdwan?'text-green-400':'text-slate-500'">{{ st.sdwan?'ON':'OFF' }}</div>
</div>
</div>
<div class="glass rounded-2xl p-5">
<h3 class="text-xs font-bold text-slate-500 uppercase mb-3">事件日志</h3>
<div class="bg-black/40 rounded-xl p-4 font-mono text-[11px] max-h-60 overflow-y-auto sb">
<div v-for="(l,i) in logs" :key="i" class="mb-1">
<span class="text-slate-600">[{{ l.t }}]</span>
<span :class="l.c==='err'?'text-red-400':l.c==='ok'?'text-green-400':'text-white'"> {{ l.m }}</span>
</div>
<div v-if="!logs.length" class="text-slate-700">暂无事件</div>
</div>
</div>
</div>
<!-- ===== 节点管理 ===== -->
<div v-if="tab==='nodes'" class="fade-in space-y-4">
<div class="flex gap-3">
<input v-model="nf" placeholder="搜索节点..." class="ipt flex-1">
<button @click="refresh" class="btn-ghost">刷新</button>
</div>
<table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 text-xs uppercase border-b border-white/5">
<th class="p-3">节点</th><th class="p-3">公网 IP</th><th class="p-3">NAT</th><th class="p-3">中继</th><th class="p-3">在线</th><th class="p-3 text-right">操作</th>
</tr></thead>
<tbody>
<tr v-for="n in fNodes" :key="n.name" class="border-b border-white/5 hover:bg-white/[0.02]">
<td class="p-3 font-mono text-white text-xs">{{ n.name }}</td>
<td class="p-3 text-slate-400 font-mono text-xs">{{ n.publicIP }}:{{ n.publicPort }}</td>
<td class="p-3"><span :class="['px-2 py-0.5 rounded text-[10px] font-bold', n.natType===1?'tag-cone':n.natType===2?'tag-symm':'tag-unk']">{{ ['Cone','Symm'][n.natType-1]||'Unk' }}</span></td>
<td class="p-3 text-xs" :class="n.relayEnabled?'text-blue-400':'text-slate-600'">{{ n.relayEnabled?'是':'否' }}</td>
<td class="p-3 text-xs text-slate-400">{{ uptime(n.loginTime) }}</td>
<td class="p-3 text-right space-x-2">
<button @click="openTunnel(n)" class="btn-ghost">隧道</button>
<button @click="openConnect(n)" class="btn-ghost">P2P连接</button>
<button @click="kickNode(n)" class="btn-red">踢出</button>
</td>
</tr>
</tbody>
</table>
<div v-if="!fNodes.length" class="text-center py-8 text-slate-600">无节点</div>
</div>
<!-- ===== SDWAN ===== -->
<div v-if="tab==='sdwan'" class="fade-in space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div class="lg:col-span-4 glass rounded-2xl p-6 space-y-4">
<div class="flex items-center justify-between">
<h3 class="font-bold">网络配置</h3>
<label class="flex items-center gap-2 cursor-pointer">
<span class="text-xs text-slate-500">启用</span>
<input type="checkbox" v-model="sd.enabled" class="w-5 h-5 accent-blue-600">
</label>
</div>
<div>
<label class="text-[10px] font-bold text-slate-500 uppercase">网关 CIDR</label>
<input v-model="sd.gatewayCIDR" class="ipt mt-1 font-mono">
</div>
<div>
<label class="text-[10px] font-bold text-slate-500 uppercase">模式</label>
<select v-model="sd.mode" class="ipt mt-1">
<option value="mesh">Mesh (全互联)</option>
<option value="hub">Hub (星型)</option>
</select>
</div>
<button @click="saveSD" :disabled="busy" class="btn-blue w-full">{{ busy ? '推送中...' : '保存并推送' }}</button>
</div>
<div class="lg:col-span-8 glass rounded-2xl overflow-hidden">
<div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
<h3 class="font-bold text-xs uppercase">IP 分配表</h3>
<button @click="autoIP" class="text-[10px] font-bold text-blue-500 hover:text-blue-400">自动分配</button>
</div>
<table class="w-full text-xs font-mono">
<thead><tr class="text-left text-slate-500 border-b border-white/5">
<th class="px-6 py-3">节点</th><th class="px-6 py-3">虚拟 IP</th><th class="px-6 py-3">状态</th><th class="px-6 py-3 text-right">操作</th>
</tr></thead>
<tbody>
<tr v-for="(sn,i) in sd.nodes" :key="sn.node" class="border-b border-white/5 hover:bg-white/[0.02]">
<td class="px-6 py-3 text-slate-300">{{ sn.node }}</td>
<td class="px-6 py-3"><input v-model="sn.ip" class="bg-transparent border-b border-white/10 outline-none w-28 text-blue-400 focus:border-blue-500"></td>
<td class="px-6 py-3"><span :class="nodeOnline(sn.node)?'text-green-400':'text-slate-600'">{{ nodeOnline(sn.node)?'在线':'离线' }}</span></td>
<td class="px-6 py-3 text-right"><button @click="sd.nodes.splice(i,1)" class="text-red-400 hover:text-red-300">删除</button></td>
</tr>
</tbody>
</table>
<div class="p-6 bg-black/20 border-t border-white/5 flex gap-3">
<select v-model="addNode" class="ipt flex-1">
<option value="">选择节点...</option>
<option v-for="n in uaNodes" :key="n.name" :value="n.name">{{ n.name }}</option>
</select>
<input v-model="addIP" placeholder="10.10.0.x" class="ipt w-32">
<button @click="addSDNode" :disabled="!addNode||!addIP" class="btn-blue disabled:opacity-50">添加</button>
</div>
</div>
</div>
</div>
<!-- ===== P2P 连接 ===== -->
<div v-if="tab==='p2p'" class="fade-in space-y-6">
<div class="glass rounded-2xl p-6 space-y-4">
<h3 class="font-bold">创建 P2P 隧道</h3>
<p class="text-xs text-slate-500">选择两个在线节点,创建端口转发隧道。从 A 的本地端口转发到 B 的目标端口。</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div>
<label class="text-[10px] font-bold text-slate-500 uppercase">源节点 (A)</label>
<select v-model="p2p.from" class="ipt mt-1">
<option value="">选择...</option>
<option v-for="n in nodes" :key="n.name" :value="n.name">{{ n.name }}</option>
</select>
</div>
<div>
<label class="text-[10px] font-bold text-slate-500 uppercase">目标节点 (B)</label>
<select v-model="p2p.to" class="ipt mt-1">
<option value="">选择...</option>
<option v-for="n in nodes" :key="n.name" :value="n.name" :disabled="n.name===p2p.from">{{ n.name }}</option>
</select>
</div>
<div>
<label class="text-[10px] font-bold text-slate-500 uppercase">应用名称</label>
<input v-model="p2p.name" placeholder="ssh-forward" class="ipt mt-1">
</div>
<div>
<label class="text-[10px] font-bold text-slate-500 uppercase">A 监听端口</label>
<input v-model.number="p2p.srcPort" type="number" class="ipt mt-1">
</div>
<div>
<label class="text-[10px] font-bold text-slate-500 uppercase">B 目标端口</label>
<input v-model.number="p2p.dstPort" type="number" class="ipt mt-1">
</div>
</div>
<div class="pt-4 flex items-center gap-4">
<button @click="doConnect" :disabled="!p2p.from||!p2p.to||busy" class="btn-blue">发起连接</button>
<span v-if="p2p.from && p2p.to" class="text-xs text-slate-500">
{{ p2p.from }} :{{ p2p.srcPort }} → {{ p2p.to }} :{{ p2p.dstPort }}
</span>
</div>
</div>
</div>
<!-- ===== 租户管理 ===== -->
<div v-if="tab==='tenant'" class="fade-in space-y-6">
<div class="glass rounded-2xl p-5">
<div class="text-slate-300 text-sm font-bold mb-3">创建租户</div>
<div class="flex gap-3">
<input v-model="newTenant" placeholder="租户名称" class="ipt">
<button @click="createTenant" class="btn-blue">创建</button>
</div>
</div>
<div class="glass rounded-2xl p-5">
<div class="text-slate-300 text-sm font-bold mb-3">生成租户 API Key</div>
<button @click="createKey" class="btn-blue">生成 Key</button>
<div class="mt-3 text-xs text-slate-400">Key:</div>
<textarea v-model="tenantKey" class="ipt mt-1" rows="2" placeholder="生成后显示"></textarea>
</div>
<div class="glass rounded-2xl p-5">
<div class="text-slate-300 text-sm font-bold mb-3">生成 Enroll Code</div>
<div class="flex gap-3">
<button @click="createEnroll" class="btn-blue">生成</button>
<input v-model="enrollCode" placeholder="Enroll Code" class="ipt">
</div>
</div>
<div class="glass rounded-2xl p-5">
<div class="text-slate-300 text-sm font-bold mb-3">兑换 Node Secret</div>
<div class="flex gap-3">
<input v-model="enrollNode" placeholder="节点名" class="ipt">
<button @click="consumeEnroll" class="btn-blue">兑换</button>
</div>
<div class="mt-3 text-xs text-slate-400">Node Secret:</div>
<textarea v-model="enrollSecret" class="ipt mt-1" rows="2" placeholder="兑换后显示"></textarea>
</div>
</div>
</div>
</main>
<!-- Tunnel Modal -->
<div v-if="tunNode" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-xl p-4">
<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-6 border-b border-white/5 flex justify-between items-center">
<h3 class="font-bold">隧道配置: <span class="text-blue-400">{{ tunNode.name }}</span></h3>
<button @click="tunNode=null" class="text-slate-500 hover:text-white text-xl">×</button>
</div>
<div class="flex-1 overflow-y-auto p-6 sb space-y-4">
<div v-for="(a,i) in tunApps" :key="i" class="glass rounded-xl p-4 flex justify-between items-center">
<div>
<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>
</div>
<button @click="tunApps.splice(i,1)" class="text-red-400 hover:text-red-300 text-xs">删除</button>
</div>
<div v-if="!tunApps.length" class="text-center py-4 text-slate-600 text-sm">暂无隧道配置</div>
<div class="glass rounded-xl p-4 space-y-3 mt-4">
<div class="text-xs font-bold text-slate-400">添加隧道</div>
<div class="grid grid-cols-2 gap-3">
<input v-model="na.appName" placeholder="名称" class="ipt">
<input v-model.number="na.srcPort" placeholder="本地端口" type="number" class="ipt">
<input v-model="na.peerNode" placeholder="对端节点" class="ipt">
<input v-model.number="na.dstPort" placeholder="对端端口" type="number" class="ipt">
</div>
<button @click="tunApps.push({...na});Object.assign(na,{appName:'',srcPort:0,peerNode:'',dstPort:0})" class="btn-ghost w-full">+ 添加</button>
</div>
</div>
<div class="p-6 border-t border-white/5 flex justify-end gap-3">
<button @click="tunNode=null" class="btn-ghost">取消</button>
<button @click="pushTun" :disabled="busy" class="btn-blue">{{ busy ? '下发中...' : '下发配置' }}</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="toast" class="fixed top-4 left-1/2 -translate-x-1/2 z-[200] px-8 py-3 rounded-full text-xs font-bold shadow-2xl fade-in"
:class="toastType==='err'?'bg-red-900 border border-red-500 text-red-200':toastType==='ok'?'bg-green-900 border border-green-500 text-green-200':'bg-blue-900 border border-blue-500 text-white'">
{{ toast }}
</div>
</div>
<script>
const{createApp,ref,reactive,computed,onMounted,watch}=Vue;
createApp({setup(){
const loggedIn=ref(!!localStorage.getItem('t'));
const loginToken=ref(''),loginErr=ref(''),busy=ref(false);
const tab=ref('dash'),nf=ref(''),toast=ref(''),toastType=ref('');
const st=ref({}),nodes=ref([]),sd=ref({enabled:false,gatewayCIDR:'10.10.0.0/24',mode:'mesh',nodes:[]});
const logs=ref([]),tunNode=ref(null),tunApps=ref([]);
const na=reactive({appName:'',srcPort:0,peerNode:'',dstPort:0});
const addNode=ref(''),addIP=ref('');
const p2p=reactive({from:'',to:'',name:'tunnel',srcPort:18080,dstPort:22});
const tenants=ref([]),newTenant=ref('');
const tenantKey=ref('');
const enrollCode=ref('');
const enrollNode=ref('');
const enrollSecret=ref('');
const tabs=[{id:'dash',label:'仪表盘'},{id:'nodes',label:'节点管理'},{id:'sdwan',label:'SDWAN 组网'},{id:'p2p',label:'P2P 连接'},{id:'tenant',label:'租户管理'}];
const show=(m,c='info',d=3000)=>{toast.value=m;toastType.value=c;setTimeout(()=>toast.value='',d)};
const log=(m,c='info')=>{logs.value.unshift({t:new Date().toLocaleTimeString(),m,c});if(logs.value.length>50)logs.value.pop()};
const api=async(path,opt={})=>{
const h={'Content-Type':'application/json',...opt.headers};
const tk=localStorage.getItem('t');if(tk)h['Authorization']='Bearer '+tk;
try{
const r=await fetch(path,{...opt,headers:h});
if(r.status===401){loggedIn.value=false;localStorage.removeItem('t');show('登录过期','err');return null}
const d=await r.json();if(r.ok)return d;throw new Error(d.message||'失败')
}catch(e){show(e.message,'err');return null}
};
const login=async()=>{
if(!loginToken.value){loginErr.value='请输入 Token';return}
busy.value=true;loginErr.value='';
const d=await api('/api/v1/auth/login',{method:'POST',body:JSON.stringify({token:loginToken.value})});
if(d&&d.error===0){localStorage.setItem('t',d.token);loggedIn.value=true;log('登录成功','ok');refresh()}
else loginErr.value='Token 错误';
busy.value=false
};
const refresh=async()=>{
if(!loggedIn.value)return;busy.value=true;
const[h,n,s]=await Promise.all([api('/api/v1/stats'),api('/api/v1/nodes'),api('/api/v1/sdwans')]);
if(h)st.value=h;
if(n)nodes.value=n.nodes||[];
if(s)sd.value=s;
busy.value=false
};
const createTenant=async()=>{
if(!newTenant.value)return;busy.value=true;
const r=await api('/api/v1/admin/tenants',{method:'POST',body:JSON.stringify({name:newTenant.value})});
busy.value=false;
if(r){show('租户已创建','ok');log('创建租户 '+newTenant.value,'ok');newTenant.value=''}
};
const createKey=async()=>{
const id=prompt('输入 tenant_id');
if(!id)return;busy.value=true;
const r=await api(`/api/v1/admin/tenants/${id}/keys`,{method:'POST',body:JSON.stringify({scope:'all',ttl:0})});
busy.value=false;
if(r){tenantKey.value=r.api_key||'';show('API Key 已生成','ok')}
};
const createEnroll=async()=>{
if(!tenantKey.value){show('先填入租户 API Key','err');return}
busy.value=true;
const r=await api('/api/v1/tenants/enroll',{method:'POST',headers:{'Authorization':'Bearer '+tenantKey.value}});
busy.value=false;
if(r){enrollCode.value=r.enroll_code||'';show('Enroll Code 已生成','ok')}
};
const consumeEnroll=async()=>{
if(!enrollCode.value||!enrollNode.value){show('需要 enroll_code 与 node 名称','err');return}
busy.value=true;
const r=await api('/api/v1/enroll/consume',{method:'POST',body:JSON.stringify({code:enrollCode.value,node:enrollNode.value})});
busy.value=false;
if(r){enrollSecret.value=r.node_secret||'';show('Node Secret 已生成','ok')}
};
const saveSD=async()=>{
busy.value=true;
const r=await api('/api/v1/sdwan/edit',{method:'POST',body:JSON.stringify(sd.value)});
busy.value=false;
if(r){show('SDWAN 配置已推送','ok');log('SDWAN 配置更新','ok')}
};
const kickNode=async(n)=>{
if(!confirm('确认踢出节点 '+n.name+''))return;
const r=await api('/api/v1/nodes/kick',{method:'POST',body:JSON.stringify({node:n.name})});
if(r){show(n.name+' 已踢出','ok');log('踢出 '+n.name,'ok');setTimeout(refresh,1000)}
};
const openTunnel=(n)=>{tunNode.value=n;tunApps.value=JSON.parse(JSON.stringify(n.apps||[]))};
const pushTun=async()=>{
busy.value=true;
const r=await api('/api/v1/nodes/apps',{method:'POST',body:JSON.stringify({node:tunNode.value.name,apps:tunApps.value})});
busy.value=false;
if(r){show('隧道配置已下发','ok');log('下发隧道到 '+tunNode.value.name,'ok');tunNode.value=null;setTimeout(refresh,1000)}
};
const openConnect=(n)=>{tab.value='p2p';p2p.from=n.name};
const doConnect=async()=>{
busy.value=true;
const r=await api('/api/v1/connect',{method:'POST',body:JSON.stringify({from:p2p.from,to:p2p.to,appName:p2p.name,srcPort:p2p.srcPort,dstPort:p2p.dstPort})});
busy.value=false;
if(r&&r.error===0){show('P2P 连接请求已发送','ok');log(`${p2p.from}${p2p.to} 连接请求`,'ok')}
};
const addSDNode=()=>{
if(!addNode.value||!addIP.value)return;
sd.value.nodes.push({node:addNode.value,ip:addIP.value});
addNode.value='';addIP.value='';saveSD()
};
const autoIP=()=>{
const b=sd.value.gatewayCIDR.replace('.0/24','.');let c=2;
nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)).forEach(n=>{
while(sd.value.nodes.some(s=>s.ip===b+c))c++;
sd.value.nodes.push({node:n.name,ip:b+c});c++
});saveSD()
};
const nodeOnline=(name)=>nodes.value.some(n=>n.name===name);
const uptime=(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'-'}
};
const fNodes=computed(()=>nodes.value.filter(n=>n.name.toLowerCase().includes(nf.value.toLowerCase())));
const uaNodes=computed(()=>nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)));
let timer;
onMounted(()=>{if(loggedIn.value){refresh();timer=setInterval(refresh,15000)}});
return{loggedIn,loginToken,loginErr,busy,tab,tabs,nf,toast,toastType,
st,nodes,sd,logs,tunNode,tunApps,na,addNode,addIP,p2p,
tenants,newTenant,tenantKey,enrollCode,enrollNode,enrollSecret,
login,refresh,saveSD,kickNode,openTunnel,pushTun,openConnect,doConnect,
addSDNode,autoIP,nodeOnline,uptime,fNodes,uaNodes,
createTenant,createKey,createEnroll,consumeEnroll}
}}).mount('#app');
</script>
</body>
</html>