diff --git a/deploy/lan-manager-debian12.tar.gz b/deploy/lan-manager-debian12.tar.gz index adbcdfb..48f98b0 100644 Binary files a/deploy/lan-manager-debian12.tar.gz and b/deploy/lan-manager-debian12.tar.gz differ diff --git a/server/handlers/machines.go b/server/handlers/machines.go index 79321ce..5b0fad7 100644 --- a/server/handlers/machines.go +++ b/server/handlers/machines.go @@ -354,8 +354,8 @@ func (h *MachineHandler) SyncSSH(c *gin.Context) { portsStr = portsStr[1 : len(portsStr)-1] } } - _, err = 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=?`, - req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id) + _, err = db.DB.Exec(`UPDATE machines SET os_version=?, cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`, + req.OsVersion, req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/server/services/ssh.go b/server/services/ssh.go index debe008..1c4e737 100644 --- a/server/services/ssh.go +++ b/server/services/ssh.go @@ -3,6 +3,7 @@ package services import ( "bufio" "bytes" + "fmt" "lan-manager/server/config" "lan-manager/server/models" "strconv" @@ -38,24 +39,15 @@ func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult, result.Hostname = strings.TrimSpace(out) } - // OS Version - if out, err := runSSHCommand(client, "cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'"); err == nil && out != "" { + // OS Version: source /etc/os-release is the most compatible way across Linux distros + if out, err := runSSHCommand(client, `. /etc/os-release 2>/dev/null && printf '%s\n' "$PRETTY_NAME"`); err == nil && out != "" { result.OsVersion = strings.TrimSpace(out) } else if out, err := runSSHCommand(client, "uname -sr"); err == nil { result.OsVersion = strings.TrimSpace(out) } - // CPU (two samples from /proc/stat for accurate real-time usage) - cpuScript := `c1=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && sleep 1 && c2=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && awk 'BEGIN{split("'"$c1"'", a, " "); split("'"$c2"'", b, " "); if(b[2]-a[2]>0) printf "%.1f", 100*(b[1]-a[1])/(b[2]-a[2]); else print "0.0"}'` - if out, err := runSSHCommand(client, cpuScript); err == nil && out != "" { - cpuUsage := strings.TrimSpace(out) - if cpuUsage == "" { - cpuUsage = "0.0" - } - cores := "" - if cout, err := runSSHCommand(client, "nproc"); err == nil { - cores = strings.TrimSpace(cout) - } + // CPU: read /proc/stat twice with 1s sleep in Go to avoid complex shell quoting + if cpuUsage, cores, err := getCPUInfo(client); err == nil { result.CPU = cpuUsage + "%" if cores != "" { result.CPU += " (" + cores + " cores)" @@ -63,15 +55,14 @@ func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult, result.RawCPUInfo = result.CPU } - // Memory - if out, err := runSSHCommand(client, "free -m | awk 'NR==2{printf \"%.1f/%.1f (%.0f%%)\", $3/1024,$2/1024,$3*100/$2 }'"); err == nil && out != "" { + // Memory: use /proc/meminfo instead of free for better compatibility + if out, err := runSSHCommand(client, "awk '/MemTotal/{t=$2} /MemAvailable/{a=$2} END{u=t-a; if(t>0) printf \"%.1f/%.1f (%.0f%%)\", u/1024/1024, t/1024/1024, u*100/t}' /proc/meminfo"); err == nil && out != "" { result.Memory = strings.TrimSpace(out) result.RawMemoryInfo = result.Memory } - // Disk (exclude virtual filesystems, keep all real partitions) - // Use df -hP to prevent long filesystem names from wrapping to next line - if out, err := runSSHCommand(client, "df -hP | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|squashfs|overlay|efivarfs|tracefs|securityfs|pstore|bpf|configfs|fusectl|mqueue|hugetlbfs|rpc_pipefs|nfsd|binfmt_misc|autofs|devpts|proc|sysfs|cgroup|cgroup2|ramfs)$/ {printf \"%s %s/%s (%s) \", $6,$3,$2,$5}'"); err == nil && out != "" { + // Disk: keep real block devices only, exclude all virtual filesystems + if out, err := runSSHCommand(client, "df -hP | awk 'NR>1 && ($1 ~ /^\\/dev/ || $1 ~ /^(LABEL=|UUID=)/) {printf \"%s %s/%s (%s) \", $6,$3,$2,$5}'"); err == nil && out != "" { result.Disk = strings.TrimSpace(out) result.RawDiskInfo = result.Disk } @@ -81,21 +72,9 @@ func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult, result.Uptime = strings.TrimSpace(out) } - // Listen ports - if out, err := runSSHCommand(client, "ss -tlnp | awk 'NR>1 {print $4}' | sed 's/.*://' | sort -n | uniq"); err == nil && out != "" { - ports := []int{} - scanner := bufio.NewScanner(strings.NewReader(out)) - for scanner.Scan() { - p := strings.TrimSpace(scanner.Text()) - if p == "" { - continue - } - if pi, err := strconv.Atoi(p); err == nil { - ports = append(ports, pi) - } - } - result.ListenPorts = ports - } + // Listen ports: try ss first, fallback to netstat, fallback to /proc/net/tcp + ports, _ := getListenPorts(client) + result.ListenPorts = ports return result, nil } @@ -111,3 +90,109 @@ func runSSHCommand(client *ssh.Client, cmd string) (string, error) { err = session.Run(cmd) return b.String(), err } + +func getCPUInfo(client *ssh.Client) (usage string, cores string, err error) { + readStat := func() (idle, total float64, e error) { + out, e := runSSHCommand(client, "awk '/^cpu /{print $2,$3,$4,$5}' /proc/stat") + if e != nil { + return 0, 0, e + } + parts := strings.Fields(strings.TrimSpace(out)) + if len(parts) < 4 { + return 0, 0, fmt.Errorf("unexpected /proc/stat format") + } + vals := make([]float64, 4) + for i := 0; i < 4; i++ { + v, e := strconv.ParseFloat(parts[i], 64) + if e != nil { + return 0, 0, e + } + vals[i] = v + } + user, nice, system, idleTick := vals[0], vals[1], vals[2], vals[3] + idle = idleTick + total = user + nice + system + idleTick + return idle, total, nil + } + + idle1, total1, err := readStat() + if err != nil { + return "", "", err + } + time.Sleep(1 * time.Second) + idle2, total2, err := readStat() + if err != nil { + return "", "", err + } + + dTotal := total2 - total1 + dIdle := idle2 - idle1 + if dTotal > 0 { + pct := 100.0 * (dTotal - dIdle) / dTotal + if pct < 0 { + pct = 0 + } + if pct > 100 { + pct = 100 + } + usage = fmt.Sprintf("%.1f", pct) + } else { + usage = "0.0" + } + + if cout, e := runSSHCommand(client, "nproc"); e == nil { + cores = strings.TrimSpace(cout) + } + return usage, cores, nil +} + +func getListenPorts(client *ssh.Client) ([]int, error) { + var out string + var err error + + // try ss without -p (no need for process info, more compatible) + out, err = runSSHCommand(client, "ss -tln 2>/dev/null | awk 'NR>1 {print $4}' | sed 's/.*://' | sort -n | uniq") + if err != nil || strings.TrimSpace(out) == "" { + // fallback to netstat + out, err = runSSHCommand(client, "netstat -tln 2>/dev/null | awk 'NR>2 {print $4}' | sed 's/.*://' | sort -n | uniq") + } + if err != nil || strings.TrimSpace(out) == "" { + // fallback to /proc/net/tcp + out, err = runSSHCommand(client, "awk 'NR>1 {printf \"%04X\\n\", strtonum(\"0x\"$2)}' /proc/net/tcp") + } + if err != nil { + return nil, err + } + + ports := []int{} + scanner := bufio.NewScanner(strings.NewReader(out)) + for scanner.Scan() { + p := strings.TrimSpace(scanner.Text()) + if p == "" { + continue + } + // handle both decimal ports and hex ports from /proc/net/tcp + var pi int + var e error + if len(p) == 4 && isHex(p) { + var h uint64 + h, e = strconv.ParseUint(p, 16, 32) + pi = int(h) + } else { + pi, e = strconv.Atoi(p) + } + if e == nil && pi > 0 { + ports = append(ports, pi) + } + } + return ports, nil +} + +func isHex(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} diff --git a/web/src/views/MachineDetail.vue b/web/src/views/MachineDetail.vue index 44210d0..86efdb5 100644 --- a/web/src/views/MachineDetail.vue +++ b/web/src/views/MachineDetail.vue @@ -422,7 +422,10 @@ async function openSSH() { try { const res = await sshInfo(machineId, { username: machine.value.ssh_username, password: '' }) sshResult.value = res.data - await syncSSH(machineId, sshResult.value) + // 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库 + const syncData = { ...sshResult.value } + delete syncData.hostname + await syncSSH(machineId, syncData) ElMessage.success('获取并同步成功') await loadMachine() sshLoading.value = false @@ -461,7 +464,10 @@ async function fetchSSH() { const res = await sshInfo(machineId, sshForm.value) sshResult.value = res.data sshDialogVisible.value = false - await syncSSH(machineId, sshResult.value) + // 保护用户自定义的主机名,不将 SSH 采集到的 hostname 写入数据库 + const syncData = { ...sshResult.value } + delete syncData.hostname + await syncSSH(machineId, syncData) ElMessage.success('获取并同步成功') await loadMachine() } catch (e) {}