package systemmetrics import ( "context" "math" "os" "path/filepath" "strconv" "strings" "sync" "time" "tera-clone/services/tera-api/internal/domain" ) type Reader interface { Read(context.Context, []domain.NetworkInterface) domain.SystemRuntimeMetrics } type UnavailableReader struct{} func (UnavailableReader) Read(context.Context, []domain.NetworkInterface) domain.SystemRuntimeMetrics { return domain.SystemRuntimeMetrics{} } type LinuxReaderOptions struct { ProcRoot string SysRoot string EtcRoot string Clock func() time.Time StoragePath string StorageReader func(string) (*domain.StorageMetrics, error) } type LinuxReader struct { procRoot string sysRoot string etcRoot string clock func() time.Time mu sync.Mutex previous cpuCounters storagePath string storageReader func(string) (*domain.StorageMetrics, error) } type cpuCounters struct { total uint64 idle uint64 valid bool } func NewLinuxReader(options LinuxReaderOptions) *LinuxReader { procRoot := options.ProcRoot if procRoot == "" { procRoot = "/proc" } sysRoot := options.SysRoot if sysRoot == "" { sysRoot = "/sys" } etcRoot := options.EtcRoot if etcRoot == "" { etcRoot = "/etc" } clock := options.Clock if clock == nil { clock = time.Now } storagePath := options.StoragePath if storagePath == "" { storagePath = "/userdata" } storageReader := options.StorageReader if storageReader == nil { storageReader = readStorage } return &LinuxReader{procRoot: procRoot, sysRoot: sysRoot, etcRoot: etcRoot, clock: clock, storagePath: storagePath, storageReader: storageReader} } func (r *LinuxReader) Read(ctx context.Context, interfaces []domain.NetworkInterface) domain.SystemRuntimeMetrics { metrics := domain.SystemRuntimeMetrics{SystemTime: r.clock().UTC(), LoadAverages: []float64{}, NetworkInterfaces: []domain.SystemNetworkInterface{}} if ctx.Err() != nil { return metrics } metrics.BuildNumber = firstNonEmptyFile(filepath.Join(r.etcRoot, "tera", "build-number"), filepath.Join(r.etcRoot, "tera-release")) metrics.DeviceID = firstNonEmptyFile(filepath.Join(r.etcRoot, "machine-id")) metrics.UptimeSeconds = readUptime(filepath.Join(r.procRoot, "uptime")) metrics.LoadAverages = readLoadAverages(filepath.Join(r.procRoot, "loadavg")) metrics.TemperatureCelsius = readTemperature(r.sysRoot) metrics.CPUUsagePercent = r.readCPUUsage(filepath.Join(r.procRoot, "stat")) metrics.MemoryTotalBytes, metrics.MemoryAvailableBytes, metrics.MemoryUsedBytes, metrics.MemoryUsagePercent = readMemory(filepath.Join(r.procRoot, "meminfo")) if storage, err := r.storageReader(r.storagePath); err == nil { metrics.Storage = storage } for _, item := range interfaces { if ctx.Err() != nil { break } metrics.NetworkInterfaces = append(metrics.NetworkInterfaces, r.readInterface(item)) } return metrics } func (r *LinuxReader) readInterface(item domain.NetworkInterface) domain.SystemNetworkInterface { result := domain.SystemNetworkInterface{Name: item.Name, Device: item.Device, Link: strings.ToLower(strings.TrimSpace(item.Link)), Addresses: []string{}} if item.Address != "" { result.Addresses = append(result.Addresses, item.Address) } if item.IPv6Address != "" { result.Addresses = append(result.Addresses, item.IPv6Address) } if !validDeviceName(item.Device) { return result } root := filepath.Join(r.sysRoot, "class", "net", item.Device) result.MACAddress = readTrimmed(filepath.Join(root, "address")) if link := readTrimmed(filepath.Join(root, "operstate")); link != "" { result.Link = strings.ToLower(link) } if speed, err := strconv.Atoi(readTrimmed(filepath.Join(root, "speed"))); err == nil && speed > 0 { result.SpeedMbps = speed } result.RXBytes = readUint(filepath.Join(root, "statistics", "rx_bytes")) result.TXBytes = readUint(filepath.Join(root, "statistics", "tx_bytes")) return result } func (r *LinuxReader) readCPUUsage(path string) *float64 { content, err := os.ReadFile(path) if err != nil { return nil } line := strings.SplitN(string(content), "\n", 2)[0] fields := strings.Fields(line) if len(fields) < 5 || fields[0] != "cpu" { return nil } values := make([]uint64, 0, len(fields)-1) for _, field := range fields[1:] { value, err := strconv.ParseUint(field, 10, 64) if err != nil { return nil } values = append(values, value) } var total uint64 for _, value := range values { total += value } idle := values[3] if len(values) > 4 { idle += values[4] } r.mu.Lock() previous := r.previous r.previous = cpuCounters{total: total, idle: idle, valid: true} r.mu.Unlock() if !previous.valid || total <= previous.total { return nil } deltaTotal := total - previous.total deltaIdle := idle - previous.idle value := 100 * (1 - float64(deltaIdle)/float64(deltaTotal)) value = math.Round(math.Max(0, math.Min(100, value))*10) / 10 return &value } func readUptime(path string) int64 { fields := strings.Fields(readTrimmed(path)) if len(fields) == 0 { return 0 } value, err := strconv.ParseFloat(fields[0], 64) if err != nil || value < 0 { return 0 } return int64(value) } func readLoadAverages(path string) []float64 { fields := strings.Fields(readTrimmed(path)) if len(fields) < 3 { return []float64{} } result := make([]float64, 0, 3) for _, field := range fields[:3] { value, err := strconv.ParseFloat(field, 64) if err != nil { return []float64{} } result = append(result, value) } return result } func readTemperature(sysRoot string) *float64 { paths, _ := filepath.Glob(filepath.Join(sysRoot, "class", "thermal", "thermal_zone*", "temp")) for _, path := range paths { value, err := strconv.ParseFloat(readTrimmed(path), 64) if err != nil { continue } if math.Abs(value) > 1000 { value /= 1000 } if value >= -50 && value <= 200 { return &value } } return nil } func readMemory(path string) (totalBytes, availableBytes, usedBytes uint64, usagePercent *float64) { values := make(map[string]uint64) for _, line := range strings.Split(readTrimmed(path), "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } key := strings.TrimSuffix(fields[0], ":") value, err := strconv.ParseUint(fields[1], 10, 64) if err != nil { continue } if len(fields) >= 3 && !strings.EqualFold(fields[2], "kB") { continue } if value > ^uint64(0)/1024 { continue } values[key] = value * 1024 } totalBytes = values["MemTotal"] if totalBytes == 0 { return 0, 0, 0, nil } var availableKnown bool availableBytes, availableKnown = values["MemAvailable"] if !availableKnown { availableBytes = values["MemFree"] + values["Buffers"] + values["Cached"] + values["SReclaimable"] if shmem := values["Shmem"]; shmem < availableBytes { availableBytes -= shmem } } if availableBytes > totalBytes { availableBytes = totalBytes } usedBytes = totalBytes - availableBytes percent := math.Round((float64(usedBytes)/float64(totalBytes))*1000) / 10 return totalBytes, availableBytes, usedBytes, &percent } func firstNonEmptyFile(paths ...string) string { for _, path := range paths { if value := readTrimmed(path); value != "" { if newline := strings.IndexByte(value, '\n'); newline >= 0 { value = strings.TrimSpace(value[:newline]) } if len(value) > 128 { value = value[:128] } return value } } return "" } func readTrimmed(path string) string { content, err := os.ReadFile(path) if err != nil || len(content) > 4096 { return "" } return strings.TrimSpace(strings.ToValidUTF8(string(content), "")) } func readUint(path string) uint64 { value, _ := strconv.ParseUint(readTrimmed(path), 10, 64) return value } func validDeviceName(value string) bool { if value == "" || len(value) > 32 { return false } for _, char := range value { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' { continue } return false } return true }