From d27cc47d8c5c4d0c3ef05d9beafd39f93b6fe5cf Mon Sep 17 00:00:00 2001 From: kyaring Date: Mon, 28 Sep 2026 12:33:06 +0800 Subject: [PATCH] feat: persist device offline lifecycle --- cmd/agent/main.go | 7 +- cmd/hub/main.go | 2 +- internal/api/api.go | 2 +- internal/cluster/manager.go | 139 +++++++++++----- internal/cluster/protocol.go | 2 + internal/storage/device_lifecycle_test.go | 51 ++++++ internal/storage/storage.go | 183 +++++++++++++++++----- internal/web/static/app.css | 4 +- internal/web/static/app.js | 6 +- 9 files changed, 314 insertions(+), 82 deletions(-) create mode 100644 internal/storage/device_lifecycle_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 8c415a6..89b3b3a 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "os" + "runtime" "sync" "time" @@ -72,7 +73,7 @@ func run(hub, name, token string, insecure bool) error { // 初始握手附带画像 initProf := sysinfo.Collect("APPLICATION") - if err := writeJSON(cluster.Message{Event: "hello", Device: name, Profile: &initProf, TS: time.Now().Unix()}); err != nil { + if err := writeJSON(cluster.Message{Event: "hello", Device: name, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &initProf, TS: time.Now().Unix()}); err != nil { return err } @@ -85,7 +86,7 @@ func run(hub, name, token string, insecure bool) error { select { case <-t.C: prof := sysinfo.Collect("APPLICATION") - _ = writeJSON(cluster.Message{Event: "ping", Device: name, Profile: &prof, TS: time.Now().Unix()}) + _ = writeJSON(cluster.Message{Event: "ping", Device: name, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &prof, TS: time.Now().Unix()}) case <-stopPing: return } @@ -107,7 +108,7 @@ func run(hub, name, token string, insecure bool) error { } if msg.Event == "ping" { prof := sysinfo.Collect("APPLICATION") - if err := writeJSON(cluster.Message{Event: "pong", Device: name, Profile: &prof, TS: msg.TS}); err != nil { + if err := writeJSON(cluster.Message{Event: "pong", Device: name, Arch: runtime.GOARCH, OS: runtime.GOOS, Profile: &prof, TS: msg.TS}); err != nil { return err } continue diff --git a/cmd/hub/main.go b/cmd/hub/main.go index e799464..c210981 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: func() *cluster.Manager { m := cluster.NewManager(clusterSecret); m.SetStore(store); return m }(), + Cluster: cluster.NewManager(clusterSecret, store), Store: store, LocalName: local, } diff --git a/internal/api/api.go b/internal/api/api.go index c1eaedc..78f2f2c 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -179,7 +179,7 @@ func (s *Server) exec(w http.ResponseWriter, r *http.Request) { var res executor.Result if s.isRemote(req.Device) { if s.Cluster == nil || !s.Cluster.Online(req.Device) { - http.Error(w, "device offline", 404) + http.Error(w, "device offline", 409) return } rr, err := s.Cluster.Exec(ctx, req.Device, cluster.ExecPayload{Command: req.Command, Workdir: req.Workdir, Timeout: req.Timeout}) diff --git a/internal/cluster/manager.go b/internal/cluster/manager.go index db5a530..ff95251 100644 --- a/internal/cluster/manager.go +++ b/internal/cluster/manager.go @@ -16,6 +16,8 @@ import ( "github.com/wjyhk/vps-commander/internal/sysinfo" ) +const deviceOfflineAfter = 70 * time.Second + type Manager struct { Secret string Store *storage.Store @@ -24,12 +26,18 @@ type Manager struct { } type Node struct { - Name string - Conn *websocket.Conn - WriteMu sync.Mutex - Pending sync.Map - LastSeen atomic.Int64 - LastProfile *sysinfo.Profile + Name string + Conn *websocket.Conn + ConnectionID string + WriteMu sync.Mutex + Pending sync.Map + LastSeen atomic.Int64 + LastHeartbeat atomic.Int64 + Connected atomic.Bool + LastProfile *sysinfo.Profile + Arch string + OS string + IsLocal bool } type Result struct { @@ -47,8 +55,30 @@ type FileResult struct { EOF bool `json:"eof"` } -func NewManager(secret string) *Manager { - return &Manager{Secret: secret, nodes: make(map[string]*Node)} +func NewManager(secret string, store *storage.Store) *Manager { + m := &Manager{Secret: secret, Store: store, nodes: make(map[string]*Node)} + if store != nil { + if records, err := store.ListDevices(); err == nil { + for _, d := range records { + if d.IsLocal { + continue + } + n := &Node{Name: d.Name, Arch: d.Arch, OS: d.OS} + n.LastSeen.Store(d.LastSeen * int64(time.Second)) + n.LastHeartbeat.Store(d.LastHeartbeat * int64(time.Second)) + if d.ProfileJSON != "" { + var p sysinfo.Profile + if json.Unmarshal([]byte(d.ProfileJSON), &p) == nil { + n.LastProfile = &p + } + } + n.Connected.Store(false) + m.nodes[d.Name] = n + _ = store.UpdateDeviceStatus(d.Name, "offline", d.LastHeartbeat) + } + } + } + return m } func (m *Manager) SetStore(s *storage.Store) { @@ -83,14 +113,17 @@ func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) { if err != nil { return } - n := &Node{Name: name, Conn: c} + n := &Node{Name: name, Conn: c, ConnectionID: fmt.Sprintf("%s-%d", name, time.Now().UnixNano())} n.LastSeen.Store(time.Now().UnixNano()) + n.LastHeartbeat.Store(time.Now().UnixNano()) + n.Connected.Store(true) m.mu.Lock() - if old := m.nodes[name]; old != nil { - _ = old.Conn.Close() - } + old := m.nodes[name] m.nodes[name] = n m.mu.Unlock() + if old != nil && old.Conn != nil { + _ = old.Conn.Close() + } _ = n.write(Message{Event: "registered", Device: name, TS: time.Now().Unix()}) go m.heartbeat(n) go m.readLoop(n) @@ -107,33 +140,59 @@ func (n *Node) write(msg Message) error { func (m *Manager) readLoop(n *Node) { defer func() { - m.mu.Lock() - if m.nodes[n.Name] == n { - delete(m.nodes, n.Name) + n.Connected.Store(false) + conn := n.Conn + m.mu.RLock() + current := m.nodes[n.Name] == n + m.mu.RUnlock() + if conn != nil { + _ = conn.Close() } - m.mu.Unlock() - _ = n.Conn.Close() n.Pending.Range(func(key, v any) bool { if ch, ok := v.(chan Message); ok { select { case ch <- Message{Status: "error", Error: "device disconnected"}: default: } - n.Pending.Delete(key) } + n.Pending.Delete(key) return true }) + if current && m.Store != nil { + _ = m.Store.UpdateDeviceStatus(n.Name, "offline", n.LastHeartbeat.Load()/int64(time.Second)) + } }() - _ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + conn := n.Conn + if conn == nil { + return + } + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) for { var msg Message - if err := n.Conn.ReadJSON(&msg); err != nil { + if err := conn.ReadJSON(&msg); err != nil { return } - n.LastSeen.Store(time.Now().UnixNano()) - _ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + now := time.Now().UnixNano() + n.LastSeen.Store(now) + _ = conn.SetReadDeadline(time.Now().Add(75 * time.Second)) + if msg.Arch != "" { + n.Arch = msg.Arch + } + if msg.OS != "" { + n.OS = msg.OS + } if msg.Profile != nil { n.LastProfile = msg.Profile + n.LastHeartbeat.Store(now) + if m.Store != nil { + b, _ := json.Marshal(msg.Profile) + _ = m.Store.UpsertRemote(n.Name, "online", n.Arch, n.OS, now/int64(time.Second), string(b)) + } + } else { + n.LastHeartbeat.Store(now) + if m.Store != nil { + _ = m.Store.UpdateDeviceStatus(n.Name, "online", now/int64(time.Second)) + } } if msg.Event == "pong" || msg.Event == "ping" || msg.Event == "hello" || msg.Event == "profile" { continue @@ -153,21 +212,26 @@ func (m *Manager) readLoop(n *Node) { func (m *Manager) Online(name string) bool { m.mu.RLock() - defer m.mu.RUnlock() n := m.nodes[name] - return n != nil && time.Since(time.Unix(0, n.LastSeen.Load())) < 70*time.Second + m.mu.RUnlock() + if n == nil || !n.Connected.Load() { + return false + } + return time.Since(time.Unix(0, n.LastSeen.Load())) < deviceOfflineAfter } 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() - } + n := m.nodes[name] + delete(m.nodes, name) m.mu.Unlock() - + if n != nil { + n.Connected.Store(false) + if n.Conn != nil { + _ = n.write(Message{Event: "revoked", Error: "revoked by admin"}) + _ = n.Conn.Close() + } + } if m.Store != nil { return m.Store.RevokeDevice(name, reason) } @@ -179,16 +243,15 @@ func (m *Manager) Devices() []map[string]any { defer m.mu.RUnlock() out := make([]map[string]any, 0, len(m.nodes)) for name, n := range m.nodes { - status := "online" + status := "offline" lastSeen := time.Unix(0, n.LastSeen.Load()) - if time.Since(lastSeen) >= 70*time.Second { - status = "offline" + if n.Connected.Load() && time.Since(lastSeen) < deviceOfflineAfter { + status = "online" } item := map[string]any{ - "name": name, - "status": status, - "is_local": false, - "last_heartbeat": lastSeen.Unix(), + "name": name, "status": status, "is_local": n.IsLocal, + "arch": n.Arch, "os": n.OS, + "last_heartbeat": n.LastHeartbeat.Load() / int64(time.Second), } if n.LastProfile != nil { item["profile"] = n.LastProfile diff --git a/internal/cluster/protocol.go b/internal/cluster/protocol.go index c89a409..937e105 100644 --- a/internal/cluster/protocol.go +++ b/internal/cluster/protocol.go @@ -7,6 +7,8 @@ type Message struct { Event string `json:"event,omitempty"` Action string `json:"action,omitempty"` Device string `json:"device,omitempty"` + Arch string `json:"arch,omitempty"` + OS string `json:"os,omitempty"` Status string `json:"status,omitempty"` Payload any `json:"payload,omitempty"` Profile *sysinfo.Profile `json:"profile,omitempty"` diff --git a/internal/storage/device_lifecycle_test.go b/internal/storage/device_lifecycle_test.go new file mode 100644 index 0000000..0a8c1c9 --- /dev/null +++ b/internal/storage/device_lifecycle_test.go @@ -0,0 +1,51 @@ +package storage + +import ( + "path/filepath" + "testing" +) + +func TestDeviceLifecyclePersistence(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "commander.db")) + if err != nil { + t.Fatal(err) + } + defer s.DB.Close() + + if err := s.UpsertRemote("node1", "online", "amd64", "linux", 100, "{\"role\":\"APPLICATION\"}"); err != nil { + t.Fatal(err) + } + rows, err := s.ListDevices() + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%d err=%v", len(rows), err) + } + if rows[0].Status != "online" || rows[0].Arch != "amd64" || rows[0].OS != "linux" { + t.Fatalf("unexpected row: %+v", rows[0]) + } + + if err := s.UpdateDeviceStatus("node1", "offline", 120); err != nil { + t.Fatal(err) + } + rows, err = s.ListDevices() + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%d err=%v", len(rows), err) + } + if rows[0].Status != "offline" || rows[0].OfflineAt == nil { + t.Fatalf("offline state not persisted: %+v", rows[0]) + } + + if err := s.RevokeDevice("node1", "test"); err != nil { + t.Fatal(err) + } + rows, err = s.ListDevices() + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Fatalf("revoked device still present: %+v", rows) + } + revoked, err := s.IsRevoked("node1") + if err != nil || !revoked { + t.Fatalf("revoked=%v err=%v", revoked, err) + } +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 86d3ace..5c5bd11 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -18,8 +18,6 @@ func Open(path string) (*Store, error) { if err != nil { return nil, err } - // SQLite has a single-writer model. Keep one application connection so - // concurrent callers queue instead of opening competing writer connections. db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) s := &Store{db} @@ -31,28 +29,144 @@ func Open(path string) (*Store, error) { } func (s *Store) init() error { - if _, err := s.DB.Exec(`PRAGMA journal_mode = WAL`); err != nil { + if _, err := s.DB.Exec("PRAGMA journal_mode = WAL"); err != nil { return err } - if _, err := s.DB.Exec(`PRAGMA busy_timeout = 5000`); err != nil { + if _, err := s.DB.Exec("PRAGMA busy_timeout = 5000"); err != nil { return err } - _, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS devices ( - name TEXT PRIMARY KEY, status TEXT NOT NULL, is_local INTEGER NOT NULL, - arch TEXT, os TEXT, updated_at INTEGER NOT NULL - ); CREATE TABLE IF NOT EXISTS audit_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, - caller_ip TEXT, device TEXT, action TEXT, command TEXT, - exit_code INTEGER, duration_ms INTEGER, error_msg TEXT - );`) + _, err := s.DB.Exec("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); " + + "CREATE TABLE IF NOT EXISTS audit_logs (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL, caller_ip TEXT, device TEXT, action TEXT, command TEXT, " + + "exit_code INTEGER, duration_ms INTEGER, error_msg TEXT); " + + "CREATE TABLE IF NOT EXISTS revoked_devices (name TEXT PRIMARY KEY, revoked_at INTEGER NOT NULL, reason TEXT);") + if err != nil { + return err + } + return s.migrateDevices() +} + +func (s *Store) migrateDevices() error { + cols := map[string]bool{} + rows, err := s.DB.Query("PRAGMA table_info(devices)") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var cid, notnull, pk int + var name, typ string + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + return err + } + cols[name] = true + } + if err := rows.Err(); err != nil { + return err + } + migrations := []struct{ name, sql string }{ + {"last_seen", "ALTER TABLE devices ADD COLUMN last_seen INTEGER NOT NULL DEFAULT 0"}, + {"last_heartbeat", "ALTER TABLE devices ADD COLUMN last_heartbeat INTEGER NOT NULL DEFAULT 0"}, + {"offline_at", "ALTER TABLE devices ADD COLUMN offline_at INTEGER"}, + {"profile_json", "ALTER TABLE devices ADD COLUMN profile_json TEXT"}, + {"created_at", "ALTER TABLE devices ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0"}, + } + for _, m := range migrations { + if !cols[m.name] { + if _, err := s.DB.Exec(m.sql); err != nil { + return err + } + } + } + _, err = s.DB.Exec("UPDATE devices SET created_at=CASE WHEN created_at=0 THEN updated_at ELSE created_at END, " + + "last_seen=CASE WHEN last_seen=0 THEN updated_at ELSE last_seen END, " + + "last_heartbeat=CASE WHEN last_heartbeat=0 THEN updated_at ELSE last_heartbeat END " + + "WHERE created_at=0 OR last_seen=0 OR last_heartbeat=0") return err } func (s *Store) UpsertLocal(name, arch, osName string) error { - _, err := s.DB.Exec(`INSERT INTO devices(name,status,is_local,arch,os,updated_at) - VALUES(?,?,?,?,?,?) ON CONFLICT(name) DO UPDATE SET status=excluded.status, - arch=excluded.arch, os=excluded.os, updated_at=excluded.updated_at`, - name, "online", 1, arch, osName, time.Now().Unix()) + now := time.Now().Unix() + _, err := s.DB.Exec("INSERT INTO devices(name,status,is_local,arch,os,last_seen,last_heartbeat,offline_at,profile_json,created_at,updated_at) "+ + "VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(name) DO UPDATE SET status='online', is_local=1, arch=excluded.arch, os=excluded.os, offline_at=NULL, updated_at=excluded.updated_at", + name, "online", 1, arch, osName, now, now, nil, nil, now, now) + return err +} + +func (s *Store) UpsertRemote(name, status, arch, osName string, lastHeartbeat int64, profileJSON string) error { + now := time.Now().Unix() + if lastHeartbeat <= 0 { + lastHeartbeat = now + } + _, err := s.DB.Exec("INSERT INTO devices(name,status,is_local,arch,os,last_seen,last_heartbeat,offline_at,profile_json,created_at,updated_at) "+ + "VALUES(?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(name) DO UPDATE SET status=excluded.status, is_local=0, arch=excluded.arch, os=excluded.os, "+ + "last_seen=excluded.last_seen, last_heartbeat=excluded.last_heartbeat, offline_at=NULL, profile_json=excluded.profile_json, updated_at=excluded.updated_at", + name, status, 0, arch, osName, lastHeartbeat, lastHeartbeat, nil, profileJSON, now, now) + return err +} + +func (s *Store) UpdateRemoteProfile(name string, lastHeartbeat int64, arch, osName, profileJSON string) error { + now := time.Now().Unix() + if lastHeartbeat <= 0 { + lastHeartbeat = now + } + _, err := s.DB.Exec("UPDATE devices SET status='online', arch=?, os=?, last_seen=?, last_heartbeat=?, offline_at=NULL, profile_json=?, updated_at=? WHERE name=? AND is_local=0", + arch, osName, lastHeartbeat, lastHeartbeat, profileJSON, now, name) + return err +} + +func (s *Store) UpdateDeviceStatus(name, status string, lastHeartbeat int64) error { + now := time.Now().Unix() + if lastHeartbeat <= 0 { + lastHeartbeat = now + } + var offlineAt any + if status == "offline" { + offlineAt = now + } + _, err := s.DB.Exec("UPDATE devices SET status=?, last_seen=?, last_heartbeat=?, offline_at=?, updated_at=? WHERE name=? AND is_local=0", + status, lastHeartbeat, lastHeartbeat, offlineAt, now, name) + return err +} + +type DeviceRecord struct { + Name, Status, Arch, OS, ProfileJSON string + IsLocal bool + LastSeen, LastHeartbeat int64 + OfflineAt *int64 + UpdatedAt int64 +} + +func (s *Store) ListDevices() ([]DeviceRecord, error) { + rows, err := s.DB.Query("SELECT name,status,is_local,COALESCE(arch,''),COALESCE(os,''),last_seen,last_heartbeat,offline_at,COALESCE(profile_json,''),updated_at FROM devices ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + var out []DeviceRecord + for rows.Next() { + var d DeviceRecord + var local int + var offline sql.NullInt64 + if err := rows.Scan(&d.Name, &d.Status, &local, &d.Arch, &d.OS, &d.LastSeen, &d.LastHeartbeat, &offline, &d.ProfileJSON, &d.UpdatedAt); err != nil { + return nil, err + } + d.IsLocal = local != 0 + if offline.Valid { + v := offline.Int64 + d.OfflineAt = &v + } + out = append(out, d) + } + return out, rows.Err() +} + +func (s *Store) DeleteDevice(name string) error { + _, err := s.DB.Exec("DELETE FROM devices WHERE name=? AND is_local=0", name) return err } @@ -69,8 +183,8 @@ type AuditLog struct { } func (s *Store) Audit(ip, device, action, command string, code int, ms int64, msg string) error { - _, err := s.DB.Exec(`INSERT INTO audit_logs(timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg) - VALUES(?,?,?,?,?,?,?,?)`, time.Now().Unix(), ip, device, action, command, code, ms, msg) + _, err := s.DB.Exec("INSERT INTO audit_logs(timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg) VALUES(?,?,?,?,?,?,?,?)", + time.Now().Unix(), ip, device, action, command, code, ms, msg) return err } @@ -81,8 +195,7 @@ func (s *Store) RecentAudits(limit int) ([]AuditLog, error) { if limit > 500 { limit = 500 } - rows, err := s.DB.Query(`SELECT id,timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg - FROM audit_logs ORDER BY id DESC LIMIT ?`, limit) + rows, err := s.DB.Query("SELECT id,timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg FROM audit_logs ORDER BY id DESC LIMIT ?", limit) if err != nil { return nil, err } @@ -98,29 +211,29 @@ 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 + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.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); err != nil { + return err + } + if _, err := tx.Exec("DELETE FROM devices WHERE name=? AND is_local=0", name); err != nil { + return err + } + return tx.Commit() } func (s *Store) UnrevokeDevice(name string) error { - _, err := s.DB.Exec(`DELETE FROM revoked_devices WHERE name = ?`, name) + _, 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) + err := s.DB.QueryRow("SELECT COUNT(*) FROM revoked_devices WHERE name = ?", name).Scan(&count) return count > 0, err } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 22887ba..2ca1636 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -37,4 +37,6 @@ header strong{font-size:18px}header span{margin-left:12px;color:var(--text-muted .p-val{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-weight:600;font-size:12px;color:var(--text-main);white-space:nowrap} .badge-docker-text{padding:1px 6px;border-radius:4px;font-size:11px;font-weight:700} .badge-docker-text.yes{background:rgba(34,197,94,.15);color:var(--dot-online);border:1px solid rgba(34,197,94,.3)} -.badge-docker-text.no{background:rgba(148,163,184,.15);color:var(--text-muted);border:1px solid var(--border)} \ No newline at end of file +.badge-docker-text.no{background:rgba(148,163,184,.15);color:var(--text-muted);border:1px solid var(--border)} +.device-card.offline{opacity:.62;filter:saturate(.45)} +.device-card.offline .btn-revoke{opacity:1} diff --git a/internal/web/static/app.js b/internal/web/static/app.js index e179161..5603271 100644 --- a/internal/web/static/app.js +++ b/internal/web/static/app.js @@ -38,7 +38,7 @@ async function loadDevices(){ const p = d.profile || {}; const role = p.role || "APPLICATION"; const isOnline = d.status === "online"; - const statusText = isOnline ? "在线" : "离线"; + const statusText = isOnline ? "在线" : "离线";\n 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) : '-'; @@ -58,7 +58,7 @@ async function loadDevices(){ -
${archOs}${statusText}
+
${archOs}${statusText}${isOnline ? "" : (lastText ? " · 最后活跃: "+lastText : "")}
CPU${cpuStr}
@@ -72,7 +72,7 @@ async function loadDevices(){ `; }).join(""); - $("#device").innerHTML=ds.map(d=>'').join(""); + $("#device").innerHTML=ds.map(d=>``).join(""); } async function loadAudits(){