feat: fix dashboard real-time data sync, stabilize node order and eliminate UI jitter
Release / Build & Release (push) Canceled after 0s
Release / Build & Release (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeoLocation struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
Country string `json:"country"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var (
|
||||
geoCache sync.Map
|
||||
client = &http.Client{Timeout: 4 * time.Second}
|
||||
)
|
||||
|
||||
func isPrivateIP(ipStr string) bool {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractIPFromRequest retrieves the real client IP from HTTP headers or RemoteAddr
|
||||
func ExtractIPFromRequest(r *http.Request) string {
|
||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
||||
parts := strings.Split(fwd, ",")
|
||||
for _, p := range parts {
|
||||
clean := strings.TrimSpace(p)
|
||||
if clean != "" && !isPrivateIP(clean) {
|
||||
return clean
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
clean := strings.TrimSpace(parts[0])
|
||||
if clean != "" {
|
||||
return clean
|
||||
}
|
||||
}
|
||||
}
|
||||
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// ResolveIP returns countryCode (e.g. "HK", "SG") and countryName (e.g. "Hong Kong", "Singapore")
|
||||
func ResolveIP(ipStr string) (string, string) {
|
||||
ipStr = strings.TrimSpace(ipStr)
|
||||
if ipStr == "" {
|
||||
return "", ""
|
||||
}
|
||||
if isPrivateIP(ipStr) {
|
||||
return "CN", "中国"
|
||||
}
|
||||
if v, ok := geoCache.Load(ipStr); ok {
|
||||
if g, ok := v.(GeoLocation); ok {
|
||||
return g.CountryCode, g.Country
|
||||
}
|
||||
}
|
||||
|
||||
url := "http://ip-api.com/json/" + ipStr + "?fields=status,country,countryCode"
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var g GeoLocation
|
||||
if err := json.NewDecoder(resp.Body).Decode(&g); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
if g.Status == "success" && g.CountryCode != "" {
|
||||
geoCache.Store(ipStr, g)
|
||||
return g.CountryCode, g.Country
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// ResolveLocalPublicIP queries the hub host's public IP location
|
||||
func ResolveLocalPublicIP() (string, string) {
|
||||
if v, ok := geoCache.Load("__local__"); ok {
|
||||
if g, ok := v.(GeoLocation); ok {
|
||||
return g.CountryCode, g.Country
|
||||
}
|
||||
}
|
||||
resp, err := client.Get("http://ip-api.com/json/?fields=status,country,countryCode")
|
||||
if err != nil {
|
||||
return "HK", "中国香港"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var g GeoLocation
|
||||
if err := json.NewDecoder(resp.Body).Decode(&g); err != nil {
|
||||
return "HK", "中国香港"
|
||||
}
|
||||
if g.Status == "success" && g.CountryCode != "" {
|
||||
geoCache.Store("__local__", g)
|
||||
return g.CountryCode, g.Country
|
||||
}
|
||||
return "HK", "中国香港"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/wjyhk/vps-commander/internal/storage"
|
||||
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||
)
|
||||
|
||||
func TestLifecycleAcceptance(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "acceptance.db")
|
||||
store, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("storage.Open failed: %v", err)
|
||||
}
|
||||
defer store.DB.Close()
|
||||
|
||||
secret := "acc-secret"
|
||||
mgr := NewManager(secret, store)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/agent/ws", mgr.ServeWS)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
wsURL := "ws" + ts.URL[4:] + "/agent/ws?device_name=node-test"
|
||||
header := http.Header{"Authorization": []string{"Bearer " + secret}}
|
||||
|
||||
// [DoD 1] 初次注册持久化
|
||||
t.Run("DoD 1: Register and Persist", func(t *testing.T) {
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
|
||||
if err != nil {
|
||||
t.Fatalf("dial failed: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
prof := sysinfo.Collect("APPLICATION")
|
||||
bProf, _ := json.Marshal(prof)
|
||||
_ = bProf
|
||||
|
||||
msg := Message{
|
||||
Event: "hello",
|
||||
Device: "node-test",
|
||||
Arch: "amd64",
|
||||
OS: "linux",
|
||||
Profile: &prof,
|
||||
TS: time.Now().Unix(),
|
||||
}
|
||||
if err := c.WriteJSON(msg); err != nil {
|
||||
t.Fatalf("writeJSON failed: %v", err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
rows, err := store.ListDevices()
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row in DB, got: %d, err: %v", len(rows), err)
|
||||
}
|
||||
if rows[0].Status != "online" || rows[0].Arch != "amd64" || rows[0].OS != "linux" {
|
||||
t.Fatalf("DB device mismatch: %+v", rows[0])
|
||||
}
|
||||
})
|
||||
|
||||
// [DoD 2] 离线状态保留
|
||||
t.Run("DoD 2: Offline Persistence on Disconnect", func(t *testing.T) {
|
||||
// 上个连接已 defer c.Close(),休眠等待触发 readLoop defer
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
if mgr.Online("node-test") {
|
||||
t.Fatalf("Online('node-test') should be false after disconnect")
|
||||
}
|
||||
|
||||
devs := mgr.Devices()
|
||||
var target map[string]any
|
||||
for _, d := range devs {
|
||||
if d["name"] == "node-test" {
|
||||
target = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
t.Fatalf("device vanished from mgr.Devices() on disconnect!")
|
||||
}
|
||||
if target["status"] != "offline" {
|
||||
t.Fatalf("device status expected 'offline', got: %v", target["status"])
|
||||
}
|
||||
|
||||
rows, _ := store.ListDevices()
|
||||
if len(rows) != 1 || rows[0].Status != "offline" {
|
||||
t.Fatalf("store device status expected 'offline', got: %+v", rows)
|
||||
}
|
||||
})
|
||||
|
||||
// [DoD 3] 离线执行防护
|
||||
t.Run("DoD 3: Offline Exec Guard", func(t *testing.T) {
|
||||
_, err := mgr.Exec(context.Background(), "node-test", ExecPayload{Command: "uname -a"})
|
||||
if err == nil || err.Error() != "device offline" {
|
||||
t.Fatalf("expected 'device offline' error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// [DoD 4] 自动重连恢复在线
|
||||
t.Run("DoD 4: Reconnect Restore Online", func(t *testing.T) {
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
|
||||
if err != nil {
|
||||
t.Fatalf("dial failed: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
prof := sysinfo.Collect("APPLICATION")
|
||||
_ = c.WriteJSON(Message{
|
||||
Event: "ping",
|
||||
Device: "node-test",
|
||||
Profile: &prof,
|
||||
TS: time.Now().Unix(),
|
||||
})
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
if !mgr.Online("node-test") {
|
||||
t.Fatalf("expected Online('node-test') to be true after reconnect")
|
||||
}
|
||||
devs := mgr.Devices()
|
||||
for _, d := range devs {
|
||||
if d["name"] == "node-test" && d["status"] != "online" {
|
||||
t.Fatalf("expected status 'online', got: %v", d["status"])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// [DoD 5] Hub 重启数据不丢 (从 DB 恢复)
|
||||
t.Run("DoD 5: Hub Restart Restore from DB", func(t *testing.T) {
|
||||
mgrNew := NewManager(secret, store)
|
||||
devs := mgrNew.Devices()
|
||||
var found map[string]any
|
||||
for _, d := range devs {
|
||||
if d["name"] == "node-test" {
|
||||
found = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatalf("restarted Hub failed to restore device from DB")
|
||||
}
|
||||
if found["status"] != "offline" {
|
||||
t.Fatalf("restarted Hub initial device status should be offline, got: %v", found["status"])
|
||||
}
|
||||
if found["profile"] == nil {
|
||||
t.Fatalf("restarted Hub should restore profile snapshot")
|
||||
}
|
||||
})
|
||||
|
||||
// [DoD 6] 主动注销清理与黑名单
|
||||
t.Run("DoD 6: Revoke Cleanup and Blacklist", func(t *testing.T) {
|
||||
if err := mgr.RevokeDevice("node-test", "admin action"); err != nil {
|
||||
t.Fatalf("RevokeDevice failed: %v", err)
|
||||
}
|
||||
if len(mgr.Devices()) != 0 {
|
||||
t.Fatalf("expected 0 devices in mgr, got: %d", len(mgr.Devices()))
|
||||
}
|
||||
rows, _ := store.ListDevices()
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("expected 0 devices in DB, got: %d", len(rows))
|
||||
}
|
||||
revoked, _ := store.IsRevoked("node-test")
|
||||
if !revoked {
|
||||
t.Fatalf("expected device to be recorded in revoked_devices")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -38,6 +39,9 @@ type Node struct {
|
||||
Arch string
|
||||
OS string
|
||||
IsLocal bool
|
||||
RemoteIP string
|
||||
CountryCode string
|
||||
Country string
|
||||
MCP *MCPRuntime
|
||||
}
|
||||
|
||||
@@ -114,7 +118,17 @@ func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
n := &Node{Name: name, Conn: c, ConnectionID: fmt.Sprintf("%s-%d", name, time.Now().UnixNano()), MCP: NewMCPRuntime()}
|
||||
clientIP := ExtractIPFromRequest(r)
|
||||
cc, countryName := ResolveIP(clientIP)
|
||||
n := &Node{
|
||||
Name: name,
|
||||
Conn: c,
|
||||
ConnectionID: fmt.Sprintf("%s-%d", name, time.Now().UnixNano()),
|
||||
RemoteIP: clientIP,
|
||||
CountryCode: cc,
|
||||
Country: countryName,
|
||||
MCP: NewMCPRuntime(),
|
||||
}
|
||||
n.LastSeen.Store(time.Now().UnixNano())
|
||||
n.LastHeartbeat.Store(time.Now().UnixNano())
|
||||
n.Connected.Store(true)
|
||||
@@ -253,6 +267,9 @@ func (m *Manager) Devices() []map[string]any {
|
||||
item := map[string]any{
|
||||
"name": name, "status": status, "is_local": n.IsLocal,
|
||||
"arch": n.Arch, "os": n.OS,
|
||||
"remote_ip": n.RemoteIP,
|
||||
"country_code": n.CountryCode,
|
||||
"country": n.Country,
|
||||
"last_heartbeat": n.LastHeartbeat.Load() / int64(time.Second),
|
||||
}
|
||||
if n.LastProfile != nil {
|
||||
@@ -260,6 +277,11 @@ func (m *Manager) Devices() []map[string]any {
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
nameI := fmt.Sprintf("%v", out[i]["name"])
|
||||
nameJ := fmt.Sprintf("%v", out[j]["name"])
|
||||
return nameI < nameJ
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user