web: restore full single-file console and fix enroll revoke route
This commit is contained in:
892
web/index.html
892
web/index.html
@@ -1,457 +1,557 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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://unpkg.com/vue@3/dist/vue.global.prod.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{width:100%;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:.75rem;padding:.65rem .9rem;font-size:.82rem;outline:none;color:#93c5fd}
|
||||
.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)}
|
||||
.btn{background:#3b82f6;color:#fff;font-weight:700;padding:.6rem 1rem;border-radius:.7rem;font-size:.78rem}
|
||||
.btn:hover{background:#2563eb}
|
||||
.btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
.btn2{background:rgba(255,255,255,.06);color:#cbd5e1;font-weight:700;padding:.6rem 1rem;border-radius:.7rem;font-size:.78rem;border:1px solid rgba(255,255,255,.1)}
|
||||
.btn2:hover{background:rgba(255,255,255,.1);color:#fff}
|
||||
.chip{font-size:.7rem;border-radius:999px;padding:.15rem .5rem;border:1px solid rgba(255,255,255,.1)}
|
||||
.tab{padding:.7rem .8rem;border-radius:.65rem;font-size:.82rem;color:#94a3b8;font-weight:700;cursor:pointer}
|
||||
.tab:hover{background:rgba(255,255,255,.05);color:#fff}
|
||||
.tab.active{background:rgba(59,130,246,.14);border:1px solid rgba(59,130,246,.45);color:#fff}
|
||||
.ok{color:#22c55e}.warn{color:#eab308}.err{color:#ef4444}
|
||||
</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 id="app" v-cloak class="min-h-screen">
|
||||
<div v-if="!loggedIn" class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md glass rounded-3xl p-8">
|
||||
<h1 class="text-2xl font-black text-white mb-2">INP2P 控制台</h1>
|
||||
<p class="text-slate-500 text-sm mb-6">登录后可管理节点、SDWAN、连接与租户</p>
|
||||
<div class="space-y-3">
|
||||
<input v-model="loginTenant" class="ipt" placeholder="Tenant ID(用户登录)" @keyup.enter="login">
|
||||
<input v-model="loginUser" class="ipt" placeholder="用户名(如 admin)" @keyup.enter="login">
|
||||
<input v-model="loginPass" class="ipt" type="password" placeholder="密码" @keyup.enter="login">
|
||||
<div class="text-xs text-slate-500 text-center">或使用主 Token 登录(管理员)</div>
|
||||
<input v-model="loginToken" class="ipt" type="password" placeholder="Master Token" @keyup.enter="login">
|
||||
<button class="btn w-full" :disabled="busy" @click="login">{{ busy ? '登录中...' : '登录' }}</button>
|
||||
<div class="text-[11px] text-slate-500 text-center">Build: {{ buildVersion }}</div>
|
||||
<div v-if="loginErr" class="text-red-400 text-sm">{{ loginErr }}</div>
|
||||
</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 v-else class="max-w-7xl mx-auto p-4 md:p-6">
|
||||
<div class="glass rounded-2xl p-4 flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<div class="text-white font-black">INP2P Console</div>
|
||||
<div class="text-xs text-slate-500">Role: {{ role || 'unknown' }} · Build: {{ buildVersion }}</div>
|
||||
</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 class="flex items-center gap-2">
|
||||
<label class="text-xs text-slate-500">自动刷新(s)</label>
|
||||
<input class="ipt w-20" type="number" min="5" max="300" v-model.number="refreshSec">
|
||||
<button class="btn2" :disabled="busy" @click="refreshAll">刷新</button>
|
||||
<button class="btn" @click="logout">登出</button>
|
||||
</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>
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<div v-for="t in filteredTabs" :key="t.id" class="tab" :class="{active: tab===t.id}" @click="tab=t.id">{{ t.name }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="msg" class="mb-4 text-sm" :class="msgType==='error'?'err':'ok'">{{ msg }}</div>
|
||||
|
||||
<div v-if="tab==='dashboard'" class="space-y-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-6 gap-3">
|
||||
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">在线节点</div><div class="text-xl font-black">{{ stats.nodes || 0 }}</div></div>
|
||||
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">中继</div><div class="text-xl font-black">{{ stats.relay || 0 }}</div></div>
|
||||
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Cone</div><div class="text-xl font-black">{{ stats.cone || 0 }}</div></div>
|
||||
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Symmetric</div><div class="text-xl font-black">{{ stats.symmetric || 0 }}</div></div>
|
||||
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">Unknown</div><div class="text-xl font-black">{{ stats.unknown || 0 }}</div></div>
|
||||
<div class="glass rounded-xl p-4"><div class="text-xs text-slate-500">SDWAN</div><div class="text-xl font-black" :class="stats.sdwan ? 'ok':'warn'">{{ stats.sdwan ? 'ON':'OFF' }}</div></div>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-4 text-sm text-slate-300">
|
||||
<div>服务版本:{{ stats.version || '-' }}</div>
|
||||
<div>健康状态:<span :class="health.status==='ok'?'ok':'err'">{{ health.status || '-' }}</span></div>
|
||||
<div>健康上报节点:{{ health.nodes || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab==='nodes'" class="glass rounded-2xl overflow-hidden">
|
||||
<div class="p-3 border-b border-white/10 flex gap-2">
|
||||
<input v-model="nodeKeyword" class="ipt" placeholder="筛选节点名 / IP">
|
||||
</div>
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full text-sm min-w-[900px]">
|
||||
<thead class="text-slate-400"><tr>
|
||||
<th class="p-3 text-left">节点</th><th class="p-3 text-left">公网</th><th class="p-3 text-left">NAT</th><th class="p-3 text-left">租户</th><th class="p-3 text-left">版本</th><th class="p-3 text-left">在线时长</th><th class="p-3 text-left">动作</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>
|
||||
<tr v-for="n in filteredNodes" :key="n.name" class="border-t border-white/5">
|
||||
<td class="p-3">{{ n.name }}</td>
|
||||
<td class="p-3">{{ n.publicIP }}:{{ n.publicPort }}</td>
|
||||
<td class="p-3">{{ natText(n.natType) }}</td>
|
||||
<td class="p-3">{{ n.tenantId || 0 }}</td>
|
||||
<td class="p-3">{{ n.version || '-' }}</td>
|
||||
<td class="p-3">{{ uptime(n.loginTime) }}</td>
|
||||
<td class="p-3">
|
||||
<div class="flex gap-2">
|
||||
<button class="btn2" @click="openConnect(n.name)">发起连接</button>
|
||||
<button class="btn2" @click="openAppManager(n.name)">推配置</button>
|
||||
<button class="btn2" @click="kickNode(n.name)">踢下线</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredNodes.length"><td class="p-6 text-center text-slate-500" colspan="7">暂无节点</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab==='sdwan'" class="space-y-4">
|
||||
<div class="glass rounded-xl p-4 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<label class="text-sm"><input type="checkbox" v-model="sd.enabled"> 启用 SDWAN</label>
|
||||
<input class="ipt max-w-xs" v-model="sd.name" placeholder="名称">
|
||||
<input class="ipt max-w-xs" v-model="sd.gatewayCIDR" placeholder="网段,如 10.10.0.0/24">
|
||||
<select class="ipt max-w-[140px]" v-model="sd.mode"><option value="mesh">mesh</option><option value="hub">hub</option></select>
|
||||
<input class="ipt max-w-[120px]" type="number" min="1200" max="9000" v-model.number="sd.mtu" placeholder="MTU">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn2" @click="autoAssignIPs">自动分配 IP</button>
|
||||
<button class="btn" :disabled="busy" @click="saveSDWAN">保存 SDWAN</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl p-4">
|
||||
<div class="font-bold mb-3">节点映射</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(n,i) in sd.nodes" :key="i" class="grid grid-cols-1 md:grid-cols-5 gap-2">
|
||||
<select class="ipt" v-model="n.node">
|
||||
<option value="">选择节点</option>
|
||||
<option v-for="x in nodes" :key="x.name" :value="x.name">{{ x.name }}</option>
|
||||
</select>
|
||||
<input class="ipt md:col-span-2" v-model="n.ip" placeholder="10.10.0.X">
|
||||
<button class="btn2" @click="removeSDWANNode(i)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn2 mt-3" @click="addSDWANNode">+ 添加节点</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab==='p2p'" class="space-y-4">
|
||||
<div class="glass rounded-xl p-4 space-y-3">
|
||||
<div class="font-bold">手动触发连接</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
<select class="ipt" v-model="connectForm.from"><option value="">From</option><option v-for="n in nodes" :key="'f'+n.name" :value="n.name">{{ n.name }}</option></select>
|
||||
<select class="ipt" v-model="connectForm.to"><option value="">To</option><option v-for="n in nodes" :key="'t'+n.name" :value="n.name">{{ n.name }}</option></select>
|
||||
<input class="ipt" type="number" v-model.number="connectForm.srcPort" placeholder="srcPort">
|
||||
<input class="ipt" type="number" v-model.number="connectForm.dstPort" placeholder="dstPort">
|
||||
</div>
|
||||
<input class="ipt" v-model="connectForm.appName" placeholder="appName(可空)">
|
||||
<button class="btn" :disabled="busy" @click="doConnect">发送连接请求</button>
|
||||
</div>
|
||||
|
||||
<div class="glass rounded-xl p-4 space-y-3">
|
||||
<div class="font-bold">远程推配置(/api/v1/nodes/apps)</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<select class="ipt" v-model="appPushNode">
|
||||
<option value="">选择目标节点</option>
|
||||
<option v-for="n in nodes" :key="'p'+n.name" :value="n.name">{{ n.name }}</option>
|
||||
</select>
|
||||
<div class="md:col-span-2 text-xs text-slate-400">示例:[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]</div>
|
||||
</div>
|
||||
<textarea class="ipt" style="min-height:130px" v-model="appPushRaw"></textarea>
|
||||
<button class="btn2" :disabled="busy" @click="pushAppConfigs">发送配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab==='tenants'" class="space-y-4">
|
||||
<div class="glass rounded-xl p-4 space-y-2">
|
||||
<div class="font-bold">创建租户</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
|
||||
<input class="ipt" v-model="tenantForm.name" placeholder="租户名">
|
||||
<input class="ipt" v-model="tenantForm.admin_password" placeholder="admin 密码">
|
||||
<input class="ipt" v-model="tenantForm.operator_password" placeholder="operator 密码">
|
||||
<button class="btn" :disabled="busy" @click="createTenant">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-4 overflow-auto">
|
||||
<table class="w-full text-sm min-w-[700px]">
|
||||
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">名称</th><th class="p-2 text-left">子网</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">动作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="t in tenants" :key="t.id" class="border-t border-white/5">
|
||||
<td class="p-2">{{ t.id }}</td><td class="p-2">{{ t.name }}</td><td class="p-2">{{ t.subnet || '-' }}</td><td class="p-2">{{ t.status===1?'启用':'停用' }}</td>
|
||||
<td class="p-2 flex gap-2">
|
||||
<button class="btn2" @click="setTenantStatus(t.id, t.status===1?0:1)">{{ t.status===1?'停用':'启用' }}</button>
|
||||
<button class="btn2" @click="activeTenant=t.id;tab='apikeys';loadKeys()">Key</button>
|
||||
<button class="btn2" @click="activeTenant=t.id;tab='users';loadUsers()">用户</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 v-if="tab==='apikeys'" class="space-y-4">
|
||||
<div class="glass rounded-xl p-4 flex flex-wrap gap-2 items-center">
|
||||
<input class="ipt max-w-[120px]" type="number" v-model.number="activeTenant" placeholder="Tenant ID">
|
||||
<input class="ipt max-w-[120px]" type="number" v-model.number="keyForm.ttl" placeholder="TTL(s)">
|
||||
<input class="ipt max-w-[140px]" v-model="keyForm.scope" placeholder="scope(all)">
|
||||
<button class="btn" @click="createKey">创建 API Key</button>
|
||||
<button class="btn2" @click="loadKeys">刷新 Key</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 class="glass rounded-xl p-4 overflow-auto">
|
||||
<table class="w-full text-sm min-w-[900px]">
|
||||
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">Scope</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">过期</th><th class="p-2 text-left">动作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="k in keys" :key="k.id" class="border-t border-white/5">
|
||||
<td class="p-2">{{ k.id }}</td><td class="p-2">{{ k.scope }}</td><td class="p-2">{{ k.status===1?'启用':'停用' }}</td><td class="p-2">{{ fmtTime(k.expires_at) }}</td>
|
||||
<td class="p-2"><button class="btn2" @click="setKeyStatus(k.id, k.status===1?0:1)">{{ k.status===1?'停用':'启用' }}</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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 v-if="tab==='users'" class="space-y-4">
|
||||
<div class="glass rounded-xl p-4 grid grid-cols-1 md:grid-cols-6 gap-2">
|
||||
<input class="ipt" type="number" v-model.number="activeTenant" placeholder="Tenant ID">
|
||||
<select class="ipt" v-model="userForm.role"><option value="admin">admin</option><option value="operator">operator</option></select>
|
||||
<input class="ipt" v-model="userForm.email" placeholder="email/username">
|
||||
<input class="ipt" v-model="userForm.password" placeholder="password">
|
||||
<button class="btn" @click="createUser">创建用户</button>
|
||||
<button class="btn2" @click="loadUsers">刷新用户</button>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-4 overflow-auto">
|
||||
<table class="w-full text-sm min-w-[1000px]">
|
||||
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">Role</th><th class="p-2 text-left">Email</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">动作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.id" class="border-t border-white/5">
|
||||
<td class="p-2">{{ u.id }}</td><td class="p-2">{{ u.role }}</td><td class="p-2">{{ u.email }}</td><td class="p-2">{{ u.status===1?'启用':'停用' }}</td>
|
||||
<td class="p-2 flex gap-2">
|
||||
<button class="btn2" @click="setUserStatus(u.id, u.status===1?0:1)">{{ u.status===1?'停用':'启用' }}</button>
|
||||
<button class="btn2" @click="resetUserPassword(u.id)">重置密码</button>
|
||||
<button class="btn2" @click="deleteUser(u.id)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tab==='enroll'" class="space-y-4">
|
||||
<div class="glass rounded-xl p-4 flex gap-2">
|
||||
<button class="btn" @click="createEnroll">生成 enroll_code</button>
|
||||
<button class="btn2" @click="loadEnrolls">刷新 enroll</button>
|
||||
</div>
|
||||
<div class="glass rounded-xl p-4 overflow-auto">
|
||||
<table class="w-full text-sm min-w-[900px]">
|
||||
<thead class="text-slate-400"><tr><th class="p-2 text-left">ID</th><th class="p-2 text-left">Code</th><th class="p-2 text-left">状态</th><th class="p-2 text-left">过期</th><th class="p-2 text-left">动作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="e in enrolls" :key="e.id" class="border-t border-white/5">
|
||||
<td class="p-2">{{ e.id }}</td><td class="p-2">{{ e.code || '-' }}</td><td class="p-2">{{ e.status===1?'可用':'停用' }}</td><td class="p-2">{{ fmtTime(e.expires_at) }}</td>
|
||||
<td class="p-2"><button class="btn2" @click="setEnrollStatus(e.id,0)">作废</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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 { createApp, ref, computed, onMounted, watch } = Vue;
|
||||
createApp({
|
||||
setup(){
|
||||
const buildVersion = ref('20260303-1838');
|
||||
const tab = ref('dashboard');
|
||||
const tabs = [
|
||||
{id:'dashboard',name:'仪表盘'},{id:'nodes',name:'节点'},{id:'sdwan',name:'SDWAN'},{id:'p2p',name:'P2P'},
|
||||
{id:'tenants',name:'租户'},{id:'apikeys',name:'API Key'},{id:'users',name:'用户'},{id:'enroll',name:'Enroll'}
|
||||
];
|
||||
|
||||
const tabs=[{id:'dash',label:'仪表盘'},{id:'nodes',label:'节点管理'},{id:'sdwan',label:'SDWAN 组网'},{id:'p2p',label:'P2P 连接'},{id:'tenant',label:'租户管理'}];
|
||||
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
|
||||
const role = ref(''), status = ref(1);
|
||||
const loginTenant = ref('1'), loginUser = ref('admin'), loginPass = ref('admin'), loginToken = ref(''), loginErr = ref('');
|
||||
const refreshSec = ref(15), timer = ref(null);
|
||||
|
||||
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 health = ref({}), stats = ref({}), nodes = ref([]), nodeKeyword = ref('');
|
||||
const sd = ref({ enabled:false, name:'sdwan-main', gatewayCIDR:'10.10.0.0/24', mode:'mesh', mtu:1420, nodes:[], routes:['10.10.0.0/24'] });
|
||||
const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' });
|
||||
|
||||
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 tenants = ref([]), activeTenant = ref(1), keys = ref([]), users = ref([]), enrolls = ref([]);
|
||||
const tenantForm = ref({ name:'', admin_password:'', operator_password:'' });
|
||||
const keyForm = ref({ scope:'all', ttl:0 });
|
||||
const userForm = ref({ role:'operator', email:'', password:'' });
|
||||
|
||||
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 isAdmin = computed(() => role.value === 'admin' && localStorage.getItem('t') === localStorage.getItem('master_t'));
|
||||
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll'].includes(t.id)));
|
||||
const filteredNodes = computed(() => {
|
||||
const k = (nodeKeyword.value || '').trim().toLowerCase();
|
||||
if (!k) return nodes.value;
|
||||
return nodes.value.filter(n => (n.name||'').toLowerCase().includes(k) || (n.publicIP||'').toLowerCase().includes(k));
|
||||
});
|
||||
|
||||
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 toast = (text, t='ok') => { msg.value = text; msgType.value = t; setTimeout(() => { if (msg.value === text) msg.value = ''; }, 2500); };
|
||||
const bearer = () => ({ Authorization: 'Bearer ' + (localStorage.getItem('t') || '') });
|
||||
|
||||
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 api = async (path, opt={}) => {
|
||||
const headers = { 'Content-Type': 'application/json', ...(opt.headers||{}), ...bearer() };
|
||||
const r = await fetch(path, { ...opt, headers });
|
||||
let d = {};
|
||||
try { d = await r.json(); } catch(_) {}
|
||||
if (!r.ok) {
|
||||
if (r.status === 401) {
|
||||
loggedIn.value = false;
|
||||
throw new Error('401 登录已过期');
|
||||
}
|
||||
throw new Error(d.message || ('HTTP ' + r.status));
|
||||
}
|
||||
return d;
|
||||
};
|
||||
|
||||
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 natText = t => t===1?'Cone':(t===2?'Symmetric':'Unknown');
|
||||
const uptime = ts => {
|
||||
if(!ts) return '-';
|
||||
const sec = Math.max(0, Math.floor((Date.now() - new Date(ts).getTime()) / 1000));
|
||||
if(sec < 60) return sec + 's';
|
||||
if(sec < 3600) return Math.floor(sec/60) + 'm';
|
||||
if(sec < 86400) return Math.floor(sec/3600) + 'h';
|
||||
return Math.floor(sec/86400) + 'd';
|
||||
};
|
||||
const fmtTime = t => t ? new Date(t).toLocaleString() : '-';
|
||||
|
||||
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 login = async () => {
|
||||
loginErr.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
let d;
|
||||
if ((loginToken.value || '').trim()) {
|
||||
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ token: loginToken.value.trim() }) }).then(r=>r.json());
|
||||
if (d.error) throw new Error(d.message || 'token 登录失败');
|
||||
localStorage.setItem('master_t', d.token || '');
|
||||
} else {
|
||||
d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ tenant: Number(loginTenant.value || 1), username: loginUser.value, password: loginPass.value }) }).then(r=>r.json());
|
||||
if (d.error) throw new Error(d.message || '用户名密码登录失败');
|
||||
}
|
||||
localStorage.setItem('t', d.token || '');
|
||||
role.value = d.role || '';
|
||||
status.value = d.status ?? 1;
|
||||
if (status.value !== 1) throw new Error('账号已停用');
|
||||
loggedIn.value = true;
|
||||
await refreshAll();
|
||||
toast('登录成功');
|
||||
} catch (e) {
|
||||
loginErr.value = e.message || '登录失败';
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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 logout = () => {
|
||||
localStorage.removeItem('t');
|
||||
localStorage.removeItem('master_t');
|
||||
loggedIn.value = false;
|
||||
role.value = '';
|
||||
stopTimer();
|
||||
};
|
||||
|
||||
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 refreshAll = async () => {
|
||||
if (!loggedIn.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
[health.value, stats.value] = await Promise.all([api('/api/v1/health'), api('/api/v1/stats')]);
|
||||
const nd = await api('/api/v1/nodes');
|
||||
nodes.value = nd.nodes || [];
|
||||
sd.value = await api('/api/v1/sdwans');
|
||||
} catch (e) {
|
||||
toast(e.message || '刷新失败', 'error');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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 saveSDWAN = async () => {
|
||||
try {
|
||||
await api('/api/v1/sdwan/edit', { method:'POST', body: JSON.stringify(sd.value) });
|
||||
toast('SDWAN 保存成功');
|
||||
await refreshAll();
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
};
|
||||
const addSDWANNode = () => sd.value.nodes = [...(sd.value.nodes || []), { node:'', ip:'' }];
|
||||
const removeSDWANNode = i => sd.value.nodes.splice(i, 1);
|
||||
const autoAssignIPs = () => {
|
||||
const used = new Set();
|
||||
(sd.value.nodes || []).forEach(n => { const p = (n.ip||'').split('.'); if (p.length===4) used.add(Number(p[3])); });
|
||||
let k = 2;
|
||||
for (const n of sd.value.nodes || []) {
|
||||
if (!n.ip) {
|
||||
while (used.has(k) && k < 254) k++;
|
||||
n.ip = `10.10.0.${k}`;
|
||||
used.add(k);
|
||||
k++;
|
||||
}
|
||||
}
|
||||
toast('已自动分配缺失 IP');
|
||||
};
|
||||
|
||||
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 kickNode = async (node) => {
|
||||
if (!confirm(`确认踢下线节点 ${node} ?`)) return;
|
||||
try { await api('/api/v1/nodes/kick', { method:'POST', body: JSON.stringify({ node }) }); toast('已发送踢下线'); refreshAll(); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const openConnect=(n)=>{tab.value='p2p';p2p.from=n.name};
|
||||
const appPushNode = ref('');
|
||||
const appPushRaw = ref('[{"appName":"demo","protocol":"tcp","srcPort":8080,"peerNode":"","dstHost":"127.0.0.1","dstPort":80,"enabled":1}]');
|
||||
const openAppManager = (node) => { appPushNode.value = node; toast(`已选中 ${node},请在控制台执行推配置`); tab.value = 'p2p'; };
|
||||
const pushAppConfigs = async () => {
|
||||
if (!appPushNode.value) return toast('请先选择节点', 'error');
|
||||
let apps;
|
||||
try { apps = JSON.parse(appPushRaw.value); } catch(_) { return toast('配置 JSON 格式错误', 'error'); }
|
||||
try { await api('/api/v1/nodes/apps', { method:'POST', body: JSON.stringify({ node: appPushNode.value, apps }) }); toast('配置已推送'); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
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 openConnect = (from) => { connectForm.value.from = from; tab.value = 'p2p'; };
|
||||
const doConnect = async () => {
|
||||
const req = { ...connectForm.value };
|
||||
if (!req.from || !req.to) return toast('请选择 from/to 节点', 'error');
|
||||
try {
|
||||
await api('/api/v1/connect', { method:'POST', body: JSON.stringify(req) });
|
||||
toast('连接请求已发送');
|
||||
} catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const addSDNode=()=>{
|
||||
if(!addNode.value||!addIP.value)return;
|
||||
sd.value.nodes.push({node:addNode.value,ip:addIP.value});
|
||||
addNode.value='';addIP.value='';saveSD()
|
||||
};
|
||||
const loadTenants = async () => {
|
||||
if (!isAdmin.value) { tenants.value = []; return; }
|
||||
try { const d = await api('/api/v1/admin/tenants'); tenants.value = d.tenants || []; }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const createTenant = async () => {
|
||||
if (!tenantForm.value.name) return toast('请输入租户名', 'error');
|
||||
try {
|
||||
await api('/api/v1/admin/tenants', { method:'POST', body: JSON.stringify(tenantForm.value) });
|
||||
tenantForm.value = { name:'', admin_password:'', operator_password:'' };
|
||||
toast('租户创建成功');
|
||||
await loadTenants();
|
||||
} catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const setTenantStatus = async (id, st) => {
|
||||
try { await api(`/api/v1/admin/tenants/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('租户状态已更新'); loadTenants(); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
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 loadKeys = async () => {
|
||||
if (!activeTenant.value) return;
|
||||
try { const d = await api(`/api/v1/admin/tenants/${activeTenant.value}/keys`); keys.value = d.keys || []; }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const createKey = async () => {
|
||||
if (!activeTenant.value) return toast('请先填写 Tenant ID', 'error');
|
||||
try {
|
||||
const d = await api(`/api/v1/admin/tenants/${activeTenant.value}/keys`, { method:'POST', body: JSON.stringify(keyForm.value) });
|
||||
toast(`API Key 创建成功: ${d.api_key || ''}`);
|
||||
await loadKeys();
|
||||
} catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const setKeyStatus = async (id, st) => {
|
||||
try { await api(`/api/v1/admin/tenants/${activeTenant.value}/keys/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('Key 状态已更新'); loadKeys(); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
const nodeOnline=(name)=>nodes.value.some(n=>n.name===name);
|
||||
const loadUsers = async () => {
|
||||
if (!activeTenant.value) return;
|
||||
try { const d = await api(`/api/v1/admin/users?tenant=${activeTenant.value}`); users.value = d.users || []; }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const createUser = async () => {
|
||||
if (!activeTenant.value || !userForm.value.email || !userForm.value.password) return toast('请补全用户信息', 'error');
|
||||
try {
|
||||
await api('/api/v1/admin/users', { method:'POST', body: JSON.stringify({ tenant: Number(activeTenant.value), ...userForm.value }) });
|
||||
userForm.value = { role:'operator', email:'', password:'' };
|
||||
toast('用户创建成功');
|
||||
loadUsers();
|
||||
} catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const setUserStatus = async (id, st) => {
|
||||
try { await api(`/api/v1/admin/users/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('用户状态已更新'); loadUsers(); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const resetUserPassword = async (id) => {
|
||||
const p = prompt('请输入新密码(至少6位)');
|
||||
if (!p) return;
|
||||
try { await api(`/api/v1/admin/users/${id}/password`, { method:'POST', body: JSON.stringify({ password: p }) }); toast('密码已重置'); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const deleteUser = async (id) => {
|
||||
if (!confirm('确认删除该用户?')) return;
|
||||
try { await api(`/api/v1/admin/users/${id}/delete`, { method:'POST', body:'{}' }); toast('用户已删除'); loadUsers(); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
|
||||
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 loadEnrolls = async () => {
|
||||
try { const d = await api('/api/v1/tenants/enroll'); enrolls.value = d.enrolls || []; }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const createEnroll = async () => {
|
||||
try {
|
||||
const d = await api('/api/v1/tenants/enroll', { method:'POST', body:'{}' });
|
||||
toast(`enroll_code: ${d.enroll_code || ''}`);
|
||||
loadEnrolls();
|
||||
} catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const setEnrollStatus = async (id, st) => {
|
||||
try { await api(`/api/v1/enroll/consume/${id}?status=${st}`, { method:'POST', body:'{}' }); toast('enroll 状态已更新'); loadEnrolls(); }
|
||||
catch(e){ toast(e.message, 'error'); }
|
||||
};
|
||||
const consumeEnroll = async () => {
|
||||
toast('consumeEnroll 为客户端配网流程,控制台当前不直接调用');
|
||||
};
|
||||
|
||||
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)));
|
||||
const updateCharts = () => {};
|
||||
|
||||
let timer;
|
||||
onMounted(()=>{if(loggedIn.value){refresh();timer=setInterval(refresh,15000)}});
|
||||
const stopTimer = () => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
const startTimer = () => {
|
||||
stopTimer();
|
||||
if (!loggedIn.value) return;
|
||||
const sec = Math.max(5, Number(refreshSec.value || 15));
|
||||
timer.value = setInterval(refreshAll, sec * 1000);
|
||||
};
|
||||
|
||||
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');
|
||||
watch(refreshSec, startTimer);
|
||||
watch(loggedIn, (v) => {
|
||||
if (v) startTimer();
|
||||
else stopTimer();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
logout();
|
||||
});
|
||||
|
||||
return {
|
||||
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status,
|
||||
loginTenant, loginUser, loginPass, loginToken, loginErr, refreshSec,
|
||||
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
|
||||
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
|
||||
natText, uptime, fmtTime,
|
||||
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, autoAssignIPs,
|
||||
kickNode, openAppManager, pushAppConfigs, openConnect, doConnect,
|
||||
createTenant, loadTenants, setTenantStatus,
|
||||
createKey, loadKeys, setKeyStatus,
|
||||
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
|
||||
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
|
||||
updateCharts
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user