691 lines
36 KiB
HTML
691 lines
36 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.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)}
|
||
.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}
|
||
.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="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="loginUser" class="ipt" placeholder="用户名(全局唯一,字母≥6位)" @keyup.enter="login">
|
||
<input v-model="loginPass" class="ipt" type="password" placeholder="密码" @keyup.enter="login">
|
||
<div class="text-xs text-slate-500 text-center">用户名要求:仅字母、长度≥6、全局唯一</div>
|
||
<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>
|
||
</div>
|
||
</div>
|
||
|
||
<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>
|
||
<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 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">唯一ID</th><th class="p-3 text-left">虚拟IP</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 filteredNodes" :key="n.nodeUUID || n.name" class="border-t border-white/5">
|
||
<td class="p-3">
|
||
<div class="font-semibold">{{ n.alias || n.name }}</div>
|
||
<div class="text-xs text-slate-500">hostname: {{ n.name }}</div>
|
||
</td>
|
||
<td class="p-3 text-xs text-slate-400">{{ n.nodeUUID || '-' }}</td>
|
||
<td class="p-3">{{ n.virtualIP || '-' }}</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 flex-wrap">
|
||
<button class="btn2" @click="renameNode(n)">改昵称</button>
|
||
<button class="btn2" @click="changeNodeIP(n)">改IP</button>
|
||
<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="9">暂无节点</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>
|
||
<select v-if="sd.mode==='hub'" class="ipt max-w-[220px]" v-model="sd.hubNode">
|
||
<option value="">选择 Hub 节点</option>
|
||
<option v-for="n in nodes" :key="'hub'+n.name" :value="n.name">{{ n.alias || n.name }}</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 v-if="sd.mode==='hub'" class="text-xs text-slate-400">Hub 离线将自动回 Mesh</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="glass rounded-xl p-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div class="font-bold">节点映射</div>
|
||
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存节点映射</button>
|
||
</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.alias || x.name) }}(在线)</option>
|
||
<option v-for="x in (sd.nodes||[])" :key="'off'+x.node" v-if="x.node && !isOnline(x.node)" :value="x.node">{{ x.node }}(离线)</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 class="glass rounded-xl p-4">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div class="font-bold">子网代理(Subnet Proxy)</div>
|
||
<button class="btn2" :disabled="busy" @click="saveSDWAN">保存子网代理</button>
|
||
</div>
|
||
<div class="text-xs text-slate-400 mb-2">示例:local 192.168.0.0/24 → virtual 10.0.100.0/24(掩码需一致)</div>
|
||
<div class="space-y-2">
|
||
<div v-for="(s,i) in sd.subnetProxies" :key="i" class="grid grid-cols-1 md:grid-cols-6 gap-2">
|
||
<select class="ipt" v-model="s.node">
|
||
<option value="">选择节点</option>
|
||
<option v-for="x in nodes" :key="'sp'+x.name" :value="x.name">{{ x.name }}</option>
|
||
</select>
|
||
<input class="ipt md:col-span-2" v-model="s.localCIDR" placeholder="192.168.0.0/24">
|
||
<input class="ipt md:col-span-2" v-model="s.virtualCIDR" placeholder="10.0.100.0/24">
|
||
<button class="btn2" @click="removeSubnetProxy(i)">删除</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn2 mt-3" @click="addSubnetProxy">+ 添加代理</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==='settings'" class="glass rounded-2xl p-4 space-y-4">
|
||
<div class="font-bold">高级设置(越权能力)</div>
|
||
<div class="text-xs text-slate-400">仅系统管理员可见。开启会记录审计日志。</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_impersonate" true-value="1" false-value="0"> 代理租户(Impersonate)</label>
|
||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_force_network" true-value="1" false-value="0"> 强制干预租户网络</label>
|
||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" v-model="settings.advanced_cross_tenant" true-value="1" false-value="0"> 跨租户互通策略</label>
|
||
</div>
|
||
<button class="btn" :disabled="busy" @click="saveSettings">保存高级设置</button>
|
||
</div>
|
||
|
||
<div v-if="tab==='audit'" class="glass rounded-2xl p-4 space-y-4">
|
||
<div class="font-bold">审计日志</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<input class="ipt max-w-[180px]" v-model="auditTenant" placeholder="tenant id (可空)">
|
||
<button class="btn2" :disabled="busy" @click="loadAudit">刷新</button>
|
||
</div>
|
||
<div class="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">Actor</th><th class="p-2 text-left">Action</th><th class="p-2 text-left">Target</th><th class="p-2 text-left">Detail</th><th class="p-2 text-left">IP</th><th class="p-2 text-left">Time</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
<tr v-for="a in audit" :key="a.id" class="border-t border-white/5">
|
||
<td class="p-2">{{ a.id }}</td>
|
||
<td class="p-2">{{ a.actor_type }}:{{ a.actor_id }}</td>
|
||
<td class="p-2">{{ a.action }}</td>
|
||
<td class="p-2">{{ a.target_type }}:{{ a.target_id }}</td>
|
||
<td class="p-2 text-xs text-slate-400">{{ a.detail }}</td>
|
||
<td class="p-2">{{ a.ip }}</td>
|
||
<td class="p-2">{{ fmtTime(a.created_at*1000) }}</td>
|
||
</tr>
|
||
<tr v-if="!audit.length"><td class="p-4 text-center text-slate-500" colspan="7">暂无审计记录</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</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>
|
||
</div>
|
||
|
||
<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="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 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, 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'},
|
||
{id:'settings',name:'高级设置'},{id:'audit',name:'审计日志'}
|
||
];
|
||
|
||
const loggedIn = ref(false), busy = ref(false), msg = ref(''), msgType = ref('ok');
|
||
const role = ref(''), status = ref(1);
|
||
const loginUser = ref(''), loginPass = ref(''), loginErr = ref('');
|
||
const refreshSec = ref(15), timer = ref(null);
|
||
|
||
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', hubNode:'', mtu:1420, nodes:[], routes:['10.10.0.0/24'], subnetProxies:[] });
|
||
const connectForm = ref({ from:'', to:'', srcPort:80, dstPort:80, appName:'manual-connect' });
|
||
|
||
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 tokenType = ref('');
|
||
const settings = ref({ advanced_impersonate:'0', advanced_force_network:'0', advanced_cross_tenant:'0' });
|
||
const audit = ref([]);
|
||
const auditTenant = ref('');
|
||
const isAdmin = computed(() => role.value === 'admin');
|
||
const filteredTabs = computed(() => isAdmin.value ? tabs : tabs.filter(t => !['tenants','apikeys','users','enroll','settings','audit'].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 onlineMap = computed(() => new Set((nodes.value || []).map(n => n.name)));
|
||
const nodeStatus = (name) => (name && onlineMap.value.has(name)) ? '在线' : '离线';
|
||
const isOnline = (name) => (name && onlineMap.value.has(name));
|
||
|
||
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 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 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 login = async () => {
|
||
loginErr.value = '';
|
||
busy.value = true;
|
||
try {
|
||
const uname = (loginUser.value || '').trim();
|
||
if (!/^[A-Za-z]{6,}$/.test(uname)) throw new Error('用户名需仅字母且≥6位');
|
||
const d = await fetch('/api/v1/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ username: uname, 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;
|
||
tokenType.value = d.token_type || 'session';
|
||
if (status.value !== 1) throw new Error('账号已停用');
|
||
loggedIn.value = true;
|
||
await refreshAll();
|
||
toast('登录成功');
|
||
} catch (e) {
|
||
loginErr.value = e.message || '登录失败';
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
};
|
||
|
||
const logout = () => {
|
||
localStorage.removeItem('t');
|
||
localStorage.removeItem('master_t');
|
||
loggedIn.value = false;
|
||
role.value = '';
|
||
tokenType.value = '';
|
||
stopTimer();
|
||
};
|
||
|
||
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');
|
||
if (isAdmin.value) {
|
||
try { settings.value = await api('/api/v1/admin/settings'); } catch(_) {}
|
||
try { const a = await api('/api/v1/admin/audit?limit=50'); audit.value = a.logs || []; } catch(_) {}
|
||
}
|
||
} catch (e) {
|
||
toast(e.message || '刷新失败', 'error');
|
||
} finally {
|
||
busy.value = false;
|
||
}
|
||
};
|
||
|
||
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 addSubnetProxy = () => sd.value.subnetProxies = [...(sd.value.subnetProxies || []), { node:'', localCIDR:'', virtualCIDR:'' }];
|
||
const removeSubnetProxy = i => sd.value.subnetProxies.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 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 renameNode = async (node) => {
|
||
if (!node.nodeUUID) return toast('该节点尚无UUID,稍后重连后再试', 'error');
|
||
const nextAlias = prompt('输入新昵称(留空则清除)', node.alias || '');
|
||
if (nextAlias === null) return;
|
||
try {
|
||
await api('/api/v1/nodes/alias', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, alias: nextAlias }) });
|
||
toast('昵称已更新');
|
||
refreshAll();
|
||
} catch(e){ toast(e.message, 'error'); }
|
||
};
|
||
|
||
const changeNodeIP = async (node) => {
|
||
if (!node.nodeUUID) return toast('该节点尚无UUID,稍后重连后再试', 'error');
|
||
const nextIP = prompt('输入新的虚拟IP(必须在本网络CIDR内)', node.virtualIP || '');
|
||
if (!nextIP) return;
|
||
try {
|
||
await api('/api/v1/nodes/ip', { method:'POST', body: JSON.stringify({ node_uuid: node.nodeUUID, virtual_ip: nextIP }) });
|
||
toast('IP已更新,节点将按新IP重连');
|
||
refreshAll();
|
||
} catch(e){ toast(e.message, 'error'); }
|
||
};
|
||
|
||
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 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 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 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 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 loadEnrolls = async () => {
|
||
try { const d = await api('/api/v1/tenants/enroll'); enrolls.value = d.enrolls || []; }
|
||
catch(e){ toast(e.message, 'error'); }
|
||
};
|
||
|
||
const saveSettings = async () => {
|
||
if (!isAdmin.value) return;
|
||
try {
|
||
for (const k of Object.keys(settings.value || {})) {
|
||
await api('/api/v1/admin/settings', { method:'POST', body: JSON.stringify({ key: k, value: String(settings.value[k]) }) });
|
||
}
|
||
toast('高级设置已保存');
|
||
settings.value = await api('/api/v1/admin/settings');
|
||
} catch (e) { toast(e.message, 'error'); }
|
||
};
|
||
|
||
const loadAudit = async () => {
|
||
if (!isAdmin.value) return;
|
||
try {
|
||
const q = auditTenant.value ? `?tenant=${auditTenant.value}&limit=100` : '?limit=100';
|
||
const d = await api('/api/v1/admin/audit' + q);
|
||
audit.value = d.logs || [];
|
||
toast('审计日志已刷新');
|
||
} 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 updateCharts = () => {};
|
||
|
||
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);
|
||
};
|
||
|
||
watch(refreshSec, startTimer);
|
||
watch(loggedIn, (v) => {
|
||
if (v) startTimer();
|
||
else stopTimer();
|
||
});
|
||
|
||
// keep session token in localStorage; do not force logout on load
|
||
onMounted(() => {
|
||
if (localStorage.getItem('t')) {
|
||
loggedIn.value = true;
|
||
refreshAll();
|
||
}
|
||
});
|
||
|
||
return {
|
||
buildVersion, tab, filteredTabs, loggedIn, busy, msg, msgType, role, status, tokenType,
|
||
loginUser, loginPass, loginErr, refreshSec,
|
||
health, stats, nodes, nodeKeyword, filteredNodes, sd, connectForm,
|
||
tenants, activeTenant, keys, users, enrolls, tenantForm, keyForm, userForm,
|
||
settings, audit, auditTenant,
|
||
onlineMap, nodeStatus, isOnline,
|
||
natText, uptime, fmtTime,
|
||
login, logout, refreshAll, saveSDWAN, addSDWANNode, removeSDWANNode, addSubnetProxy, removeSubnetProxy, autoAssignIPs,
|
||
kickNode, renameNode, changeNodeIP, openAppManager, pushAppConfigs, openConnect, doConnect,
|
||
createTenant, loadTenants, setTenantStatus,
|
||
createKey, loadKeys, setKeyStatus,
|
||
createUser, loadUsers, setUserStatus, resetUserPassword, deleteUser,
|
||
createEnroll, loadEnrolls, setEnrollStatus, consumeEnroll,
|
||
saveSettings, loadAudit,
|
||
updateCharts
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|