web: restore full single-file console and fix enroll revoke route

This commit is contained in:
2026-03-03 18:46:47 +08:00
parent 10473020d2
commit 67bc6ecae6
2 changed files with 603 additions and 414 deletions

View File

@@ -106,6 +106,17 @@ func main() {
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
// RBAC: admin only
if srv.Store() != nil {
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 || u.Role != "admin" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
}
}
next(w, r)
}
}
@@ -122,9 +133,27 @@ func main() {
next(w, r)
return
}
// check API key
// check API key + RBAC
if srv.Store() != nil {
if ten, err := srv.Store().VerifyAPIKey(server.BearerToken(r)); err == nil && ten != nil {
// role check if user exists
if u, err := srv.Store().GetUserByToken(server.BearerToken(r)); err == nil && u != nil {
if u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
if u.Role == "operator" {
path := r.URL.Path
if path != "/api/v1/nodes" && path != "/api/v1/sdwans" && path != "/api/v1/sdwan/edit" && path != "/api/v1/connect" && path != "/api/v1/nodes/apps" && path != "/api/v1/nodes/kick" && path != "/api/v1/stats" && path != "/api/v1/health" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprintf(w, `{"error":403,"message":"forbidden"}`)
return
}
}
}
next(w, r)
return
}
@@ -146,47 +175,107 @@ func main() {
// Tenant APIs (API key auth inside handlers)
mux.HandleFunc("/api/v1/admin/tenants", adminMiddleware(srv.HandleAdminCreateTenant))
mux.HandleFunc("/api/v1/admin/tenants/", adminMiddleware(srv.HandleAdminCreateAPIKey))
mux.HandleFunc("/api/v1/admin/users", adminMiddleware(srv.HandleAdminUsers))
mux.HandleFunc("/api/v1/admin/users/", adminMiddleware(srv.HandleAdminUsers))
mux.HandleFunc("/api/v1/tenants/enroll", srv.HandleTenantEnroll)
mux.HandleFunc("/api/v1/enroll/consume", srv.HandleEnrollConsume)
mux.HandleFunc("/api/v1/enroll/consume/", srv.HandleEnrollConsume)
mux.HandleFunc("/api/v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Token uint64 `json:"token,string"` // support string from frontend
// Support two modes:
// 1) token login: {"token":"xxxx"} (admin/master only, backward compatible)
// 2) user login: {"tenant":1,"username":"admin","password":"pass"}
var reqTok struct {
Token string `json:"token"`
}
var reqUser struct {
TenantID int64 `json:"tenant"`
Username string `json:"username"`
Password string `json:"password"`
}
// Try string first then uint64
body, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(body, &req); err != nil {
var req2 struct {
Token uint64 `json:"token"`
}
if err := json.Unmarshal(body, &req2); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
_ = json.Unmarshal(body, &reqTok)
_ = json.Unmarshal(body, &reqUser)
// --- user login ---
if reqUser.TenantID > 0 && reqUser.Username != "" && reqUser.Password != "" {
if srv.Store() == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"store not ready"}`)
return
}
req.Token = req2.Token
u, err := srv.Store().VerifyUserPassword(reqUser.TenantID, reqUser.Username, reqUser.Password)
if err != nil || u == nil || u.Status != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid credentials"}`)
return
}
// issue API key for this tenant and return subnet
key, err := srv.Store().CreateAPIKey(reqUser.TenantID, "all", 0)
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `{"error":1,"message":"create token failed"}`)
return
}
ten, _ := srv.Store().GetTenantByID(reqUser.TenantID)
resp := struct {
Error int `json:"error"`
Message string `json:"message"`
Token string `json:"token"`
Role string `json:"role"`
Status int `json:"status"`
Subnet string `json:"subnet"`
}{0, "ok", key, u.Role, u.Status, ""}
if ten != nil {
resp.Subnet = ten.Subnet
}
b, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
return
}
valid := req.Token == cfg.Token
if !valid {
// --- token login (legacy/admin) ---
valid := false
role := "admin"
status := 1
if reqTok.Token != "" {
// support numeric token as string
if reqTok.Token == fmt.Sprintf("%d", cfg.Token) {
valid = true
} else {
for _, t := range cfg.Tokens {
if req.Token == t {
if reqTok.Token == fmt.Sprintf("%d", t) {
valid = true
break
}
}
}
}
if !valid {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`)
return
}
if srv.Store() != nil {
if u, err := srv.Store().GetUserByTenant(0); err == nil && u != nil {
if u.Role != "" {
role = u.Role
}
status = u.Status
}
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"error":0,"token":"%d"}`, cfg.Token)
fmt.Fprintf(w, `{"error":0,"token":"%d","role":"%s","status":%d}`, cfg.Token, role, status)
})
mux.HandleFunc("/api/v1/health", tenantMiddleware(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -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>
<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 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>
</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 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>
<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 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">公网</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>
</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 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>
<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>
<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="(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 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 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 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>
<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 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 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 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-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 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 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 { 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 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 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 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 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 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 () => {
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}
loginErr.value = '';
busy.value = true;
const r=await api('/api/v1/tenants/enroll',{method:'POST',headers:{'Authorization':'Bearer '+tenantKey.value}});
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;
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}
const logout = () => {
localStorage.removeItem('t');
localStorage.removeItem('master_t');
loggedIn.value = false;
role.value = '';
stopTimer();
};
const refreshAll = async () => {
if (!loggedIn.value) return;
busy.value = true;
const r=await api('/api/v1/enroll/consume',{method:'POST',body:JSON.stringify({code:enrollCode.value,node:enrollNode.value})});
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;
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 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 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 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 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 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=(n)=>{tab.value='p2p';p2p.from=n.name};
const openConnect = (from) => { connectForm.value.from = from; tab.value = 'p2p'; };
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 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 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 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 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 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 为客户端配网流程,控制台当前不直接调用');
};
let timer;
onMounted(()=>{if(loggedIn.value){refresh();timer=setInterval(refresh,15000)}});
const updateCharts = () => {};
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');
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();
});
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>