diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 9ad8032..8c415a6 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -16,6 +16,7 @@ import ( "github.com/gorilla/websocket" "github.com/wjyhk/vps-commander/internal/cluster" "github.com/wjyhk/vps-commander/internal/executor" + "github.com/wjyhk/vps-commander/internal/sysinfo" ) func main() { @@ -68,23 +69,29 @@ func run(hub, name, token string, insecure bool) error { return err } exec := executor.Local{MaxOutput: 1024 * 1024} - if err := writeJSON(cluster.Message{Event: "hello", Device: name, TS: time.Now().Unix()}); err != nil { + + // 初始握手附带画像 + initProf := sysinfo.Collect("APPLICATION") + if err := writeJSON(cluster.Message{Event: "hello", Device: name, Profile: &initProf, TS: time.Now().Unix()}); err != nil { return err } + stopPing := make(chan struct{}) defer close(stopPing) go func() { - t := time.NewTicker(30 * time.Second) + t := time.NewTicker(15 * time.Second) defer t.Stop() for { select { case <-t.C: - _ = writeJSON(cluster.Message{Event: "ping", Device: name, TS: time.Now().Unix()}) + prof := sysinfo.Collect("APPLICATION") + _ = writeJSON(cluster.Message{Event: "ping", Device: name, Profile: &prof, TS: time.Now().Unix()}) case <-stopPing: return } } }() + for { var msg cluster.Message if err := c.ReadJSON(&msg); err != nil { @@ -94,8 +101,13 @@ func run(hub, name, token string, insecure bool) error { if msg.Event == "registered" { continue } + if msg.Event == "revoked" { + log.Printf("device revoked by admin: %s", msg.Error) + return errors.New("revoked by admin") + } if msg.Event == "ping" { - if err := writeJSON(cluster.Message{Event: "pong", Device: name, TS: msg.TS}); err != nil { + prof := sysinfo.Collect("APPLICATION") + if err := writeJSON(cluster.Message{Event: "pong", Device: name, Profile: &prof, TS: msg.TS}); err != nil { return err } continue @@ -185,6 +197,7 @@ func decodePayload(v any, out any) error { } return json.Unmarshal(b, out) } + func sendError(writeJSON func(cluster.Message) error, id string, err error) error { return writeJSON(cluster.Message{ID: id, Status: "error", Error: err.Error()}) } diff --git a/cmd/hub/main.go b/cmd/hub/main.go index 7125a38..e799464 100644 --- a/cmd/hub/main.go +++ b/cmd/hub/main.go @@ -55,7 +55,7 @@ func main() { s := &api.Server{ Auth: authManager, Exec: executor.Local{MaxOutput: 1024 * 1024}, - Cluster: cluster.NewManager(clusterSecret), + Cluster: func() *cluster.Manager { m := cluster.NewManager(clusterSecret); m.SetStore(store); return m }(), Store: store, LocalName: local, } diff --git a/docs/M4_FINAL_ACCEPTANCE_REPORT.md b/docs/M4_FINAL_ACCEPTANCE_REPORT.md index faaeeef..947bcb2 100644 --- a/docs/M4_FINAL_ACCEPTANCE_REPORT.md +++ b/docs/M4_FINAL_ACCEPTANCE_REPORT.md @@ -1,7 +1,7 @@ # VPS-Commander M4 最终验收报告(待人工签收) 日期:2026-09-28 -状态:🟡 技术收尾完成,等待 Custom GPT Actions 人工签收 +状态:🟡 技术收尾完成;管理面板 UI 已人工验收并上线,M4 总体验收仍等待 Custom GPT Actions 人工签收 ## 已完成 @@ -25,3 +25,5 @@ 4. 完成后由用户人工确认 M4 通过。 在此之前,本报告不得视为 Release Ready / M4 Passed。 + +- 管理面板 UI 已完成手机端多 Agent 适配与深/浅双态主题;用户人工验收通过后已重新构建 Hub 并重启 systemd,线上 embed 静态资源已立即生效。 diff --git a/docs/PROGRESS_TRACKING.md b/docs/PROGRESS_TRACKING.md index bc31ccc..825417d 100644 --- a/docs/PROGRESS_TRACKING.md +++ b/docs/PROGRESS_TRACKING.md @@ -107,6 +107,14 @@ - [x] 临时凭据文件 `/tmp/m4-prod.env` 已删除;`/etc/vps-commander/{hub,agent}.env` 权限均为 `0600`。 - [ ] Custom GPT Actions UI 仍需用户侧完成正式 OpenAPI 导入、Bearer Key 配置及对话调用;未完成前不得签署 M4。 +### M4 管理面板 UI 验收记录(2026-09-28) +- [x] 手机端多 Agent 显示异常整改完成:设备卡片、Header、Actions、设备选择器、审计表均完成窄屏适配。 +- [x] 面板颜色体系改为 CSS Variables 双态主题,支持深色/浅色切换。 +- [x] 主题选择持久化到 localStorage,登录页与主面板统一。 +- [x] UI 静态资源与 Go embed 资源保持同步,并完成 Node JS 语法、go test ./...、release build 验证。 +- [x] 用户已于 2026-09-28 人工验收通过;已重新构建 Hub 二进制并重启 systemd 服务,正式线上 UI 已立即生效。 +- [ ] M4 总体验收仍保留 Custom GPT Actions 正式联调、Key 轮换及最终人工签收门禁。 + ### M4 终验签收记录(2026-09-28) - [x] 安全清理:已彻底清除 `openapi.json` 中冗余残留的 `apiKey` 定义,统一收敛为标准 `bearerAuth`。 - [x] 二次回归验证通过:全链路 API 鉴权、受控节点列表获取及本地命令执行均现场实测 100% PASS。 diff --git a/internal/api/api.go b/internal/api/api.go index 76b7894..c1eaedc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -18,6 +18,7 @@ import ( "github.com/wjyhk/vps-commander/internal/executor" "github.com/wjyhk/vps-commander/internal/model" "github.com/wjyhk/vps-commander/internal/storage" + "github.com/wjyhk/vps-commander/internal/sysinfo" panelweb "github.com/wjyhk/vps-commander/internal/web" ) @@ -104,7 +105,8 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", 405) return } - out := []model.Device{{Name: s.LocalName, Status: "online", Local: true, Arch: runtime.GOARCH, OS: runtime.GOOS}} + localProf := sysinfo.Collect("CONTROL") + out := []model.Device{{Name: s.LocalName, Status: "online", Local: true, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &localProf}} if s.Cluster != nil { for _, d := range s.Cluster.Devices() { b, _ := json.Marshal(d) @@ -116,6 +118,28 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request) { jsonOut(w, out) } +func (s *Server) DeleteDevice(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "device name required", 400) + return + } + if name == s.LocalName || name == "local" { + http.Error(w, "cannot delete local control node", 403) + return + } + if s.Cluster != nil { + if err := s.Cluster.RevokeDevice(name, "revoked by admin via web console"); err != nil { + http.Error(w, "revoke failed: "+err.Error(), 500) + return + } + } else if s.Store != nil { + _ = s.Store.RevokeDevice(name, "revoked by admin via web console") + } + _ = s.Store.Audit(clientIP(r), name, "revoke_device", "revoked by admin", 0, 0, "") + jsonOut(w, map[string]any{"ok": true, "revoked": name}) +} + func (s *Server) agentWS(w http.ResponseWriter, r *http.Request) { if s.Cluster == nil { http.Error(w, "cluster unavailable", 503) diff --git a/internal/cluster/manager.go b/internal/cluster/manager.go index 9979910..db5a530 100644 --- a/internal/cluster/manager.go +++ b/internal/cluster/manager.go @@ -12,20 +12,24 @@ import ( "time" "github.com/gorilla/websocket" + "github.com/wjyhk/vps-commander/internal/storage" + "github.com/wjyhk/vps-commander/internal/sysinfo" ) type Manager struct { Secret string + Store *storage.Store mu sync.RWMutex nodes map[string]*Node } type Node struct { - Name string - Conn *websocket.Conn - WriteMu sync.Mutex - Pending sync.Map - LastSeen atomic.Int64 + Name string + Conn *websocket.Conn + WriteMu sync.Mutex + Pending sync.Map + LastSeen atomic.Int64 + LastProfile *sysinfo.Profile } type Result struct { @@ -46,6 +50,11 @@ type FileResult struct { func NewManager(secret string) *Manager { return &Manager{Secret: secret, nodes: make(map[string]*Node)} } + +func (m *Manager) SetStore(s *storage.Store) { + m.Store = s +} + func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) { auth := r.Header.Get("Authorization") provided := "" @@ -61,6 +70,14 @@ func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) { http.Error(w, "device_name required", 400) return } + + if m.Store != nil { + if revoked, _ := m.Store.IsRevoked(name); revoked { + http.Error(w, "device has been revoked by admin", 403) + return + } + } + up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} c, err := up.Upgrade(w, r, nil) if err != nil { @@ -115,7 +132,10 @@ func (m *Manager) readLoop(n *Node) { } n.LastSeen.Store(time.Now().UnixNano()) _ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second)) - if msg.Event == "pong" || msg.Event == "ping" { + if msg.Profile != nil { + n.LastProfile = msg.Profile + } + if msg.Event == "pong" || msg.Event == "ping" || msg.Event == "hello" || msg.Event == "profile" { continue } if msg.ID != "" { @@ -130,6 +150,7 @@ func (m *Manager) readLoop(n *Node) { } } } + func (m *Manager) Online(name string) bool { m.mu.RLock() defer m.mu.RUnlock() @@ -137,6 +158,22 @@ func (m *Manager) Online(name string) bool { return n != nil && time.Since(time.Unix(0, n.LastSeen.Load())) < 70*time.Second } +func (m *Manager) RevokeDevice(name, reason string) error { + m.mu.Lock() + n, exists := m.nodes[name] + if exists { + delete(m.nodes, name) + _ = n.write(Message{Event: "revoked", Error: "revoked by admin"}) + _ = n.Conn.Close() + } + m.mu.Unlock() + + if m.Store != nil { + return m.Store.RevokeDevice(name, reason) + } + return nil +} + func (m *Manager) Devices() []map[string]any { m.mu.RLock() defer m.mu.RUnlock() @@ -147,7 +184,16 @@ func (m *Manager) Devices() []map[string]any { if time.Since(lastSeen) >= 70*time.Second { status = "offline" } - out = append(out, map[string]any{"name": name, "status": status, "is_local": false, "last_heartbeat": lastSeen.Unix()}) + item := map[string]any{ + "name": name, + "status": status, + "is_local": false, + "last_heartbeat": lastSeen.Unix(), + } + if n.LastProfile != nil { + item["profile"] = n.LastProfile + } + out = append(out, item) } return out } @@ -180,6 +226,7 @@ func (m *Manager) call(ctx context.Context, name, action string, payload any) (M return Message{}, ctx.Err() } } + func (m *Manager) Exec(ctx context.Context, name string, p ExecPayload) (Result, error) { msg, err := m.call(ctx, name, "exec", p) if err != nil { diff --git a/internal/cluster/protocol.go b/internal/cluster/protocol.go index 7b6dbe4..c89a409 100644 --- a/internal/cluster/protocol.go +++ b/internal/cluster/protocol.go @@ -1,18 +1,21 @@ package cluster +import "github.com/wjyhk/vps-commander/internal/sysinfo" + type Message struct { - ID string `json:"id,omitempty"` - Event string `json:"event,omitempty"` - Action string `json:"action,omitempty"` - Device string `json:"device,omitempty"` - Status string `json:"status,omitempty"` - Payload any `json:"payload,omitempty"` - ExitCode int `json:"exit_code,omitempty"` - Stdout string `json:"stdout,omitempty"` - Stderr string `json:"stderr,omitempty"` - DurationMS int64 `json:"duration_ms,omitempty"` - Error string `json:"error,omitempty"` - TS int64 `json:"ts,omitempty"` + ID string `json:"id,omitempty"` + Event string `json:"event,omitempty"` + Action string `json:"action,omitempty"` + Device string `json:"device,omitempty"` + Status string `json:"status,omitempty"` + Payload any `json:"payload,omitempty"` + Profile *sysinfo.Profile `json:"profile,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` + TS int64 `json:"ts,omitempty"` } type ExecPayload struct { diff --git a/internal/model/model.go b/internal/model/model.go index 5a2f6d5..fff65cc 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -1,5 +1,7 @@ package model +import "github.com/wjyhk/vps-commander/internal/sysinfo" + type ExecRequest struct { Device string `json:"device,omitempty"` Command string `json:"command"` @@ -16,11 +18,12 @@ 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"` + 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"` } type FileReadRequest struct { diff --git a/internal/storage/storage.go b/internal/storage/storage.go index daaf11d..86d3ace 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -97,3 +97,30 @@ func (s *Store) RecentAudits(limit int) ([]AuditLog, error) { } return out, rows.Err() } + +func (s *Store) InitRevocationTable() error { + _, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS revoked_devices ( + name TEXT PRIMARY KEY, + revoked_at INTEGER NOT NULL, + reason TEXT + );`) + return err +} + +func (s *Store) RevokeDevice(name, reason string) error { + _, err := s.DB.Exec(`INSERT INTO revoked_devices(name, revoked_at, reason) + VALUES(?, ?, ?) ON CONFLICT(name) DO UPDATE SET revoked_at=excluded.revoked_at, reason=excluded.reason`, + name, time.Now().Unix(), reason) + return err +} + +func (s *Store) UnrevokeDevice(name string) error { + _, err := s.DB.Exec(`DELETE FROM revoked_devices WHERE name = ?`, name) + return err +} + +func (s *Store) IsRevoked(name string) (bool, error) { + var count int + err := s.DB.QueryRow(`SELECT COUNT(*) FROM revoked_devices WHERE name = ?`, name).Scan(&count) + return count > 0, err +} diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go new file mode 100644 index 0000000..83eef2c --- /dev/null +++ b/internal/sysinfo/sysinfo.go @@ -0,0 +1,247 @@ +package sysinfo + +import ( + "bufio" + "fmt" + "os" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +type Profile struct { + Role string `json:"role"` + CPUCores int `json:"cpu_cores"` + CPUUsagePercent int `json:"cpu_usage_percent"` + MemPercent int `json:"mem_percent"` + MemAvailable string `json:"mem_available"` + SwapPercent int `json:"swap_percent"` + DiskPercent int `json:"disk_percent"` + IOPSI float64 `json:"io_psi"` + Docker bool `json:"docker"` + UpdatedAt int64 `json:"updated_at"` +} + +func Collect(defaultRole string) Profile { + role := os.Getenv("VPS_COMMANDER_ROLE") + if role == "" { + role = defaultRole + } + if role == "" { + role = "APPLICATION" + } + + cores := runtime.NumCPU() + cpuPercent := getCPUUsage() + memPct, memAvl, swapPct := getMemAndSwap() + diskPct := getDiskPercent() + psi := getIOPSI() + docker := hasDocker() + + return Profile{ + Role: role, + CPUCores: cores, + CPUUsagePercent: cpuPercent, + MemPercent: memPct, + MemAvailable: memAvl, + SwapPercent: swapPct, + DiskPercent: diskPct, + IOPSI: psi, + Docker: docker, + UpdatedAt: time.Now().Unix(), + } +} + +func getCPUUsage() int { + // 尝试从 /proc/stat 快速采样或 /proc/loadavg + pct := sampleCPUStat() + if pct >= 0 { + return pct + } + // 回退到 loadavg / cores * 100 + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + if l1, err := strconv.ParseFloat(fields[0], 64); err == nil { + cores := float64(runtime.NumCPU()) + if cores < 1 { + cores = 1 + } + val := int((l1 / cores) * 100) + if val > 100 { + val = 100 + } + return val + } + } + } + return 0 +} + +func sampleCPUStat() int { + readStat := func() (idle, total uint64, ok bool) { + f, err := os.Open("/proc/stat") + if err != nil { + return 0, 0, false + } + defer f.Close() + scanner := bufio.NewScanner(f) + if scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) >= 5 && fields[0] == "cpu" { + var sum uint64 + for i := 1; i < len(fields); i++ { + v, _ := strconv.ParseUint(fields[i], 10, 64) + sum += v + } + idleVal, _ := strconv.ParseUint(fields[4], 10, 64) + return idleVal, sum, true + } + } + return 0, 0, false + } + + idle1, total1, ok1 := readStat() + if !ok1 { + return -1 + } + time.Sleep(50 * time.Millisecond) + idle2, total2, ok2 := readStat() + if !ok2 || total2 <= total1 { + return -1 + } + deltaTotal := total2 - total1 + deltaIdle := idle2 - idle1 + if deltaTotal == 0 { + return 0 + } + busy := float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100 + if busy < 0 { + busy = 0 + } + if busy > 100 { + busy = 100 + } + return int(busy) +} + +func getMemAndSwap() (memPct int, memAvl string, swapPct int) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return 0, "N/A", 0 + } + defer f.Close() + + var total, free, avail, buffers, cached, swapTotal, swapFree uint64 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + valFields := strings.Fields(parts[1]) + if len(valFields) == 0 { + continue + } + v, _ := strconv.ParseUint(valFields[0], 10, 64) + switch key { + case "MemTotal": + total = v + case "MemFree": + free = v + case "MemAvailable": + avail = v + case "Buffers": + buffers = v + case "Cached": + cached = v + case "SwapTotal": + swapTotal = v + case "SwapFree": + swapFree = v + } + } + + if avail == 0 { + avail = free + buffers + cached + } + + if total > 0 { + used := total - avail + memPct = int(float64(used) / float64(total) * 100) + if avail >= 1024*1024 { + memAvl = fmt.Sprintf("%.1fG", float64(avail)/(1024*1024)) + } else { + memAvl = fmt.Sprintf("%dM", avail/1024) + } + } else { + memAvl = "N/A" + } + + if swapTotal > 0 { + swapUsed := swapTotal - swapFree + swapPct = int(float64(swapUsed) / float64(swapTotal) * 100) + } + + return memPct, memAvl, swapPct +} + +func getDiskPercent() int { + var stat syscall.Statfs_t + if err := syscall.Statfs("/", &stat); err != nil { + return 0 + } + total := stat.Blocks * uint64(stat.Bsize) + free := stat.Bfree * uint64(stat.Bsize) + if total == 0 { + return 0 + } + used := total - free + pct := int(float64(used) / float64(total) * 100) + if pct > 100 { + pct = 100 + } + return pct +} + +func getIOPSI() float64 { + // 读取 /proc/pressure/io + data, err := os.ReadFile("/proc/pressure/io") + if err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "some") { + fields := strings.Fields(line) + for _, f := range fields { + if strings.HasPrefix(f, "avg10=") { + val, err := strconv.ParseFloat(strings.TrimPrefix(f, "avg10="), 64) + if err == nil { + return val + } + } + } + } + } + } + // 回退:/proc/loadavg 的 1min load + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + if val, err := strconv.ParseFloat(fields[0], 64); err == nil { + return val + } + } + } + return 0.0 +} + +func hasDocker() bool { + if _, err := os.Stat("/var/run/docker.sock"); err == nil { + return true + } + return false +} diff --git a/internal/sysinfo/sysinfo_test.go b/internal/sysinfo/sysinfo_test.go new file mode 100644 index 0000000..e591cb6 --- /dev/null +++ b/internal/sysinfo/sysinfo_test.go @@ -0,0 +1,16 @@ +package sysinfo + +import ( + "testing" +) + +func TestCollect(t *testing.T) { + p := Collect("CONTROL") + if p.Role != "CONTROL" { + t.Fatalf("expected CONTROL, got %s", p.Role) + } + if p.CPUCores <= 0 { + t.Fatalf("expected >0 CPU cores, got %d", p.CPUCores) + } + t.Logf("Profile: %+v", p) +} diff --git a/internal/web/panel.go b/internal/web/panel.go index bda2511..cbb441f 100644 --- a/internal/web/panel.go +++ b/internal/web/panel.go @@ -20,6 +20,7 @@ type Runner interface { AuditsJSON(http.ResponseWriter, *http.Request) PanelExec(http.ResponseWriter, *http.Request) RotateAPIKey(http.ResponseWriter, *http.Request) + DeleteDevice(http.ResponseWriter, *http.Request) } type Panel struct { @@ -38,7 +39,7 @@ func (p *Panel) Handler() http.Handler { mux.HandleFunc("/panel/login", p.login) mux.HandleFunc("/panel/logout", p.logout) mux.HandleFunc("/static/", p.static) - mux.HandleFunc("/panel/api/devices", p.protected(p.Runner.DevicesJSON)) + mux.HandleFunc("/panel/api/devices", p.protected(p.handleDevices)) mux.HandleFunc("/panel/api/audits", p.protected(p.Runner.AuditsJSON)) mux.HandleFunc("/panel/api/exec", p.protected(p.Runner.PanelExec)) mux.HandleFunc("/panel/api/key/rotate", p.protected(p.Runner.RotateAPIKey)) @@ -46,6 +47,14 @@ func (p *Panel) Handler() http.Handler { return mux } +func (p *Panel) handleDevices(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + p.Runner.DeleteDevice(w, r) + return + } + p.Runner.DevicesJSON(w, r) +} + func (p *Panel) index(w http.ResponseWriter, r *http.Request) { if !p.valid(r) { http.Redirect(w, r, "/panel/login", http.StatusFound) @@ -141,6 +150,9 @@ func (p *Panel) static(w http.ResponseWriter, r *http.Request) { if len(path) >= 3 && path[len(path)-3:] == ".js" { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") } + if strings.HasSuffix(path, ".png") { + w.Header().Set("Content-Type", "image/png") + } _, _ = w.Write(data) } diff --git a/internal/web/panel_test.go b/internal/web/panel_test.go index 345ea46..2fa1075 100644 --- a/internal/web/panel_test.go +++ b/internal/web/panel_test.go @@ -13,6 +13,7 @@ func (testRunner) DevicesJSON(w http.ResponseWriter, r *http.Request) {} func (testRunner) AuditsJSON(w http.ResponseWriter, r *http.Request) {} func (testRunner) PanelExec(w http.ResponseWriter, r *http.Request) {} func (testRunner) RotateAPIKey(w http.ResponseWriter, r *http.Request) {} +func (testRunner) DeleteDevice(w http.ResponseWriter, r *http.Request) {} func TestStaticCSSContentType(t *testing.T) { p := New("test-password", testRunner{}) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 6982e4c..1489c95 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -16,3 +16,26 @@ header strong{font-size:18px}header span{margin-left:12px;color:var(--text-muted .theme-svg{display:none;width:18px;height:18px} :root[data-theme="dark"] .svg-sun{display:block;color:#facc15} :root[data-theme="light"] .svg-moon{display:block;color:#475569} + +/* 设备卡片与画像升级 */ +.device-card{min-width:0;padding:16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-sub);display:flex;flex-direction:column;gap:10px} +.device-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0} +.device-title{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap} +.device-name{font-size:15px;color:var(--text-main);overflow-wrap:anywhere;word-break:break-all} +.role-badge{font-size:11px;padding:2px 6px;border-radius:5px;font-weight:600;letter-spacing:.5px;border:1px solid var(--border-strong)} +.role-control{background:rgba(59,130,246,.15);color:#60a5fa;border-color:rgba(59,130,246,.3)} +.role-application{background:rgba(16,185,129,.15);color:#34d399;border-color:rgba(16,185,129,.3)} +.role-database{background:rgba(245,158,11,.15);color:#fbbf24;border-color:rgba(245,158,11,.3)} +.btn-revoke{padding:3px 8px;font-size:12px;border-radius:6px;background:transparent;border:1px solid var(--border-strong);color:var(--dot-offline);cursor:pointer} +.btn-revoke:hover{background:rgba(239,68,68,.15);border-color:var(--dot-offline)} +.device-meta{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;flex-wrap:wrap} + +/* 资源画像 2列紧凑栅格 */ +.profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;padding:10px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;font-size:12px} +.p-item{display:flex;justify-content:space-between;align-items:center;gap:6px} +.p-label{color:var(--text-muted)} +.p-val{font-family:ui-monospace,monospace;font-weight:600;color:var(--text-main)} +.p-docker{grid-column:1/-1;display:flex;justify-content:flex-end} +.badge-docker{font-size:11px;padding:2px 8px;border-radius:4px;font-weight:600} +.badge-docker.yes{background:rgba(34,197,94,.15);color:var(--dot-online);border:1px solid rgba(34,197,94,.3)} +.badge-docker.no{background:rgba(148,163,184,.15);color:var(--text-muted);border:1px solid var(--border)} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index 4bc9750..9e55cc8 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -9,24 +9,76 @@ const $=s=>document.querySelector(s); localStorage.setItem("vpc_theme",next); }; })(); + async function api(path,opt={}){ let r=await fetch("/panel/api"+path,opt); if(r.status===401){location.href="/panel/login";throw Error("unauthorized")} if(!r.ok)throw Error(await r.text()); return r.json() } + function esc(v){ return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])) } + +async function removeDevice(name){ + if(!confirm(`确认注销并踢出节点 [${name}] 吗?\n注销后该节点将被断开并禁止再次连入。`)) return; + try{ + await api("/devices?name="+encodeURIComponent(name), {method: "DELETE"}); + await loadDevices(); + }catch(e){ + alert("注销失败: "+e.message); + } +} + async function loadDevices(){ let ds=await api("/devices"); - $("#devices").innerHTML=ds.map(d=>'
'+esc(d.name)+'
'+esc(d.status)+' · '+(d.is_local?"local":"remote")+' · '+esc(d.arch||"")+'/'+esc(d.os||"")+'
').join(""); - $("#device").innerHTML=ds.map(d=>'').join("") + $("#devices").innerHTML=ds.map(d=>{ + const p = d.profile || {}; + const role = p.role || (d.is_local ? "CONTROL" : "APPLICATION"); + const isOnline = d.status === "online"; + const cpuStr = p.cpu_cores ? `${p.cpu_cores}C · ${p.cpu_usage_percent ?? 0}%` : '-'; + const memStr = p.mem_percent !== undefined ? `${p.mem_percent}%` + (p.mem_available ? ` (${p.mem_available})` : '') : '-'; + const swapStr = p.swap_percent !== undefined ? `${p.swap_percent}%` : '-'; + const diskStr = p.disk_percent !== undefined ? `${p.disk_percent}%` : '-'; + const psiStr = p.io_psi !== undefined ? p.io_psi.toFixed(2) : '-'; + const dockerBadge = p.docker ? 'Docker YES' : 'Docker NO'; + + return ` +
+
+
+ + ${esc(d.name)} + ${esc(role)} +
+ ${d.is_local ? '' : ``} +
+
+ ${esc(d.status)} · + ${d.is_local ? "local" : "remote"} · + ${esc(d.arch||"")}/${esc(d.os||"")} +
+
+
CPU${cpuStr}
+
Mem${memStr}
+
Swap${swapStr}
+
Disk${diskStr}
+
IO PSI${psiStr}
+
${dockerBadge}
+
+
+ `; + }).join(""); + + $("#device").innerHTML=ds.map(d=>'').join(""); } + async function loadAudits(){ let a=await api("/audits?limit=100"); $("#audits").innerHTML=a.map(x=>''+new Date(x.timestamp*1000).toLocaleString()+''+esc(x.device)+''+esc(x.action)+''+esc(x.command)+''+x.exit_code+''+x.duration_ms+'ms'+esc(x.caller_ip)+'').join("") } + $("#refresh").onclick=loadDevices; $("#auditRefresh").onclick=loadAudits; $("#run").onclick=async()=>{ diff --git a/web/static/app.css b/web/static/app.css index 6982e4c..1489c95 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -16,3 +16,26 @@ header strong{font-size:18px}header span{margin-left:12px;color:var(--text-muted .theme-svg{display:none;width:18px;height:18px} :root[data-theme="dark"] .svg-sun{display:block;color:#facc15} :root[data-theme="light"] .svg-moon{display:block;color:#475569} + +/* 设备卡片与画像升级 */ +.device-card{min-width:0;padding:16px;border:1px solid var(--border);border-radius:12px;background:var(--bg-sub);display:flex;flex-direction:column;gap:10px} +.device-head{display:flex;align-items:center;justify-content:space-between;gap:8px;min-width:0} +.device-title{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap} +.device-name{font-size:15px;color:var(--text-main);overflow-wrap:anywhere;word-break:break-all} +.role-badge{font-size:11px;padding:2px 6px;border-radius:5px;font-weight:600;letter-spacing:.5px;border:1px solid var(--border-strong)} +.role-control{background:rgba(59,130,246,.15);color:#60a5fa;border-color:rgba(59,130,246,.3)} +.role-application{background:rgba(16,185,129,.15);color:#34d399;border-color:rgba(16,185,129,.3)} +.role-database{background:rgba(245,158,11,.15);color:#fbbf24;border-color:rgba(245,158,11,.3)} +.btn-revoke{padding:3px 8px;font-size:12px;border-radius:6px;background:transparent;border:1px solid var(--border-strong);color:var(--dot-offline);cursor:pointer} +.btn-revoke:hover{background:rgba(239,68,68,.15);border-color:var(--dot-offline)} +.device-meta{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:6px;flex-wrap:wrap} + +/* 资源画像 2列紧凑栅格 */ +.profile-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;padding:10px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px;font-size:12px} +.p-item{display:flex;justify-content:space-between;align-items:center;gap:6px} +.p-label{color:var(--text-muted)} +.p-val{font-family:ui-monospace,monospace;font-weight:600;color:var(--text-main)} +.p-docker{grid-column:1/-1;display:flex;justify-content:flex-end} +.badge-docker{font-size:11px;padding:2px 8px;border-radius:4px;font-weight:600} +.badge-docker.yes{background:rgba(34,197,94,.15);color:var(--dot-online);border:1px solid rgba(34,197,94,.3)} +.badge-docker.no{background:rgba(148,163,184,.15);color:var(--text-muted);border:1px solid var(--border)} diff --git a/web/static/app.js b/web/static/app.js index 4bc9750..9e55cc8 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -9,24 +9,76 @@ const $=s=>document.querySelector(s); localStorage.setItem("vpc_theme",next); }; })(); + async function api(path,opt={}){ let r=await fetch("/panel/api"+path,opt); if(r.status===401){location.href="/panel/login";throw Error("unauthorized")} if(!r.ok)throw Error(await r.text()); return r.json() } + function esc(v){ return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c])) } + +async function removeDevice(name){ + if(!confirm(`确认注销并踢出节点 [${name}] 吗?\n注销后该节点将被断开并禁止再次连入。`)) return; + try{ + await api("/devices?name="+encodeURIComponent(name), {method: "DELETE"}); + await loadDevices(); + }catch(e){ + alert("注销失败: "+e.message); + } +} + async function loadDevices(){ let ds=await api("/devices"); - $("#devices").innerHTML=ds.map(d=>'
'+esc(d.name)+'
'+esc(d.status)+' · '+(d.is_local?"local":"remote")+' · '+esc(d.arch||"")+'/'+esc(d.os||"")+'
').join(""); - $("#device").innerHTML=ds.map(d=>'').join("") + $("#devices").innerHTML=ds.map(d=>{ + const p = d.profile || {}; + const role = p.role || (d.is_local ? "CONTROL" : "APPLICATION"); + const isOnline = d.status === "online"; + const cpuStr = p.cpu_cores ? `${p.cpu_cores}C · ${p.cpu_usage_percent ?? 0}%` : '-'; + const memStr = p.mem_percent !== undefined ? `${p.mem_percent}%` + (p.mem_available ? ` (${p.mem_available})` : '') : '-'; + const swapStr = p.swap_percent !== undefined ? `${p.swap_percent}%` : '-'; + const diskStr = p.disk_percent !== undefined ? `${p.disk_percent}%` : '-'; + const psiStr = p.io_psi !== undefined ? p.io_psi.toFixed(2) : '-'; + const dockerBadge = p.docker ? 'Docker YES' : 'Docker NO'; + + return ` +
+
+
+ + ${esc(d.name)} + ${esc(role)} +
+ ${d.is_local ? '' : ``} +
+
+ ${esc(d.status)} · + ${d.is_local ? "local" : "remote"} · + ${esc(d.arch||"")}/${esc(d.os||"")} +
+
+
CPU${cpuStr}
+
Mem${memStr}
+
Swap${swapStr}
+
Disk${diskStr}
+
IO PSI${psiStr}
+
${dockerBadge}
+
+
+ `; + }).join(""); + + $("#device").innerHTML=ds.map(d=>'').join(""); } + async function loadAudits(){ let a=await api("/audits?limit=100"); $("#audits").innerHTML=a.map(x=>''+new Date(x.timestamp*1000).toLocaleString()+''+esc(x.device)+''+esc(x.action)+''+esc(x.command)+''+x.exit_code+''+x.duration_ms+'ms'+esc(x.caller_ip)+'').join("") } + $("#refresh").onclick=loadDevices; $("#auditRefresh").onclick=loadAudits; $("#run").onclick=async()=>{