diff --git a/deploy/lan-manager-debian12.tar.gz b/deploy/lan-manager-debian12.tar.gz index d2f6d36..adbcdfb 100644 Binary files a/deploy/lan-manager-debian12.tar.gz and b/deploy/lan-manager-debian12.tar.gz differ diff --git a/server/db/db.go b/server/db/db.go index 2fd0b4a..5c42d5a 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -93,6 +93,20 @@ func migrate() error { `CREATE INDEX IF NOT EXISTS idx_rel_src ON relationships(source_machine_id)`, `CREATE INDEX IF NOT EXISTS idx_rel_tgt ON relationships(target_machine_id)`, `CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at)`, + `ALTER TABLE machines ADD COLUMN offline_count INTEGER DEFAULT 0`, + `ALTER TABLE machines ADD COLUMN total_offline_seconds INTEGER DEFAULT 0`, + `ALTER TABLE machines ADD COLUMN last_offline_at DATETIME`, + `ALTER TABLE machines ADD COLUMN last_offline_reason TEXT`, + `CREATE TABLE IF NOT EXISTS offline_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE, + reason TEXT, + started_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ended_at DATETIME, + duration_seconds INTEGER + )`, + `CREATE INDEX IF NOT EXISTS idx_offline_logs_machine ON offline_logs(machine_id)`, + `CREATE INDEX IF NOT EXISTS idx_offline_logs_started ON offline_logs(started_at)`, } for _, s := range stmts { if _, err := DB.Exec(s); err != nil { diff --git a/server/handlers/machines.go b/server/handlers/machines.go index 3eeec8d..79321ce 100644 --- a/server/handlers/machines.go +++ b/server/handlers/machines.go @@ -38,7 +38,7 @@ func (h *MachineHandler) List(c *gin.Context) { osFilter := c.Query("os_type") search := c.Query("search") - query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE 1=1` + query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE 1=1` args := []interface{}{} if osFilter != "" { query += ` AND os_type = ?` @@ -66,12 +66,14 @@ func (h *MachineHandler) List(c *gin.Context) { for rows.Next() { var m models.Machine var lp, ss *time.Time - err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt) + var lo *time.Time + err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt) if err != nil { continue } m.LastPingAt = lp m.SSHSyncedAt = ss + m.LastOfflineAt = lo m.SSHPassword = models.NullString{} // never return password if !isAdmin { sanitizeMachine(&m) @@ -113,8 +115,10 @@ func (h *MachineHandler) Get(c *gin.Context) { var m models.Machine var lp, ss *time.Time - err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id). - Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt) + var lo *time.Time + err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE id = ?`, id). + Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt) + m.LastOfflineAt = lo if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return @@ -165,6 +169,32 @@ func (h *MachineHandler) Get(c *gin.Context) { }) } +func (h *MachineHandler) OfflineLogs(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + rows, err := db.DB.Query(`SELECT id, machine_id, reason, started_at, ended_at, duration_seconds FROM offline_logs WHERE machine_id = ? ORDER BY started_at DESC LIMIT 50`, id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + defer rows.Close() + logs := []models.OfflineLog{} + for rows.Next() { + var l models.OfflineLog + var endedAt *time.Time + var dur *int + if err := rows.Scan(&l.ID, &l.MachineID, &l.Reason, &l.StartedAt, &endedAt, &dur); err == nil { + l.EndedAt = endedAt + l.DurationSeconds = dur + logs = append(logs, l) + } + } + c.JSON(http.StatusOK, logs) +} + func (h *MachineHandler) Create(c *gin.Context) { var m models.Machine if err := c.ShouldBindJSON(&m); err != nil { diff --git a/server/main.go b/server/main.go index ffbd60a..19f06f0 100644 --- a/server/main.go +++ b/server/main.go @@ -77,6 +77,7 @@ func main() { admin.DELETE("/machines/:id", mh.Delete) admin.POST("/machines/:id/ssh-info", mh.SSHInfo) admin.POST("/machines/:id/sync-ssh", mh.SyncSSH) + admin.GET("/machines/:id/offline-logs", mh.OfflineLogs) sh := handlers.NewServiceHandler() admin.GET("/machines/:id/services", sh.ListByMachine) diff --git a/server/models/models.go b/server/models/models.go index 38d9fc9..0a3c35d 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -53,7 +53,20 @@ type Machine struct { SSHSyncedAt *time.Time `json:"ssh_synced_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - ServiceCount int `json:"service_count"` + ServiceCount int `json:"service_count"` + OfflineCount int `json:"offline_count"` + TotalOfflineSeconds int `json:"total_offline_seconds"` + LastOfflineAt *time.Time `json:"last_offline_at"` + LastOfflineReason NullString `json:"last_offline_reason"` +} + +type OfflineLog struct { + ID int64 `json:"id"` + MachineID int64 `json:"machine_id"` + Reason string `json:"reason"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at"` + DurationSeconds *int `json:"duration_seconds"` } type Service struct { diff --git a/server/services/ping.go b/server/services/ping.go index 0281921..d768eb0 100644 --- a/server/services/ping.go +++ b/server/services/ping.go @@ -6,39 +6,74 @@ import ( "fmt" "lan-manager/server/db" "lan-manager/server/utils" + "net" + "os" "os/exec" "runtime" "strings" "time" - "net" - "golang.org/x/net/icmp" "golang.org/x/net/ipv4" ) -func PingHost(ip string) bool { - // Try system ping first (no special privileges needed on Linux) +// PingResult holds connectivity status and failure reason +type PingResult struct { + Online bool + Reason string // empty if online, otherwise human-readable failure reason +} + +func PingHost(ip string, port int) PingResult { if ok := pingWithSystem(ip); ok { - return true + return PingResult{Online: true} } - // Fallback to raw ICMP - return pingWithICMP(ip) + if ok := pingWithICMP(ip); ok { + return PingResult{Online: true} + } + if ok := pingWithTCP(ip, port); ok { + return PingResult{Online: true} + } + return PingResult{Online: false, Reason: "unreachable (ping/icmp/tcp all failed)"} +} + +// PingHostRepeated pings the host up to retries times. +// If any attempt succeeds, it returns online immediately. +// Only if all retries fail does it return offline. +func PingHostRepeated(ip string, port int, retries int) PingResult { + if retries <= 0 { + retries = 1 + } + var lastReason string + for i := 0; i < retries; i++ { + if i > 0 { + time.Sleep(2 * time.Second) + } + res := PingHost(ip, port) + if res.Online { + return res + } + lastReason = res.Reason + } + return PingResult{Online: false, Reason: fmt.Sprintf("unreachable after %d retries (%s)", retries, lastReason)} } func pingWithSystem(ip string) bool { var cmd *exec.Cmd if runtime.GOOS == "windows" { - cmd = exec.Command("ping", "-n", "1", "-w", "3000", ip) + cmd = exec.Command("ping", "-n", "1", "-w", "5000", ip) } else if runtime.GOOS == "darwin" { - // macOS ping -W is in milliseconds - cmd = exec.Command("ping", "-c", "1", "-W", "3000", ip) + cmd = exec.Command("ping", "-c", "1", "-W", "5000", ip) } else { - // Linux ping -W is in seconds - cmd = exec.Command("ping", "-c", "1", "-W", "3", ip) + cmd = exec.Command("ping", "-c", "1", "-W", "5", ip) } - err := cmd.Run() - return err == nil + out, err := cmd.CombinedOutput() + if err != nil { + if _, ok := err.(*exec.ExitError); !ok { + fmt.Printf("[Ping] system ping error for %s: %v, output: %s\n", ip, err, strings.TrimSpace(string(out))) + } + return false + } + return true } func pingWithICMP(ip string) bool { @@ -48,10 +83,11 @@ func pingWithICMP(ip string) bool { } defer c.Close() + id := os.Getpid() & 0xffff m := &icmp.Message{ Type: ipv4.ICMPTypeEcho, Code: 0, - Body: &icmp.Echo{ID: 1, Seq: 1, Data: []byte("lan-manager")}, + Body: &icmp.Echo{ID: id, Seq: 1, Data: []byte("lan-manager")}, } wb, err := m.Marshal(nil) if err != nil { @@ -62,7 +98,7 @@ func pingWithICMP(ip string) bool { } rb := make([]byte, 1500) - _ = c.SetReadDeadline(time.Now().Add(3 * time.Second)) + _ = c.SetReadDeadline(time.Now().Add(5 * time.Second)) n, _, err := c.ReadFrom(rb) if err != nil { return false @@ -74,6 +110,19 @@ func pingWithICMP(ip string) bool { return rm.Type == ipv4.ICMPTypeEchoReply } +func pingWithTCP(ip string, port int) bool { + if port <= 0 { + port = 22 + } + addr := fmt.Sprintf("%s:%d", ip, port) + conn, err := net.DialTimeout("tcp", addr, 3*time.Second) + if err != nil { + return false + } + _ = conn.Close() + return true +} + func parseIP(ip string) []byte { parts := strings.Split(ip, ".") if len(parts) != 4 { @@ -88,77 +137,127 @@ func parseIP(ip string) []byte { return res } +type machinePing struct { + id int64 + ip string + sshPort int + sshUsername string + sshPassword string + wasOnline bool +} + +func handlePingResult(m *machinePing, res PingResult) { + online := res.Online + reason := res.Reason + var onlineInt int + if online { + onlineInt = 1 + } + + // State transition: online -> offline + if m.wasOnline && !online { + _, dbErr := db.DB.Exec(`UPDATE machines SET is_online=0, last_ping_at=CURRENT_TIMESTAMP, offline_count=offline_count+1, last_offline_at=CURRENT_TIMESTAMP, last_offline_reason=? WHERE id=?`, + reason, m.id) + if dbErr != nil { + fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr) + } else { + fmt.Printf("[Ping] %s -> online=false, reason=%s\n", m.ip, reason) + } + _, _ = db.DB.Exec(`INSERT INTO offline_logs (machine_id, reason, started_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, m.id, reason) + m.wasOnline = false + return + } + + // State transition: offline -> online + if !m.wasOnline && online { + _, dbErr := db.DB.Exec(`UPDATE machines SET is_online=1, last_ping_at=CURRENT_TIMESTAMP, last_offline_reason='' WHERE id=?`, m.id) + if dbErr != nil { + fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr) + } else { + fmt.Printf("[Ping] %s -> online=true\n", m.ip) + } + var logID int64 + var startedAt time.Time + row := db.DB.QueryRow(`SELECT id, started_at FROM offline_logs WHERE machine_id=? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1`, m.id) + if err := row.Scan(&logID, &startedAt); err == nil { + duration := int(time.Since(startedAt).Seconds()) + _, _ = db.DB.Exec(`UPDATE offline_logs SET ended_at=CURRENT_TIMESTAMP, duration_seconds=? WHERE id=?`, duration, logID) + _, _ = db.DB.Exec(`UPDATE machines SET total_offline_seconds=total_offline_seconds+? WHERE id=?`, duration, m.id) + } + m.wasOnline = true + return + } + + // No state change + _, dbErr := db.DB.Exec(`UPDATE machines SET is_online=?, last_ping_at=CURRENT_TIMESTAMP WHERE id=?`, onlineInt, m.id) + if dbErr != nil { + fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr) + } else { + fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online) + } + + if online && m.sshUsername != "" && m.sshPassword != "" { + go func(mid int64, mip string, mport int, muser, mpass string) { + plainPass, decryptErr := utils.Decrypt(mpass) + if decryptErr != nil { + fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr) + return + } + result, err := GetSSHInfo(mip, mport, muser, plainPass) + if err != nil { + fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err) + return + } + portsStr := "" + if len(result.ListenPorts) > 0 { + b, _ := json.Marshal(result.ListenPorts) + portsStr = string(b) + if len(portsStr) > 2 { + portsStr = portsStr[1 : len(portsStr)-1] + } + } + _, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`, + result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid) + if dbErr != nil { + fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr) + } else { + fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport) + } + }(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword) + } +} + func StartPingService(interval int) { - ticker := time.NewTicker(time.Duration(interval) * time.Second) + const step = 30 * time.Second + go func() { - for range ticker.C { - rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password FROM machines`) + time.Sleep(2 * time.Second) + + for { + rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password, is_online FROM machines ORDER BY id ASC`) if err != nil { fmt.Printf("[Ping] query error: %v\n", err) + time.Sleep(step) continue } - type machinePing struct { - id int64 - ip string - sshPort int - sshUsername string - sshPassword string - } + list := []machinePing{} for rows.Next() { var m machinePing - if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword); err == nil { + var isOnline int + if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword, &isOnline); err == nil { + m.wasOnline = isOnline == 1 list = append(list, m) - } else { - fmt.Printf("[Ping] scan error: %v\n", err) } } rows.Close() - fmt.Printf("[Ping] tick processed %d machines\n", len(list)) - for _, m := range list { - online := PingHost(m.ip) - var onlineInt int - if online { - onlineInt = 1 - } - _, dbErr := db.DB.Exec(`UPDATE machines SET is_online = ?, last_ping_at = CURRENT_TIMESTAMP WHERE id = ?`, onlineInt, m.id) - if dbErr != nil { - fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr) - } else { - fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online) - } + fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step) - // Auto fetch SSH info if credentials are saved - if m.sshUsername != "" && m.sshPassword != "" { - go func(mid int64, mip string, mport int, muser, mpass string) { - plainPass, decryptErr := utils.Decrypt(mpass) - if decryptErr != nil { - fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr) - return - } - result, err := GetSSHInfo(mip, mport, muser, plainPass) - if err != nil { - fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err) - return - } - portsStr := "" - if len(result.ListenPorts) > 0 { - b, _ := json.Marshal(result.ListenPorts) - portsStr = string(b) - if len(portsStr) > 2 { - portsStr = portsStr[1 : len(portsStr)-1] - } - } - _, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`, - result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid) - if dbErr != nil { - fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr) - } else { - fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport) - } - }(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword) - } + for i := range list { + res := PingHostRepeated(list[i].ip, list[i].sshPort, 3) + handlePingResult(&list[i], res) + time.Sleep(step) } } }() @@ -175,7 +274,8 @@ func CleanupLogs(ctx context.Context, retentionDays int) { case <-ctx.Done(): return case <-ticker.C: - _, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-`+fmt.Sprintf("%d", retentionDays)+` days')`) + _, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`) + _, _ = db.DB.Exec(`DELETE FROM offline_logs WHERE started_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`) } } }() diff --git a/web/src/App.vue b/web/src/App.vue index c6be7af..cdf41fb 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -3,24 +3,64 @@ diff --git a/web/src/views/MachineList.vue b/web/src/views/MachineList.vue index a84ae73..2e1f574 100644 --- a/web/src/views/MachineList.vue +++ b/web/src/views/MachineList.vue @@ -20,11 +20,12 @@
-
+
- + {{ m.hostname }} + {{ m.is_online ? '在线' : '离线' }} 服务 {{ m.service_count }}
@@ -343,25 +344,15 @@ function formatTime(t) { padding: 18px; box-shadow: var(--shadow); border: 1px solid var(--border); - border-left-width: 4px; + border-top-width: 3px; + border-top-color: var(--border-strong); cursor: pointer; - transition: transform .12s ease, box-shadow .2s ease, opacity .2s ease; -} -.server-card.os-linux { border-left-color: #3b82f6; background: linear-gradient(90deg, rgba(59,130,246,0.04) 0%, var(--surface) 20px); } -.server-card.os-windows { border-left-color: #22c55e; background: linear-gradient(90deg, rgba(34,197,94,0.04) 0%, var(--surface) 20px); } -.server-card.os-macos { border-left-color: #a855f7; background: linear-gradient(90deg, rgba(168,85,247,0.04) 0%, var(--surface) 20px); } -.server-card.os-other { border-left-color: #6b7280; background: linear-gradient(90deg, rgba(107,114,128,0.04) 0%, var(--surface) 20px); } - -.server-card.offline-card { - opacity: 0.72; - filter: grayscale(0.45); -} -.server-card.offline-card .hostname { - color: var(--text-secondary); + transition: transform .12s ease, box-shadow .2s ease, opacity .2s ease, border-color .2s ease; } .server-card:hover { transform: translateY(-2px); - box-shadow: 0 8px 30px rgba(0,0,0,0.06); + box-shadow: var(--shadow-lg); + border-color: var(--border-strong); } .server-card.guest-card { cursor: default; @@ -369,6 +360,15 @@ function formatTime(t) { .server-card.guest-card:hover { transform: none; box-shadow: var(--shadow); + border-color: var(--border); +} + +.server-card.offline-card { + opacity: 0.65; + border-top-color: var(--border); +} +.server-card.offline-card .hostname { + color: var(--text-muted); } .card-header { @@ -381,11 +381,42 @@ function formatTime(t) { align-items: center; gap: 10px; } -.status-dot { - width: 8px; height: 8px; border-radius: 50%; +.os-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); } -.status-dot.online { background: var(--success); box-shadow: 0 0 0 3px rgba(34,197,94,.18); } -.status-dot.offline { background: var(--danger); box-shadow: 0 0 0 3px rgba(239,68,68,.18); } +.os-dot.os-linux { background: #3b82f6; } +.os-dot.os-windows { background: #22c55e; } +.os-dot.os-macos { background: #a855f7; } +.os-dot.os-other { background: #64748b; } + +.status-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; + line-height: 1; +} +.status-badge.online { + background: rgba(34,197,94,0.12); + color: #15803d; +} +.status-badge.offline { + background: rgba(239,68,68,0.10); + color: #b91c1c; +} +html.dark .status-badge.online { + background: rgba(52,211,153,0.15); + color: #34d399; +} +html.dark .status-badge.offline { + background: rgba(248,113,113,0.15); + color: #f87171; +} + .hostname { font-weight: 700; font-size: 15px; @@ -410,7 +441,8 @@ function formatTime(t) { .meta-ip { color: var(--text); font-weight: 500; - background: #f3f4f6; + background: var(--surface-hover); + border: 1px solid var(--border); padding: 1px 6px; border-radius: 4px; } @@ -429,24 +461,26 @@ function formatTime(t) { gap: 10px; } .stat-pill { - background: linear-gradient(180deg, rgba(17,24,39,0.72), rgba(17,24,39,0.62)); - border-radius: 12px; - padding: 10px; - color: #fff; + background: var(--pill-bg); + border: 1px solid var(--pill-border); + border-radius: 10px; + padding: 9px 10px; + color: var(--pill-text); display: flex; align-items: center; gap: 8px; + transition: background .2s ease, border-color .2s ease; } .pill-icon { - width: 24px; height: 24px; border-radius: 6px; + width: 22px; height: 22px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 800; - background: rgba(255,255,255,0.12); + background: rgba(255,255,255,0.10); flex-shrink: 0; } -.pill-icon.cpu { color: #a5f3fc; } -.pill-icon.mem { color: #bbf7d0; } -.pill-icon.disk { color: #bfdbfe; } +.pill-icon.cpu { color: #7dd3fc; } +.pill-icon.mem { color: #86efac; } +.pill-icon.disk { color: #93c5fd; } .pill-body { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; } diff --git a/web/src/views/Topology.vue b/web/src/views/Topology.vue index b5df942..1c5e73b 100644 --- a/web/src/views/Topology.vue +++ b/web/src/views/Topology.vue @@ -21,7 +21,7 @@