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:
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
766
web/index.html
766
web/index.html
@@ -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">
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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">
|
||||
<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>
|
||||
</select>
|
||||
</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">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
</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 }}
|
||||
<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>
|
||||
|
||||
<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: '设置' };
|
||||
<!-- 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>
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
};
|
||||
<!-- 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">
|
||||
|
||||
const showToast = (text, type = 'info', duration = 3000) => {
|
||||
msg.value = text;
|
||||
msgType.value = type;
|
||||
setTimeout(() => msg.value = '', duration);
|
||||
};
|
||||
<!-- ===== 仪表盘 ===== -->
|
||||
<div v-if="tab==='dash'" class="fade-in space-y-6">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="glass rounded-2xl p-5 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-bold uppercase">节点</div>
|
||||
<div class="text-3xl font-black text-white mt-1">{{ st.nodes||0 }}</div>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-5 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-bold uppercase">中继</div>
|
||||
<div class="text-3xl font-black text-blue-400 mt-1">{{ st.relay||0 }}</div>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-5 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-bold uppercase">Cone / Symm</div>
|
||||
<div class="text-xl font-black mt-1"><span class="text-green-400">{{ st.cone||0 }}</span> / <span class="text-yellow-400">{{ st.symmetric||0 }}</span></div>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-5 text-center">
|
||||
<div class="text-slate-500 text-[10px] font-bold uppercase">SDWAN</div>
|
||||
<div class="text-3xl font-black mt-1" :class="st.sdwan?'text-green-400':'text-slate-500'">{{ st.sdwan?'ON':'OFF' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="glass rounded-2xl p-5">
|
||||
<h3 class="text-xs font-bold text-slate-500 uppercase mb-3">事件日志</h3>
|
||||
<div class="bg-black/40 rounded-xl p-4 font-mono text-[11px] max-h-60 overflow-y-auto sb">
|
||||
<div v-for="(l,i) in logs" :key="i" class="mb-1">
|
||||
<span class="text-slate-600">[{{ l.t }}]</span>
|
||||
<span :class="l.c==='err'?'text-red-400':l.c==='ok'?'text-green-400':'text-white'"> {{ l.m }}</span>
|
||||
</div>
|
||||
<div v-if="!logs.length" class="text-slate-700">暂无事件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const addLog = (message, type = 'info') => {
|
||||
activityLogs.value.unshift({ time: new Date().toLocaleTimeString(), msg: message, type: type });
|
||||
if (activityLogs.value.length > 50) activityLogs.value.pop();
|
||||
};
|
||||
<!-- ===== 节点管理 ===== -->
|
||||
<div v-if="tab==='nodes'" class="fade-in space-y-4">
|
||||
<div class="flex gap-3">
|
||||
<input v-model="nf" placeholder="搜索节点..." class="ipt flex-1">
|
||||
<button @click="refresh" class="btn-ghost">刷新</button>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead><tr class="text-left text-slate-500 text-xs uppercase border-b border-white/5">
|
||||
<th class="p-3">节点</th><th class="p-3">公网 IP</th><th class="p-3">NAT</th><th class="p-3">中继</th><th class="p-3">在线</th><th class="p-3 text-right">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="n in fNodes" :key="n.name" class="border-b border-white/5 hover:bg-white/[0.02]">
|
||||
<td class="p-3 font-mono text-white text-xs">{{ n.name }}</td>
|
||||
<td class="p-3 text-slate-400 font-mono text-xs">{{ n.publicIP }}:{{ n.publicPort }}</td>
|
||||
<td class="p-3"><span :class="['px-2 py-0.5 rounded text-[10px] font-bold', n.natType===1?'tag-cone':n.natType===2?'tag-symm':'tag-unk']">{{ ['Cone','Symm'][n.natType-1]||'Unk' }}</span></td>
|
||||
<td class="p-3 text-xs" :class="n.relayEnabled?'text-blue-400':'text-slate-600'">{{ n.relayEnabled?'是':'否' }}</td>
|
||||
<td class="p-3 text-xs text-slate-400">{{ uptime(n.loginTime) }}</td>
|
||||
<td class="p-3 text-right space-x-2">
|
||||
<button @click="openTunnel(n)" class="btn-ghost">隧道</button>
|
||||
<button @click="openConnect(n)" class="btn-ghost">P2P连接</button>
|
||||
<button @click="kickNode(n)" class="btn-red">踢出</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="!fNodes.length" class="text-center py-8 text-slate-600">无节点</div>
|
||||
</div>
|
||||
|
||||
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;
|
||||
};
|
||||
<!-- ===== SDWAN ===== -->
|
||||
<div v-if="tab==='sdwan'" class="fade-in space-y-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
<div class="lg:col-span-4 glass rounded-2xl p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-bold">网络配置</h3>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<span class="text-xs text-slate-500">启用</span>
|
||||
<input type="checkbox" v-model="sd.enabled" class="w-5 h-5 accent-blue-600">
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase">网关 CIDR</label>
|
||||
<input v-model="sd.gatewayCIDR" class="ipt mt-1 font-mono">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase">模式</label>
|
||||
<select v-model="sd.mode" class="ipt mt-1">
|
||||
<option value="mesh">Mesh (全互联)</option>
|
||||
<option value="hub">Hub (星型)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="saveSD" :disabled="busy" class="btn-blue w-full">{{ busy ? '推送中...' : '保存并推送' }}</button>
|
||||
</div>
|
||||
<div class="lg:col-span-8 glass rounded-2xl overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-white/5 flex justify-between items-center">
|
||||
<h3 class="font-bold text-xs uppercase">IP 分配表</h3>
|
||||
<button @click="autoIP" class="text-[10px] font-bold text-blue-500 hover:text-blue-400">自动分配</button>
|
||||
</div>
|
||||
<table class="w-full text-xs font-mono">
|
||||
<thead><tr class="text-left text-slate-500 border-b border-white/5">
|
||||
<th class="px-6 py-3">节点</th><th class="px-6 py-3">虚拟 IP</th><th class="px-6 py-3">状态</th><th class="px-6 py-3 text-right">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(sn,i) in sd.nodes" :key="sn.node" class="border-b border-white/5 hover:bg-white/[0.02]">
|
||||
<td class="px-6 py-3 text-slate-300">{{ sn.node }}</td>
|
||||
<td class="px-6 py-3"><input v-model="sn.ip" class="bg-transparent border-b border-white/10 outline-none w-28 text-blue-400 focus:border-blue-500"></td>
|
||||
<td class="px-6 py-3"><span :class="nodeOnline(sn.node)?'text-green-400':'text-slate-600'">{{ nodeOnline(sn.node)?'在线':'离线' }}</span></td>
|
||||
<td class="px-6 py-3 text-right"><button @click="sd.nodes.splice(i,1)" class="text-red-400 hover:text-red-300">删除</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="p-6 bg-black/20 border-t border-white/5 flex gap-3">
|
||||
<select v-model="addNode" class="ipt flex-1">
|
||||
<option value="">选择节点...</option>
|
||||
<option v-for="n in uaNodes" :key="n.name" :value="n.name">{{ n.name }}</option>
|
||||
</select>
|
||||
<input v-model="addIP" placeholder="10.10.0.x" class="ipt w-32">
|
||||
<button @click="addSDNode" :disabled="!addNode||!addIP" class="btn-blue disabled:opacity-50">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('inp2p_token');
|
||||
isLoggedIn.value = false;
|
||||
};
|
||||
<!-- ===== P2P 连接 ===== -->
|
||||
<div v-if="tab==='p2p'" class="fade-in space-y-6">
|
||||
<div class="glass rounded-2xl p-6 space-y-4">
|
||||
<h3 class="font-bold">创建 P2P 隧道</h3>
|
||||
<p class="text-xs text-slate-500">选择两个在线节点,创建端口转发隧道。从 A 的本地端口转发到 B 的目标端口。</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase">源节点 (A)</label>
|
||||
<select v-model="p2p.from" class="ipt mt-1">
|
||||
<option value="">选择...</option>
|
||||
<option v-for="n in nodes" :key="n.name" :value="n.name">{{ n.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase">目标节点 (B)</label>
|
||||
<select v-model="p2p.to" class="ipt mt-1">
|
||||
<option value="">选择...</option>
|
||||
<option v-for="n in nodes" :key="n.name" :value="n.name" :disabled="n.name===p2p.from">{{ n.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase">应用名称</label>
|
||||
<input v-model="p2p.name" placeholder="ssh-forward" class="ipt mt-1">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase">A 监听端口</label>
|
||||
<input v-model.number="p2p.srcPort" type="number" class="ipt mt-1">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] font-bold text-slate-500 uppercase">B 目标端口</label>
|
||||
<input v-model.number="p2p.dstPort" type="number" class="ipt mt-1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4 flex items-center gap-4">
|
||||
<button @click="doConnect" :disabled="!p2p.from||!p2p.to||busy" class="btn-blue">发起连接</button>
|
||||
<span v-if="p2p.from && p2p.to" class="text-xs text-slate-500">
|
||||
{{ p2p.from }} :{{ p2p.srcPort }} → {{ p2p.to }} :{{ p2p.dstPort }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
let refreshTimer = null;
|
||||
const startRefresh = () => {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(refreshAll, refreshInterval.value * 1000);
|
||||
};
|
||||
watch(refreshInterval, () => { if (isLoggedIn.value) startRefresh(); });
|
||||
<!-- 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>
|
||||
|
||||
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();
|
||||
};
|
||||
<!-- 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>
|
||||
|
||||
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');
|
||||
};
|
||||
<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 openAppManager = (node) => {
|
||||
appManagerNode.value = node;
|
||||
appConfigs.value = JSON.parse(JSON.stringify(node.apps || []));
|
||||
};
|
||||
const tabs=[{id:'dash',label:'仪表盘'},{id:'nodes',label:'节点管理'},{id:'sdwan',label:'SDWAN 组网'},{id:'p2p',label:'P2P 连接'}];
|
||||
|
||||
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 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 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 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}
|
||||
};
|
||||
|
||||
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 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 formatUptime = (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 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
|
||||
};
|
||||
|
||||
onMounted(() => { if (isLoggedIn.value) { refreshAll(); startRefresh(); } window.addEventListener('resize', () => myChart && myChart.resize()); });
|
||||
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')}
|
||||
};
|
||||
|
||||
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');
|
||||
</script>
|
||||
const kickNode=async(n)=>{
|
||||
if(!confirm('确认踢出节点 '+n.name+'?'))return;
|
||||
const r=await api('/api/v1/nodes/kick',{method:'POST',body:JSON.stringify({node:n.name})});
|
||||
if(r){show(n.name+' 已踢出','ok');log('踢出 '+n.name,'ok');setTimeout(refresh,1000)}
|
||||
};
|
||||
|
||||
const openTunnel=(n)=>{tunNode.value=n;tunApps.value=JSON.parse(JSON.stringify(n.apps||[]))};
|
||||
const pushTun=async()=>{
|
||||
busy.value=true;
|
||||
const r=await api('/api/v1/nodes/apps',{method:'POST',body:JSON.stringify({node:tunNode.value.name,apps:tunApps.value})});
|
||||
busy.value=false;
|
||||
if(r){show('隧道配置已下发','ok');log('下发隧道到 '+tunNode.value.name,'ok');tunNode.value=null;setTimeout(refresh,1000)}
|
||||
};
|
||||
|
||||
const openConnect=(n)=>{tab.value='p2p';p2p.from=n.name};
|
||||
|
||||
const doConnect=async()=>{
|
||||
busy.value=true;
|
||||
const r=await api('/api/v1/connect',{method:'POST',body:JSON.stringify({from:p2p.from,to:p2p.to,appName:p2p.name,srcPort:p2p.srcPort,dstPort:p2p.dstPort})});
|
||||
busy.value=false;
|
||||
if(r&&r.error===0){show('P2P 连接请求已发送','ok');log(`${p2p.from} → ${p2p.to} 连接请求`,'ok')}
|
||||
};
|
||||
|
||||
const addSDNode=()=>{
|
||||
if(!addNode.value||!addIP.value)return;
|
||||
sd.value.nodes.push({node:addNode.value,ip:addIP.value});
|
||||
addNode.value='';addIP.value='';saveSD()
|
||||
};
|
||||
|
||||
const autoIP=()=>{
|
||||
const b=sd.value.gatewayCIDR.replace('.0/24','.');let c=2;
|
||||
nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)).forEach(n=>{
|
||||
while(sd.value.nodes.some(s=>s.ip===b+c))c++;
|
||||
sd.value.nodes.push({node:n.name,ip:b+c});c++
|
||||
});saveSD()
|
||||
};
|
||||
|
||||
const nodeOnline=(name)=>nodes.value.some(n=>n.name===name);
|
||||
|
||||
const uptime=(t)=>{
|
||||
if(!t)return'-';try{
|
||||
const s=Math.floor((Date.now()-new Date(t).getTime())/1000);
|
||||
if(s<60)return s+'s';if(s<3600)return Math.floor(s/60)+'m';
|
||||
if(s<86400)return Math.floor(s/3600)+'h';return Math.floor(s/86400)+'d'
|
||||
}catch{return'-'}
|
||||
};
|
||||
|
||||
const fNodes=computed(()=>nodes.value.filter(n=>n.name.toLowerCase().includes(nf.value.toLowerCase())));
|
||||
const uaNodes=computed(()=>nodes.value.filter(n=>!sd.value.nodes.some(s=>s.node===n.name)));
|
||||
|
||||
let timer;
|
||||
onMounted(()=>{if(loggedIn.value){refresh();timer=setInterval(refresh,15000)}});
|
||||
|
||||
return{loggedIn,loginToken,loginErr,busy,tab,tabs,nf,toast,toastType,
|
||||
st,nodes,sd,logs,tunNode,tunApps,na,addNode,addIP,p2p,
|
||||
login,refresh,saveSD,kickNode,openTunnel,pushTun,openConnect,doConnect,
|
||||
addSDNode,autoIP,nodeOnline,uptime,fNodes,uaNodes}
|
||||
}}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user