package main import ( "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "time" ) var cpuStat struct { sync.Mutex lastIdle uint64 lastTotal uint64 usage float64 } var systemInfoCache struct { sync.RWMutex data map[string]any } func initCPUUsage() { updateCPUUsage() } func updateSystemInfoCache() { memPercent, memUsed, memTotal := getMemInfo() diskPercent, diskUsed, diskTotal := getDiskInfo() data := map[string]any{ "cpu_usage": updateCPUUsage(), "cpu_cores": getCPUCore(), "cpu_freq": getCPUFreq(), "cpu_temp": getCPUTemperature(), "mem_percent": memPercent, "mem_used": memUsed, "mem_total": memTotal, "disk_percent": diskPercent, "disk_used": diskUsed, "disk_total": diskTotal, } systemInfoCache.Lock() systemInfoCache.data = data systemInfoCache.Unlock() } func startSystemInfoCache() { updateSystemInfoCache() go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for range ticker.C { updateSystemInfoCache() } }() } func systemInfoHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") systemInfoCache.RLock() data := systemInfoCache.data systemInfoCache.RUnlock() json.NewEncoder(w).Encode(data) } func updateCPUUsage() float64 { cpuStat.Lock() defer cpuStat.Unlock() data, err := os.ReadFile("/proc/stat") if err != nil { return cpuStat.usage } fields := strings.Fields(strings.SplitN(string(data), "\n", 2)[0]) if len(fields) < 5 { return cpuStat.usage } var idle uint64 var total uint64 for i := 1; i < len(fields); i++ { v, err := strconv.ParseUint(fields[i], 10, 64) if err != nil { continue } total += v if i == 4 || i == 5 { idle += v } } if cpuStat.lastTotal == 0 { cpuStat.lastTotal = total cpuStat.lastIdle = idle return 0 } deltaTotal := total - cpuStat.lastTotal deltaIdle := idle - cpuStat.lastIdle cpuStat.lastTotal = total cpuStat.lastIdle = idle if deltaTotal == 0 { return cpuStat.usage } cpuStat.usage = float64(deltaTotal-deltaIdle) * 100 / float64(deltaTotal) return cpuStat.usage } func getCPUCore() int { return runtime.NumCPU() } func getCPUFreq() string { var total, count int for i := 0; ; i++ { data, err := os.ReadFile(fmt.Sprintf("/sys/devices/system/cpu/cpu%d/cpufreq/scaling_cur_freq", i)) if err != nil { break } freq, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil { continue } total += freq / 1000 count++ } if count > 0 { return strconv.Itoa(total / count) } data, err := os.ReadFile("/proc/cpuinfo") if err == nil { for _, line := range strings.Split(string(data), "\n") { if strings.HasPrefix(line, "cpu MHz") { f := strings.Split(line, ":") if len(f) == 2 { v, err := strconv.ParseFloat(strings.TrimSpace(f[1]), 64) if err == nil { return strconv.Itoa(int(v)) } } } } } return noValue } func getCPUTemperature() string { files, _ := filepath.Glob("/sys/class/thermal/thermal_zone*/temp") max := 0 for _, f := range files { data, err := os.ReadFile(f) if err != nil { continue } temp, err := strconv.Atoi(strings.TrimSpace(string(data))) if err == nil && temp > max { max = temp } } if max == 0 { return noValue } return fmt.Sprintf("%d℃", max/1000) } func getMemInfo() (int, string, string) { data, err := os.ReadFile("/proc/meminfo") if err != nil { return 0, noValue, noValue } var total, avail uint64 for _, line := range strings.Split(string(data), "\n") { f := strings.Fields(line) if len(f) < 2 { continue } switch f[0] { case "MemTotal:": total, _ = strconv.ParseUint(f[1], 10, 64) case "MemAvailable:": avail, _ = strconv.ParseUint(f[1], 10, 64) } } if total == 0 { return 0, noValue, noValue } used := total - avail return int(used * 100 / total), formatSize(used * 1024), formatSize(total * 1024) } func getDiskInfo() (int, string, string) { out, err := exec.Command("df", "-hP", "/").Output() if err != nil { return 0, noValue, noValue } lines := strings.Split(string(out), "\n") if len(lines) < 2 { return 0, noValue, noValue } f := strings.Fields(lines[1]) if len(f) < 5 { return 0, noValue, noValue } percent, err := strconv.Atoi(strings.TrimSuffix(f[4], "%")) if err != nil { percent = 0 } return percent, f[2], f[1] } func formatSize(size uint64) string { const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case size >= GB: return fmt.Sprintf("%.1fGB", float64(size)/GB) case size >= MB: return fmt.Sprintf("%.0fMB", float64(size)/MB) case size >= KB: return fmt.Sprintf("%.0fKB", float64(size)/KB) default: return fmt.Sprintf("%dB", size) } } func systemLoadAverageHandler(w http.ResponseWriter, r *http.Request) { data, err := os.ReadFile("/proc/loadavg") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } fields := strings.Fields(string(data)) if len(fields) < 4 { http.Error(w, "invalid /proc/loadavg", http.StatusInternalServerError) return } boot, uptime := noValue, noValue if b, err := os.ReadFile("/proc/uptime"); err == nil { if fields := strings.Fields(string(b)); len(fields) > 0 { if sec, err := strconv.ParseFloat(fields[0], 64); err == nil { d := time.Duration(sec) * time.Second boot = time.Now().Add(-d).Format("2006-01-02 15:04:05") day := int(d.Hours() / 24) if day > 0 { uptime = fmt.Sprintf("%d天 %02d:%02d:%02d", int(d.Hours()/24), int(d.Hours())%24, int(d.Minutes())%60, int(d.Seconds())%60) } else { uptime = fmt.Sprintf("%02d:%02d:%02d", int(d.Hours())%24, int(d.Minutes())%60, int(d.Seconds())%60) } } } } resp := struct { Load1m string `json:"load1m"` Load5m string `json:"load5m"` Load15m string `json:"load15m"` RunQueue string `json:"runQueue"` BootTime string `json:"bootTime"` UpTime string `json:"uptime"` }{ Load1m: formatLoad(fields[0]), Load5m: formatLoad(fields[1]), Load15m: formatLoad(fields[2]), RunQueue: fields[3], BootTime: boot, UpTime: uptime, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func formatLoad(v string) string { f, err := strconv.ParseFloat(v, 64) if err != nil { return noValue } return strconv.FormatFloat(f, 'f', 2, 64) } var services = []struct { name string dir string bin string }{ {"yfkj-networkd.service", "/opt/yfkj/networkd.service", "networkd"}, {"yfkj-timesyncd.service", "/opt/yfkj/timesyncd.service", "timesyncd"}, {"yfkj-gnss.service", "/opt/yfkj/gnss.service", "gnss"}, {"yfkj-sshd-mqtt-bridge.service", "/opt/yfkj/sshd-mqtt-bridge.service", "sshd-mqtt-bridge"}, {"yfkj-camera-capture.service", "/opt/yfkj/camera-capture.service", "camera-capture"}, {"yfkj-app-install.service", "/opt/yfkj/app-install.service", "app-install"}, {"yfkj-web-ui.service", "/opt/yfkj/web-ui.service", "web-ui"}, {"yfkj-frp-client.service", "/opt/yfkj/frp-client.service", "frpc"}, {"yfkj-upgrade.service", "/opt/yfkj/upgrade.service", "upgrade"}, {"yfkj-local-startup.service", "/opt/yfkj/local-startup.service", "startup.sh"}, } var serviceStatusActive struct { sync.Mutex lastAccess time.Time } var serviceStatusCache struct { sync.RWMutex data map[string]string } func updateServiceStatus() { data := make(map[string]string, len(services)*2) args := []string{"show", "-p", "Id", "-p", "LoadState", "-p", "ActiveState", "-p", "SubState", "-p", "Result", } for _, s := range services { args = append(args, s.name) } statusMap := make(map[string]string, len(services)) out, _ := exec.Command("systemctl", args...).CombinedOutput() var name, loadState, activeState, subState, result string save := func() { if name == "" { return } status := noValue switch { case activeState == "active" && subState == "running": status = "🟢运行中" case activeState == "active" && subState == "exited": status = "🔵已完成" case loadState == "loaded" && activeState == "inactive": status = "🟡已停止" case activeState == "failed" && result == "timeout": status = "🔴超时停" case activeState == "failed": status = "🔴异常停" } statusMap[name] = status } for line := range strings.SplitSeq(string(out), "\n") { if line == "" { // Empty line indicates the end of a service status block save() ///// Save the previous service status before starting a new one name = "" loadState = "" activeState = "" subState = "" result = "" continue } if v, ok := strings.CutPrefix(line, "Id="); ok { name = v } else if v, ok := strings.CutPrefix(line, "LoadState="); ok { loadState = v } else if v, ok := strings.CutPrefix(line, "ActiveState="); ok { activeState = v } else if v, ok := strings.CutPrefix(line, "SubState="); ok { subState = v } else if v, ok := strings.CutPrefix(line, "Result="); ok { result = v } } save() // Save the last service status for i, s := range services { n := i + 1 if v, ok := statusMap[s.name]; ok { data[fmt.Sprintf("var%d", n)] = v } else { data[fmt.Sprintf("var%d", n)] = noValue } if v, err := os.ReadFile(filepath.Join(s.dir, "version.txt")); err == nil { data[fmt.Sprintf("var%d%d", n, n)] = "v" + strings.TrimSpace(string(v)) } else { data[fmt.Sprintf("var%d%d", n, n)] = noValue } } serviceStatusCache.Lock() serviceStatusCache.data = data serviceStatusCache.Unlock() } func startServiceCache() { go func() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for range ticker.C { serviceStatusActive.Lock() if !serviceStatusActive.lastAccess.IsZero() && time.Since(serviceStatusActive.lastAccess) >= 10*time.Second { serviceStatusActive.lastAccess = time.Time{} } active := !serviceStatusActive.lastAccess.IsZero() serviceStatusActive.Unlock() if active { updateServiceStatus() } } }() } func serviceStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } serviceStatusActive.Lock() first := serviceStatusActive.lastAccess.IsZero() || time.Since(serviceStatusActive.lastAccess) >= 10*time.Second serviceStatusActive.lastAccess = time.Now() serviceStatusActive.Unlock() if first { updateServiceStatus() } serviceStatusCache.RLock() data := serviceStatusCache.data serviceStatusCache.RUnlock() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } type VersionSwitchReq struct { Service string `json:"service"` Action string `json:"action"` Password string `json:"password"` } type VersionResp struct { Status string `json:"status"` Message string `json:"message,omitempty"` } var versionSwitchLock sync.Mutex func serviceVersionSwitchHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } versionSwitchLock.Lock() defer versionSwitchLock.Unlock() var req VersionSwitchReq if json.NewDecoder(r.Body).Decode(&req) != nil { json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: "invalid request"}) return } if req.Password != "yfkj123456" { json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: "输入的密码不正确"}) return } var cfg struct{ name, dir, bin string } for _, v := range services { if v.name == req.Service { cfg = v break } } if cfg.name == "" { json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: "unknown service"}) return } link := filepath.Join(cfg.dir, cfg.bin) oldLink, err := os.Readlink(link) if err != nil { json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: "读取当前版本失败"}) return } current := filepath.Base(filepath.Dir(oldLink)) if current != "a" && current != "b" && current != "c" { json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: "当前版本状态异常"}) return } target := "" switch req.Action { case "factory": if current == "a" { json.NewEncoder(w).Encode(VersionResp{Status: "ok", Message: "当前已是出厂版本"}) return } target = "a" case "previous": switch current { case "b": if info, err := os.Stat(filepath.Join(cfg.dir, "c", cfg.bin)); err == nil && !info.IsDir() && info.Size() > 0 && info.Mode().Perm()&0111 != 0 { target = "c" } case "c": if info, err := os.Stat(filepath.Join(cfg.dir, "b", cfg.bin)); err == nil && !info.IsDir() && info.Size() > 0 && info.Mode().Perm()&0111 != 0 { target = "b" } } if target == "" { json.NewEncoder(w).Encode(VersionResp{ Status: "error", Message: "当前没有可回退的上一版本", }) return } default: json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: "invalid action"}) return } targetBin := filepath.Join(cfg.dir, target, cfg.bin) info, err := os.Stat(targetBin) if err != nil || info.IsDir() || info.Size() == 0 || info.Mode().Perm()&0111 == 0 { json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: fmt.Sprintf("目标版本文件异常: %s", target)}) return } if out, err := exec.Command("systemctl", "stop", req.Service).CombinedOutput(); err != nil { json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: fmt.Sprintf("停止当前服务失败: %s", out)}) return } tmp := filepath.Join(cfg.dir, cfg.bin+".tmp") os.Remove(tmp) if err := os.Symlink(filepath.Join(target, cfg.bin), tmp); err != nil { exec.Command("systemctl", "start", req.Service).Run() json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: err.Error()}) return } if err := os.Rename(tmp, link); err != nil { os.Remove(tmp) exec.Command("systemctl", "start", req.Service).Run() json.NewEncoder(w).Encode(VersionResp{Status: "error", Message: err.Error()}) return } if out, err := exec.Command("systemctl", "start", req.Service).CombinedOutput(); err != nil { tmp := filepath.Join(cfg.dir, cfg.bin+".tmp") os.Remove(tmp) if e := os.Symlink(oldLink, tmp); e == nil { if e = os.Rename(tmp, link); e != nil { os.Remove(tmp) } } exec.Command("systemctl", "start", req.Service).Run() json.NewEncoder(w).Encode(VersionResp{ Status: "error", Message: fmt.Sprintf("目标版本启动失败,已恢复旧版本: %s", out), }) return } json.NewEncoder(w).Encode(VersionResp{Status: "ok"}) }