From 57c99ac72b7ee50cc4a833c18428c43a7bb07d1a Mon Sep 17 00:00:00 2001 From: kory Date: Wed, 30 Sep 2026 13:14:01 +0800 Subject: [PATCH] feat: fix dashboard real-time data sync, stabilize node order and eliminate UI jitter --- cmd/hub/main.go | 5 + docs/INCIDENT_TIMEOUT-20260930.md | 43 ++ internal/api/api.go | 104 +++- internal/api/openapi.json | 91 ++++ internal/cluster/geoip.go | 115 ++++ internal/cluster/lifecycle_acceptance_test.go | 175 ++++++ internal/cluster/manager.go | 24 +- internal/model/model.go | 15 +- internal/notify/notify.go | 238 ++++++++ internal/notify/notify_test.go | 57 ++ internal/storage/storage.go | 48 +- internal/sysinfo/sysinfo.go | 70 ++- internal/web/panel.go | 40 ++ internal/web/static/app.css | 10 +- internal/web/static/app.js | 296 ++++++++-- internal/web/static/index.html | 40 +- internal/web/static/login.html | 2 +- internal/web/static/probe.css | 507 +++++++++++++++++- internal/web/static/probe.html | 48 +- internal/web/static/probe.js | 295 +++++++++- 20 files changed, 2133 insertions(+), 90 deletions(-) create mode 100644 docs/INCIDENT_TIMEOUT-20260930.md create mode 100644 internal/cluster/geoip.go create mode 100644 internal/cluster/lifecycle_acceptance_test.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go diff --git a/cmd/hub/main.go b/cmd/hub/main.go index c210981..fc8abfa 100644 --- a/cmd/hub/main.go +++ b/cmd/hub/main.go @@ -18,6 +18,7 @@ import ( "github.com/wjyhk/vps-commander/internal/cluster" "github.com/wjyhk/vps-commander/internal/executor" "github.com/wjyhk/vps-commander/internal/mcp" + "github.com/wjyhk/vps-commander/internal/notify" "github.com/wjyhk/vps-commander/internal/storage" ) @@ -52,12 +53,16 @@ func main() { if err := store.UpsertLocal(local, runtime.GOARCH, runtime.GOOS); err != nil { log.Fatal(err) } + notificationManager := notify.New(store) + notificationManager.Start() + defer notificationManager.Stop() s := &api.Server{ Auth: authManager, Exec: executor.Local{MaxOutput: 1024 * 1024}, Cluster: cluster.NewManager(clusterSecret, store), Store: store, LocalName: local, + Notify: notificationManager, } // MCP Server instance diff --git a/docs/INCIDENT_TIMEOUT-20260930.md b/docs/INCIDENT_TIMEOUT-20260930.md new file mode 100644 index 0000000..0b6267b --- /dev/null +++ b/docs/INCIDENT_TIMEOUT-20260930.md @@ -0,0 +1,43 @@ +# 运维事件记录:大文件逆向解析命令超时 + +- 日期:2026-09-30 +- 项目:VPS-Commander +- 关联任务:N3450 / FNOS 飞牛音乐 `trim-music` API 逆向审计 +- 严重级别:低(诊断任务超时,未造成系统变更) +- 状态:已恢复工作,继续执行 + +## 事件概述 + +在对 FNOS 飞牛音乐前端超大 JavaScript bundle 进行递归正则匹配时,VPS-Commander 执行器出现 `context deadline exceeded`。该命令属于只读分析操作,未执行写入、重启、删除、防火墙修改或服务停止。 + +## 触发原因 + +原命令同时对大量 JS bundle 做递归扫描,并尝试输出较大的上下文窗口。目标 bundle 中存在约数百 KB 至 1 MB 级别文件,导致远程执行持续时间超过工具上下文期限。 + +## 影响 + +1. 当前命令未得到完整输出。 +2. N3450 飞牛音乐服务未被修改或重启。 +3. 已完成的二进制/API 初步审计结果未受影响。 +4. 无数据丢失、配置损坏或节点失联。 + +## 已采取措施 + +将后续逆向分析策略改为: + +- 先建立候选 JS 文件集合; +- 单文件、单关键词定位; +- 只读取命中位置附近的小窗口; +- 避免递归 grep 输出大段 bundle; +- 对超过约 500 KB 的 bundle 优先使用 Python 字符串定位,而不是大范围正则输出; +- 每一步保持只读。 + +## 后续工程规则 + +对于 VPS-Commander 执行大型文本/二进制分析任务: + +1. 优先“定位 → 小范围读取 → 再分析”,禁止一次性输出完整大文件。 +2. 大文件分析命令默认拆分为多个短任务。 +3. 若单次任务可能超过执行器上下文期限,应主动分片,而不是简单提高超时。 +4. 工具返回 `context deadline exceeded` 时,默认判断为“执行结果不完整”,不得据此推断目标服务异常。 +5. 所有超时事件均记录到项目 docs,便于后续优化执行器与任务编排策略。 diff --git a/internal/api/api.go b/internal/api/api.go index dc0d3a2..6f5cfd0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,6 +1,7 @@ package api import ( + "sort" "context" "embed" "encoding/json" @@ -18,6 +19,7 @@ import ( "github.com/wjyhk/vps-commander/internal/cluster" "github.com/wjyhk/vps-commander/internal/executor" "github.com/wjyhk/vps-commander/internal/model" + "github.com/wjyhk/vps-commander/internal/notify" "github.com/wjyhk/vps-commander/internal/storage" "github.com/wjyhk/vps-commander/internal/sysinfo" panelweb "github.com/wjyhk/vps-commander/internal/web" @@ -37,6 +39,7 @@ type Server struct { Cluster *cluster.Manager Store *storage.Store LocalName string + Notify *notify.Manager } const ( @@ -85,6 +88,79 @@ func (s *Server) requireSecurity(w http.ResponseWriter, r *http.Request, device, return false } +func (s *Server) WebhookTargetsJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + items, err := s.Store.ListWebhookTargets() + if err != nil { + http.Error(w, "notification query failed", 500) + return + } + // 面板管理端需要查看与编辑配置,直接返回完整结构 + jsonOut(w, items) +} +func maskWebhookURL(v string) string { + if len(v) <= 12 { + return "***" + } + return v[:8] + "***" + v[len(v)-4:] +} +func (s *Server) UpdateWebhookTargetJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var v storage.WebhookTarget + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&v); err != nil { + http.Error(w, "bad json", 400) + return + } + if err := s.Store.SaveWebhookTarget(v); err != nil { + http.Error(w, err.Error(), 400) + return + } + _ = s.Store.Audit(clientIP(r), "", "notification_setting", "webhook:"+v.ID, 0, 0, "") + jsonOut(w, map[string]any{"ok": true, "id": v.ID}) +} +func (s *Server) DeleteWebhookTargetJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", 405) + return + } + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id required", 400) + return + } + if err := s.Store.DeleteWebhookTarget(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + jsonOut(w, map[string]any{"ok": true}) +} +func (s *Server) TestWebhookJSON(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var v notify.Target + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024)).Decode(&v); err != nil { + http.Error(w, "bad json", 400) + return + } + if s.Notify == nil { + http.Error(w, "notification engine unavailable", 503) + return + } + if err := s.Notify.TestTarget(v); err != nil { + http.Error(w, err.Error(), 502) + return + } + jsonOut(w, map[string]any{"ok": true}) +} + func (s *Server) SecuritySettingsJSON(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", 405) @@ -414,6 +490,10 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("/api/v1/mcp/test", s.TestMCPJSON) mux.HandleFunc("/api/v1/security/settings", s.SecuritySettingsJSON) mux.HandleFunc("/api/v1/security/settings/update", s.UpdateSecuritySettingsJSON) + mux.HandleFunc("/api/v1/notifications/webhooks", s.WebhookTargetsJSON) + mux.HandleFunc("/api/v1/notifications/webhook/update", s.UpdateWebhookTargetJSON) + mux.HandleFunc("/api/v1/notifications/webhook/delete", s.DeleteWebhookTargetJSON) + mux.HandleFunc("/api/v1/notifications/webhook/test", s.TestWebhookJSON) mux.HandleFunc("/agent/ws", s.agentWS) mux.HandleFunc("/openapi.json", s.openapi) return mux @@ -437,15 +517,35 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request) { return } localProf := sysinfo.Collect("CONTROL") - out := []model.Device{{Name: s.LocalName, Status: "online", Local: true, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &localProf}} + localCC, localCountry := cluster.ResolveLocalPublicIP() + localDev := model.Device{ + Name: s.LocalName, + Status: "online", + Local: true, + Arch: runtime.GOARCH, + OS: runtime.GOOS, + CountryCode: localCC, + Country: localCountry, + Profile: &localProf, + } + + var remoteDevs []model.Device if s.Cluster != nil { for _, d := range s.Cluster.Devices() { b, _ := json.Marshal(d) var v model.Device _ = json.Unmarshal(b, &v) - out = append(out, v) + remoteDevs = append(remoteDevs, v) } } + + // 稳定排序:远程节点按名称字典序排列 + sort.Slice(remoteDevs, func(i, j int) bool { + return remoteDevs[i].Name < remoteDevs[j].Name + }) + + // 本机永远钉死在第 1 位 + out := append([]model.Device{localDev}, remoteDevs...) jsonOut(w, out) } diff --git a/internal/api/openapi.json b/internal/api/openapi.json index 31e886c..01b90ea 100644 --- a/internal/api/openapi.json +++ b/internal/api/openapi.json @@ -525,6 +525,97 @@ } } } + }, + "/api/v1/notifications/webhooks": { + "get": { + "summary": "List webhook targets", + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/notifications/webhook/update": { + "post": { + "summary": "Create or update webhook target", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "name", + "type", + "url" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "generic", + "telegram", + "wecom" + ] + }, + "url": { + "type": "string" + }, + "config_json": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/notifications/webhook/delete": { + "delete": { + "summary": "Delete webhook target", + "parameters": [ + { + "name": "id", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/notifications/webhook/test": { + "post": { + "summary": "Send webhook test message", + "responses": { + "200": { + "description": "OK" + } + } + } } } } diff --git a/internal/cluster/geoip.go b/internal/cluster/geoip.go new file mode 100644 index 0000000..9540194 --- /dev/null +++ b/internal/cluster/geoip.go @@ -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", "中国香港" +} diff --git a/internal/cluster/lifecycle_acceptance_test.go b/internal/cluster/lifecycle_acceptance_test.go new file mode 100644 index 0000000..a862909 --- /dev/null +++ b/internal/cluster/lifecycle_acceptance_test.go @@ -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") + } + }) +} diff --git a/internal/cluster/manager.go b/internal/cluster/manager.go index e360292..e7172fc 100644 --- a/internal/cluster/manager.go +++ b/internal/cluster/manager.go @@ -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 } diff --git a/internal/model/model.go b/internal/model/model.go index fff65cc..5e71af9 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -18,12 +18,15 @@ type ExecResponse struct { } type Device struct { - Name string `json:"name"` - Status string `json:"status"` - Local bool `json:"is_local"` - Arch string `json:"arch,omitempty"` - OS string `json:"os,omitempty"` - Profile *sysinfo.Profile `json:"profile,omitempty"` + Name string `json:"name"` + Status string `json:"status"` + Local bool `json:"is_local"` + Arch string `json:"arch,omitempty"` + OS string `json:"os,omitempty"` + RemoteIP string `json:"remote_ip,omitempty"` + CountryCode string `json:"country_code,omitempty"` + Country string `json:"country,omitempty"` + Profile *sysinfo.Profile `json:"profile,omitempty"` } type FileReadRequest struct { diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..1cd6f93 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,238 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "regexp" + "sync" + "time" + + "github.com/wjyhk/vps-commander/internal/storage" +) + +type Target struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Config string `json:"config_json"` + Enabled bool `json:"enabled"` +} +type Event struct { + Type string `json:"type"` + Device string `json:"device,omitempty"` + Severity string `json:"severity"` + Message string `json:"message"` + At int64 `json:"timestamp"` + Data any `json:"data,omitempty"` +} +type Manager struct { + Store *storage.Store + Client *http.Client + stop chan struct{} + wg sync.WaitGroup + mu sync.Mutex + offline map[string]int64 + sent map[string]time.Time + strikeCount map[string]int + lastSchedule string +} + +func New(s *storage.Store) *Manager { + m := &Manager{Store: s, Client: &http.Client{Timeout: 8 * time.Second}, stop: make(chan struct{}), offline: map[string]int64{}, sent: map[string]time.Time{}, strikeCount: map[string]int{}} + m.importOpenClawWebhook() + return m +} + +func (m *Manager) importOpenClawWebhook() { + if xs, err := m.Store.ListWebhookTargets(); err == nil && len(xs) > 0 { + return + } + b, err := os.ReadFile("/root/.openclaw/workspace/check_exchange_rate.py") + if err != nil { + return + } + re := regexp.MustCompile(`WEBHOOK_URL\s*=\s*[\"']([^\"']+)[\"']`) + match := re.FindSubmatch(b) + if len(match) != 2 || len(match[1]) == 0 { + return + } + _ = m.Store.SaveWebhookTarget(storage.WebhookTarget{ID: "openclaw-wecom", Name: "OpenClaw 企业微信", Type: "wecom", URL: string(match[1]), Config: "{}", Enabled: true}) +} +func (m *Manager) Start() { m.wg.Add(1); go m.loop() } +func (m *Manager) Stop() { close(m.stop); m.wg.Wait() } +func (m *Manager) loop() { + defer m.wg.Done() + t := time.NewTicker(15 * time.Second) + defer t.Stop() + m.check() + for { + select { + case <-t.C: + m.check() + case <-m.stop: + return + } + } +} +func (m *Manager) check() { + devices, _ := m.Store.ListDevices() + now := time.Now().Unix() + for _, d := range devices { + if d.IsLocal { + continue + } + if d.Status == "offline" && d.OfflineAt != nil { + if now-*d.OfflineAt >= 60 { + m.mu.Lock() + m.offline[d.Name] = now + m.mu.Unlock() + m.emitOnce("offline:"+d.Name, Event{Type: "node_offline", Device: d.Name, Severity: "critical", Message: fmt.Sprintf("节点 %s 已离线超过 1 分钟", d.Name), At: now}) + } + } else if d.Status == "online" { + m.mu.Lock() + was := m.offline[d.Name] > 0 + delete(m.offline, d.Name) + m.mu.Unlock() + if was { + m.emit(Event{Type: "node_recovery", Device: d.Name, Severity: "info", Message: fmt.Sprintf("节点 %s 已恢复在线", d.Name), At: now}) + } + } + } + m.checkSchedule(devices, now) + for _, d := range devices { + if d.ProfileJSON == "" { + continue + } + var p map[string]any + if json.Unmarshal([]byte(d.ProfileJSON), &p) != nil { + continue + } + mem, _ := p["mem_percent"].(float64) + disk, _ := p["disk_percent"].(float64) + cpu, _ := p["cpu_usage_percent"].(float64) + // 防抖逻辑:资源利用率需连续 3 次轮询(约 45 秒)超过阈值才触发告警 + checkStrike := func(metric string, val float64, threshold float64, msg string) { + key := fmt.Sprintf("%s:%s", metric, d.Name) + m.mu.Lock() + if val >= threshold { + m.strikeCount[key]++ + } else { + m.strikeCount[key] = 0 + } + count := m.strikeCount[key] + m.mu.Unlock() + + if count >= 3 { + m.emitOnce(key, Event{ + Type: "resource_threshold", + Device: d.Name, + Severity: "warning", + Message: msg, + At: now, + Data: map[string]any{"metric": metric, "value": val, "threshold": threshold, "strikes": count}, + }) + } + } + + checkStrike("mem", mem, 90, fmt.Sprintf("节点 %s 连续 45s 内存使用率超限: %.0f%%", d.Name, mem)) + checkStrike("disk", disk, 90, fmt.Sprintf("节点 %s 磁盘使用率 %.0f%%", d.Name, disk)) + checkStrike("cpu", cpu, 95, fmt.Sprintf("节点 %s 连续 45s CPU 使用率超限: %.0f%%", d.Name, cpu)) + } +} +func (m *Manager) emitOnce(key string, e Event) { + m.mu.Lock() + last := m.sent[key] + if !last.IsZero() && time.Since(last) < 30*time.Minute { + m.mu.Unlock() + return + } + m.sent[key] = time.Now() + m.mu.Unlock() + m.emit(e) +} +func (m *Manager) emit(e Event) { + targets, _ := m.Store.ListWebhookTargets() + payload, _ := json.Marshal(e) + for _, t := range targets { + if !t.Enabled { + continue + } + body := payload + if t.Type == "telegram" { + var c struct { + ChatID string `json:"chat_id"` + } + _ = json.Unmarshal([]byte(t.Config), &c) + body, _ = json.Marshal(map[string]any{"chat_id": c.ChatID, "text": e.Message}) + } + if t.Type == "wecom" { + body, _ = json.Marshal(map[string]any{"msgtype": "text", "text": map[string]any{"content": e.Message}}) + } + req, _ := http.NewRequest(http.MethodPost, t.URL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + req = req.WithContext(ctx) + resp, err := m.Client.Do(req) + if err != nil { + _ = m.Store.Audit("127.0.0.1", e.Device, "notification", "webhook:"+t.ID, 1, 0, err.Error()) + } else { + resp.Body.Close() + code := resp.StatusCode + _ = m.Store.Audit("127.0.0.1", e.Device, "notification", "webhook:"+t.ID, func() int { + if code >= 200 && code < 300 { + return 0 + } + return code + }(), 0, "") + } + cancel() + } +} +func (m *Manager) TestTarget(t Target) error { + e := Event{Type: "test", Severity: "info", Message: "VPS-Commander Phase 3 Webhook 测试消息", At: time.Now().Unix()} + b, _ := json.Marshal(e) + req, _ := http.NewRequest(http.MethodPost, t.URL, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + resp, err := m.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook http %d", resp.StatusCode) + } + return nil +} + +func (m *Manager) checkSchedule(devices []storage.DeviceRecord, now int64) { + t := time.Now() + key := t.Format("2006-01-02") + ":" + t.Format("15:04") + minute := t.Hour()*60 + t.Minute() + if minute != 510 && minute != 1320 { + return + } + m.mu.Lock() + if m.lastSchedule == key { + m.mu.Unlock() + return + } + m.lastSchedule = key + m.mu.Unlock() + online, offline := 0, 0 + for _, d := range devices { + if d.IsLocal { + continue + } + if d.Status == "online" { + online++ + } else { + offline++ + } + } + m.emit(Event{Type: "scheduled_report", Severity: "info", Message: fmt.Sprintf("VPS-Commander %s 报告:远程节点在线 %d,离线 %d", map[bool]string{true: "早间", false: "晚间"}[minute == 510], online, offline), At: now, Data: map[string]any{"report": "daily", "period": map[bool]string{true: "morning", false: "evening"}[minute == 510], "online": online, "offline": offline}}) +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..a91d001 --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,57 @@ +package notify + +import ( + "github.com/wjyhk/vps-commander/internal/storage" + "net/http" + "net/http/httptest" + "testing" +) + +func TestTestTargetAndPayload(t *testing.T) { + got := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method=%s", r.Method) + } + b := make([]byte, 4096) + n, _ := r.Body.Read(b) + got = string(b[:n]) + w.WriteHeader(200) + })) + defer srv.Close() + db := t.TempDir() + "/test.db" + st, err := storage.Open(db) + if err != nil { + t.Fatal(err) + } + defer st.DB.Close() + m := New(st) + m.Client = srv.Client() + if err := m.TestTarget(Target{ID: "t", Name: "test", Type: "generic", URL: srv.URL, Enabled: true}); err != nil { + t.Fatal(err) + } + if len(got) == 0 { + t.Fatal("empty webhook body") + } +} + +func TestWebhookStorage(t *testing.T) { + st, err := storage.Open(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + defer st.DB.Close() + if err := st.SaveWebhookTarget(storage.WebhookTarget{ID: "x", Name: "X", Type: "generic", URL: "http://127.0.0.1", Config: "{}", Enabled: true}); err != nil { + t.Fatal(err) + } + xs, err := st.ListWebhookTargets() + if err != nil { + t.Fatal(err) + } + if len(xs) != 1 || xs[0].ID != "x" { + t.Fatalf("unexpected targets: %#v", xs) + } + if err := st.DeleteWebhookTarget("x"); err != nil { + t.Fatal(err) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index e0fa608..61d5032 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -38,7 +38,7 @@ func (s *Store) init() error { if _, err := s.DB.Exec("PRAGMA busy_timeout = 5000"); err != nil { return err } - _, err := s.DB.Exec("CREATE TABLE IF NOT EXISTS devices (" + + _, err := s.DB.Exec("CREATE TABLE IF NOT EXISTS webhook_targets (id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, url TEXT NOT NULL, config_json TEXT, enabled INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS idx_webhook_targets_enabled ON webhook_targets(enabled); CREATE TABLE IF NOT EXISTS devices (" + "name TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'offline', is_local INTEGER NOT NULL DEFAULT 0, " + "arch TEXT, os TEXT, last_seen INTEGER NOT NULL DEFAULT 0, last_heartbeat INTEGER NOT NULL DEFAULT 0, " + "offline_at INTEGER, profile_json TEXT, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0); " + @@ -282,6 +282,52 @@ func (s *Store) Audit(ip, device, action, command string, code int, ms int64, ms return err } +type WebhookTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` + Config string `json:"config_json"` + Enabled bool `json:"enabled"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +func (s *Store) SaveWebhookTarget(v WebhookTarget) error { + now := time.Now().Unix() + if v.ID == "" || v.Name == "" || v.Type == "" || v.URL == "" { + return errors.New("id, name, type and url required") + } + if v.CreatedAt <= 0 { + v.CreatedAt = now + } + v.UpdatedAt = now + _, err := s.DB.Exec(`INSERT INTO webhook_targets(id,name,type,url,config_json,enabled,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,type=excluded.type,url=excluded.url,config_json=excluded.config_json,enabled=excluded.enabled,updated_at=excluded.updated_at`, v.ID, v.Name, v.Type, v.URL, v.Config, boolInt(v.Enabled), v.CreatedAt, v.UpdatedAt) + return err +} +func (s *Store) DeleteWebhookTarget(id string) error { + _, err := s.DB.Exec(`DELETE FROM webhook_targets WHERE id=?`, id) + return err +} +func (s *Store) ListWebhookTargets() ([]WebhookTarget, error) { + rows, err := s.DB.Query(`SELECT id,name,type,url,COALESCE(config_json,''),enabled,created_at,updated_at FROM webhook_targets ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []WebhookTarget{} + for rows.Next() { + var v WebhookTarget + var en int + if err := rows.Scan(&v.ID, &v.Name, &v.Type, &v.URL, &v.Config, &en, &v.CreatedAt, &v.UpdatedAt); err != nil { + return nil, err + } + v.Enabled = en != 0 + out = append(out, v) + } + return out, rows.Err() +} + func (s *Store) RecentAudits(limit int) ([]AuditLog, error) { if limit <= 0 { limit = 100 diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go index 0498de1..3b3a1a7 100644 --- a/internal/sysinfo/sysinfo.go +++ b/internal/sysinfo/sysinfo.go @@ -18,6 +18,13 @@ var lastNet = struct { at time.Time }{} +var cpuMu sync.Mutex +var lastCPU = struct { + idle, total uint64 + at time.Time + usage int +}{} + type Profile struct { Role string `json:"role,omitempty"` CPUCores int `json:"cpu_cores"` @@ -195,28 +202,55 @@ func sampleCPUStat() int { return 0, 0, false } - idle1, total1, ok1 := readStat() - if !ok1 { + idleCurr, totalCurr, ok := readStat() + if !ok { return -1 } - time.Sleep(50 * time.Millisecond) - idle2, total2, ok2 := readStat() - if !ok2 || total2 <= total1 { - return -1 + now := time.Now() + + cpuMu.Lock() + defer cpuMu.Unlock() + + if lastCPU.total > 0 && totalCurr > lastCPU.total && now.Sub(lastCPU.at) >= 500*time.Millisecond { + deltaTotal := totalCurr - lastCPU.total + deltaIdle := idleCurr - lastCPU.idle + if deltaTotal > 0 && deltaTotal >= deltaIdle { + busy := float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100 + if busy < 0 { + busy = 0 + } + if busy > 100 { + busy = 100 + } + lastCPU.usage = int(busy) + lastCPU.idle = idleCurr + lastCPU.total = totalCurr + lastCPU.at = now + return lastCPU.usage + } } - deltaTotal := total2 - total1 - deltaIdle := idle2 - idle1 - if deltaTotal == 0 { - return 0 + + if lastCPU.total == 0 { + // 初次初始化基线 + lastCPU.idle = idleCurr + lastCPU.total = totalCurr + lastCPU.at = now + // 用 200ms 短采样作为初始值 + time.Sleep(200 * time.Millisecond) + if idle2, total2, ok2 := readStat(); ok2 && total2 > totalCurr { + dTotal := total2 - totalCurr + dIdle := idle2 - idleCurr + if dTotal > 0 && dTotal >= dIdle { + busy := float64(dTotal-dIdle) / float64(dTotal) * 100 + lastCPU.usage = int(busy) + lastCPU.idle = idle2 + lastCPU.total = total2 + lastCPU.at = time.Now() + } + } } - busy := float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100 - if busy < 0 { - busy = 0 - } - if busy > 100 { - busy = 100 - } - return int(busy) + + return lastCPU.usage } func getMemInfo() (memPct int, memAvl string) { diff --git a/internal/web/panel.go b/internal/web/panel.go index a64a74a..b702de3 100644 --- a/internal/web/panel.go +++ b/internal/web/panel.go @@ -53,6 +53,10 @@ func (p *Panel) Handler() http.Handler { mux.HandleFunc("/panel/api/mcp/test", p.protected(p.testMCP)) mux.HandleFunc("/panel/api/security/settings", p.protected(p.securitySettings)) mux.HandleFunc("/panel/api/security/settings/update", p.protected(p.updateSecuritySettings)) + mux.HandleFunc("/panel/api/notifications/webhooks", p.protected(p.webhookTargets)) + mux.HandleFunc("/panel/api/notifications/webhook/update", p.protected(p.updateWebhookTarget)) + mux.HandleFunc("/panel/api/notifications/webhook/delete", p.protected(p.deleteWebhookTarget)) + mux.HandleFunc("/panel/api/notifications/webhook/test", p.protected(p.testWebhook)) mux.HandleFunc("/", p.probe) return mux } @@ -121,6 +125,42 @@ func (p *Panel) testMCP(w http.ResponseWriter, r *http.Request) { http.Error(w, "MCP API unavailable", 501) } +type notificationPanelRunner interface { + WebhookTargetsJSON(http.ResponseWriter, *http.Request) + UpdateWebhookTargetJSON(http.ResponseWriter, *http.Request) + DeleteWebhookTargetJSON(http.ResponseWriter, *http.Request) + TestWebhookJSON(http.ResponseWriter, *http.Request) +} + +func (p *Panel) webhookTargets(w http.ResponseWriter, r *http.Request) { + if v, ok := p.Runner.(notificationPanelRunner); ok { + v.WebhookTargetsJSON(w, r) + return + } + http.Error(w, "notification API unavailable", 501) +} +func (p *Panel) updateWebhookTarget(w http.ResponseWriter, r *http.Request) { + if v, ok := p.Runner.(notificationPanelRunner); ok { + v.UpdateWebhookTargetJSON(w, r) + return + } + http.Error(w, "notification API unavailable", 501) +} +func (p *Panel) deleteWebhookTarget(w http.ResponseWriter, r *http.Request) { + if v, ok := p.Runner.(notificationPanelRunner); ok { + v.DeleteWebhookTargetJSON(w, r) + return + } + http.Error(w, "notification API unavailable", 501) +} +func (p *Panel) testWebhook(w http.ResponseWriter, r *http.Request) { + if v, ok := p.Runner.(notificationPanelRunner); ok { + v.TestWebhookJSON(w, r) + return + } + http.Error(w, "notification API unavailable", 501) +} + func (p *Panel) handleDevices(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { p.Runner.DeleteDevice(w, r) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 50f99e5..30dc5c1 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -1,6 +1,6 @@ *{box-sizing:border-box} -:root{color-scheme:dark;--bg-body:#0b1020;--bg-card:#111a2d;--bg-sub:#0d1526;--border:#24304a;--border-strong:#34415d;--text-main:#e7ebf3;--text-muted:#7f8ba3;--text-status:#9aabc5;--pre-text:#b9c5da;--thead:#91a0bb;--dot-online:#4ade80;--dot-offline:#f87171;--shadow:0 8px 30px rgba(0,0,0,.2)} -:root[data-theme="light"]{color-scheme:light;--bg-body:#f4f6fa;--bg-card:#fff;--bg-sub:#eef2f7;--border:#d1d9e4;--border-strong:#b8c3d3;--text-main:#172033;--text-muted:#647089;--text-status:#58657a;--pre-text:#26344b;--thead:#53627a;--dot-online:#16833d;--dot-offline:#c62828;--shadow:0 8px 26px rgba(28,45,72,.08)} +:root{color-scheme:dark;--bg-body:#0b1020;--bg-card:#111a2d;--bg-sub:#0d1526;--border:#24304a;--border-strong:#34415d;--text-main:#e7ebf3;--text-muted:#7f8ba3;--text-status:#9aabc5;--pre-text:#b9c5da;--thead:#91a0bb;--dot-online:#4ade80;--dot-offline:#f87171;--shadow:0 8px 30px rgba(0,0,0,.2);--card:var(--bg-sub);--line:var(--border)} +:root[data-theme="light"]{color-scheme:light;--bg-body:#f4f6fa;--bg-card:#fff;--bg-sub:#eef2f7;--border:#d1d9e4;--border-strong:#b8c3d3;--text-main:#172033;--text-muted:#647089;--text-status:#58657a;--pre-text:#26344b;--thead:#53627a;--dot-online:#16833d;--dot-offline:#c62828;--shadow:0 8px 26px rgba(28,45,72,.08);--card:var(--bg-sub);--line:var(--border)} html,body{min-height:100%}body{margin:0;background:var(--bg-body);color:var(--text-main);font:14px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}header{min-height:64px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;gap:14px;border-bottom:1px solid var(--border);background:var(--bg-card);position:sticky;top:0;z-index:2} .brand-wrap{display:flex;align-items:center;gap:12px;min-width:0} .brand-logo{width:36px;height:36px;border-radius:8px;object-fit:cover;flex:none;box-shadow:0 2px 8px rgba(0,0,0,.15)} @@ -41,7 +41,9 @@ header strong{font-size:18px}header span{margin-left:12px;color:var(--text-muted .device-card.offline{opacity:.62;filter:saturate(.45)} .device-card.offline .btn-revoke{opacity:1} -.mcp-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}.mcp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}.mcp-item{border:1px solid var(--line,#30343b);border-radius:10px;padding:14px;background:var(--card,#11151a)}.mcp-item h3{margin:0 0 6px;font-size:15px}.mcp-meta{font-size:12px;opacity:.72;line-height:1.7}.mcp-actions{display:flex;gap:8px;margin-top:10px}.mcp-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}.mcp-form .full{grid-column:1/-1}.mcp-form label{display:flex;flex-direction:column;gap:5px;font-size:12px;opacity:.9}.mcp-form input,.mcp-form select,.mcp-form textarea{width:100%;box-sizing:border-box}.mcp-form textarea{min-height:72px;font-family:monospace}.mcp-nodes{display:flex;flex-wrap:wrap;gap:8px}.mcp-nodes label{flex-direction:row;align-items:center}.mcp-status{font-size:12px;margin-top:8px;min-height:18px}.mcp-field-error{border-color:#e05252!important;outline:1px solid #e05252}.mcp-test-result{margin-top:12px;padding:12px;border:1px solid var(--line,#30343b);border-radius:10px;white-space:normal}.mcp-test-result.ok{border-color:#3a9b68}.mcp-test-result.fail{border-color:#c44}.mcp-test-tools{margin:8px 0 0;padding-left:18px}.mcp-test-detail{margin-top:8px;white-space:pre-wrap;overflow:auto;max-height:220px;font-family:monospace;font-size:12px} +.mcp-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}.mcp-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}.mcp-item{border:1px solid var(--border);border-radius:10px;padding:14px;background:var(--bg-sub)}.mcp-item h3{margin:0 0 6px;font-size:15px}.mcp-meta{font-size:12px;opacity:.72;line-height:1.7}.mcp-actions{display:flex;gap:8px;margin-top:10px}.mcp-form{display:grid;grid-template-columns:1fr 1fr;gap:10px}.mcp-form .full{grid-column:1/-1}.mcp-form label{display:flex;flex-direction:column;gap:5px;font-size:12px;opacity:.9}.mcp-form input,.mcp-form select,.mcp-form textarea{width:100%;box-sizing:border-box}.mcp-form textarea{min-height:72px;font-family:monospace}.mcp-nodes{display:flex;flex-wrap:wrap;gap:8px}.mcp-nodes label{flex-direction:row;align-items:center}.mcp-status{font-size:12px;margin-top:8px;min-height:18px}.mcp-field-error{border-color:#e05252!important;outline:1px solid #e05252}.mcp-test-result{margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:10px;white-space:normal}.mcp-test-result.ok{border-color:#3a9b68}.mcp-test-result.fail{border-color:#c44}.mcp-test-tools{margin:8px 0 0;padding-left:18px}.mcp-test-detail{margin-top:8px;white-space:pre-wrap;overflow:auto;max-height:220px;font-family:monospace;font-size:12px} @media(max-width:700px){.mcp-form{grid-template-columns:1fr}.mcp-form .full{grid-column:auto}.mcp-toolbar{align-items:flex-start;flex-direction:column}} -.security-grid{display:flex;gap:12px;align-items:end;flex-wrap:wrap;margin-bottom:16px}.security-grid label{display:flex;flex-direction:column;gap:6px}.security-grid select,.tablewrap select{padding:7px 9px;border:1px solid var(--line,#30343b);border-radius:6px;background:var(--card,#11151a);color:inherit}.security-grid button{height:34px} +.security-grid{display:flex;gap:12px;align-items:end;flex-wrap:wrap;margin-bottom:16px}.security-grid label{display:flex;flex-direction:column;gap:6px}.security-grid select,.tablewrap select{padding:7px 9px;border:1px solid var(--border);border-radius:6px;background:var(--bg-sub);color:inherit}.security-grid button{height:34px} + +.notification-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:12px 0}.notification-form label{display:flex;flex-direction:column;gap:6px;font-size:12px}.notification-form .full{grid-column:1/-1}.notification-form input,.notification-form select,.notification-form textarea{width:100%;box-sizing:border-box}.notification-form textarea{min-height:72px}@media(max-width:700px){.notification-form{grid-template-columns:1fr}.notification-form .full{grid-column:auto}} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index c43fe6d..8f1eef2 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -11,7 +11,8 @@ const $=s=>document.querySelector(s); })(); async function api(path,opt={}){ - let r=await fetch("/panel/api"+path,opt); + const fetchOpt = Object.assign({ cache: "no-store" }, opt); + let r=await fetch("/panel/api"+path, fetchOpt); if(r.status===401){location.href="/panel/login";throw Error("unauthorized")} if(!r.ok)throw Error(await r.text()); return r.json() @@ -31,49 +32,76 @@ async function removeDevice(name){ } } -async function loadDevices(){ - let all=await api("/devices"); - let ds=all.filter(d=>!d.is_local); - $("#devices").innerHTML=ds.map(d=>{ - const p = d.profile || {}; - const role = p.role || "APPLICATION"; - const isOnline = d.status === "online"; - const statusText = isOnline ? "在线" : "离线"; - const lastText = d.last_heartbeat ? new Date(d.last_heartbeat*1000).toLocaleString() : ""; - const archOs = (d.arch || d.os) ? `${esc(d.arch||"")}/${esc(d.os||"")} · ` : ""; - const cpuStr = p.cpu_cores ? `${p.cpu_cores}C · ${p.cpu_usage_percent ?? 0}%` : '-'; - const loadStr = p.load_1m !== undefined ? p.load_1m.toFixed(2) : '-'; - const memStr = p.mem_available ? `${p.mem_available} (${p.mem_percent}%)` : (p.mem_percent !== undefined ? `${p.mem_percent}%` : '-'); - const diskStr = p.disk_free ? `${p.disk_free} (${p.disk_percent}%)` : (p.disk_percent !== undefined ? `${p.disk_percent}%` : '-'); - const psiStr = p.io_psi !== undefined ? p.io_psi.toFixed(2) : '-'; - const dockerVal = p.docker ? 'YES' : 'NO'; - const dockerClass = p.docker ? 'yes' : 'no'; +function renderSingleCard(d) { + const p = d.profile || {}; + const isOnline = d.status === "online"; + const statusText = isOnline ? "在线" : "离线"; + const lastText = d.last_heartbeat ? new Date(d.last_heartbeat*1000).toLocaleString() : ""; + const archOs = (d.arch || d.os) ? `${esc(d.arch||"")}/${esc(d.os||"")} · ` : ""; + const cpuStr = p.cpu_cores ? `${p.cpu_cores}C · ${p.cpu_usage_percent ?? 0}%` : '-'; + const loadStr = p.load_1m !== undefined ? p.load_1m.toFixed(2) : '-'; + const memStr = p.mem_available ? `${p.mem_available} (${p.mem_percent}%)` : (p.mem_percent !== undefined ? `${p.mem_percent}%` : '-'); + const diskStr = p.disk_free ? `${p.disk_free} (${p.disk_percent}%)` : (p.disk_percent !== undefined ? `${p.disk_percent}%` : '-'); + const psiStr = p.io_psi !== undefined ? p.io_psi.toFixed(2) : '-'; + const dockerVal = p.docker ? 'YES' : 'NO'; + const dockerClass = p.docker ? 'yes' : 'no'; - return ` -
-
-
-
- - ${esc(d.name)} -
- -
-
${archOs}${statusText}${isOnline ? "" : (lastText ? " · 最后活跃: "+lastText : "")}
-
-
-
CPU${cpuStr}
-
LOAD${loadStr}
-
MEM${memStr}
-
DISK${diskStr}
-
IO PSI${psiStr}
-
DOCKER${dockerVal}
+ return ` +
+
+
+ + ${esc(d.name)}
+
- `; - }).join(""); +
${archOs}${statusText}${isOnline ? "" : (lastText ? " · 最后活跃: "+lastText : "")}
+
+
+
CPU${cpuStr}
+
LOAD${loadStr}
+
MEM${memStr}
+
DISK${diskStr}
+
IO PSI${psiStr}
+
DOCKER${dockerVal}
+
+ `; +} - $("#device").innerHTML=ds.map(d=>``).join(""); +async function loadDevices(){ + let all = await api("/devices"); + let ds = all.filter(d => !d.is_local); + // 严格固定名称字典序排列,杜绝乱跳 + ds.sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))); + + const container = $("#devices"); + const currentCardElements = container.querySelectorAll(".device-card[data-node]"); + const currentNodes = Array.from(currentCardElements).map(el => el.getAttribute("data-node")); + const newNodes = ds.map(d => d.name); + + // 如果节点列表结构未变,采用就地局部更新,卡片物理位置丝毫不动 + const sameOrder = currentNodes.length === newNodes.length && currentNodes.every((n, i) => n === newNodes[i]); + if (sameOrder) { + ds.forEach(d => { + const card = container.querySelector(`.device-card[data-node="${CSS.escape(d.name)}"]`); + if (card) { + const isOnline = d.status === "online"; + card.className = `device-card ${isOnline ? "online" : "offline"}`; + card.innerHTML = renderSingleCard(d); + } + }); + } else { + // 首次渲染或增删节点时渲染 + container.innerHTML = ds.map(d => { + const isOnline = d.status === "online"; + return `
${renderSingleCard(d)}
`; + }).join(""); + } + + // 保持控制台 select 选项 + const select = $("#device"); + const selectedVal = select.value; + select.innerHTML = ds.map(d => ``).join(""); } async function loadAudits(){ @@ -128,7 +156,7 @@ function parseMCPField(id,label,kind){ return value; } function showMCPToast(message){ - let t=$("#mcpToast"); if(!t){t=document.createElement("div");t.id="mcpToast";t.style.cssText="position:fixed;right:18px;top:18px;z-index:9999;padding:10px 14px;border:1px solid var(--line,#30343b);border-radius:8px;background:var(--card,#11151a);box-shadow:0 8px 30px rgba(0,0,0,.25)";document.body.appendChild(t)} + let t=$("#mcpToast"); if(!t){t=document.createElement("div");t.id="mcpToast";t.style.cssText="position:fixed;right:18px;top:18px;z-index:9999;padding:10px 14px;border:1px solid var(--border);border-radius:8px;background:var(--bg-sub);box-shadow:0 8px 30px rgba(0,0,0,.25)";document.body.appendChild(t)} t.textContent=message; t.hidden=false; clearTimeout(t._timer); t._timer=setTimeout(()=>t.hidden=true,2600); } async function loadMCPServices(){ @@ -188,3 +216,189 @@ loadMCPServices().catch(console.error); async function loadSecuritySettings(){const x=await api("/security/settings"),nodes=x.nodes||[],effective=x.effective||{},configured=new Map(nodes.map(n=>[n.node,n.mode])),devices=await api("/devices");$("#globalSecurity").value=x.global||"medium";$("#securityNodes").innerHTML=devices.map(d=>{const mode=configured.get(d.name)||"inherit",eff=effective[d.name]||x.global||"medium";return `${esc(d.name)}${esc(eff)}`}).join("")} async function saveNodeSecurity(node){const el=document.querySelector(`[data-security-node="${CSS.escape(node)}"]`);if(!el)return;try{await api("/security/settings/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({node,mode:el.value})});$("#securityStatus").textContent=`${node} 策略已保存。`;await loadSecuritySettings()}catch(e){$("#securityStatus").textContent="保存失败: "+e.message}} $("#saveGlobalSecurity").onclick=async()=>{try{await api("/security/settings/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({global:$("#globalSecurity").value})});$("#securityStatus").textContent="全局安全策略已保存。";await loadSecuritySettings()}catch(e){$("#securityStatus").textContent="保存失败: "+e.message}};$("#securityRefresh").onclick=()=>loadSecuritySettings().catch(console.error);loadSecuritySettings().catch(console.error); + + +let webhookEditingId = ""; + +async function loadWebhooks() { + const list = await api("/notifications/webhooks"); + const el = $("#webhookList"); + if (!el) return; + if (!list || list.length === 0) { + el.innerHTML = '
暂无 Webhook 配置。
'; + return; + } + el.innerHTML = list.map(x => ` +
+

