458 lines
24 KiB
HTML
458 lines
24 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>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>
|