feat: web console with real management operations

Backend APIs added:
- POST /api/v1/nodes/kick - disconnect a node
- POST /api/v1/connect - trigger P2P tunnel between nodes
- GET /api/v1/stats - detailed server statistics

Frontend features:
- Dashboard: real stats from /api/v1/stats (cone/symm/relay counts)
- Node management: table view, kick node, configure tunnels
- SDWAN: enable/disable, CIDR config, IP allocation, online status
- P2P Connect: create tunnel between two nodes from UI
- Event log: tracks all operations
This commit is contained in:
2026-03-03 00:42:01 +08:00
parent 6d5b1f50ab
commit 9f6e065f3a
4 changed files with 569 additions and 440 deletions

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
@@ -84,25 +85,94 @@ func main() {
startSTUN("TCP", cfg.STUNTCP2, nat.ServeTCPSTUN)
}
// ─── Signaling Server ───
// ─── Signaling Server ───
srv := server.New(cfg)
srv.StartCleanup()
// Auth Middleware
authMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v1/auth/login" {
next(w, r)
return
}
// Check Authorization header
authHeader := r.Header.Get("Authorization")
expected := fmt.Sprintf("Bearer %d", cfg.Token)
if authHeader != expected {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":401,"message":"unauthorized"}`)
return
}
next(w, r)
}
}
mux := http.NewServeMux()
mux.HandleFunc("/ws", srv.HandleWS)
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
// Serve Static Web Console
webDir := "/root/.openclaw/workspace/inp2p/web"
mux.Handle("/", http.FileServer(http.Dir(webDir)))
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
}
// 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)
return
}
req.Token = req2.Token
}
if req.Token != cfg.Token {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":1,"message":"invalid token"}`)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"error":0,"token":"%d"}`, cfg.Token)
})
mux.HandleFunc("/api/v1/health", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","version":"%s","nodes":%d}`, config.Version, len(srv.GetOnlineNodes()))
})
mux.HandleFunc("/api/v1/sdwans", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/nodes", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
nodes := srv.GetOnlineNodes()
_ = json.NewEncoder(w).Encode(map[string]any{"nodes": nodes})
}))
mux.HandleFunc("/api/v1/sdwans", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(srv.GetSDWAN())
})
mux.HandleFunc("/api/v1/sdwan/edit", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/v1/sdwan/edit", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -118,7 +188,127 @@ func main() {
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "ok"})
}))
// Remote Config Push API
mux.HandleFunc("/api/v1/nodes/apps", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Node string `json:"node"`
Apps []protocol.AppConfig `json:"apps"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
node := srv.GetNode(req.Node)
if node == nil {
http.Error(w, "node not found", http.StatusNotFound)
return
}
// Push to client
_ = node.Conn.Write(protocol.MsgPush, protocol.SubPushConfig, req.Apps)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "config push sent"})
}))
// Kick (disconnect) a node
mux.HandleFunc("/api/v1/nodes/kick", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Node string `json:"node"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
node := srv.GetNode(req.Node)
if node == nil {
http.Error(w, "node not found or offline", http.StatusNotFound)
return
}
node.Conn.Close()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "node kicked"})
}))
// Trigger P2P connect between two nodes
mux.HandleFunc("/api/v1/connect", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
From string `json:"from"`
To string `json:"to"`
SrcPort int `json:"srcPort"`
DstPort int `json:"dstPort"`
AppName string `json:"appName"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
fromNode := srv.GetNode(req.From)
if fromNode == nil {
http.Error(w, "source node offline", http.StatusNotFound)
return
}
app := protocol.AppConfig{
AppName: req.AppName,
Protocol: "tcp",
SrcPort: req.SrcPort,
PeerNode: req.To,
DstHost: "127.0.0.1",
DstPort: req.DstPort,
Enabled: 1,
}
if err := srv.PushConnect(fromNode, req.To, app); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
_ = json.NewEncoder(w).Encode(map[string]any{"error": 1, "message": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"error": 0, "message": "connect request sent"})
}))
// Server uptime + detailed stats
mux.HandleFunc("/api/v1/stats", authMiddleware(func(w http.ResponseWriter, r *http.Request) {
nodes := srv.GetOnlineNodes()
coneCount, symmCount, unknCount := 0, 0, 0
relayCount := 0
for _, n := range nodes {
switch n.NATType {
case 1:
coneCount++
case 2:
symmCount++
default:
unknCount++
}
if n.RelayEnabled || n.SuperRelay {
relayCount++
}
}
sdwan := srv.GetSDWAN()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"nodes": len(nodes),
"relay": relayCount,
"cone": coneCount,
"symmetric": symmCount,
"unknown": unknCount,
"sdwan": sdwan.Enabled,
"version": config.Version,
})
}))
// ─── HTTP Listener ───
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.WSPort))

View File

@@ -17,26 +17,26 @@ import (
// NodeInfo represents a connected client node.
type NodeInfo struct {
Name string
Token uint64
User string
Version string
NATType protocol.NATType
PublicIP string
PublicPort int
LanIP string
OS string
Mac string
ShareBandwidth int
RelayEnabled bool
SuperRelay bool
HasIPv4 int
IPv6 string
LoginTime time.Time
LastHeartbeat time.Time
Conn *signal.Conn
Apps []protocol.AppConfig
mu sync.RWMutex
Name string `json:"name"`
Token uint64 `json:"-"`
User string `json:"user"`
Version string `json:"version"`
NATType protocol.NATType `json:"natType"`
PublicIP string `json:"publicIP"`
PublicPort int `json:"publicPort"`
LanIP string `json:"lanIP"`
OS string `json:"os"`
Mac string `json:"mac"`
ShareBandwidth int `json:"shareBandwidth"`
RelayEnabled bool `json:"relayEnabled"`
SuperRelay bool `json:"superRelay"`
HasIPv4 int `json:"hasIPv4"`
IPv6 string `json:"ipv6"`
LoginTime time.Time `json:"loginTime"`
LastHeartbeat time.Time `json:"lastHeartbeat"`
Conn *signal.Conn `json:"-"`
Apps []protocol.AppConfig `json:"apps"`
mu sync.RWMutex `json:"-"`
}
// IsOnline checks if node has sent heartbeat recently.

View File

@@ -65,6 +65,7 @@ const (
SubPushSDWANConfig // push sdwan config to client
SubPushSDWANPeer // push sdwan peer online/update
SubPushSDWANDel // push sdwan peer offline/delete
SubPushConfig // generic remote config push
)
// Sub types: MsgTunnel

View File

@@ -3,443 +3,381 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>INP2P Control Plane</title>
<title>INP2P Console</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/echarts@5.4.3/dist/echarts.min.js"></script>
<style>
[v-cloak] { display: none !important; }
body { background: #070a14; color: #e2e8f0; font-family: system-ui, sans-serif; overflow: hidden; }
.glass-card { background: rgba(15, 20, 37, 0.7); backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.05); }
.active-tab { background: rgba(59, 130, 246, 0.1); border-left: 3px solid #3b82f6; color: white; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 4px; }
.animate-in { animation: fadeIn 0.3s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
[v-cloak]{display:none!important}
body{background:#070a14;color:#e2e8f0;font-family:system-ui,sans-serif}
.glass{background:rgba(15,20,37,.7);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,.05)}
.active-tab{background:rgba(59,130,246,.1);border-left:3px solid #3b82f6;color:white}
.sb::-webkit-scrollbar{width:4px}.sb::-webkit-scrollbar-thumb{background:#1e293b;border-radius:4px}
.fade-in{animation:fi .3s ease-out}@keyframes fi{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.btn-blue{background:#3b82f6;color:white;font-weight:700;padding:.75rem 1.5rem;border-radius:.75rem;font-size:.75rem;transition:all .15s}
.btn-blue:hover{background:#2563eb}.btn-blue:disabled{opacity:.5}
.btn-red{background:#dc2626;color:white;font-weight:700;padding:.5rem 1rem;border-radius:.75rem;font-size:.7rem;transition:all .15s}
.btn-red:hover{background:#b91c1c}
.btn-ghost{background:transparent;color:#94a3b8;font-weight:700;padding:.5rem 1rem;border-radius:.75rem;font-size:.7rem;border:1px solid rgba(255,255,255,.1);transition:all .15s}
.btn-ghost:hover{background:rgba(255,255,255,.05);color:white}
.ipt{width:100%;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.1);border-radius:.75rem;padding:.75rem 1rem;font-size:.8rem;outline:none;color:#93c5fd;transition:border .15s}
.ipt:focus{border-color:#3b82f6}
.tag-cone{background:rgba(34,197,94,.1);color:#22c55e;border:1px solid rgba(34,197,94,.2)}
.tag-symm{background:rgba(234,179,8,.1);color:#eab308;border:1px solid rgba(234,179,8,.2)}
.tag-unk{background:rgba(239,68,68,.1);color:#ef4444;border:1px solid rgba(239,68,68,.2)}
</style>
</head>
<body class="min-h-screen">
<div id="app" v-cloak class="flex h-screen w-full relative">
<body>
<div id="app" v-cloak class="flex h-screen">
<!-- Login -->
<div v-if="!isLoggedIn" class="fixed inset-0 z-[200] flex items-center justify-center bg-[#070a14] px-6">
<div class="w-full max-w-sm glass-card rounded-3xl p-8 shadow-2xl">
<div class="mb-8 text-center">
<div class="w-12 h-12 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h1 class="text-2xl font-black text-white italic">INP2P</h1>
<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>
<div class="space-y-4">
<input v-model="loginToken" type="password" placeholder="Token" class="w-full bg-black/40 border border-white/10 rounded-2xl px-5 py-4 text-center font-mono text-blue-400 focus:border-blue-500 outline-none" @keyup.enter="login">
<button @click="login" :disabled="loading" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-black py-4 rounded-2xl flex items-center justify-center gap-2 disabled:opacity-50">
<svg v-if="loading" class="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
<span>{{ loading ? '验证中...' : '登 录' }}</span>
</button>
<div v-if="loginError" class="text-red-400 text-xs text-center">{{ loginError }}</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="isLoggedIn" class="hidden lg:flex w-64 bg-[#0a0d1a] border-r border-white/5 flex-col flex-shrink-0">
<div class="p-6">
<div class="flex items-center gap-3 mb-10">
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center"><svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg></div>
<span class="text-xl font-black text-white italic tracking-tighter">INP2P</span>
<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">
<button @click="activeTab = 'dashboard'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'dashboard' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">仪表盘</button>
<button @click="activeTab = 'nodes'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'nodes' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">节点资产</button>
<button @click="activeTab = 'sdwan'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'sdwan' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">SDWAN</button>
<button @click="activeTab = 'settings'" :class="['w-full flex items-center gap-3 px-4 py-3 text-sm rounded-r-xl', activeTab === 'settings' ? 'active-tab' : 'text-slate-400 hover:bg-white/5']">设置</button>
<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-6 border-t border-white/5">
<div class="text-[10px] text-slate-600 font-bold mb-4">v{{stats.version || '0.1.0'}}</div>
<button @click="logout" class="text-[10px] text-slate-500 hover:text-red-400 uppercase font-black">安全登出</button>
<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="isLoggedIn" class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 border-b border-white/5 flex items-center justify-between px-8 bg-[#0a0d1a]/50">
<h2 class="text-lg font-black text-white italic uppercase">{{ tabNames[activeTab] }}</h2>
<div class="flex items-center gap-4">
<span class="text-xs text-slate-500">{{ refreshInterval }}s</span>
<button @click="refreshAll" :disabled="loading" class="p-2 hover:bg-white/5 rounded-lg" :class="{'animate-spin': loading}">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
</button>
</div>
<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 class="flex-1 overflow-y-auto p-8 custom-scrollbar space-y-8">
<!-- Dashboard -->
<div v-if="activeTab === 'dashboard'" class="space-y-8">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="glass-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">在线节点</div>
<div class="text-3xl font-black text-white">{{ stats.nodes || 0 }}</div>
<!-- ===== 仪表盘 ===== -->
<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-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">中继</div>
<div class="text-3xl font-black text-blue-400">{{ relayCount }}</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-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">打洞率</div>
<div class="text-3xl font-black" :class="punchRate >= 80 ? 'text-green-400' : punchRate >= 50 ? 'text-yellow-400' : 'text-red-400'">{{ punchRate }}%</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-card rounded-2xl p-6 text-center">
<div class="text-slate-500 text-[10px] font-black uppercase mb-1">SDWAN</div>
<div class="text-3xl font-black" :class="sdwan.enabled ? 'text-green-400' : 'text-slate-500'">{{ sdwan.enabled ? 'ON' : 'OFF' }}</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="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-1 glass-card rounded-2xl p-6 h-[320px]">
<h3 class="text-xs font-black text-slate-500 mb-6 uppercase">NAT 分布</h3>
<div id="natChart" class="w-full h-full pb-10"></div>
</div>
<div class="lg:col-span-2 glass-card rounded-2xl p-6 flex flex-col h-[320px]">
<h3 class="text-xs font-black text-slate-500 mb-6 uppercase">信令日志</h3>
<div class="flex-1 bg-black/40 rounded-xl p-4 font-mono text-[10px] overflow-y-auto custom-scrollbar">
<div v-for="(log, i) in activityLogs" :key="i" class="mb-1">
<span class="text-slate-600">[{{ log.time }}]</span>
<span :class="log.type === 'error' ? 'text-red-400' : log.type === 'success' ? 'text-green-400' : 'text-white'">{{ log.msg }}</span>
</div>
<div v-if="activityLogs.length === 0" class="text-slate-700 italic">等待数据...</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>
<!-- Nodes -->
<div v-if="activeTab === 'nodes'" class="space-y-6">
<div class="glass-card p-4 rounded-2xl flex items-center gap-3">
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input v-model="nodeFilter" placeholder="搜索节点..." class="bg-transparent border-none text-sm w-full outline-none">
<!-- ===== 节点管理 ===== -->
<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>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="n in filteredNodes" :key="n.name" class="glass-card rounded-3xl p-6 hover:border-blue-500/30 transition-all">
<div class="flex justify-between items-start mb-6">
<div>
<h4 class="font-bold text-white">{{ n.name }}</h4>
<div class="text-[10px] text-slate-500 font-mono">{{ n.publicIP }}:{{ n.publicPort }}</div>
</div>
<div :class="['px-2 py-0.5 rounded text-[8px] font-black uppercase', n.natType === 1 ? 'bg-green-500/10 text-green-400' : n.natType === 2 ? 'bg-yellow-500/10 text-yellow-400' : 'bg-red-500/10 text-red-400']">
{{ ['CONE','SYMM','STAT'][n.natType-1] || 'UNK' }}
</div>
</div>
<div class="space-y-2 mb-6 text-xs text-slate-400">
<div class="flex justify-between"><span>OS</span><span class="text-slate-300">{{ n.os || 'linux' }}</span></div>
<div class="flex justify-between"><span>版本</span><span class="text-slate-300">{{ n.version }}</span></div>
<div class="flex justify-between"><span>中继</span><span :class="n.relayEnabled ? 'text-blue-400' : 'text-slate-600'">{{ n.relayEnabled ? '是' : '否' }}</span></div>
<div class="flex justify-between"><span>在线</span><span class="text-slate-300">{{ formatUptime(n.loginTime) }}</span></div>
</div>
<button @click="openAppManager(n)" class="w-full bg-blue-600/10 hover:bg-blue-600 text-blue-400 hover:text-white py-3 rounded-2xl text-[10px] font-black uppercase">配置</button>
</div>
</div>
</div>
<!-- SDWAN -->
<div v-if="activeTab === 'sdwan'" class="pb-20">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<div class="lg:col-span-4 glass-card rounded-3xl p-8 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-black italic">网络配置</h3>
<label class="flex items-center gap-2 cursor-pointer">
<span class="text-xs text-slate-500">启用</span>
<input type="checkbox" v-model="sdwan.enabled" @change="saveSDWAN" class="w-6 h-6 accent-blue-600">
</label>
</div>
<div class="space-y-4 pt-4 border-t border-white/5">
<div>
<label class="text-[9px] font-black text-slate-500 uppercase block mb-1">网关 CIDR</label>
<input v-model="sdwan.gatewayCIDR" @blur="saveSDWAN" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 font-mono text-blue-400">
</div>
<button @click="saveSDWAN" :disabled="sdSaving" class="w-full bg-white text-black font-black py-4 rounded-xl flex items-center justify-center gap-2">
<svg v-if="sdSaving" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ sdSaving ? '推送中...' : '保存配置' }}
</button>
</div>
</div>
<div class="lg:col-span-8 glass-card rounded-3xl overflow-hidden">
<div class="px-8 py-5 border-b border-white/5 flex justify-between items-center bg-white/[0.02]">
<h3 class="font-bold text-xs uppercase">IP 分配</h3>
<button @click="autoAssignIPs" class="text-[10px] font-black text-blue-500 uppercase">自动分配</button>
</div>
<table class="w-full text-xs font-mono">
<tbody class="divide-y divide-white/5">
<tr v-for="(sn, idx) in sdwan.nodes" :key="sn.node" class="hover:bg-white/[0.02]">
<td class="px-8 py-4 text-slate-300">{{ sn.node }}</td>
<td class="px-8 py-4"><input v-model="sn.ip" @blur="saveSDWAN" class="bg-transparent border-b border-white/10 outline-none w-32 text-blue-400"></td>
<td class="px-8 py-4 text-right"><button @click="removeSDWANNode(idx)" class="text-red-400">删除</button></td>
<table class="w-full text-sm">
<thead><tr class="text-left text-slate-500 text-xs uppercase border-b border-white/5">
<th class="p-3">节点</th><th class="p-3">公网 IP</th><th class="p-3">NAT</th><th class="p-3">中继</th><th class="p-3">在线</th><th class="p-3 text-right">操作</th>
</tr></thead>
<tbody>
<tr v-for="n in fNodes" :key="n.name" class="border-b border-white/5 hover:bg-white/[0.02]">
<td class="p-3 font-mono text-white text-xs">{{ n.name }}</td>
<td class="p-3 text-slate-400 font-mono text-xs">{{ n.publicIP }}:{{ n.publicPort }}</td>
<td class="p-3"><span :class="['px-2 py-0.5 rounded text-[10px] font-bold', n.natType===1?'tag-cone':n.natType===2?'tag-symm':'tag-unk']">{{ ['Cone','Symm'][n.natType-1]||'Unk' }}</span></td>
<td class="p-3 text-xs" :class="n.relayEnabled?'text-blue-400':'text-slate-600'">{{ n.relayEnabled?'是':'否' }}</td>
<td class="p-3 text-xs text-slate-400">{{ uptime(n.loginTime) }}</td>
<td class="p-3 text-right space-x-2">
<button @click="openTunnel(n)" class="btn-ghost">隧道</button>
<button @click="openConnect(n)" class="btn-ghost">P2P连接</button>
<button @click="kickNode(n)" class="btn-red">踢出</button>
</td>
</tr>
</tbody>
</table>
<div class="p-8 bg-black/20 border-t border-white/5 flex gap-4">
<select v-model="newNodeToAssign" class="flex-1 bg-black/40 border border-white/10 rounded-xl px-4 text-xs h-10">
<option value="">选择节点...</option>
<option v-for="n in unassignedNodes" :key="n.name" :value="n.name">{{ n.name }}</option>
<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>
<input v-model="newIPToAssign" placeholder="10.10.0.x" class="w-32 bg-black/40 border border-white/10 rounded-xl px-4 text-xs h-10">
<button @click="addSDWANNode" :disabled="!newNodeToAssign || !newIPToAssign" class="bg-blue-600 px-6 rounded-xl text-[10px] font-black disabled:opacity-50">添加</button>
</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>
<!-- Settings -->
<div v-if="activeTab === 'settings'" class="space-y-6">
<div class="glass-card rounded-2xl p-6 space-y-4">
<h3 class="text-lg font-black">系统设置</h3>
<div class="flex items-center justify-between py-3 border-b border-white/5">
<!-- ===== 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>
<div class="text-sm font-bold">自动刷新</div>
<div class="text-xs text-slate-500">当前: {{ refreshInterval }} 秒</div>
</div>
<select v-model="refreshInterval" class="bg-black/40 border border-white/10 rounded-lg px-3 py-2 text-sm">
<option :value="10">10 秒</option>
<option :value="15">15 秒</option>
<option :value="30">30 秒</option>
<option :value="60">60 秒</option>
<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>
</main>
<!-- App Modal -->
<div v-if="appManagerNode" class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/90 backdrop-blur-xl">
<!-- 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-8 border-b border-white/5 flex justify-between items-center">
<h3 class="text-xl font-black italic">隧道: {{ appManagerNode.name }}</h3>
<button @click="appManagerNode = null" class="text-slate-500 hover:text-white">关闭</button>
<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-8 custom-scrollbar grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-4">
<div v-for="(a, i) in appConfigs" :key="i" class="bg-white/5 border border-white/5 rounded-2xl p-4 relative">
<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>
<button @click="appConfigs.splice(i,1)" class="absolute top-4 right-4 text-red-400">×</button>
<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="bg-white/[0.02] border border-white/5 rounded-3xl p-6 space-y-4">
<input v-model="newApp.appName" placeholder="名称" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<input v-model.number="newApp.srcPort" placeholder="本地端口" type="number" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<input v-model="newApp.peerNode" placeholder="对端节点" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<input v-model.number="newApp.dstPort" placeholder="对端端口" type="number" class="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-xs outline-none">
<button @click="appConfigs.push({...newApp}); Object.assign(newApp,{appName:'',srcPort:18080,peerNode:'',dstPort:8080})" class="w-full bg-blue-600 text-white font-black py-3 rounded-xl text-xs">+ 添加</button>
</div>
</div>
<div class="p-8 border-t border-white/5 flex justify-end gap-4">
<button @click="appManagerNode = null" class="px-6 py-3 text-slate-500 hover:text-white">取消</button>
<button @click="pushAppConfigs" :disabled="appSaving" class="bg-blue-600 text-white font-black px-12 py-4 rounded-2xl flex items-center gap-2">
<svg v-if="appSaving" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
{{ appSaving ? '下发中...' : '下发配置' }}
</button>
<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="msg" class="fixed top-6 left-1/2 -translate-x-1/2 z-[300] px-10 py-4 rounded-full text-xs font-black uppercase shadow-2xl"
:class="msgType === 'error' ? 'bg-red-900 border border-red-500 text-red-200' : msgType === 'success' ? 'bg-green-900 border border-green-500 text-green-200' : 'bg-blue-900 border border-blue-500 text-white'">
{{ msg }}
<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, onMounted, computed, watch, nextTick } = Vue;
createApp({
setup() {
const isLoggedIn = ref(!!localStorage.getItem('inp2p_token'));
const loginToken = ref('');
const loginError = ref('');
const activeTab = ref('dashboard');
const loading = ref(false);
const sdSaving = ref(false);
const appSaving = ref(false);
const stats = ref({});
const nodes = ref([]);
const sdwan = ref({ enabled: false, gatewayCIDR: '10.10.0.0/24', mode: 'mesh', nodes: [] });
const msg = ref('');
const msgType = ref('info');
const nodeFilter = ref('');
const activityLogs = ref([]);
const appManagerNode = ref(null);
const appConfigs = ref([]);
const newApp = reactive({ appName: '', srcPort: 18080, peerNode: '', dstPort: 8080 });
const newNodeToAssign = ref('');
const newIPToAssign = ref('');
const refreshInterval = ref(15);
const punchRate = ref(0);
const tabNames = { dashboard: '仪表盘', nodes: '节点资产', sdwan: 'SDWAN', settings: '设置' };
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 fetchAPI = async (path, options = {}) => {
const headers = { 'Content-Type': 'application/json', ...options.headers };
const token = localStorage.getItem('inp2p_token');
if (token) headers['Authorization'] = 'Bearer ' + token;
const tabs=[{id:'dash',label:'仪表盘'},{id:'nodes',label:'节点管理'},{id:'sdwan',label:'SDWAN 组网'},{id:'p2p',label:'P2P 连接'}];
const show=(m,c='info',d=3000)=>{toast.value=m;toastType.value=c;setTimeout(()=>toast.value='',d)};
const log=(m,c='info')=>{logs.value.unshift({t:new Date().toLocaleTimeString(),m,c});if(logs.value.length>50)logs.value.pop()};
const api=async(path,opt={})=>{
const h={'Content-Type':'application/json',...opt.headers};
const tk=localStorage.getItem('t');if(tk)h['Authorization']='Bearer '+tk;
try{
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
isLoggedIn.value = false;
localStorage.removeItem('inp2p_token');
showToast('登录已过期', 'error');
return null;
}
const data = await response.json();
if (response.ok) return data;
throw new Error(data.message || '请求失败');
} catch (e) {
showToast(e.message, 'error');
return null;
}
};
const showToast = (text, type = 'info', duration = 3000) => {
msg.value = text;
msgType.value = type;
setTimeout(() => msg.value = '', duration);
};
const addLog = (message, type = 'info') => {
activityLogs.value.unshift({ time: new Date().toLocaleTimeString(), msg: message, type: type });
if (activityLogs.value.length > 50) activityLogs.value.pop();
const r=await fetch(path,{...opt,headers:h});
if(r.status===401){loggedIn.value=false;localStorage.removeItem('t');show('登录过期','err');return null}
const d=await r.json();if(r.ok)return d;throw new Error(d.message||'失败')
}catch(e){show(e.message,'err');return null}
};
const login=async()=>{
if (!loginToken.value) { loginError.value = '请输入 Token'; return; }
loading.value = true;
loginError.value = '';
const d = await fetchAPI('/api/v1/auth/login', { method: 'POST', body: JSON.stringify({ token: loginToken.value }) });
if (d && d.error === 0) {
localStorage.setItem('inp2p_token', d.token);
isLoggedIn.value = true;
addLog('登录成功', 'success');
refreshAll();
} else {
loginError.value = 'Token 验证失败';
}
loading.value = false;
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 logout = () => {
localStorage.removeItem('inp2p_token');
isLoggedIn.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
};
let refreshTimer = null;
const startRefresh = () => {
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(refreshAll, refreshInterval.value * 1000);
};
watch(refreshInterval, () => { if (isLoggedIn.value) startRefresh(); });
const refreshAll = async () => {
if (!isLoggedIn.value) return;
loading.value = true;
const [health, nodeData, sdwanData] = await Promise.all([
fetchAPI('/api/v1/health'),
fetchAPI('/api/v1/nodes'),
fetchAPI('/api/v1/sdwans')
]);
if (health) {
stats.value = health;
punchRate.value = Math.min(95, 50 + (health.nodes || 0) * 15);
}
if (nodeData) nodes.value = nodeData.nodes || [];
if (sdwanData) sdwan.value = sdwanData;
loading.value = false;
updateCharts();
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 () => {
sdSaving.value = true;
const r = await fetchAPI('/api/v1/sdwan/edit', { method: 'POST', body: JSON.stringify(sdwan.value) });
sdSaving.value = false;
if (r) showToast('配置已推送', 'success');
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 openAppManager = (node) => {
appManagerNode.value = node;
appConfigs.value = JSON.parse(JSON.stringify(node.apps || []));
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 pushAppConfigs = async () => {
appSaving.value = true;
const r = await fetchAPI('/api/v1/nodes/apps', { method: 'POST', body: JSON.stringify({ node: appManagerNode.value.name, apps: appConfigs.value }) });
appSaving.value = false;
if (r) { showToast('配置已下发', 'success'); appManagerNode.value = null; setTimeout(refreshAll, 1000); }
const openConnect=(n)=>{tab.value='p2p';p2p.from=n.name};
const doConnect=async()=>{
busy.value=true;
const r=await api('/api/v1/connect',{method:'POST',body:JSON.stringify({from:p2p.from,to:p2p.to,appName:p2p.name,srcPort:p2p.srcPort,dstPort:p2p.dstPort})});
busy.value=false;
if(r&&r.error===0){show('P2P 连接请求已发送','ok');log(`${p2p.from}${p2p.to} 连接请求`,'ok')}
};
const addSDWANNode = () => {
if (!newNodeToAssign.value || !newIPToAssign.value) return;
sdwan.value.nodes.push({ node: newNodeToAssign.value, ip: newIPToAssign.value });
newNodeToAssign.value = ''; newIPToAssign.value = '';
saveSDWAN();
};
const removeSDWANNode = (idx) => { sdwan.value.nodes.splice(idx, 1); saveSDWAN(); };
const autoAssignIPs = () => {
const base = sdwan.value.gatewayCIDR.replace('.0/24', '.');
let cur = 2;
nodes.value.filter(n => !sdwan.value.nodes.some(sn => sn.node === n.name)).forEach(n => {
while (sdwan.value.nodes.some(sn => sn.ip === base + cur)) cur++;
sdwan.value.nodes.push({ node: n.name, ip: base + cur }); cur++;
});
saveSDWAN();
const addSDNode=()=>{
if(!addNode.value||!addIP.value)return;
sd.value.nodes.push({node:addNode.value,ip:addIP.value});
addNode.value='';addIP.value='';saveSD()
};
let myChart = null;
const updateCharts = () => {
if (activeTab.value !== 'dashboard' || !isLoggedIn.value) return;
nextTick(() => {
const dom = document.getElementById('natChart');
if (!dom) return;
if (!myChart) myChart = echarts.init(dom, 'dark', { backgroundColor: 'transparent' });
const counts = nodes.value.reduce((acc, n) => {
const l = ['Cone', 'Symmetric', 'Static'][n.natType - 1] || 'Unknown';
acc[l] = (acc[l] || 0) + 1;
return acc;
}, {});
myChart.setOption({
tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['40%', '70%'], itemStyle: { borderRadius: 10, borderColor: '#0f1425', borderWidth: 2 }, label: { show: false },
data: Object.entries(counts).map(([name, value]) => ({ name, value, itemStyle: { color: name === 'Cone' ? '#22c55e' : name === 'Symmetric' ? '#eab308' : '#ef4444' } })) }]
});
});
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 formatUptime = (t) => {
if (!t) return '-';
try {
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 '-'; }
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'-'}
};
onMounted(() => { if (isLoggedIn.value) { refreshAll(); startRefresh(); } window.addEventListener('resize', () => myChart && myChart.resize()); });
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)));
return {
isLoggedIn, loginToken, loginError, activeTab, loading, sdSaving, appSaving,
stats, nodes, sdwan, msg, msgType, nodeFilter, activityLogs,
appManagerNode, appConfigs, newApp, newNodeToAssign, newIPToAssign,
refreshInterval, punchRate, tabNames,
login, logout, refreshAll, saveSDWAN, openAppManager, pushAppConfigs,
addSDWANNode, removeSDWANNode, autoAssignIPs,
relayCount: computed(() => nodes.value.filter(n => n.relayEnabled || n.superRelay).length),
filteredNodes: computed(() => nodes.value.filter(n => n.name.toLowerCase().includes(nodeFilter.value.toLowerCase()))),
unassignedNodes: computed(() => nodes.value.filter(n => !sdwan.value.nodes.some(sn => sn.node === n.name))),
formatUptime
};
}
}).mount('#app');
let timer;
onMounted(()=>{if(loggedIn.value){refresh();timer=setInterval(refresh,15000)}});
return{loggedIn,loginToken,loginErr,busy,tab,tabs,nf,toast,toastType,
st,nodes,sd,logs,tunNode,tunApps,na,addNode,addIP,p2p,
login,refresh,saveSD,kickNode,openTunnel,pushTun,openConnect,doConnect,
addSDNode,autoIP,nodeOnline,uptime,fNodes,uaNodes}
}}).mount('#app');
</script>
</body>
</html>