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=>`
${esc(d.name)}${d.status==="offline" ? " [离线]" : ""} `).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 => `
${esc(d.name)}${d.status==="offline" ? " [离线]" : ""} `).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)} 跟随全局 低 · Low 中 · Medium 高 · High ${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
设备看板 刷新
Actions 向导 当前 Hub 的 OpenAPI Schema:
复制 Schema 地址 复制 System Prompt 轮换 API Key
/openapi.json
MCP 挂载 新增 MCP
-
快速控制台 执行
安全设置 刷新 全局安全等级低 · Low 中 · Medium 高 · High 保存全局策略
审计日志 刷新
+
VPS-Commander
设备看板 刷新
Actions 向导 当前 Hub 的 OpenAPI Schema:
复制 Schema 地址 复制 System Prompt 轮换 API Key
/openapi.json
MCP 新增 MCP
+
快速控制台 执行
安全 刷新 全局安全等级低 · Low 中 · Medium 高 · High 保存全局策略
+
+
+
+
日志 刷新
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 Login
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 - 节点监控探针
+
+
+
+
+
+
+
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 ? '在线' : '离线'}
+
+
+
+
+
+
+
+ 实时网络 (↓ / ↑)
+
+ ↓ ${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 `
+
+
+
+
+ 节点名称
+ 状态
+ 运行时间
+ 负载 (1/5/15m)
+ CPU
+ 内存
+ 实时网速
+ 累计流量
+ 磁盘
+
+
+
+ ${rows}
+
+
+
+ `;
+ };
+
+ 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);
})();