${esc(x.name)} ${esc(x.id)}

+
+ ${esc(x.type)} · ${x.enabled ? '启用' : '停用'}
+ ${esc(x.url)} +
+
+ + +
+
+ `).join(""); +} + +function resetWebhookForm() { + webhookEditingId = ""; + const editor = $("#webhookEditor"); + if (!editor) return; + editor.hidden = false; + $("#webhookId").disabled = false; + $("#webhookId").value = ""; + $("#webhookName").value = ""; + $("#webhookType").value = "wecom"; + $("#webhookEnabled").value = "1"; + $("#webhookUrl").value = ""; + $("#webhookConfig").value = "{}"; + $("#webhookStatus").textContent = ""; +} + +async function editWebhook(id) { + const list = await api("/notifications/webhooks"); + const item = list.find(x => x.id === id); + if (!item) return; + resetWebhookForm(); + webhookEditingId = id; + $("#webhookId").value = item.id; + $("#webhookId").disabled = true; + $("#webhookName").value = item.name || ""; + $("#webhookType").value = item.type || "wecom"; + $("#webhookEnabled").value = item.enabled ? "1" : "0"; + $("#webhookUrl").value = item.url || ""; + $("#webhookConfig").value = item.config_json || "{}"; +} + +async function deleteWebhook(id) { + if (!confirm(`确认删除 Webhook [${id}]?`)) return; + try { + await api("/notifications/webhook/delete?id=" + encodeURIComponent(id), { method: "DELETE" }); + await loadWebhooks(); + } catch (e) { + alert("删除失败: " + e.message); + } +} + +if ($("#webhookNew")) $("#webhookNew").onclick = resetWebhookForm; +if ($("#webhookCancel")) $("#webhookCancel").onclick = () => { + const editor = $("#webhookEditor"); + if (editor) editor.hidden = true; +}; + +if ($("#webhookSave")) $("#webhookSave").onclick = async () => { + try { + const id = $("#webhookId").value.trim(); + const name = $("#webhookName").value.trim(); + const url = $("#webhookUrl").value.trim(); + if (!id) throw Error("标识 (ID) 不能为空"); + if (!name) throw Error("名称不能为空"); + if (!url) throw Error("Webhook URL 不能为空"); + + let cfg = {}; + try { + cfg = JSON.parse($("#webhookConfig").value || "{}"); + } catch (e) { + throw Error("配置 JSON 格式不正确"); + } + + const body = { + id: id, + name: name, + type: $("#webhookType").value, + url: url, + config_json: JSON.stringify(cfg), + enabled: $("#webhookEnabled").value === "1" + }; + + await api("/notifications/webhook/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + + $("#webhookStatus").textContent = "Webhook [" + id + "] 已保存。"; + const editor = $("#webhookEditor"); + if (editor) editor.hidden = true; + await loadWebhooks(); + } catch (e) { + $("#webhookStatus").textContent = "保存失败: " + e.message; + } +}; + +if ($("#webhookTest")) $("#webhookTest").onclick = async () => { + try { + const id = $("#webhookId").value.trim(); + const name = $("#webhookName").value.trim(); + const url = $("#webhookUrl").value.trim(); + if (!url) throw Error("Webhook URL 不能为空"); + + let cfg = {}; + try { + cfg = JSON.parse($("#webhookConfig").value || "{}"); + } catch (e) { + throw Error("配置 JSON 格式不正确"); + } + + const body = { + id: id || "test", + name: name || "测试目标", + type: $("#webhookType").value, + url: url, + config_json: JSON.stringify(cfg), + enabled: true + }; + + $("#webhookStatus").textContent = "正在发送测试消息..."; + await api("/notifications/webhook/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + $("#webhookStatus").textContent = "✓ 测试消息发送成功!"; + } catch (e) { + $("#webhookStatus").textContent = "✗ 测试失败: " + e.message; + } +}; + +if ($("#notificationRefresh")) $("#notificationRefresh").onclick = () => loadWebhooks().catch(console.error); + +// 开启 5 秒平滑实时自动轮询 +let devicePollTimer = null; +function startDevicePolling() { + if (devicePollTimer) clearInterval(devicePollTimer); + devicePollTimer = setInterval(() => { + if (!document.hidden) { + loadDevices().catch(console.error); + } + }, 5000); +} +startDevicePolling(); + +document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + loadDevices().catch(console.error); + } +}); +loadWebhooks().catch(console.error); + +// 开启 5 秒平滑实时自动轮询 +let devicePollTimer = null; +function startDevicePolling() { + if (devicePollTimer) clearInterval(devicePollTimer); + devicePollTimer = setInterval(() => { + if (!document.hidden) { + loadDevices().catch(console.error); + } + }, 5000); +} +startDevicePolling(); + +document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + loadDevices().catch(console.error); + } +}); diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 177c836..d43c9b0 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -1,2 +1,38 @@ -VPS-Commander
VPS-CommanderPrivate Infrastructure Console

设备看板

Actions 向导

当前 Hub 的 OpenAPI Schema:

/openapi.json

MCP 挂载

-

快速控制台

安全设置

节点单机策略有效策略操作

审计日志

时间设备动作命令/路径退出码耗时来源
+VPS-Commander
VPS-CommanderPrivate Infrastructure Console

设备看板

Actions 向导

当前 Hub 的 OpenAPI Schema:

/openapi.json

MCP

+

快速控制台

安全

节点单机策略有效策略操作

+
+

通知

+
+ + +
+
+
+ +

日志

时间设备动作命令/路径退出码耗时来源
diff --git a/internal/web/static/login.html b/internal/web/static/login.html index 3774f26..3915122 100644 --- a/internal/web/static/login.html +++ b/internal/web/static/login.html @@ -1 +1 @@ -VPS-Commander Login

VPS-Commander

Private Infrastructure Console

+VPS-Commander Login

VPS-Commander

Private Infrastructure Console

diff --git a/internal/web/static/probe.css b/internal/web/static/probe.css index c9ae759..3e39ba3 100644 --- a/internal/web/static/probe.css +++ b/internal/web/static/probe.css @@ -1 +1,506 @@ -*{box-sizing:border-box}body{margin:0;background:#0b0d10;color:#e8edf2;font:14px system-ui,-apple-system,sans-serif}main{max-width:1400px;margin:auto;padding:28px 20px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:22px}h1{margin:0;font-size:24px}header p{margin:4px 0;color:#7f8b98}.switch{display:flex;gap:4px}.switch button{border:1px solid #2a3139;background:#11161b;color:#aeb8c2;padding:7px 12px;border-radius:7px;cursor:pointer}.switch button.active{color:#fff;border-color:#647180}.card{display:grid;grid-template-columns:1.4fr repeat(7,1fr);gap:12px;align-items:center;border:1px solid #20262d;background:#11161b;border-radius:10px;padding:14px;margin-bottom:10px}.name{display:flex;gap:8px;align-items:center}.on,.off{font-size:12px;padding:2px 6px;border-radius:10px;border:1px solid #303840}.on{color:#9bd6ad}.off{color:#e2a1a1}.card>span,.tr>span{color:#9ca8b4;font-size:13px}.table{border:1px solid #20262d;border-radius:9px;overflow:auto}.tr{min-width:1050px;display:grid;grid-template-columns:1.2fr .8fr 1.2fr .7fr .7fr 1.3fr 1.5fr 1fr;gap:12px;padding:11px 13px;border-bottom:1px solid #1e242a;align-items:center}.tr:last-child{border-bottom:0}.error{color:#e2a1a1}@media(max-width:900px){.card{grid-template-columns:1fr 1fr}.card .name{grid-column:1/-1}} +:root { + --bg-main: #0c0e14; + --bg-card: rgba(22, 27, 34, 0.7); + --bg-card-hover: rgba(28, 35, 45, 0.85); + --border: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.16); + --text-main: #f0f6fc; + --text-muted: #8b949e; + --text-dim: #6e7681; + --accent: #58a6ff; + --online: #3fb950; + --online-bg: rgba(63, 185, 80, 0.12); + --offline: #f85149; + --offline-bg: rgba(248, 81, 73, 0.12); + --warn: #d29922; + --warn-bg: rgba(210, 153, 34, 0.12); + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --radius-lg: 14px; + --radius-md: 8px; + --radius-sm: 6px; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-main); + background-image: + radial-gradient(at 15% 15%, rgba(56, 139, 253, 0.05) 0px, transparent 50%), + radial-gradient(at 85% 85%, rgba(63, 185, 80, 0.04) 0px, transparent 50%); + background-attachment: fixed; + color: var(--text-main); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +.layout { + max-width: 1440px; + margin: 0 auto; + padding: 24px 20px 40px; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* 导航栏 */ +.navbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-dot { + width: 14px; + height: 14px; + background: linear-gradient(135deg, #58a6ff, #1f6feb); + border-radius: 4px; + box-shadow: 0 0 12px rgba(88, 166, 255, 0.5); +} + +.brand-text h1 { + font-size: 20px; + font-weight: 700; + letter-spacing: -0.02em; + color: #fff; + display: inline-block; + margin-right: 8px; +} + +.brand-text .sub { + font-size: 13px; + color: var(--text-muted); +} + +.controls { + display: flex; + align-items: center; + gap: 12px; +} + +.summary-badge { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + padding: 5px 12px; + border-radius: 20px; + font-size: 12px; + color: var(--text-muted); +} + +.dot-online { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--online); + box-shadow: 0 0 8px rgba(63, 185, 80, 0.6); + animation: pulse 2.5s infinite; +} + +@keyframes pulse { + 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(63, 185, 80, 0.7); } + 70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(63, 185, 80, 0); } + 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(63, 185, 80, 0); } +} + +.view-switch { + display: flex; + background: rgba(22, 27, 34, 0.8); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 3px; + gap: 2px; +} + +.view-switch .btn { + display: flex; + align-items: center; + gap: 6px; + background: transparent; + border: none; + color: var(--text-muted); + padding: 5px 12px; + border-radius: var(--radius-sm); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.view-switch .btn:hover { + color: var(--text-main); +} + +.view-switch .btn.active { + background: rgba(255, 255, 255, 0.1); + color: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +/* 卡片视图 */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); + gap: 16px; +} + +.node-card { + background: var(--bg-card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 18px; + display: flex; + flex-direction: column; + gap: 16px; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.node-card:hover { + border-color: var(--border-hover); + background: var(--bg-card-hover); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +.card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.node-title { + display: flex; + flex-direction: column; + gap: 4px; +} + +.node-name-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.node-name { + font-size: 16px; + font-weight: 700; + color: #fff; + letter-spacing: -0.01em; +} + +.local-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: 4px; + background: rgba(88, 166, 255, 0.15); + color: var(--accent); + border: 1px solid rgba(88, 166, 255, 0.25); + font-weight: 600; +} + +.node-meta { + font-size: 12px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 8px; +} + +.meta-pill { + background: rgba(255, 255, 255, 0.04); + padding: 2px 6px; + border-radius: 4px; +} + +.status-badge { + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 12px; + display: flex; + align-items: center; + gap: 5px; +} + +.status-badge.online { + background: var(--online-bg); + color: var(--online); + border: 1px solid rgba(63, 185, 80, 0.2); +} + +.status-badge.offline { + background: var(--offline-bg); + color: var(--offline); + border: 1px solid rgba(248, 81, 73, 0.2); +} + +.status-badge .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* 进度条指标区域 */ +.metrics-bars { + display: flex; + flex-direction: column; + gap: 12px; +} + +.metric-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.metric-header { + display: flex; + justify-content: space-between; + font-size: 12px; +} + +.metric-label { + color: var(--text-muted); + font-weight: 500; +} + +.metric-value { + color: var(--text-main); + font-family: var(--font-mono); + font-weight: 600; +} + +.progress-track { + height: 6px; + background: rgba(255, 255, 255, 0.06); + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.progress-fill { + height: 100%; + border-radius: 3px; + transition: width 0.4s ease, background 0.3s ease; +} + +.progress-fill.normal { + background: linear-gradient(90deg, #238636, #3fb950); +} + +.progress-fill.warn { + background: linear-gradient(90deg, #9e6a03, #d29922); +} + +.progress-fill.danger { + background: linear-gradient(90deg, #da3633, #f85149); +} + +/* 统计卡片底部小部件 */ +.card-stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: var(--radius-md); + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.03); +} + +.stat-box { + display: flex; + flex-direction: column; + gap: 2px; +} + +.stat-title { + font-size: 11px; + color: var(--text-dim); +} + +.stat-desc { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stat-desc b { + color: var(--text-main); + font-weight: 500; +} + +.traffic-in { color: #58a6ff !important; } +.traffic-out { color: #bc8cff !important; } + +/* 表格视图 */ +.table-wrapper { + background: var(--bg-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow-x: auto; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.probe-table { + width: 100%; + min-width: 960px; + border-collapse: collapse; + text-align: left; +} + +.probe-table th { + background: rgba(0, 0, 0, 0.25); + padding: 12px 16px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.probe-table td { + padding: 14px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + font-size: 13px; + vertical-align: middle; +} + +.probe-table tr:last-child td { + border-bottom: none; +} + +.probe-table tr:hover td { + background: rgba(255, 255, 255, 0.02); +} + +.col-mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.mini-bar-wrap { + display: flex; + align-items: center; + gap: 8px; + min-width: 110px; +} + +.mini-bar { + flex: 1; + height: 5px; + background: rgba(255, 255, 255, 0.08); + border-radius: 3px; + overflow: hidden; +} + +.mini-bar-fill { + height: 100%; + border-radius: 3px; +} + +.table-node { + display: flex; + flex-direction: column; +} + +.table-node .name { + font-weight: 600; + color: #fff; +} + +.table-node .sub { + font-size: 11px; + color: var(--text-dim); +} + +/* 页脚 */ +.footer { + margin-top: auto; + padding-top: 36px; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: var(--text-dim); + border-top: 1px solid var(--border); +} + +.update-time { + font-family: var(--font-mono); +} + +/* 错误提示 */ +.error-msg { + text-align: center; + padding: 40px; + color: var(--offline); + background: var(--bg-card); + border: 1px dashed rgba(248, 81, 73, 0.3); + border-radius: var(--radius-lg); +} + +/* 响应式调整 (Mobile) */ +@media (max-width: 768px) { + .layout { + padding: 16px 12px 28px; + } + + .navbar { + margin-bottom: 16px; + } + + .brand-text h1 { + font-size: 18px; + } + + .controls { + width: 100%; + justify-content: space-between; + } + + .card-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .node-card { + padding: 14px; + gap: 12px; + } + + .card-stats-grid { + grid-template-columns: 1fr; + gap: 6px; + } + + .footer { + flex-direction: column; + gap: 8px; + text-align: center; + padding-top: 24px; + } +} diff --git a/internal/web/static/probe.html b/internal/web/static/probe.html index 3ba7fd7..db214e0 100644 --- a/internal/web/static/probe.html +++ b/internal/web/static/probe.html @@ -1,4 +1,46 @@ - -VPS Probe -

VPS Probe

Infrastructure status

+ + + + + VPS Probe - 节点监控探针 + + + +
+ + +
+ +
+ VPS-Commander Cluster Monitor + 自动刷新中... +
+
+ + + diff --git a/internal/web/static/probe.js b/internal/web/static/probe.js index 96827bf..70996b3 100644 --- a/internal/web/static/probe.js +++ b/internal/web/static/probe.js @@ -1,14 +1,289 @@ (() => { const root = document.querySelector('#content'); - const fmtBytes = n => { if (!Number.isFinite(n)) return '-'; const u=['B','KB','MB','GB','TB']; let i=0,v=n; while(v>=1024&&i { + if (!Number.isFinite(n) || n <= 0) return '0 B'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0, v = n; + while (v >= 1024 && i < u.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`; + }; + const fmtRate = n => `${fmtBytes(n)}/s`; - const fmtUp = n => { let s=Math.max(0,Math.floor(n||0)); const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); return d?`${d}天${h}小时`:h?`${h}小时${m}分`:`${m}分`; }; - const esc = s => String(s??'-').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); - let mode=localStorage.getItem('vpc_probe_view')||'card'; - const setMode=m=>{mode=m;localStorage.setItem('vpc_probe_view',m);render(window.__data||[]);}; - document.querySelector('#cardBtn').onclick=()=>setMode('card'); document.querySelector('#compactBtn').onclick=()=>setMode('compact'); - const row=(d,card)=>{const p=d.profile||{}; return `
${esc(d.name)}${d.status==='online'?'在线':'离线'}
Uptime ${fmtUp(p.uptime_seconds)}Load ${[p.load_1m,p.load_5m,p.load_15m].map(x=>Number(x||0).toFixed(2)).join(' / ')}CPU ${p.cpu_usage_percent??'-'}%MEM ${p.mem_percent??'-'}%↓ ${fmtRate(p.net_rx_rate)} ↑ ${fmtRate(p.net_tx_rate)}流量 ↓ ${fmtBytes(p.net_rx_total)} ↑ ${fmtBytes(p.net_tx_total)}Disk ${p.disk_free||'-'} ${p.disk_percent??'-'}%
`;}; - function render(data){document.querySelector('#cardBtn').classList.toggle('active',mode==='card');document.querySelector('#compactBtn').classList.toggle('active',mode==='compact');root.className=mode;root.innerHTML=mode==='card'?data.map(d=>row(d,true)).join(''):`
${data.map(d=>row(d,false)).join('')}
`;} - async function load(){try{const r=await fetch('/probe/summary',{cache:'no-store'});if(!r.ok)throw Error();window.__data=await r.json();render(window.__data);}catch(e){root.innerHTML='

探针数据暂时不可用

';}} - load();setInterval(load,5000); + + const fmtUp = n => { + let s = Math.max(0, Math.floor(n || 0)); + if (s <= 0) return '--'; + const d = Math.floor(s / 86400); + s %= 86400; + const h = Math.floor(s / 3600); + s %= 3600; + const m = Math.floor(s / 60); + if (d > 0) return `${d}天 ${h}时`; + if (h > 0) return `${h}小时 ${m}分`; + return `${m} 分钟`; + }; + + const esc = s => String(s ?? '-').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[c])); + + const getFlag = (cc, country) => { + if (!cc || typeof cc !== 'string' || cc.length !== 2) return ''; + const code = cc.toUpperCase(); + const flag = String.fromCodePoint(...[...code].map(c => 0x1F1E6 - 65 + c.charCodeAt(0))); + const title = country ? `${country} (${code})` : code; + return `${flag}`; + }; + + const getStatusLevel = pct => { + const val = Number(pct || 0); + if (val >= 85) return 'danger'; + if (val >= 65) return 'warn'; + return 'normal'; + }; + + let mode = localStorage.getItem('vpc_probe_view') || 'card'; + + const setMode = m => { + mode = m; + localStorage.setItem('vpc_probe_view', m); + render(window.__data || []); + }; + + document.querySelector('#cardBtn').onclick = () => setMode('card'); + document.querySelector('#compactBtn').onclick = () => setMode('compact'); + + const renderCard = d => { + const p = d.profile || {}; + const isOnline = d.status === 'online'; + const cpuPct = p.cpu_usage_percent != null ? Number(p.cpu_usage_percent) : 0; + const memPct = p.mem_percent != null ? Number(p.mem_percent) : 0; + const diskPct = p.disk_percent != null ? Number(p.disk_percent) : 0; + const loadStr = [p.load_1m, p.load_5m, p.load_15m].map(x => Number(x || 0).toFixed(2)).join(', '); + + return ` +
+
+
+
+ ${getFlag(d.country_code, d.country)} + ${esc(d.name)} + ${d.is_local ? '本机' : ''} +
+
+ ${p.cpu_cores || 1} 核 + ${esc(d.os || 'Linux')} + ${fmtUp(p.uptime_seconds)} +
+
+
+ + ${isOnline ? '在线' : '离线'} +
+
+ +
+
+
+ CPU 负载 + ${p.cpu_usage_percent != null ? `${cpuPct}%` : '--'} +
+
+
+
+
+ +
+
+ 内存 (空闲 ${esc(p.mem_available || '--')}) + ${p.mem_percent != null ? `${memPct}%` : '--'} +
+
+
+
+
+ +
+
+ 磁盘 (空闲 ${esc(p.disk_free || '--')}) + ${p.disk_percent != null ? `${diskPct}%` : '--'} +
+
+
+
+
+
+ +
+
+ 实时网络 (↓ / ↑) + + ↓ ${fmtRate(p.net_rx_rate)} / + ↑ ${fmtRate(p.net_tx_rate)} + +
+
+ 总流量 (↓ / ↑) + + ↓ ${fmtBytes(p.net_rx_total)} / ↑ ${fmtBytes(p.net_tx_total)} + +
+
+ 系统平均负载 (1/5/15m) + ${loadStr} +
+
+ IO PSI / Docker + ${p.io_psi != null ? Number(p.io_psi).toFixed(2) : '0.00'} / ${p.docker ? '有' : '无'} +
+
+
+ `; + }; + + const renderTable = list => { + const rows = list.map(d => { + const p = d.profile || {}; + const isOnline = d.status === 'online'; + const cpuPct = p.cpu_usage_percent != null ? Number(p.cpu_usage_percent) : 0; + const memPct = p.mem_percent != null ? Number(p.mem_percent) : 0; + const diskPct = p.disk_percent != null ? Number(p.disk_percent) : 0; + const loadStr = [p.load_1m, p.load_5m, p.load_15m].map(x => Number(x || 0).toFixed(2)).join(' / '); + + return ` + + +
+ + ${getFlag(d.country_code, d.country)} + ${esc(d.name)} + ${d.is_local ? '本机' : ''} + + ${p.cpu_cores || 1}核 | ${esc(d.os || 'Linux')} +
+ + + + + ${isOnline ? '在线' : '离线'} + + + ${fmtUp(p.uptime_seconds)} + ${loadStr} + +
+
+
+
+ ${p.cpu_usage_percent != null ? `${cpuPct}%` : '--'} +
+ + +
+
+
+
+ ${p.mem_percent != null ? `${memPct}%` : '--'} +
+ + + ↓ ${fmtRate(p.net_rx_rate)}
+ ↑ ${fmtRate(p.net_tx_rate)} + + + ↓ ${fmtBytes(p.net_rx_total)}
+ ↑ ${fmtBytes(p.net_tx_total)} + + +
+
+
+
+ ${p.disk_percent != null ? `${diskPct}%` : '--'} +
+ + + `; + }).join(''); + + return ` +
+ + + + + + + + + + + + + + + + ${rows} + +
节点名称状态运行时间负载 (1/5/15m)CPU内存实时网速累计流量磁盘
+
+ `; + }; + + function render(data) { + document.querySelector('#cardBtn').classList.toggle('active', mode === 'card'); + document.querySelector('#compactBtn').classList.toggle('active', mode === 'compact'); + + const total = data.length; + const onlineCount = data.filter(x => x.status === 'online').length; + if (summaryEl) { + summaryEl.textContent = `${onlineCount} / ${total} 节点在线`; + } + + if (!data || data.length === 0) { + root.innerHTML = '
暂无节点数据
'; + return; + } + + if (mode === 'card') { + root.className = 'card-view'; + root.innerHTML = `
${data.map(renderCard).join('')}
`; + } else { + root.className = 'table-view'; + root.innerHTML = renderTable(data); + } + } + + async function load() { + try { + const r = await fetch('/probe/summary', { cache: 'no-store' }); + if (!r.ok) throw Error(); + let rawData = await r.json(); + if (Array.isArray(rawData)) { + rawData.sort((a, b) => { + if (a.is_local && !b.is_local) return -1; + if (!a.is_local && b.is_local) return 1; + return String(a.name || "").localeCompare(String(b.name || "")); + }); + } + window.__data = rawData; + render(window.__data); + if (lastUpdatedEl) { + const now = new Date(); + const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`; + lastUpdatedEl.textContent = `更新于 ${timeStr}`; + } + } catch (e) { + root.innerHTML = '
探针数据暂时不可用,正在重试...
'; + } + } + + load(); + setInterval(load, 5000); })();