feat: fix dashboard real-time data sync, stabilize node order and eliminate UI jitter

This commit is contained in:
kory
2026-09-30 13:16:27 +08:00
parent 512e7c91c8
commit 57c99ac72b
20 changed files with 2133 additions and 90 deletions
+115
View File
@@ -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")
}
})
}
+23 -1
View File
@@ -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
}