Files
lan-manager/server/services/ping.go
shirainbown 1a5f5ee3b5 feat: 机器离线统计/日志、连通性重试检测与暗黑模式支持
后端更新:
- 在 machines 表新增 offline_count、total_offline_seconds、last_offline_at、last_offline_reason 字段,用于记录机器离线统计信息。
- 新增 offline_logs 表,记录每次离线的开始时间、结束时间、持续时长及离线原因。
- 重写 ping 服务为状态机模式:
  – 单台机器依次使用 system ping / ICMP / TCP(SSH端口) 三种方式检测连通性;
  – 任意一次成功即视为在线;
  – 若一次失败,则会连续重试 3 次(间隔 2 秒),3 次均失败才判定为离线,避免网络抖动导致误判。
  – 仅在状态发生 online→offline 或 offline→online 变化时更新 offline_logs 与累计时长。
  – 机器按顺序逐个检测,每台间隔 30 秒。
- 新增 API:GET /admin/machines/:id/offline-logs,用于查询最近 50 条离线记录。
- CleanupLogs 增加对 offline_logs 的过期清理。

前端更新:
- 机器详情页(MachineDetail.vue)新增离线统计卡片,展示离线次数、累计离线时长、上次离线原因及最近 5 条离线记录。
- 全局支持暗黑模式:
  – App.vue 引入 light/dark CSS 变量,并加载 Element Plus 深色主题(dark/css-vars.css)。
  – MainLayout.vue 侧边栏新增主题切换按钮,支持深浅色切换并持久化到 localStorage。
  – Topology.vue 监听主题变化,动态重绘 G6 节点/边的颜色、背景与阴影。
  – MachineList.vue / MachineDetail.vue 全面适配深色变量(卡片、表格、标签、进度条等)。
- 机器列表卡片 UI 调整:改为顶部 OS 色点 + 在线/离线状态胶囊标签,离线卡片降低透明度并去色。

构建与部署:
- 重新构建前端并打包静态资源。
- 交叉编译 Linux amd64 二进制并更新 deploy/lan-manager-debian12.tar.gz 部署包。
2026-04-15 01:34:06 +08:00

283 lines
7.7 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"lan-manager/server/db"
"lan-manager/server/utils"
"net"
"os"
"os/exec"
"runtime"
"strings"
"time"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
)
// 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 PingResult{Online: true}
}
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", "5000", ip)
} else if runtime.GOOS == "darwin" {
cmd = exec.Command("ping", "-c", "1", "-W", "5000", ip)
} else {
cmd = exec.Command("ping", "-c", "1", "-W", "5", ip)
}
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 {
c, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return false
}
defer c.Close()
id := os.Getpid() & 0xffff
m := &icmp.Message{
Type: ipv4.ICMPTypeEcho,
Code: 0,
Body: &icmp.Echo{ID: id, Seq: 1, Data: []byte("lan-manager")},
}
wb, err := m.Marshal(nil)
if err != nil {
return false
}
if _, err := c.WriteTo(wb, &net.IPAddr{IP: parseIP(ip)}); err != nil {
return false
}
rb := make([]byte, 1500)
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
n, _, err := c.ReadFrom(rb)
if err != nil {
return false
}
rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n])
if err != nil {
return false
}
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 {
return nil
}
res := make([]byte, 4)
for i, p := range parts {
var v byte
fmt.Sscanf(p, "%d", &v)
res[i] = v
}
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) {
const step = 30 * time.Second
go func() {
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
}
list := []machinePing{}
for rows.Next() {
var m machinePing
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)
}
}
rows.Close()
fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step)
for i := range list {
res := PingHostRepeated(list[i].ip, list[i].sshPort, 3)
handlePingResult(&list[i], res)
time.Sleep(step)
}
}
}()
}
func CleanupLogs(ctx context.Context, retentionDays int) {
if retentionDays <= 0 {
return
}
ticker := time.NewTicker(24 * time.Hour)
go func() {
for {
select {
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 offline_logs WHERE started_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`)
}
}
}()
}