feat: add node resource profiling and admin device revocation
This commit is contained in:
+17
-4
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/wjyhk/vps-commander/internal/cluster"
|
"github.com/wjyhk/vps-commander/internal/cluster"
|
||||||
"github.com/wjyhk/vps-commander/internal/executor"
|
"github.com/wjyhk/vps-commander/internal/executor"
|
||||||
|
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -68,23 +69,29 @@ func run(hub, name, token string, insecure bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
exec := executor.Local{MaxOutput: 1024 * 1024}
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
stopPing := make(chan struct{})
|
stopPing := make(chan struct{})
|
||||||
defer close(stopPing)
|
defer close(stopPing)
|
||||||
go func() {
|
go func() {
|
||||||
t := time.NewTicker(30 * time.Second)
|
t := time.NewTicker(15 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-t.C:
|
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:
|
case <-stopPing:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var msg cluster.Message
|
var msg cluster.Message
|
||||||
if err := c.ReadJSON(&msg); err != nil {
|
if err := c.ReadJSON(&msg); err != nil {
|
||||||
@@ -94,8 +101,13 @@ func run(hub, name, token string, insecure bool) error {
|
|||||||
if msg.Event == "registered" {
|
if msg.Event == "registered" {
|
||||||
continue
|
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 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
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -185,6 +197,7 @@ func decodePayload(v any, out any) error {
|
|||||||
}
|
}
|
||||||
return json.Unmarshal(b, out)
|
return json.Unmarshal(b, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendError(writeJSON func(cluster.Message) error, id string, err error) error {
|
func sendError(writeJSON func(cluster.Message) error, id string, err error) error {
|
||||||
return writeJSON(cluster.Message{ID: id, Status: "error", Error: err.Error()})
|
return writeJSON(cluster.Message{ID: id, Status: "error", Error: err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -55,7 +55,7 @@ func main() {
|
|||||||
s := &api.Server{
|
s := &api.Server{
|
||||||
Auth: authManager,
|
Auth: authManager,
|
||||||
Exec: executor.Local{MaxOutput: 1024 * 1024},
|
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,
|
Store: store,
|
||||||
LocalName: local,
|
LocalName: local,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# VPS-Commander M4 最终验收报告(待人工签收)
|
# VPS-Commander M4 最终验收报告(待人工签收)
|
||||||
|
|
||||||
日期:2026-09-28
|
日期:2026-09-28
|
||||||
状态:🟡 技术收尾完成,等待 Custom GPT Actions 人工签收
|
状态:🟡 技术收尾完成;管理面板 UI 已人工验收并上线,M4 总体验收仍等待 Custom GPT Actions 人工签收
|
||||||
|
|
||||||
## 已完成
|
## 已完成
|
||||||
|
|
||||||
@@ -25,3 +25,5 @@
|
|||||||
4. 完成后由用户人工确认 M4 通过。
|
4. 完成后由用户人工确认 M4 通过。
|
||||||
|
|
||||||
在此之前,本报告不得视为 Release Ready / M4 Passed。
|
在此之前,本报告不得视为 Release Ready / M4 Passed。
|
||||||
|
|
||||||
|
- 管理面板 UI 已完成手机端多 Agent 适配与深/浅双态主题;用户人工验收通过后已重新构建 Hub 并重启 systemd,线上 embed 静态资源已立即生效。
|
||||||
|
|||||||
@@ -107,6 +107,14 @@
|
|||||||
- [x] 临时凭据文件 `/tmp/m4-prod.env` 已删除;`/etc/vps-commander/{hub,agent}.env` 权限均为 `0600`。
|
- [x] 临时凭据文件 `/tmp/m4-prod.env` 已删除;`/etc/vps-commander/{hub,agent}.env` 权限均为 `0600`。
|
||||||
- [ ] Custom GPT Actions UI 仍需用户侧完成正式 OpenAPI 导入、Bearer Key 配置及对话调用;未完成前不得签署 M4。
|
- [ ] 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)
|
### M4 终验签收记录(2026-09-28)
|
||||||
- [x] 安全清理:已彻底清除 `openapi.json` 中冗余残留的 `apiKey` 定义,统一收敛为标准 `bearerAuth`。
|
- [x] 安全清理:已彻底清除 `openapi.json` 中冗余残留的 `apiKey` 定义,统一收敛为标准 `bearerAuth`。
|
||||||
- [x] 二次回归验证通过:全链路 API 鉴权、受控节点列表获取及本地命令执行均现场实测 100% PASS。
|
- [x] 二次回归验证通过:全链路 API 鉴权、受控节点列表获取及本地命令执行均现场实测 100% PASS。
|
||||||
|
|||||||
+25
-1
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/wjyhk/vps-commander/internal/executor"
|
"github.com/wjyhk/vps-commander/internal/executor"
|
||||||
"github.com/wjyhk/vps-commander/internal/model"
|
"github.com/wjyhk/vps-commander/internal/model"
|
||||||
"github.com/wjyhk/vps-commander/internal/storage"
|
"github.com/wjyhk/vps-commander/internal/storage"
|
||||||
|
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||||
panelweb "github.com/wjyhk/vps-commander/internal/web"
|
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)
|
http.Error(w, "method not allowed", 405)
|
||||||
return
|
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 {
|
if s.Cluster != nil {
|
||||||
for _, d := range s.Cluster.Devices() {
|
for _, d := range s.Cluster.Devices() {
|
||||||
b, _ := json.Marshal(d)
|
b, _ := json.Marshal(d)
|
||||||
@@ -116,6 +118,28 @@ func (s *Server) devices(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOut(w, out)
|
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) {
|
func (s *Server) agentWS(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.Cluster == nil {
|
if s.Cluster == nil {
|
||||||
http.Error(w, "cluster unavailable", 503)
|
http.Error(w, "cluster unavailable", 503)
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/wjyhk/vps-commander/internal/storage"
|
||||||
|
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
Secret string
|
Secret string
|
||||||
|
Store *storage.Store
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
nodes map[string]*Node
|
nodes map[string]*Node
|
||||||
}
|
}
|
||||||
@@ -26,6 +29,7 @@ type Node struct {
|
|||||||
WriteMu sync.Mutex
|
WriteMu sync.Mutex
|
||||||
Pending sync.Map
|
Pending sync.Map
|
||||||
LastSeen atomic.Int64
|
LastSeen atomic.Int64
|
||||||
|
LastProfile *sysinfo.Profile
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -46,6 +50,11 @@ type FileResult struct {
|
|||||||
func NewManager(secret string) *Manager {
|
func NewManager(secret string) *Manager {
|
||||||
return &Manager{Secret: secret, nodes: make(map[string]*Node)}
|
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) {
|
func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
provided := ""
|
provided := ""
|
||||||
@@ -61,6 +70,14 @@ func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "device_name required", 400)
|
http.Error(w, "device_name required", 400)
|
||||||
return
|
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 }}
|
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
c, err := up.Upgrade(w, r, nil)
|
c, err := up.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,7 +132,10 @@ func (m *Manager) readLoop(n *Node) {
|
|||||||
}
|
}
|
||||||
n.LastSeen.Store(time.Now().UnixNano())
|
n.LastSeen.Store(time.Now().UnixNano())
|
||||||
_ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second))
|
_ = 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
|
continue
|
||||||
}
|
}
|
||||||
if msg.ID != "" {
|
if msg.ID != "" {
|
||||||
@@ -130,6 +150,7 @@ func (m *Manager) readLoop(n *Node) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Online(name string) bool {
|
func (m *Manager) Online(name string) bool {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
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
|
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 {
|
func (m *Manager) Devices() []map[string]any {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
@@ -147,7 +184,16 @@ func (m *Manager) Devices() []map[string]any {
|
|||||||
if time.Since(lastSeen) >= 70*time.Second {
|
if time.Since(lastSeen) >= 70*time.Second {
|
||||||
status = "offline"
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -180,6 +226,7 @@ func (m *Manager) call(ctx context.Context, name, action string, payload any) (M
|
|||||||
return Message{}, ctx.Err()
|
return Message{}, ctx.Err()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) Exec(ctx context.Context, name string, p ExecPayload) (Result, error) {
|
func (m *Manager) Exec(ctx context.Context, name string, p ExecPayload) (Result, error) {
|
||||||
msg, err := m.call(ctx, name, "exec", p)
|
msg, err := m.call(ctx, name, "exec", p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package cluster
|
package cluster
|
||||||
|
|
||||||
|
import "github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string `json:"id,omitempty"`
|
ID string `json:"id,omitempty"`
|
||||||
Event string `json:"event,omitempty"`
|
Event string `json:"event,omitempty"`
|
||||||
@@ -7,6 +9,7 @@ type Message struct {
|
|||||||
Device string `json:"device,omitempty"`
|
Device string `json:"device,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
Payload any `json:"payload,omitempty"`
|
Payload any `json:"payload,omitempty"`
|
||||||
|
Profile *sysinfo.Profile `json:"profile,omitempty"`
|
||||||
ExitCode int `json:"exit_code,omitempty"`
|
ExitCode int `json:"exit_code,omitempty"`
|
||||||
Stdout string `json:"stdout,omitempty"`
|
Stdout string `json:"stdout,omitempty"`
|
||||||
Stderr string `json:"stderr,omitempty"`
|
Stderr string `json:"stderr,omitempty"`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
import "github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||||
|
|
||||||
type ExecRequest struct {
|
type ExecRequest struct {
|
||||||
Device string `json:"device,omitempty"`
|
Device string `json:"device,omitempty"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
@@ -21,6 +23,7 @@ type Device struct {
|
|||||||
Local bool `json:"is_local"`
|
Local bool `json:"is_local"`
|
||||||
Arch string `json:"arch,omitempty"`
|
Arch string `json:"arch,omitempty"`
|
||||||
OS string `json:"os,omitempty"`
|
OS string `json:"os,omitempty"`
|
||||||
|
Profile *sysinfo.Profile `json:"profile,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileReadRequest struct {
|
type FileReadRequest struct {
|
||||||
|
|||||||
@@ -97,3 +97,30 @@ func (s *Store) RecentAudits(limit int) ([]AuditLog, error) {
|
|||||||
}
|
}
|
||||||
return out, rows.Err()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+13
-1
@@ -20,6 +20,7 @@ type Runner interface {
|
|||||||
AuditsJSON(http.ResponseWriter, *http.Request)
|
AuditsJSON(http.ResponseWriter, *http.Request)
|
||||||
PanelExec(http.ResponseWriter, *http.Request)
|
PanelExec(http.ResponseWriter, *http.Request)
|
||||||
RotateAPIKey(http.ResponseWriter, *http.Request)
|
RotateAPIKey(http.ResponseWriter, *http.Request)
|
||||||
|
DeleteDevice(http.ResponseWriter, *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Panel struct {
|
type Panel struct {
|
||||||
@@ -38,7 +39,7 @@ func (p *Panel) Handler() http.Handler {
|
|||||||
mux.HandleFunc("/panel/login", p.login)
|
mux.HandleFunc("/panel/login", p.login)
|
||||||
mux.HandleFunc("/panel/logout", p.logout)
|
mux.HandleFunc("/panel/logout", p.logout)
|
||||||
mux.HandleFunc("/static/", p.static)
|
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/audits", p.protected(p.Runner.AuditsJSON))
|
||||||
mux.HandleFunc("/panel/api/exec", p.protected(p.Runner.PanelExec))
|
mux.HandleFunc("/panel/api/exec", p.protected(p.Runner.PanelExec))
|
||||||
mux.HandleFunc("/panel/api/key/rotate", p.protected(p.Runner.RotateAPIKey))
|
mux.HandleFunc("/panel/api/key/rotate", p.protected(p.Runner.RotateAPIKey))
|
||||||
@@ -46,6 +47,14 @@ func (p *Panel) Handler() http.Handler {
|
|||||||
return mux
|
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) {
|
func (p *Panel) index(w http.ResponseWriter, r *http.Request) {
|
||||||
if !p.valid(r) {
|
if !p.valid(r) {
|
||||||
http.Redirect(w, r, "/panel/login", http.StatusFound)
|
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" {
|
if len(path) >= 3 && path[len(path)-3:] == ".js" {
|
||||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
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)
|
_, _ = w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func (testRunner) DevicesJSON(w http.ResponseWriter, r *http.Request) {}
|
|||||||
func (testRunner) AuditsJSON(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) PanelExec(w http.ResponseWriter, r *http.Request) {}
|
||||||
func (testRunner) RotateAPIKey(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) {
|
func TestStaticCSSContentType(t *testing.T) {
|
||||||
p := New("test-password", testRunner{})
|
p := New("test-password", testRunner{})
|
||||||
|
|||||||
@@ -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}
|
.theme-svg{display:none;width:18px;height:18px}
|
||||||
:root[data-theme="dark"] .svg-sun{display:block;color:#facc15}
|
:root[data-theme="dark"] .svg-sun{display:block;color:#facc15}
|
||||||
:root[data-theme="light"] .svg-moon{display:block;color:#475569}
|
: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)}
|
||||||
|
|||||||
@@ -9,24 +9,76 @@ const $=s=>document.querySelector(s);
|
|||||||
localStorage.setItem("vpc_theme",next);
|
localStorage.setItem("vpc_theme",next);
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
async function api(path,opt={}){
|
async function api(path,opt={}){
|
||||||
let r=await fetch("/panel/api"+path,opt);
|
let r=await fetch("/panel/api"+path,opt);
|
||||||
if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}
|
if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}
|
||||||
if(!r.ok)throw Error(await r.text());
|
if(!r.ok)throw Error(await r.text());
|
||||||
return r.json()
|
return r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(v){
|
function esc(v){
|
||||||
return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))
|
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(){
|
async function loadDevices(){
|
||||||
let ds=await api("/devices");
|
let ds=await api("/devices");
|
||||||
$("#devices").innerHTML=ds.map(d=>'<div class="device '+(d.status==="online"?"":"offline")+'"><b><i class="dot"></i>'+esc(d.name)+'</b><div class="status">'+esc(d.status)+' · '+(d.is_local?"local":"remote")+' · '+esc(d.arch||"")+'/'+esc(d.os||"")+'</div></div>').join("");
|
$("#devices").innerHTML=ds.map(d=>{
|
||||||
$("#device").innerHTML=ds.map(d=>'<option>'+esc(d.name)+'</option>').join("")
|
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 ? '<span class="badge-docker yes">Docker YES</span>' : '<span class="badge-docker no">Docker NO</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="device-card ${isOnline ? "online" : "offline"}">
|
||||||
|
<div class="device-head">
|
||||||
|
<div class="device-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<strong class="device-name">${esc(d.name)}</strong>
|
||||||
|
<span class="role-badge role-${role.toLowerCase()}">${esc(role)}</span>
|
||||||
|
</div>
|
||||||
|
${d.is_local ? '' : `<button class="btn-revoke" onclick="removeDevice('${esc(d.name)}')">注销</button>`}
|
||||||
|
</div>
|
||||||
|
<div class="device-meta">
|
||||||
|
<span>${esc(d.status)}</span> ·
|
||||||
|
<span>${d.is_local ? "local" : "remote"}</span> ·
|
||||||
|
<span>${esc(d.arch||"")}/${esc(d.os||"")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-grid">
|
||||||
|
<div class="p-item"><span class="p-label">CPU</span><span class="p-val">${cpuStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">Mem</span><span class="p-val">${memStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">Swap</span><span class="p-val">${swapStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">Disk</span><span class="p-val">${diskStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">IO PSI</span><span class="p-val">${psiStr}</span></div>
|
||||||
|
<div class="p-item p-docker">${dockerBadge}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
$("#device").innerHTML=ds.map(d=>'<option>'+esc(d.name)+'</option>').join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAudits(){
|
async function loadAudits(){
|
||||||
let a=await api("/audits?limit=100");
|
let a=await api("/audits?limit=100");
|
||||||
$("#audits").innerHTML=a.map(x=>'<tr><td>'+new Date(x.timestamp*1000).toLocaleString()+'</td><td>'+esc(x.device)+'</td><td>'+esc(x.action)+'</td><td><code>'+esc(x.command)+'</code></td><td>'+x.exit_code+'</td><td>'+x.duration_ms+'ms</td><td>'+esc(x.caller_ip)+'</td></tr>').join("")
|
$("#audits").innerHTML=a.map(x=>'<tr><td>'+new Date(x.timestamp*1000).toLocaleString()+'</td><td>'+esc(x.device)+'</td><td>'+esc(x.action)+'</td><td><code>'+esc(x.command)+'</code></td><td>'+x.exit_code+'</td><td>'+x.duration_ms+'ms</td><td>'+esc(x.caller_ip)+'</td></tr>').join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#refresh").onclick=loadDevices;
|
$("#refresh").onclick=loadDevices;
|
||||||
$("#auditRefresh").onclick=loadAudits;
|
$("#auditRefresh").onclick=loadAudits;
|
||||||
$("#run").onclick=async()=>{
|
$("#run").onclick=async()=>{
|
||||||
|
|||||||
@@ -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}
|
.theme-svg{display:none;width:18px;height:18px}
|
||||||
:root[data-theme="dark"] .svg-sun{display:block;color:#facc15}
|
:root[data-theme="dark"] .svg-sun{display:block;color:#facc15}
|
||||||
:root[data-theme="light"] .svg-moon{display:block;color:#475569}
|
: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)}
|
||||||
|
|||||||
+54
-2
@@ -9,24 +9,76 @@ const $=s=>document.querySelector(s);
|
|||||||
localStorage.setItem("vpc_theme",next);
|
localStorage.setItem("vpc_theme",next);
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
async function api(path,opt={}){
|
async function api(path,opt={}){
|
||||||
let r=await fetch("/panel/api"+path,opt);
|
let r=await fetch("/panel/api"+path,opt);
|
||||||
if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}
|
if(r.status===401){location.href="/panel/login";throw Error("unauthorized")}
|
||||||
if(!r.ok)throw Error(await r.text());
|
if(!r.ok)throw Error(await r.text());
|
||||||
return r.json()
|
return r.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(v){
|
function esc(v){
|
||||||
return String(v??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]))
|
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(){
|
async function loadDevices(){
|
||||||
let ds=await api("/devices");
|
let ds=await api("/devices");
|
||||||
$("#devices").innerHTML=ds.map(d=>'<div class="device '+(d.status==="online"?"":"offline")+'"><b><i class="dot"></i>'+esc(d.name)+'</b><div class="status">'+esc(d.status)+' · '+(d.is_local?"local":"remote")+' · '+esc(d.arch||"")+'/'+esc(d.os||"")+'</div></div>').join("");
|
$("#devices").innerHTML=ds.map(d=>{
|
||||||
$("#device").innerHTML=ds.map(d=>'<option>'+esc(d.name)+'</option>').join("")
|
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 ? '<span class="badge-docker yes">Docker YES</span>' : '<span class="badge-docker no">Docker NO</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="device-card ${isOnline ? "online" : "offline"}">
|
||||||
|
<div class="device-head">
|
||||||
|
<div class="device-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<strong class="device-name">${esc(d.name)}</strong>
|
||||||
|
<span class="role-badge role-${role.toLowerCase()}">${esc(role)}</span>
|
||||||
|
</div>
|
||||||
|
${d.is_local ? '' : `<button class="btn-revoke" onclick="removeDevice('${esc(d.name)}')">注销</button>`}
|
||||||
|
</div>
|
||||||
|
<div class="device-meta">
|
||||||
|
<span>${esc(d.status)}</span> ·
|
||||||
|
<span>${d.is_local ? "local" : "remote"}</span> ·
|
||||||
|
<span>${esc(d.arch||"")}/${esc(d.os||"")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-grid">
|
||||||
|
<div class="p-item"><span class="p-label">CPU</span><span class="p-val">${cpuStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">Mem</span><span class="p-val">${memStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">Swap</span><span class="p-val">${swapStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">Disk</span><span class="p-val">${diskStr}</span></div>
|
||||||
|
<div class="p-item"><span class="p-label">IO PSI</span><span class="p-val">${psiStr}</span></div>
|
||||||
|
<div class="p-item p-docker">${dockerBadge}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
$("#device").innerHTML=ds.map(d=>'<option>'+esc(d.name)+'</option>').join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAudits(){
|
async function loadAudits(){
|
||||||
let a=await api("/audits?limit=100");
|
let a=await api("/audits?limit=100");
|
||||||
$("#audits").innerHTML=a.map(x=>'<tr><td>'+new Date(x.timestamp*1000).toLocaleString()+'</td><td>'+esc(x.device)+'</td><td>'+esc(x.action)+'</td><td><code>'+esc(x.command)+'</code></td><td>'+x.exit_code+'</td><td>'+x.duration_ms+'ms</td><td>'+esc(x.caller_ip)+'</td></tr>').join("")
|
$("#audits").innerHTML=a.map(x=>'<tr><td>'+new Date(x.timestamp*1000).toLocaleString()+'</td><td>'+esc(x.device)+'</td><td>'+esc(x.action)+'</td><td><code>'+esc(x.command)+'</code></td><td>'+x.exit_code+'</td><td>'+x.duration_ms+'ms</td><td>'+esc(x.caller_ip)+'</td></tr>').join("")
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#refresh").onclick=loadDevices;
|
$("#refresh").onclick=loadDevices;
|
||||||
$("#auditRefresh").onclick=loadAudits;
|
$("#auditRefresh").onclick=loadAudits;
|
||||||
$("#run").onclick=async()=>{
|
$("#run").onclick=async()=>{
|
||||||
|
|||||||
Reference in New Issue
Block a user