feat: persist device offline lifecycle
This commit is contained in:
+4
-3
@@ -10,6 +10,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ func run(hub, name, token string, insecure bool) error {
|
|||||||
|
|
||||||
// 初始握手附带画像
|
// 初始握手附带画像
|
||||||
initProf := sysinfo.Collect("APPLICATION")
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ func run(hub, name, token string, insecure bool) error {
|
|||||||
select {
|
select {
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
prof := sysinfo.Collect("APPLICATION")
|
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:
|
case <-stopPing:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -107,7 +108,7 @@ func run(hub, name, token string, insecure bool) error {
|
|||||||
}
|
}
|
||||||
if msg.Event == "ping" {
|
if msg.Event == "ping" {
|
||||||
prof := sysinfo.Collect("APPLICATION")
|
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
|
return err
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|||||||
+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: func() *cluster.Manager { m := cluster.NewManager(clusterSecret); m.SetStore(store); return m }(),
|
Cluster: cluster.NewManager(clusterSecret, store),
|
||||||
Store: store,
|
Store: store,
|
||||||
LocalName: local,
|
LocalName: local,
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -179,7 +179,7 @@ func (s *Server) exec(w http.ResponseWriter, r *http.Request) {
|
|||||||
var res executor.Result
|
var res executor.Result
|
||||||
if s.isRemote(req.Device) {
|
if s.isRemote(req.Device) {
|
||||||
if s.Cluster == nil || !s.Cluster.Online(req.Device) {
|
if s.Cluster == nil || !s.Cluster.Online(req.Device) {
|
||||||
http.Error(w, "device offline", 404)
|
http.Error(w, "device offline", 409)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rr, err := s.Cluster.Exec(ctx, req.Device, cluster.ExecPayload{Command: req.Command, Workdir: req.Workdir, Timeout: req.Timeout})
|
rr, err := s.Cluster.Exec(ctx, req.Device, cluster.ExecPayload{Command: req.Command, Workdir: req.Workdir, Timeout: req.Timeout})
|
||||||
|
|||||||
+101
-38
@@ -16,6 +16,8 @@ import (
|
|||||||
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
"github.com/wjyhk/vps-commander/internal/sysinfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const deviceOfflineAfter = 70 * time.Second
|
||||||
|
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
Secret string
|
Secret string
|
||||||
Store *storage.Store
|
Store *storage.Store
|
||||||
@@ -24,12 +26,18 @@ type Manager struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Name string
|
Name string
|
||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
WriteMu sync.Mutex
|
ConnectionID string
|
||||||
Pending sync.Map
|
WriteMu sync.Mutex
|
||||||
LastSeen atomic.Int64
|
Pending sync.Map
|
||||||
LastProfile *sysinfo.Profile
|
LastSeen atomic.Int64
|
||||||
|
LastHeartbeat atomic.Int64
|
||||||
|
Connected atomic.Bool
|
||||||
|
LastProfile *sysinfo.Profile
|
||||||
|
Arch string
|
||||||
|
OS string
|
||||||
|
IsLocal bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@@ -47,8 +55,30 @@ type FileResult struct {
|
|||||||
EOF bool `json:"eof"`
|
EOF bool `json:"eof"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(secret string) *Manager {
|
func NewManager(secret string, store *storage.Store) *Manager {
|
||||||
return &Manager{Secret: secret, nodes: make(map[string]*Node)}
|
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) {
|
func (m *Manager) SetStore(s *storage.Store) {
|
||||||
@@ -83,14 +113,17 @@ func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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.LastSeen.Store(time.Now().UnixNano())
|
||||||
|
n.LastHeartbeat.Store(time.Now().UnixNano())
|
||||||
|
n.Connected.Store(true)
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if old := m.nodes[name]; old != nil {
|
old := m.nodes[name]
|
||||||
_ = old.Conn.Close()
|
|
||||||
}
|
|
||||||
m.nodes[name] = n
|
m.nodes[name] = n
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
if old != nil && old.Conn != nil {
|
||||||
|
_ = old.Conn.Close()
|
||||||
|
}
|
||||||
_ = n.write(Message{Event: "registered", Device: name, TS: time.Now().Unix()})
|
_ = n.write(Message{Event: "registered", Device: name, TS: time.Now().Unix()})
|
||||||
go m.heartbeat(n)
|
go m.heartbeat(n)
|
||||||
go m.readLoop(n)
|
go m.readLoop(n)
|
||||||
@@ -107,33 +140,59 @@ func (n *Node) write(msg Message) error {
|
|||||||
|
|
||||||
func (m *Manager) readLoop(n *Node) {
|
func (m *Manager) readLoop(n *Node) {
|
||||||
defer func() {
|
defer func() {
|
||||||
m.mu.Lock()
|
n.Connected.Store(false)
|
||||||
if m.nodes[n.Name] == n {
|
conn := n.Conn
|
||||||
delete(m.nodes, n.Name)
|
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 {
|
n.Pending.Range(func(key, v any) bool {
|
||||||
if ch, ok := v.(chan Message); ok {
|
if ch, ok := v.(chan Message); ok {
|
||||||
select {
|
select {
|
||||||
case ch <- Message{Status: "error", Error: "device disconnected"}:
|
case ch <- Message{Status: "error", Error: "device disconnected"}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
n.Pending.Delete(key)
|
|
||||||
}
|
}
|
||||||
|
n.Pending.Delete(key)
|
||||||
return true
|
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 {
|
for {
|
||||||
var msg Message
|
var msg Message
|
||||||
if err := n.Conn.ReadJSON(&msg); err != nil {
|
if err := conn.ReadJSON(&msg); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
n.LastSeen.Store(time.Now().UnixNano())
|
now := time.Now().UnixNano()
|
||||||
_ = n.Conn.SetReadDeadline(time.Now().Add(75 * time.Second))
|
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 {
|
if msg.Profile != nil {
|
||||||
n.LastProfile = msg.Profile
|
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" {
|
if msg.Event == "pong" || msg.Event == "ping" || msg.Event == "hello" || msg.Event == "profile" {
|
||||||
continue
|
continue
|
||||||
@@ -153,21 +212,26 @@ 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()
|
|
||||||
n := m.nodes[name]
|
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 {
|
func (m *Manager) RevokeDevice(name, reason string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
n, exists := m.nodes[name]
|
n := m.nodes[name]
|
||||||
if exists {
|
delete(m.nodes, name)
|
||||||
delete(m.nodes, name)
|
|
||||||
_ = n.write(Message{Event: "revoked", Error: "revoked by admin"})
|
|
||||||
_ = n.Conn.Close()
|
|
||||||
}
|
|
||||||
m.mu.Unlock()
|
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 {
|
if m.Store != nil {
|
||||||
return m.Store.RevokeDevice(name, reason)
|
return m.Store.RevokeDevice(name, reason)
|
||||||
}
|
}
|
||||||
@@ -179,16 +243,15 @@ func (m *Manager) Devices() []map[string]any {
|
|||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
out := make([]map[string]any, 0, len(m.nodes))
|
out := make([]map[string]any, 0, len(m.nodes))
|
||||||
for name, n := range m.nodes {
|
for name, n := range m.nodes {
|
||||||
status := "online"
|
status := "offline"
|
||||||
lastSeen := time.Unix(0, n.LastSeen.Load())
|
lastSeen := time.Unix(0, n.LastSeen.Load())
|
||||||
if time.Since(lastSeen) >= 70*time.Second {
|
if n.Connected.Load() && time.Since(lastSeen) < deviceOfflineAfter {
|
||||||
status = "offline"
|
status = "online"
|
||||||
}
|
}
|
||||||
item := map[string]any{
|
item := map[string]any{
|
||||||
"name": name,
|
"name": name, "status": status, "is_local": n.IsLocal,
|
||||||
"status": status,
|
"arch": n.Arch, "os": n.OS,
|
||||||
"is_local": false,
|
"last_heartbeat": n.LastHeartbeat.Load() / int64(time.Second),
|
||||||
"last_heartbeat": lastSeen.Unix(),
|
|
||||||
}
|
}
|
||||||
if n.LastProfile != nil {
|
if n.LastProfile != nil {
|
||||||
item["profile"] = n.LastProfile
|
item["profile"] = n.LastProfile
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ type Message struct {
|
|||||||
Event string `json:"event,omitempty"`
|
Event string `json:"event,omitempty"`
|
||||||
Action string `json:"action,omitempty"`
|
Action string `json:"action,omitempty"`
|
||||||
Device string `json:"device,omitempty"`
|
Device string `json:"device,omitempty"`
|
||||||
|
Arch string `json:"arch,omitempty"`
|
||||||
|
OS string `json:"os,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"`
|
Profile *sysinfo.Profile `json:"profile,omitempty"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+148
-35
@@ -18,8 +18,6 @@ func Open(path string) (*Store, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.SetMaxOpenConns(1)
|
||||||
db.SetMaxIdleConns(1)
|
db.SetMaxIdleConns(1)
|
||||||
s := &Store{db}
|
s := &Store{db}
|
||||||
@@ -31,28 +29,144 @@ func Open(path string) (*Store, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) init() 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
|
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
|
return err
|
||||||
}
|
}
|
||||||
_, err := s.DB.Exec(`CREATE TABLE IF NOT EXISTS devices (
|
_, err := s.DB.Exec("CREATE TABLE IF NOT EXISTS devices (" +
|
||||||
name TEXT PRIMARY KEY, status TEXT NOT NULL, is_local INTEGER NOT NULL,
|
"name TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'offline', is_local INTEGER NOT NULL DEFAULT 0, " +
|
||||||
arch TEXT, os TEXT, updated_at INTEGER NOT NULL
|
"arch TEXT, os TEXT, last_seen INTEGER NOT NULL DEFAULT 0, last_heartbeat INTEGER NOT NULL DEFAULT 0, " +
|
||||||
); CREATE TABLE IF NOT EXISTS audit_logs (
|
"offline_at INTEGER, profile_json TEXT, created_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL DEFAULT 0); " +
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER NOT NULL,
|
"CREATE TABLE IF NOT EXISTS audit_logs (" +
|
||||||
caller_ip TEXT, device TEXT, action TEXT, command TEXT,
|
"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
|
"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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpsertLocal(name, arch, osName string) error {
|
func (s *Store) UpsertLocal(name, arch, osName string) error {
|
||||||
_, err := s.DB.Exec(`INSERT INTO devices(name,status,is_local,arch,os,updated_at)
|
now := time.Now().Unix()
|
||||||
VALUES(?,?,?,?,?,?) ON CONFLICT(name) DO UPDATE SET status=excluded.status,
|
_, 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) "+
|
||||||
arch=excluded.arch, os=excluded.os, updated_at=excluded.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, time.Now().Unix())
|
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
|
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 {
|
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)
|
_, err := s.DB.Exec("INSERT INTO audit_logs(timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg) VALUES(?,?,?,?,?,?,?,?)",
|
||||||
VALUES(?,?,?,?,?,?,?,?)`, time.Now().Unix(), ip, device, action, command, code, ms, msg)
|
time.Now().Unix(), ip, device, action, command, code, ms, msg)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +195,7 @@ func (s *Store) RecentAudits(limit int) ([]AuditLog, error) {
|
|||||||
if limit > 500 {
|
if limit > 500 {
|
||||||
limit = 500
|
limit = 500
|
||||||
}
|
}
|
||||||
rows, err := s.DB.Query(`SELECT id,timestamp,caller_ip,device,action,command,exit_code,duration_ms,error_msg
|
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)
|
||||||
FROM audit_logs ORDER BY id DESC LIMIT ?`, limit)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -98,29 +211,29 @@ 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 {
|
func (s *Store) RevokeDevice(name, reason string) error {
|
||||||
_, err := s.DB.Exec(`INSERT INTO revoked_devices(name, revoked_at, reason)
|
tx, err := s.DB.Begin()
|
||||||
VALUES(?, ?, ?) ON CONFLICT(name) DO UPDATE SET revoked_at=excluded.revoked_at, reason=excluded.reason`,
|
if err != nil {
|
||||||
name, time.Now().Unix(), reason)
|
return err
|
||||||
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 {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) IsRevoked(name string) (bool, error) {
|
func (s *Store) IsRevoked(name string) (bool, error) {
|
||||||
var count int
|
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
|
return count > 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
.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{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.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)}
|
.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}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ async function loadDevices(){
|
|||||||
const p = d.profile || {};
|
const p = d.profile || {};
|
||||||
const role = p.role || "APPLICATION";
|
const role = p.role || "APPLICATION";
|
||||||
const isOnline = d.status === "online";
|
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 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 cpuStr = p.cpu_cores ? `${p.cpu_cores}C · ${p.cpu_usage_percent ?? 0}%` : '-';
|
||||||
const loadStr = p.load_1m !== undefined ? p.load_1m.toFixed(2) : '-';
|
const loadStr = p.load_1m !== undefined ? p.load_1m.toFixed(2) : '-';
|
||||||
@@ -58,7 +58,7 @@ async function loadDevices(){
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn-revoke" onclick="removeDevice('${esc(d.name)}')">注销</button>
|
<button class="btn-revoke" onclick="removeDevice('${esc(d.name)}')">注销</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="v1-row2">${archOs}${statusText}</div>
|
<div class="v1-row2">${archOs}${statusText}${isOnline ? "" : (lastText ? " · 最后活跃: "+lastText : "")}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-grid">
|
<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">CPU</span><span class="p-val">${cpuStr}</span></div>
|
||||||
@@ -72,7 +72,7 @@ async function loadDevices(){
|
|||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
$("#device").innerHTML=ds.map(d=>'<option>'+esc(d.name)+'</option>').join("");
|
$("#device").innerHTML=ds.map(d=>`<option value="${esc(d.name)}" ${d.status==="offline" ? "disabled" : ""}>${esc(d.name)}${d.status==="offline" ? " [离线]" : ""}</option>`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAudits(){
|
async function loadAudits(){
|
||||||
|
|||||||
Reference in New Issue
Block a user